Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 154 additions & 0 deletions TextControlBox.Tests/HorizontalSliceMathTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using TextControlBoxNS.Core;

namespace TextControlBox.Tests;

/// <summary>
/// Unit tests for the pure horizontal-virtualization arithmetic (<see cref="HorizontalSliceMath"/>). These
/// cover the slice-window computation, the reuse "safe zone" check, and the document-column ↔ rendered-index
/// mapping — the places where an off-by-one silently misplaces the caret or selection on very long lines.
/// </summary>
[TestClass]
public class HorizontalSliceMathTests
{
[TestMethod]
public void VisibleCharRange_AtOrigin_StartsAtZeroWithRightPadding()
{
// charWidth 10 => 800px viewport spans 80 chars; +100 right padding, left padding clamped at 0.
var (start, end) = HorizontalSliceMath.VisibleCharRange(horizontalScrollPixels: 0, viewportWidthPixels: 800, charWidth: 10);
Assert.AreEqual(0, start);
Assert.AreEqual(0 + 80 + 100, end);
}

[TestMethod]
public void VisibleCharRange_Scrolled_AppliesLeftPadding()
{
// scroll 1000px / 10 = column 100, minus 50 left padding => 50.
var (start, end) = HorizontalSliceMath.VisibleCharRange(horizontalScrollPixels: 1000, viewportWidthPixels: 800, charWidth: 10);
Assert.AreEqual(50, start);
Assert.AreEqual(50 + 80 + 100, end);
}

[TestMethod]
public void VisibleCharRange_ZeroCharWidth_DoesNotDivideByZero()
{
var (start, end) = HorizontalSliceMath.VisibleCharRange(horizontalScrollPixels: 20, viewportWidthPixels: 30, charWidth: 0);
// charWidth falls back to 1: 20 - 50 clamped to 0; end = 0 + 30 + 100.
Assert.AreEqual(0, start);
Assert.AreEqual(130, end);
}

[TestMethod]
public void CanReuseWindow_InsideSafeZone_True()
{
Assert.IsTrue(HorizontalSliceMath.CanReuseWindow(visibleStart: 60, visibleEnd: 140, safeStart: 50, safeEnd: 200));
}

[TestMethod]
public void CanReuseWindow_ScrolledBeforeStart_False()
{
Assert.IsFalse(HorizontalSliceMath.CanReuseWindow(visibleStart: 40, visibleEnd: 140, safeStart: 50, safeEnd: 200));
}

[TestMethod]
public void CanReuseWindow_ScrolledPastEnd_False()
{
Assert.IsFalse(HorizontalSliceMath.CanReuseWindow(visibleStart: 60, visibleEnd: 210, safeStart: 50, safeEnd: 200));
}

[TestMethod]
public void ComputeWindow_BuffersEitherSideAndRecordsSafeZone()
{
// visible [100, 200) => 100 chars, buffer = 300.
var (sliceStart, sliceLen, safeStart, safeEnd) = HorizontalSliceMath.ComputeWindow(visibleStart: 100, visibleEnd: 200);
Assert.AreEqual(0, sliceStart); // max(0, 100 - 300) = 0
Assert.AreEqual(100 + 300 * 2, sliceLen); // 700
Assert.AreEqual(100, safeStart); // inner safe zone start = visibleStart
Assert.AreEqual(200 + 300, safeEnd); // visibleEnd + buffer
}

[TestMethod]
public void ComputeWindow_FarScroll_SliceStartIsPositive()
{
// visible [1000, 1100) => 100 chars, buffer = 300 => sliceStart = 700.
var (sliceStart, sliceLen, safeStart, safeEnd) = HorizontalSliceMath.ComputeWindow(visibleStart: 1000, visibleEnd: 1100);
Assert.AreEqual(700, sliceStart);
Assert.AreEqual(700, sliceLen);
Assert.AreEqual(1000, safeStart);
Assert.AreEqual(1400, safeEnd);
}

[TestMethod]
public void ComputeWindow_ReuseCheckHoldsForItsOwnSafeZone()
{
// A freshly computed window's safe zone must contain the viewport that produced it.
var (_, _, safeStart, safeEnd) = HorizontalSliceMath.ComputeWindow(visibleStart: 500, visibleEnd: 600);
Assert.IsTrue(HorizontalSliceMath.CanReuseWindow(500, 600, safeStart, safeEnd));
}

[TestMethod]
public void SlicedLineLength_LineInsideWindow_ReturnsRemainderCappedAtWindow()
{
// Window [200, 200+500). A 400-char line contributes 400-200 = 200.
Assert.AreEqual(200, HorizontalSliceMath.SlicedLineLength(fullLineLength: 400, sliceStart: 200, sliceLen: 500));
}

[TestMethod]
public void SlicedLineLength_LongLine_CapsAtWindowWidth()
{
// Window width 500; a 100000-char line contributes only the window's worth.
Assert.AreEqual(500, HorizontalSliceMath.SlicedLineLength(fullLineLength: 100000, sliceStart: 200, sliceLen: 500));
}

[TestMethod]
public void SlicedLineLength_LineEndsBeforeWindow_ReturnsZero()
{
Assert.AreEqual(0, HorizontalSliceMath.SlicedLineLength(fullLineLength: 150, sliceStart: 200, sliceLen: 500));
}

[TestMethod]
public void SlicedLineLength_ZeroWindow_ReturnsZero()
{
Assert.AreEqual(0, HorizontalSliceMath.SlicedLineLength(fullLineLength: 400, sliceStart: 0, sliceLen: 0));
}

[TestMethod]
public void RenderedIndexForColumn_SubtractsSliceStart()
{
int slicedLen = HorizontalSliceMath.SlicedLineLength(fullLineLength: 1000, sliceStart: 200, sliceLen: 500);
Assert.AreEqual(100, HorizontalSliceMath.RenderedIndexForColumn(characterPosition: 300, sliceStart: 200, slicedLineLength: slicedLen));
}

[TestMethod]
public void RenderedIndexForColumn_ClampsBelowZeroAndAboveSlice()
{
Assert.AreEqual(0, HorizontalSliceMath.RenderedIndexForColumn(characterPosition: 10, sliceStart: 200, slicedLineLength: 500));
Assert.AreEqual(500, HorizontalSliceMath.RenderedIndexForColumn(characterPosition: 999999, sliceStart: 200, slicedLineLength: 500));
}

[TestMethod]
public void ColumnForRenderedIndex_AddsSliceStart()
{
Assert.AreEqual(300, HorizontalSliceMath.ColumnForRenderedIndex(renderedIndex: 100, sliceStart: 200, fullLineLength: 1000));
}

[TestMethod]
public void ColumnForRenderedIndex_ClampsToLineLength()
{
Assert.AreEqual(1000, HorizontalSliceMath.ColumnForRenderedIndex(renderedIndex: 999999, sliceStart: 200, fullLineLength: 1000));
}

[TestMethod]
public void ColumnAndRenderedIndex_RoundTripInsideSlice()
{
const int sliceStart = 200;
const int fullLine = 1000;
int slicedLen = HorizontalSliceMath.SlicedLineLength(fullLine, sliceStart, sliceLen: 500);
for (int column = sliceStart; column <= sliceStart + slicedLen; column += 37)
{
int rendered = HorizontalSliceMath.RenderedIndexForColumn(column, sliceStart, slicedLen);
int roundTrip = HorizontalSliceMath.ColumnForRenderedIndex(rendered, sliceStart, fullLine);
Assert.AreEqual(column, roundTrip);
}
}
}
96 changes: 96 additions & 0 deletions TextControlBox.Tests/ScrollOffsetMathTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using TextControlBoxNS.Core;

