acdream/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs
Erik 67b0815c79 fix(ui): OP5 re-check residuals R1/R2 (coordinator pass) — OP5 CLOSED
R1: UiRoot now delivers WM_CAPTURECHANGED (0x215 — retail's own Win32
event-id space) to the element losing pointer capture on BOTH release
and re-target; UiScrollbar terminates a mid-drag gesture there,
completing it (one DragCompleted flush persisting the user's last-seen
value) and unlatching IsDragging — a panel-close keybind mid-drag or a
second-button re-target can no longer latch the drag flag forever and
silently suppress every later settings flush. Normal MouseUp paths
no-op (the latch is already clear when capture releases).

R2: the scalar latch arms BEFORE the track-click jump applies, so the
jump's own ScalarChanged tick defers its flush to the MouseUp's single
DragCompleted — one flush per press gesture, never the
inline-then-completed double; the DragCompleted doc now states the real
contract (fires once per value-capable gesture incl. capture loss)
instead of the refuted never-on-jump claim.

Tests: capture-loss mid-drag (ends + completes once + stray-MouseUp
no-double), no-drag capture-change no-op, bare-track-click
single-completion with the latch observed armed during the jump tick.
Also reconciles the research doc's U4 row to its closure (the six
caption pairs, the BN zero-fold post-mortem) per the OP6 rework's flag.

Full Release suite: 13,128 passed / 4 skipped / 0 failed (one
documented #250-class allocation flake on first run, green in
isolation and on full-suite rerun).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 08:13:24 +02:00

432 lines
15 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);
}
// ── OP5 re-check R1/R2 (2026-08-11, coordinator pass) ───────────────────
[Fact]
public void CaptureLossMidDrag_EndsTheGesture_CompletesOnce_AndUnlatchesIsDragging()
{
// R1: UiRoot can drop capture WITHOUT a MouseUp (panel hidden by a
// keybind mid-drag; a second button re-targeting capture). The
// WM_CAPTURECHANGED delivery must end the drag, fire ONE completion
// (persisting the user's last-seen value), and unlatch IsDragging —
// otherwise every later Reset/Defaults flush is silently suppressed.
int completedCount = 0;
var bar = new UiScrollbar
{
Width = 90f,
Height = 14f,
Horizontal = true,
ScalarChanged = _ => { },
DragCompleted = () => completedCount++,
};
bar.SetScalarPosition(0f);
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data1: 5)));
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseMove, Data1: 30)));
Assert.True(bar.IsDragging);
bar.OnEvent(new UiEvent(0u, bar, UiEventType.CaptureChanged));
Assert.False(bar.IsDragging);
Assert.Equal(1, completedCount);
// A later stray MouseUp (capture already gone) must not double-fire.
bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseUp, Data1: 30));
Assert.Equal(1, completedCount);
}
[Fact]
public void CaptureChange_WithNoActiveDrag_IsANoOp()
{
int completedCount = 0;
var bar = new UiScrollbar
{
Width = 90f,
Height = 14f,
Horizontal = true,
DragCompleted = () => completedCount++,
};
bar.OnEvent(new UiEvent(0u, bar, UiEventType.CaptureChanged));
Assert.False(bar.IsDragging);
Assert.Equal(0, completedCount);
}
[Fact]
public void HorizontalScalar_BareTrackClickJump_DefersItsTickAndCompletesExactlyOnce()
{
// R2: a bare track click (outside the thumb) jumps the scalar. The
// latch now arms BEFORE the jump applies, so the jump's own
// ScalarChanged tick observes IsDragging=true (a consumer defers its
// flush) and the MouseUp's single DragCompleted carries the gesture's
// one flush — never the inline-then-completed double.
int completedCount = 0;
bool draggingDuringTick = false;
UiScrollbar bar = null!;
bar = new UiScrollbar
{
Width = 90f,
Height = 14f,
Horizontal = true,
ScalarChanged = _ => draggingDuringTick = bar.IsDragging,
DragCompleted = () => completedCount++,
};
bar.SetScalarPosition(0f); // thumb spans [0, 16]
// Click far outside the thumb — the jump branch.
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data1: 70)));
Assert.True(draggingDuringTick); // the jump tick saw the latch armed
Assert.Equal(0, completedCount);
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseUp, Data1: 70)));
Assert.Equal(1, completedCount); // one gesture, one completion
}
[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);
}
}