using System.Linq; using System.Numerics; using AcDream.App.Rendering; using AcDream.App.Rendering.Gpu; using AcDream.App.Tests.Rendering.Gpu; using AcDream.App.UI; using Xunit; namespace AcDream.App.Tests.UI; /// /// Pure unit tests for — no GL dependency. /// 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))); } /// /// Fix round F11 (Campaign CC CC6b-MOUNT review): retail's chargen shade /// scrollbar (0x10000321) is authored VERTICAL (measured 33x85 /// against the installed dat — see /// CharacterCreationLiveDatTests.AppearancePage_HasGenderChoiceSpinsSwatchesShadeAndViewport), /// but before this fix UiScrollbar.OnEvent only routed to /// ScalarChanged when Horizontal was true — a vertical /// scalar bar's clicks fell through to the Model-mode branch, which /// returns false with no set, so the /// shade control never fired in production. Mirrors /// /// exactly, transposed onto Y/Height/Data2. /// [Fact] public void VerticalScalar_clickAndDrag_updatesNormalizedValue() { float value = 1f; var bar = new UiScrollbar { Width = 14f, Height = 90f, Horizontal = false, ScalarChanged = next => value = next, }; bar.SetScalarPosition(1f); Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data2: 8))); Assert.Equal(0f, value, 3); Assert.Equal(0f, bar.ScalarPosition, 3); Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseMove, Data2: 45))); Assert.Equal(0.5f, value, 3); Assert.Equal(0.5f, bar.ScalarPosition, 3); Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseUp, Data2: 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); } private sealed class NullGpuFrameSource : ICurrentGpuFrameSource { public IGpuFrame? CurrentFrame => null; } /// /// R4-2 (Campaign CC gate round 1 re-test 3): the re-test-2 single- /// sprite-thumb fallback (R3-4/R3-7, 's /// no-cap-sprites branch) used to call the same UV-repeat /// DrawTiled the 3-slice middle tile uses — for a small fixed /// "diamond" marker sprite drawn into a MUCH taller track-proportional /// thumb rect, GL_REPEAT wrapping visibly tiled the marker several /// times down the track (Summary's overview bar ~9, Skills 2, per the /// live capture). Proves the fix draws exactly ONE quad for the thumb /// texture whose V range never exceeds native (1.0) — i.e. one /// unstretched, untiled sprite instance — even though the computed /// thumb rect (168px trackLen * ThumbRatio 0.75 = 126px, well past the /// sprite's native 16px) is far taller than the sprite. /// [Fact] public void SingleSpriteThumb_DrawsOneUntiledInstance_NotRepeatedDownTrack() { var device = new RecordingGpuDevice(); var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused"); renderer.Begin(new Vector2(800f, 600f)); var ctx = new UiRenderContext(renderer, new Vector2(800f, 600f)); const uint thumbTex = 42u; var model = new UiScrollable { ContentHeight = 200, ViewHeight = 150 }; var bar = new UiScrollbar { Width = 16f, Height = 200f, SpriteResolve = id => id == thumbTex ? (thumbTex, 16, 16) : (0u, 0, 0), ThumbSprite = thumbTex, // ThumbTopSprite/ThumbBotSprite stay unset -> the R3-4/R3-7 // single-sprite fallback shape (no 3-slice caps authored). Model = model, }; bar.DrawSelfAndChildren(ctx); var thumbSegments = renderer.DebugSpriteSegmentVerts .Where(s => s.Texture == thumbTex) .ToArray(); Assert.Single(thumbSegments); var verts = thumbSegments[0].Verts; // 8 floats/vertex (x,y,u,v,r,g,b,a), one quad = 6 vertices. Assert.Equal(6, verts.Count / 8); for (int i = 0; i < verts.Count; i += 8) Assert.True(verts[i + 3] <= 1.0001f, $"thumb sprite V={verts[i + 3]} exceeds native (tiled)"); } }