namespace TextControlBox.Tests;

/// <summary>
/// Pure conversions between the pixel scroll seam (IScrollOffsetSource) and the legacy ScrollBar
/// backing store. These are the exact place a "scroll is N× too fast/slow" regression would hide,
/// so they are unit-tested in isolation (a ScrollBar cannot be instantiated headless).
/// </summary>
[TestClass]
public class ScrollOffsetMathTests
{
[TestMethod]
public void NormalizeSensitivity_FloorsAtOne()
{
Assert.AreEqual(1, ScrollOffsetMath.NormalizeSensitivity(0));
Assert.AreEqual(1, ScrollOffsetMath.NormalizeSensitivity(-5));
Assert.AreEqual(4, ScrollOffsetMath.NormalizeSensitivity(4));
}

[TestMethod]
public void VerticalValueToPixels_MultipliesBySensitivity()
{
Assert.AreEqual(400.0, ScrollOffsetMath.VerticalValueToPixels(100, 4));
Assert.AreEqual(100.0, ScrollOffsetMath.VerticalValueToPixels(100, 1));
}

[TestMethod]
public void PixelsToVerticalValue_DividesBySensitivity()
{
Assert.AreEqual(100.0, ScrollOffsetMath.PixelsToVerticalValue(400, 4));
Assert.AreEqual(100.0, ScrollOffsetMath.PixelsToVerticalValue(100, 1));
}

[TestMethod]
public void PixelsToVerticalValue_ClampsNegativeToZero()
{
Assert.AreEqual(0.0, ScrollOffsetMath.PixelsToVerticalValue(-500, 4));
}

[TestMethod]
public void VerticalOffset_RoundTripsThroughScrollBarUnits()
{
// A pixel offset stored as a legacy ScrollBar.Value and read back must be unchanged.
const int sensitivity = 4;
foreach (double pixels in new[] { 0.0, 20.0, 399.0, 4096.0, 1_000_000.0 })
{
double value = ScrollOffsetMath.PixelsToVerticalValue(pixels, sensitivity);
double roundTrip = ScrollOffsetMath.VerticalValueToPixels(value, sensitivity);
Assert.AreEqual(pixels, roundTrip, 1e-9);
}
}

[TestMethod]
public void VerticalExtent_ConvertsBetweenMaximumAndPixels()
{
const int sensitivity = 4;
const double viewport = 300.0;

// Content taller than the viewport: Maximum (legacy units) + viewport (px) == extent (px).
double extent = ScrollOffsetMath.VerticalMaximumToExtentPixels(500, sensitivity, viewport);
Assert.AreEqual(500 * sensitivity + viewport, extent);

double maximum = ScrollOffsetMath.VerticalExtentPixelsToMaximum(extent, sensitivity, viewport);
Assert.AreEqual(500.0, maximum, 1e-9);
}

[TestMethod]
public void VerticalExtentPixelsToMaximum_FloorsAtZeroWhenContentFitsViewport()
{
// Content shorter than the viewport is not scrollable → Maximum 0.
Assert.AreEqual(0.0, ScrollOffsetMath.VerticalExtentPixelsToMaximum(100, 4, 300));
}

[TestMethod]
public void HorizontalOffset_ClampsNegativeToZero_AndIsPixelIdentity()
{
Assert.AreEqual(0.0, ScrollOffsetMath.ClampHorizontalOffset(-1));
Assert.AreEqual(42.0, ScrollOffsetMath.ClampHorizontalOffset(42));
}

[TestMethod]
public void HorizontalExtent_ConvertsBetweenMaximumAndPixels()
{
const double viewport = 250.0;
double extent = ScrollOffsetMath.HorizontalMaximumToExtentPixels(1000, viewport);
Assert.AreEqual(1000 + viewport, extent);

double maximum = ScrollOffsetMath.HorizontalExtentPixelsToMaximum(extent, viewport);
Assert.AreEqual(1000.0, maximum, 1e-9);

// Content narrower than the viewport → not scrollable.
Assert.AreEqual(0.0, ScrollOffsetMath.HorizontalExtentPixelsToMaximum(100, viewport));
}
}
Loading