using Annulus; using Azimuth; using UnitTestSharp; namespace Glass.UnitTests { public class ScrollTrackTests : TestFixture { /// /// A Material with an intrinsic footprint, for tests to give a button a known PreferredSize result (its /// desired size) and BoundsSizeFor result (its minimum size) without needing real sprite/nine-slice art. /// Public so ScrollBarTests can reuse it for arrow-button sizing. /// public record FixedSizeMaterial : Material { private readonly Vector size; public FixedSizeMaterial(Vector size) { this.size = size; } protected override Vector PreferredSizeInModelSpace(Constraints constraints) => constraints.Constrain(size); public override Vector BoundsSizeFor(Vector viewportSize) => Vector.Max(base.BoundsSizeFor(viewportSize), size); } public class ScrollableRangeTests : TestFixture { public void ContentLargerThanViewport_IsTheDifference() { var track = NewTrack(Orientation.Horizontal, new Vector(100, 20), contentSize: 400, viewportSize: 100); CheckEqual(300, track.ScrollableRange); } public void ContentEqualToViewport_IsZero() { var track = NewTrack(Orientation.Horizontal, new Vector(100, 20), contentSize: 100, viewportSize: 100); CheckEqual(0, track.ScrollableRange); } public void ContentSmallerThanViewport_IsZeroNotNegative() { var track = NewTrack(Orientation.Horizontal, new Vector(100, 20), contentSize: 40, viewportSize: 100); CheckEqual(0, track.ScrollableRange); } } public class ThumbLengthTests : TestFixture { public void ProportionalToViewportOverContent() { var track = NewTrack(Orientation.Horizontal, new Vector(100, 20), contentSize: 400, viewportSize: 100); CheckEqual(25, track.ThumbLength); } public void FlooredAtMinimumThumbLength() { var track = NewTrack(Orientation.Horizontal, Vector.Zero, new Vector(100, 20), contentSize: 10000, viewportSize: 10, minimumThumbLength: 20); CheckEqual(20, track.ThumbLength); } public void CappedAtTrackLength() { var track = NewTrack(Orientation.Horizontal, new Vector(100, 20), contentSize: 50, viewportSize: 100); CheckEqual(100, track.ThumbLength); } public void ContentSizeZero_FillsTheTrack() { var track = NewTrack(Orientation.Horizontal, new Vector(100, 20), contentSize: 0, viewportSize: 100); CheckEqual(100, track.ThumbLength); } } public class ThumbOffsetTests : TestFixture { public void ContentFitsTheViewport_IsZero() { var track = NewTrack(Orientation.Horizontal, new Vector(100, 20), contentSize: 80, viewportSize: 100); CheckEqual(0, track.ThumbOffset); } public void AtStart_IsZero() { var track = NewTrack(Orientation.Horizontal, new Vector(100, 20), contentSize: 400, viewportSize: 100); CheckEqual(0, track.ThumbOffset); } public void AtEnd_IsTheFullThumbTravel() { var track = NewTrack(Orientation.Horizontal, new Vector(100, 20), contentSize: 400, viewportSize: 100); track.ScrollTo(300); // thumbLength = 100*100/400 = 25, so thumbTravel = 100 - 25 = 75. CheckEqual(75, track.ThumbOffset); } public void Halfway_IsHalfwayAlongTheThumbTravel() { var track = NewTrack(Orientation.Horizontal, new Vector(100, 20), contentSize: 400, viewportSize: 100); track.ScrollTo(150); // thumbTravel=75, scrollableRange=300; 150 is halfway, so thumbOffset = 75/2 = 37.5. CheckEqual(37.5, track.ThumbOffset); } } public class ScrollDeltaForDragTests : TestFixture { public void PositiveDelta_ScrollsForwardProportionally() { var track = NewTrack(Orientation.Horizontal, new Vector(100, 20), contentSize: 400, viewportSize: 100); // thumbLength=25, thumbTravel=75, scrollableRange=300: a 40px drag maps to 40 * 300 / 75 = 160. CheckEqual(160, track.ScrollDeltaForDrag(40)); } public void NegativeDelta_ScrollsBackProportionally() { var track = NewTrack(Orientation.Horizontal, new Vector(100, 20), contentSize: 400, viewportSize: 100); CheckEqual(-160, track.ScrollDeltaForDrag(-40)); } public void NoThumbTravel_ReturnsZero() { // ContentSize <= 0 makes ThumbLength fill the whole track, leaving no travel to drag through. var track = NewTrack(Orientation.Horizontal, new Vector(50, 20), contentSize: 0, viewportSize: 100); CheckEqual(0, track.ScrollDeltaForDrag(1000)); } } [IgnoreTest] private static void SetThumbMinimumLength(ScrollTrack track, Scalar minimumLength) { track.Thumb.ButtonMaterial = new FixedSizeMaterial(new Vector(minimumLength, minimumLength)); } [IgnoreTest] private static ScrollTrack NewTrack(Orientation orientation, Vector position, Vector boundsSize, Scalar contentSize, Scalar viewportSize, Scalar minimumThumbLength) { var track = new ScrollTrack { Orientation = orientation, ContentSize = contentSize, ViewportSize = viewportSize, }; SetThumbMinimumLength(track, minimumThumbLength); track.Bounds = Rect.FromPositionAndSize(position, boundsSize); return track; } [IgnoreTest] private static ScrollTrack NewTrack(Orientation orientation, Vector position, Vector boundsSize, Scalar contentSize, Scalar viewportSize) { return NewTrack(orientation, position, boundsSize, contentSize, viewportSize, minimumThumbLength: 0); } [IgnoreTest] private static ScrollTrack NewTrack(Orientation orientation, Vector boundsSize, Scalar contentSize, Scalar viewportSize) { return NewTrack(orientation, Vector.Zero, boundsSize, contentSize, viewportSize, minimumThumbLength: 0); } public class MaterialTests : TestFixture { public void Unset_IsNull() { CheckNull(new ScrollTrack().Material); } public void SettingMaterialWithABorder_ShrinksTheThumbLayoutImmediately() { var track = NewTrack(Orientation.Horizontal, new Vector(200, 20), contentSize: 400, viewportSize: 100); CheckEqual(50, track.Thumb.Bounds.Size.X); track.Material = new Material { BorderSize = new Rect.BorderSize { Left = 5, Right = 5 } }; // CanvasRect shrinks by the 10px border, so thumbLength = (200 - 10) * 100 / 400 = 47.5. CheckEqual(47.5, track.Thumb.Bounds.Size.X); } } public class LayoutTests : TestFixture { public void AfterScrollingHalfway_ThumbSitsHalfwayAlongItsTravel() { var track = NewTrack(Orientation.Horizontal, new Vector(200, 20), contentSize: 400, viewportSize: 100); track.ScrollTo(150); // trackLength=200, thumbLength=50, thumbTravel=150, scrollableRange=300: 150 is halfway, so the // thumb sits halfway along its own 150px travel, at 75. CheckEqual(new Vector(75, 0), track.Thumb.Bounds.TopLeft); } public void ContentFittingTheViewport_ThumbFillsTheWholeTrack() { var track = NewTrack(Orientation.Horizontal, new Vector(200, 20), contentSize: 100, viewportSize: 100); CheckEqual(new Vector(200, 20), track.Thumb.Bounds.Size); } public void ThumbMinimumSizeFromItsMaterial_FlooredThumbIsReflectedInTheActualLayout() { var track = new ScrollTrack { Orientation = Orientation.Horizontal, ContentSize = 10000, ViewportSize = 10, }; SetThumbMinimumLength(track, 25); track.Bounds = Rect.FromPositionAndSize(Vector.Zero, new Vector(200, 20)); CheckEqual(25, track.Thumb.Bounds.Size.X); } public void NoThumbMaterial_MinimumSizeIsZero() { var track = NewTrack(Orientation.Horizontal, new Vector(200, 20), contentSize: 10000, viewportSize: 10); // Proportional thumbLength here is 200*10/10000 = 0.2, so with no floor it stays tiny. Check(track.Thumb.Bounds.Size.X < 1); } public void ResizingBounds_RelaysOutTheThumb() { var track = NewTrack(Orientation.Horizontal, new Vector(200, 20), contentSize: 400, viewportSize: 100); track.Bounds = Rect.FromPositionAndSize(Vector.Zero, new Vector(100, 20)); // thumbLength = 100*100/400 = 25. CheckEqual(new Vector(25, 20), track.Thumb.Bounds.Size); } public void SettingThumbMaterialAfterConstruction_TakesEffectImmediately() { var track = NewTrack(Orientation.Horizontal, new Vector(200, 20), contentSize: 10000, viewportSize: 10); SetThumbMinimumLength(track, 25); CheckEqual(25, track.Thumb.Bounds.Size.X); } } public class PageAmountTests : TestFixture { public void Unset_DefaultsToViewportSize() { var track = NewTrack(Orientation.Horizontal, new Vector(200, 20), 400, 100); CheckEqual(100, track.PageAmount.Resolve(track.ViewportSize)); } public void ExplicitAbsoluteValue_OverridesTheDefault() { var track = NewTrack(Orientation.Horizontal, new Vector(200, 20), 400, 100); track.PageAmount = 25; CheckEqual(25, track.PageAmount.Resolve(track.ViewportSize)); } public void ExplicitAbsoluteValue_SurvivesAViewportSizeChange() { var track = NewTrack(Orientation.Horizontal, new Vector(200, 20), 400, 100); track.PageAmount = 25; track.ViewportSize = 60; CheckEqual(25, track.PageAmount.Resolve(track.ViewportSize)); } } public class ScrollOffsetTests : TestFixture { public void Unset_IsZero() { CheckEqual(0, NewTrack(Orientation.Horizontal, new Vector(200, 20), 400, 100).ScrollOffset); } public void ScrollTo_WithinRange_ReadsBackWhatWasSet() { var track = NewTrack(Orientation.Horizontal, new Vector(200, 20), 400, 100); track.ScrollTo(150); CheckEqual(150, track.ScrollOffset); } public void ScrollTo_Negative_ClampsToZero() { var track = NewTrack(Orientation.Horizontal, new Vector(200, 20), 400, 100); track.ScrollTo(-50); CheckEqual(0, track.ScrollOffset); } public void ScrollTo_PastTheEnd_ClampsToScrollableRange() { var track = NewTrack(Orientation.Horizontal, new Vector(200, 20), 400, 100); track.ScrollTo(5000); CheckEqual(300, track.ScrollOffset); } public void AfterContentSizeShrinks_ReadsBackReClampedToTheNewMax() { var track = NewTrack(Orientation.Horizontal, new Vector(200, 20), 400, 100); track.ScrollTo(300); track.ContentSize = 150; CheckEqual(50, track.ScrollOffset); } public void ScrollBy_AddsToTheCurrentOffset() { var track = NewTrack(Orientation.Horizontal, new Vector(200, 20), 400, 100); track.ScrollTo(100); track.ScrollBy(30); CheckEqual(130, track.ScrollOffset); } public void ScrollBy_RepeatedlyPastTheEnd_SaturatesRatherThanRunningAway() { var track = NewTrack(Orientation.Horizontal, new Vector(200, 20), 400, 100); track.ScrollBy(10000); track.ScrollBy(10000); track.ScrollBy(-10); CheckEqual(290, track.ScrollOffset); } public void ScrollTo_RaisesOnScrollOffsetChangedWithTheClampedValue() { var track = NewTrack(Orientation.Horizontal, new Vector(200, 20), 400, 100); Scalar? raisedWith = null; track.OnScrollOffsetChanged +=value => raisedWith = value; track.ScrollTo(5000); CheckEqual(300, raisedWith); } public void ScrollTo_SameClampedValueAgain_DoesNotRaise() { var track = NewTrack(Orientation.Horizontal, new Vector(200, 20), 400, 100); track.ScrollTo(5000); bool raised = false; track.OnScrollOffsetChanged +=_ => raised = true; track.ScrollTo(6000); CheckFalse(raised); } } public class EnabledTests : TestFixture { public void SettingFalse_DisablesThumb() { var track = NewTrack(Orientation.Horizontal, new Vector(200, 20), 400, 100); track.Enabled = false; CheckFalse(track.Thumb.Enabled); } public void SettingTrueAfterFalse_ReenablesThumb() { var track = NewTrack(Orientation.Horizontal, new Vector(200, 20), 400, 100); track.Enabled = false; track.Enabled = true; Check(track.Thumb.Enabled); } } public class ThumbDragTests : TestFixture { public void WhileDragging_ScrollsProportionallyToThePointerDelta() { var track = NewTrack(Orientation.Horizontal, new Vector(200, 20), 400, 100); track.Thumb.MouseDown(new MouseState { Position = new Vector(0, 10) }, MouseButtonFlags.Left); track.Thumb.MouseMove(new MouseState { Position = new Vector(40, 10) }); // trackLength=200, thumbLength=50, thumbTravel=150, scrollableRange=300: a 40px drag maps to // 40 * 300 / 150 = 80. CheckEqual(80, track.ScrollOffset); } public void WhileDraggingFromANonZeroOffset_MeasuresFromTheStartingOffset() { var track = NewTrack(Orientation.Horizontal, new Vector(200, 20), 400, 100); track.ScrollTo(50); track.Thumb.MouseDown(new MouseState { Position = new Vector(0, 10) }, MouseButtonFlags.Left); track.Thumb.MouseMove(new MouseState { Position = new Vector(40, 10) }); // Same 40px drag as WhileDragging_ScrollsProportionallyToThePointerDelta (+80), added to the 50 // already in place at MouseDown. CheckEqual(130, track.ScrollOffset); } public void HoveringWithoutADrag_DoesNotScroll() { var track = NewTrack(Orientation.Horizontal, new Vector(200, 20), 400, 100); track.Thumb.MouseMove(new MouseState { Position = new Vector(40, 10) }); CheckEqual(0, track.ScrollOffset); } public void RecoversCorrectlyAfterAnIntermediateMoveIsClampedAtTheEnd() { var track = NewTrack(Orientation.Horizontal, new Vector(200, 20), 400, 100); track.Thumb.MouseDown(new MouseState { Position = new Vector(0, 10) }, MouseButtonFlags.Left); track.Thumb.MouseMove(new MouseState { Position = new Vector(1000, 10) }); CheckEqual(300, track.ScrollOffset); track.Thumb.MouseMove(new MouseState { Position = new Vector(160, 10) }); CheckEqual(300, track.ScrollOffset); track.Thumb.MouseMove(new MouseState { Position = new Vector(40, 10) }); CheckEqual(80, track.ScrollOffset); } public void AfterMouseUp_NoLongerScrollsOnMove() { var track = NewTrack(Orientation.Horizontal, new Vector(200, 20), 400, 100); track.Thumb.MouseDown(new MouseState { Position = new Vector(0, 10) }, MouseButtonFlags.Left); track.Thumb.MouseUp(new MouseState { Position = new Vector(0, 10) }, MouseButtonFlags.Left); track.Thumb.MouseMove(new MouseState { Position = new Vector(40, 10) }); CheckEqual(0, track.ScrollOffset); } } public class MouseDownTests : TestFixture { public void ClickBeforeTheThumb_PagesBack() { var track = NewTrack(Orientation.Horizontal, new Vector(200, 20), 400, 100); track.PageAmount = 50; track.ScrollTo(150); // Thumb sits at [75, 125); 30 is before it. track.MouseDown(new MouseState { Position = new Vector(30, 10) }, MouseButtonFlags.Left); CheckEqual(100, track.ScrollOffset); } public void ClickAfterTheThumb_PagesForward() { var track = NewTrack(Orientation.Horizontal, new Vector(200, 20), 400, 100); track.PageAmount = 50; track.ScrollTo(150); // Thumb sits at [75, 125); 140 is after it. track.MouseDown(new MouseState { Position = new Vector(140, 10) }, MouseButtonFlags.Left); CheckEqual(200, track.ScrollOffset); } // Regression guard: state.Position arrives in the track's parent's space, not its own, so this must // rebase through CanvasOrigin before comparing against Thumb's (locally expressed) Bounds. public void TrackNotAtTheOrigin_StillClassifiesRelativeToItsOwnBounds() { var track = NewTrack(Orientation.Horizontal, position: new Vector(50, 100), boundsSize: new Vector(200, 20), contentSize: 400, viewportSize: 100); track.PageAmount = 50; track.ScrollTo(150); track.MouseDown(new MouseState { Position = new Vector(80, 110) }, MouseButtonFlags.Left); CheckEqual(100, track.ScrollOffset); } public void ReturnsTrue() { var track = NewTrack(Orientation.Horizontal, new Vector(200, 20), 400, 100); Check(track.MouseDown(new MouseState { Position = new Vector(30, 10) }, MouseButtonFlags.Left)); } public void Disabled_ReturnsFalseAndDoesNotScroll() { var track = NewTrack(Orientation.Horizontal, new Vector(200, 20), 400, 100); track.PageAmount = 50; track.Enabled = false; bool claimed = track.MouseDown(new MouseState { Position = new Vector(30, 10) }, MouseButtonFlags.Left); CheckFalse(claimed); CheckEqual(0, track.ScrollOffset); } } } }