Fixes the OP5 (Chat tab) dual-lens review findings againste71e5a96: - M1 (MUST-FIX): each opacity row's own apply closure now pushes its OWN slider's thumb from the post-link truth (bindings.Current*Opacity()), mirroring the OP4 binding pattern. Before this, a single-slider drag followed by Reset reverted the live value/link but left that slider's own thumb stuck at the dragged position. - S1 (SHOULD-FIX): the Chat tab's two opacity sliders no longer round-trip the whole settings.json on every drag MouseMove tick. UiScrollbar gains IsDragging + a DragCompleted callback (fires once, at the MouseUp that ends an actual thumb drag); the opacity apply closures flush immediately when not mid-drag (Reset/Defaults/discrete edits, same as before) and defer to DragCompleted otherwise, collapsing dozens of per-tick writes into exactly one per drag gesture. Live opacity still applies every tick. - S2 (SHOULD-FIX): filed register row AP-201 and issue #371 for the UiScrollablePanel whole-row-cull-vs-clip divergence the review found (predates OP5, made user-visible by OP5's 240-260px filter blocks). Not fixed in this round (a renderer-level scissor stack is out of scope here) — corrected the OP5 connected-gate script instead so a straddling block's disappear-then-reappear-whole is no longer reported as a self-sizing regression. - S3 (SHOULD-FIX): the chatWindowMainFilter round-trip test already existed ine71e5a96(the review missed it scrolling past line 330); added the genuinely missing coverage instead — a composed test pinning RetailUiRuntime.MountChat's window-0 SettingsStore -> ChatWindowState seed (MountChat itself needs live DAT access and isn't unit-testable directly). - N11: ScrollbarLinkage_ModelPointsAtTheChatListBoxScroll now asserts through the scoped page-slot lookup (UiElement.FindDescendant) instead of the flat layout.FindElement, which passed for the wrong reason given the shared scrollbar id 0x10000201 — matches OP6's own scrollbar-linkage test pattern. Also updated ConfigOptionsPageControllerTests' local ChatOptionsPageController Bindings fake for the new FlushOpacity parameter. Full Release suite: 13,117 passed / 4 skipped / 0 failed (baseline 13,107/4/0 post-OP6 — 10 tests added, zero skips added, zero failures). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
349 lines
12 KiB
C#
349 lines
12 KiB
C#
using AcDream.App.UI;
|
|
using Xunit;
|
|
|
|
namespace AcDream.App.Tests.UI;
|
|
|
|
/// <summary>
|
|
/// Pure unit tests for <see cref="UiScrollbar.ThumbRect"/> — no GL dependency.
|
|
/// </summary>
|
|
public class UiScrollbarTests
|
|
{
|
|
// Model: content=400, view=100, trackLen=200.
|
|
// ThumbRatio = 100/400 = 0.25 → thumbH = max(8, 200*0.25) = 50.
|
|
// Travel = 200 - 50 = 150.
|
|
|
|
[Fact]
|
|
public void ThumbRect_AtStart_HasCorrectSizeAndZeroOffset()
|
|
{
|
|
var m = new UiScrollable { ContentHeight = 400, ViewHeight = 100 };
|
|
// PositionRatio = 0 (start).
|
|
var (y, h) = UiScrollbar.ThumbRect(m, trackTop: 0f, trackLen: 200f);
|
|
Assert.Equal(50f, h, 3f);
|
|
Assert.Equal(0f, y, 3f);
|
|
}
|
|
|
|
[Fact]
|
|
public void ThumbRect_AtEnd_PinsToBottomOfTrack()
|
|
{
|
|
var m = new UiScrollable { ContentHeight = 400, ViewHeight = 100 };
|
|
m.ScrollToEnd(); // PositionRatio = 1.
|
|
float trackTop = 16f, trackLen = 200f;
|
|
var (y, h) = UiScrollbar.ThumbRect(m, trackTop, trackLen);
|
|
Assert.Equal(50f, h, 3f);
|
|
// y = trackTop + travel * 1 = 16 + 150 = 166.
|
|
Assert.Equal(166f, y, 3f);
|
|
}
|
|
|
|
[Fact]
|
|
public void ThumbRect_WithButtonH_CorrectlyOffsetsFromTrackTop()
|
|
{
|
|
// Matches task spec: content=400, view=100, trackLen=200, PositionRatio=1.
|
|
// thumbH=50; travel=150; y = trackTop + 150 = trackTop + 150.
|
|
var m = new UiScrollable { ContentHeight = 400, ViewHeight = 100 };
|
|
m.ScrollToEnd();
|
|
var (y, h) = UiScrollbar.ThumbRect(m, trackTop: 16f, trackLen: 200f);
|
|
Assert.Equal(50f, h, 3f);
|
|
Assert.Equal(166f, y, 3f); // 16 + 150
|
|
}
|
|
|
|
[Fact]
|
|
public void ThumbRect_MidScroll_InterpolatesPosition()
|
|
{
|
|
// content=400 view=100 → MaxScroll=300; ScrollY=150 → PositionRatio=0.5.
|
|
var m = new UiScrollable { ContentHeight = 400, ViewHeight = 100 };
|
|
m.SetScrollY(150);
|
|
Assert.Equal(0.5f, m.PositionRatio, 3);
|
|
|
|
var (y, h) = UiScrollbar.ThumbRect(m, trackTop: 0f, trackLen: 200f);
|
|
Assert.Equal(50f, h, 3f);
|
|
// y = 0 + 150 * 0.5 = 75.
|
|
Assert.Equal(75f, y, 3f);
|
|
}
|
|
|
|
[Fact]
|
|
public void ThumbRect_SmallContent_EnforcesMinThumb()
|
|
{
|
|
// content=1000, view=10, trackLen=200 → ThumbRatio=0.01 → raw=2 < 8 → clamp to 8.
|
|
var m = new UiScrollable { ContentHeight = 1000, ViewHeight = 10 };
|
|
var (_, h) = UiScrollbar.ThumbRect(m, trackTop: 0f, trackLen: 200f);
|
|
Assert.Equal(8f, h, 3f);
|
|
}
|
|
|
|
[Fact]
|
|
public void ThumbRect_NoOverflow_ThumbFillsTrack()
|
|
{
|
|
// content <= view → ThumbRatio = 1 → thumbH = trackLen.
|
|
var m = new UiScrollable { ContentHeight = 50, ViewHeight = 100 };
|
|
var (y, h) = UiScrollbar.ThumbRect(m, trackTop: 16f, trackLen: 100f);
|
|
Assert.Equal(100f, h, 3f);
|
|
Assert.Equal(16f, y, 3f); // travel = 0 → y = trackTop
|
|
}
|
|
|
|
[Fact]
|
|
public void HorizontalScalar_clickAndDrag_updatesNormalizedValue()
|
|
{
|
|
float value = 1f;
|
|
var bar = new UiScrollbar
|
|
{
|
|
Width = 90f,
|
|
Height = 14f,
|
|
Horizontal = true,
|
|
ScalarChanged = next => value = next,
|
|
};
|
|
bar.SetScalarPosition(1f);
|
|
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data1: 8)));
|
|
Assert.Equal(0f, value, 3);
|
|
Assert.Equal(0f, bar.ScalarPosition, 3);
|
|
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseMove, Data1: 45)));
|
|
Assert.Equal(0.5f, value, 3);
|
|
Assert.Equal(0.5f, bar.ScalarPosition, 3);
|
|
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseUp, Data1: 45)));
|
|
}
|
|
|
|
// ── OP5 review fix S1: the drag-end seam (IsDragging / DragCompleted) ────
|
|
|
|
[Fact]
|
|
public void HorizontalScalar_DragCompleted_FiresOnceAtMouseUp_NotOnEachMove()
|
|
{
|
|
int completedCount = 0;
|
|
var bar = new UiScrollbar
|
|
{
|
|
Width = 90f,
|
|
Height = 14f,
|
|
Horizontal = true,
|
|
ScalarChanged = _ => { },
|
|
DragCompleted = () => completedCount++,
|
|
};
|
|
bar.SetScalarPosition(0f); // thumb spans [0, 16]
|
|
|
|
// Click INSIDE the thumb — no "jump to click" branch, a clean drag start.
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data1: 5)));
|
|
Assert.True(bar.IsDragging);
|
|
Assert.Equal(0, completedCount);
|
|
|
|
for (int i = 0; i < 10; i++)
|
|
{
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseMove, Data1: 10 + i)));
|
|
Assert.Equal(0, completedCount); // N drag ticks fire zero completions
|
|
}
|
|
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseUp, Data1: 50)));
|
|
Assert.False(bar.IsDragging);
|
|
Assert.Equal(1, completedCount); // drag end fires exactly one
|
|
}
|
|
|
|
[Fact]
|
|
public void HorizontalScalar_DragCompleted_DoesNotFireOnAMouseUpThatWasNeverADrag()
|
|
{
|
|
int completedCount = 0;
|
|
var bar = new UiScrollbar
|
|
{
|
|
Width = 90f,
|
|
Height = 14f,
|
|
Horizontal = true,
|
|
ScalarChanged = _ => { },
|
|
DragCompleted = () => completedCount++,
|
|
};
|
|
|
|
// A bare MouseUp with no prior MouseDown/drag must not fire the callback.
|
|
bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseUp, Data1: 10));
|
|
Assert.Equal(0, completedCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void VerticalModel_DragCompleted_FiresOnlyForAnActualThumbDrag_NotAButtonClick()
|
|
{
|
|
// Height=200, default 16px decrement/increment buttons -> trackTop=16,
|
|
// trackLen=168. content=400/view=100 -> ThumbRatio=0.25 -> thumbH=42,
|
|
// travel=126. At PositionRatio=0 the thumb spans local Y [16, 58].
|
|
var model = new UiScrollable { ContentHeight = 400, ViewHeight = 100 };
|
|
int completedCount = 0;
|
|
var bar = new UiScrollbar { Width = 16f, Height = 200f, Model = model, DragCompleted = () => completedCount++ };
|
|
|
|
// A click on the decrement (up-arrow) button is never a drag.
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data1: 0, Data2: 5)));
|
|
Assert.False(bar.IsDragging);
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseUp, Data1: 0, Data2: 5)));
|
|
Assert.Equal(0, completedCount);
|
|
|
|
// A click INSIDE the thumb (local Y 30, within [16, 58]) starts a real drag.
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data1: 0, Data2: 30)));
|
|
Assert.True(bar.IsDragging);
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseMove, Data1: 0, Data2: 40)));
|
|
Assert.Equal(0, completedCount);
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseUp, Data1: 0, Data2: 40)));
|
|
Assert.Equal(1, completedCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void HorizontalModel_DragCompleted_FiresOnceAtMouseUp()
|
|
{
|
|
var model = new UiScrollable { ContentHeight = 320, ViewHeight = 80, LineHeight = 32 };
|
|
int completedCount = 0;
|
|
var bar = new UiScrollbar
|
|
{
|
|
Width = 160f,
|
|
Height = 16f,
|
|
Horizontal = true,
|
|
Model = model,
|
|
DragCompleted = () => completedCount++,
|
|
};
|
|
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data1: 20)));
|
|
Assert.True(bar.IsDragging);
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseMove, Data1: 144)));
|
|
Assert.Equal(0, completedCount);
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseUp, Data1: 144)));
|
|
Assert.False(bar.IsDragging);
|
|
Assert.Equal(1, completedCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void HorizontalModel_ButtonsTrackAndThumbDriveSharedScroll()
|
|
{
|
|
var model = new UiScrollable { ContentHeight = 320, ViewHeight = 80, LineHeight = 32 };
|
|
var bar = new UiScrollbar
|
|
{
|
|
Width = 160f,
|
|
Height = 16f,
|
|
Horizontal = true,
|
|
Model = model,
|
|
};
|
|
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data1: 159)));
|
|
Assert.Equal(32, model.ScrollY);
|
|
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data1: 100)));
|
|
Assert.True(model.ScrollY >= 80);
|
|
|
|
model.SetScrollY(0);
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data1: 20)));
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseMove, Data1: 144)));
|
|
Assert.Equal(model.MaxScroll, model.ScrollY);
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseUp, Data1: 144)));
|
|
}
|
|
|
|
[Fact]
|
|
public void HorizontalModel_UsesAuthoredArrowExtentsAndOneCellStep()
|
|
{
|
|
var model = new UiScrollable
|
|
{
|
|
ContentHeight = 640,
|
|
ViewHeight = 320,
|
|
LineHeight = 32,
|
|
};
|
|
model.SetScrollY(64);
|
|
var bar = new UiScrollbar
|
|
{
|
|
Width = 160f,
|
|
Height = 36f,
|
|
Horizontal = true,
|
|
Model = model,
|
|
DecrementButtonExtent = 23f,
|
|
IncrementButtonExtent = 29f,
|
|
};
|
|
|
|
Assert.True(bar.OnEvent(new UiEvent(
|
|
0u, bar, UiEventType.MouseDown, Data1: 22)));
|
|
Assert.Equal(32, model.ScrollY);
|
|
|
|
Assert.True(bar.OnEvent(new UiEvent(
|
|
0u, bar, UiEventType.MouseDown, Data1: 131)));
|
|
Assert.Equal(64, model.ScrollY);
|
|
}
|
|
|
|
[Fact]
|
|
public void HorizontalModel_UsesIndependentRolloverAndPressedArrowMedia()
|
|
{
|
|
var root = new UiRoot { Width = 300f, Height = 100f };
|
|
var model = new UiScrollable
|
|
{
|
|
ContentHeight = 640,
|
|
ViewHeight = 320,
|
|
LineHeight = 32,
|
|
};
|
|
var bar = new UiScrollbar
|
|
{
|
|
Width = 160f,
|
|
Height = 36f,
|
|
Horizontal = true,
|
|
Model = model,
|
|
UpSprite = 1u,
|
|
UpRolloverSprite = 2u,
|
|
UpPressedSprite = 3u,
|
|
DownSprite = 4u,
|
|
DownRolloverSprite = 5u,
|
|
DownPressedSprite = 6u,
|
|
};
|
|
root.AddChild(bar);
|
|
|
|
root.OnMouseMove(5, 10);
|
|
Assert.Equal(2u, bar.ActiveStartSpriteForTest);
|
|
Assert.Equal(4u, bar.ActiveEndSpriteForTest);
|
|
|
|
// Moving between two regions of the same procedural scrollbar must
|
|
// refresh its sub-control hover, not wait for a whole-widget leave.
|
|
root.OnMouseMove(155, 10);
|
|
Assert.Equal(1u, bar.ActiveStartSpriteForTest);
|
|
Assert.Equal(5u, bar.ActiveEndSpriteForTest);
|
|
|
|
root.OnMouseDown(UiMouseButton.Left, 155, 10);
|
|
Assert.Equal(6u, bar.ActiveEndSpriteForTest);
|
|
root.OnMouseUp(UiMouseButton.Left, 155, 10);
|
|
Assert.Equal(5u, bar.ActiveEndSpriteForTest);
|
|
}
|
|
|
|
[Fact]
|
|
public void ModelWithoutOverflow_IsDisabledAndHideDisabledSuppressesPresentation()
|
|
{
|
|
var model = new UiScrollable
|
|
{
|
|
ContentHeight = 320,
|
|
ViewHeight = 320,
|
|
LineHeight = 32,
|
|
};
|
|
var bar = new UiScrollbar
|
|
{
|
|
Width = 160f,
|
|
Height = 36f,
|
|
Horizontal = true,
|
|
Model = model,
|
|
HideWhenDisabled = true,
|
|
};
|
|
|
|
Assert.True(bar.IsModelDisabled);
|
|
Assert.False(bar.IsPresentationVisible);
|
|
Assert.False(bar.OnEvent(new UiEvent(
|
|
0u, bar, UiEventType.MouseDown, Data1: 159)));
|
|
Assert.Equal(0, model.ScrollY);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0f, 0f, 0f)]
|
|
[InlineData(0.5f, 0f, 50f)]
|
|
[InlineData(1f, 0f, 100f)]
|
|
public void ScalarFillRect_CombatPower_GrowsLeftToRight(
|
|
float fill, float expectedX, float expectedWidth)
|
|
{
|
|
var (x, width) = UiScrollbar.ScalarFillRect(100f, fill, fromRight: false);
|
|
Assert.Equal(expectedX, x, 3);
|
|
Assert.Equal(expectedWidth, width, 3);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0f, 104f, 0f)]
|
|
[InlineData(0.5f, 104f, 149.5f)]
|
|
[InlineData(1f, 104f, 299f)]
|
|
public void ScalarFillRect_CombatPower_StaysBetweenAuthoredLabels(
|
|
float fill, float expectedX, float expectedWidth)
|
|
{
|
|
var (x, width) = UiScrollbar.ScalarFillRect(
|
|
rangeLeft: 104f, rangeWidth: 299f, fill, fromRight: false);
|
|
Assert.Equal(expectedX, x, 3);
|
|
Assert.Equal(expectedWidth, width, 3);
|
|
}
|
|
|
|
}
|