Skip to content
Open
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
7 changes: 0 additions & 7 deletions .claude/settings.local.json

This file was deleted.

3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ scripts/
# Local agent instructions
AGENTS.md
agents.md
CLAUDE.md
.Claude/
.claude/settings.local.json

# Local diagnostics notes
diagnostics-instrumentation-guide.md
Expand Down
94 changes: 91 additions & 3 deletions InkkSlinger.Tests/ControlDemoDataGridSampleTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,45 @@ public void DataGridView_WheelScrollingToBottom_ShouldNotLeaveBlankSpaceAfterLas
$"Expected last row to align with viewport bottom after wheel scroll. LastRowBottom={lastRowBottom}, ViewportBottom={viewportBottom}, Offset={scrollViewer.VerticalOffset}, Extent={scrollViewer.ExtentHeight}, Viewport={scrollViewer.ViewportHeight}.");
}

[Fact]
public void DataGridView_WheelScroll_WhenAllRowsAreRealized_DoesNotInvalidateLayout()
{
var view = new DataGridView
{
Width = 1200f,
Height = 520f
};

var host = new Canvas
{
Width = 1200f,
Height = 520f
};
host.AddChild(view);

var uiRoot = new UiRoot(host);
RunLayout(uiRoot, 1200, 520);

var dataGrid = FindFirstVisualChild<DataGrid>(view);
Assert.NotNull(dataGrid);
Assert.Equal(12, dataGrid!.RealizedRowCountForTesting);

var scrollViewer = dataGrid.ScrollViewerForTesting;
var pointer = new Vector2(
scrollViewer.LayoutSlot.X + (scrollViewer.LayoutSlot.Width * 0.5f),
scrollViewer.LayoutSlot.Y + (scrollViewer.LayoutSlot.Height * 0.5f));
var measureInvalidationsBefore = uiRoot.MeasureInvalidationCount;
var arrangeInvalidationsBefore = uiRoot.ArrangeInvalidationCount;

uiRoot.ResetDirtyStateForTests();
uiRoot.RunInputDeltaForTests(CreatePointerDelta(pointer, pointerMoved: true, wheelDelta: -120));
RunLayout(uiRoot, 1200, 520);

Assert.True(scrollViewer.VerticalOffset > 0f);
Assert.Equal(measureInvalidationsBefore, uiRoot.MeasureInvalidationCount);
Assert.Equal(arrangeInvalidationsBefore, uiRoot.ArrangeInvalidationCount);
}

[Fact]
public void DataGridView_DraggingVerticalScrollBarToBottom_ShouldNotLeaveBlankSpaceAfterLastRow()
{
Expand Down Expand Up @@ -461,12 +500,14 @@ public void DataGridView_DraggingHorizontalScrollBar_KeepsFrozenLanesAligned()
thumbCenter.Y >= horizontalBar.LayoutSlot.Y &&
thumbCenter.Y <= horizontalBar.LayoutSlot.Y + horizontalBar.LayoutSlot.Height,
$"Expected horizontal thumb center to stay within the horizontal scrollbar bounds. Thumb={thumb.LayoutSlot}, CachedThumb={horizontalBar.GetThumbRectForInput()}, ScrollBar={horizontalBar.LayoutSlot}.");
Assert.Same(thumb, VisualTreeHelper.HitTest(host, thumbCenter));
var rightPointer = new Vector2(horizontalBar.LayoutSlot.X + horizontalBar.LayoutSlot.Width - 2f, thumbCenter.Y);

Assert.True(thumb.HandlePointerDownFromInput(thumbCenter));
Assert.True(thumb.HandlePointerMoveFromInput(rightPointer));
uiRoot.RunInputDeltaForTests(CreatePointerDelta(thumbCenter, pointerMoved: true));
uiRoot.RunInputDeltaForTests(CreatePointerDelta(thumbCenter, leftPressed: true));
uiRoot.RunInputDeltaForTests(CreatePointerDelta(rightPointer, pointerMoved: true));
RunLayout(uiRoot, 780, 520);
Assert.True(thumb.HandlePointerUpFromInput());
uiRoot.RunInputDeltaForTests(CreatePointerDelta(rightPointer, leftReleased: true));
RunLayout(uiRoot, 780, 520);

Assert.True(scrollViewer.HorizontalOffset > 0f);
Expand All @@ -476,6 +517,52 @@ public void DataGridView_DraggingHorizontalScrollBar_KeepsFrozenLanesAligned()
Assert.Equal(dataGrid.ColumnHeadersForTesting[1].LayoutSlot.X, row.Cells[1].LayoutSlot.X);
}

[Fact]
public void DataGridView_DraggingHorizontalScrollBar_DoesNotInvalidateWorkbenchLayout()
{
var view = new DataGridView
{
Width = 780f,
Height = 520f
};

var host = new Canvas
{
Width = 780f,
Height = 520f
};
host.AddChild(view);

var uiRoot = new UiRoot(host);
RunLayout(uiRoot, 780, 520);

var dataGrid = FindFirstVisualChild<DataGrid>(view);
Assert.NotNull(dataGrid);

var scrollViewer = dataGrid!.ScrollViewerForTesting;
var horizontalBar = GetPrivateScrollBar(scrollViewer, "_horizontalBar");
var thumb = FindFirstVisualChild<Thumb>(horizontalBar);
Assert.NotNull(thumb);

var thumbCenter = GetCenter(thumb!.LayoutSlot);
Assert.Same(thumb, VisualTreeHelper.HitTest(host, thumbCenter));
var rightPointer = new Vector2(horizontalBar.LayoutSlot.X + horizontalBar.LayoutSlot.Width - 2f, thumbCenter.Y);
var measureInvalidationsBefore = uiRoot.MeasureInvalidationCount;
var arrangeInvalidationsBefore = uiRoot.ArrangeInvalidationCount;

uiRoot.ResetDirtyStateForTests();
uiRoot.RunInputDeltaForTests(CreatePointerDelta(thumbCenter, pointerMoved: true));
uiRoot.RunInputDeltaForTests(CreatePointerDelta(thumbCenter, leftPressed: true));
uiRoot.RunInputDeltaForTests(CreatePointerDelta(rightPointer, pointerMoved: true));
RunLayout(uiRoot, 780, 520);
uiRoot.RunInputDeltaForTests(CreatePointerDelta(rightPointer, leftReleased: true));
RunLayout(uiRoot, 780, 520);

Assert.True(scrollViewer.HorizontalOffset > 0f);
Assert.Equal(measureInvalidationsBefore, uiRoot.MeasureInvalidationCount);
Assert.Equal(arrangeInvalidationsBefore, uiRoot.ArrangeInvalidationCount);
}

private static TElement? FindFirstVisualChild<TElement>(UIElement root)
where TElement : UIElement
{
Expand Down Expand Up @@ -566,4 +653,5 @@ private static ScrollBar GetPrivateScrollBar(ScrollViewer viewer, string fieldNa
Assert.NotNull(field);
return Assert.IsType<ScrollBar>(field!.GetValue(viewer));
}

}
39 changes: 37 additions & 2 deletions InkkSlinger.Tests/ControlsCatalogHoverRegressionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using Xunit;
using InkkSlinger.Tests.TestDoubles;

namespace InkkSlinger.Tests;

Expand All @@ -31,14 +32,48 @@ public void HoveringFromViewerGutterIntoButton_ShouldActivateButtonHover()
var gutterPoint = new Vector2(
verticalBar!.LayoutSlot.X - 0.25f,
button.LayoutSlot.Y + (button.LayoutSlot.Height * 0.5f));
MovePointer(uiRoot, gutterPoint);

Assert.False(button.IsMouseOver);
// Capture instrumentation during the hover sequence
using var capture = new InstrumentationCapture();
MovePointer(uiRoot, gutterPoint);
RunLayout(uiRoot, 1280, 820, 16);

var buttonPoint = new Vector2(
button.LayoutSlot.X + (button.LayoutSlot.Width * 0.5f),
button.LayoutSlot.Y + (button.LayoutSlot.Height * 0.5f));
MovePointer(uiRoot, buttonPoint);
RunLayout(uiRoot, 1280, 820, 32);

var lines = capture.GetInstrumentLines();

// Parse instrumentation
var timings = lines.Select(l => InstrumentationCapture.TryParseTiming(l)).Where(t => t.HasValue).Select(t => t!.Value).ToList();
var counters = lines.Select(l => InstrumentationCapture.TryParseCounter(l)).Where(c => c.HasValue).Select(c => c!.Value).ToList();

Console.WriteLine($"[METRICS] Raw instrument lines captured: {lines.Count}");

// Aggregate by method type and show top slow and hot
foreach (var timing in timings.OrderByDescending(t => t.microseconds).Take(10))
{
Console.WriteLine($"[METRICS] Slow: {timing.method} = {timing.microseconds}us");
}

var groupedCounters = counters.GroupBy(c => c.method).Select(g => (method: g.Key, totalCalls: g.Sum(x => x.count))).OrderByDescending(x => x.totalCalls).ToList();
foreach (var counter in groupedCounters.Take(10))
{
Console.WriteLine($"[METRICS] Hot: {counter.method} = {counter.totalCalls} calls");
}

// Check for MarkFullFrameDirty calls
var fullFrameDirtyLines = lines.Where(l => l.Contains("MarkFullFrameDirty")).ToList();
if (fullFrameDirtyLines.Count > 0)
{
Console.WriteLine($"[METRICS] MarkFullFrameDirty calls: {fullFrameDirtyLines.Count}");
foreach (var line in fullFrameDirtyLines.Take(5))
{
Console.WriteLine($" {line}");
}
}

Assert.True(
button.IsMouseOver,
Expand Down
34 changes: 33 additions & 1 deletion InkkSlinger.Tests/ControlsCatalogScrollPersistenceTests.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
Expand Down Expand Up @@ -65,6 +66,29 @@ public void SelectingPreviewViaButtonInvoke_ShouldPreserveLeftCatalogScrollOffse
$"Expected left catalog scroll offset to persist after direct invoke. before={beforeInvokeOffset:0.###}, after={afterInvokeOffset:0.###}");
}

[Fact]
public void SidebarWheelScroll_ShouldMoveCatalogScrollViewer()
{
var view = new ControlsCatalogView();
var uiRoot = new UiRoot(view);
RunLayout(uiRoot, 1280, 820, 16);

var viewer = FindFirstVisualChild<ScrollViewer>(view);
Assert.NotNull(viewer);
var verticalBar = GetPrivateScrollBar(viewer!, "_verticalBar");
var beforeThumb = verticalBar.GetThumbRectForInput();

var pointer = new Vector2(viewer!.LayoutSlot.X + 24f, viewer.LayoutSlot.Y + 48f);
uiRoot.RunInputDeltaForTests(CreatePointerDelta(pointer, pointerMoved: true));
uiRoot.RunInputDeltaForTests(CreatePointerDelta(pointer, wheelDelta: -120));
RunLayout(uiRoot, 1280, 820, 32);

var afterThumb = verticalBar.GetThumbRectForInput();

Assert.True(viewer.VerticalOffset > 0f, $"Expected catalog sidebar wheel scroll to change offset, got {viewer.VerticalOffset:0.###}.");
Assert.True(afterThumb.Y > beforeThumb.Y + 0.01f, $"Expected catalog sidebar thumb to move after wheel scroll. before={beforeThumb}, after={afterThumb}");
}

private static Vector2 GetVisibleCenter(LayoutRect slot, float verticalOffset)
{
var visibleY = slot.Y - verticalOffset;
Expand All @@ -80,6 +104,7 @@ private static void Click(UiRoot uiRoot, Vector2 pointer)
private static InputDelta CreatePointerDelta(
Vector2 pointer,
bool pointerMoved = false,
int wheelDelta = 0,
bool leftPressed = false,
bool leftReleased = false)
{
Expand All @@ -91,7 +116,7 @@ private static InputDelta CreatePointerDelta(
ReleasedKeys = new List<Keys>(),
TextInput = new List<char>(),
PointerMoved = pointerMoved || leftPressed || leftReleased,
WheelDelta = 0,
WheelDelta = wheelDelta,
LeftPressed = leftPressed,
LeftReleased = leftReleased,
RightPressed = false,
Expand Down Expand Up @@ -121,6 +146,13 @@ private static InputDelta CreatePointerDelta(
return null;
}

private static ScrollBar GetPrivateScrollBar(ScrollViewer viewer, string fieldName)
{
var field = typeof(ScrollViewer).GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(field);
return Assert.IsType<ScrollBar>(field!.GetValue(viewer));
}

private static void RunLayout(UiRoot uiRoot, int width, int height, int elapsedMs)
{
uiRoot.Update(
Expand Down
4 changes: 2 additions & 2 deletions InkkSlinger.Tests/DataGridParityChecklistTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -828,9 +828,9 @@ public void HorizontalScroll_RepositionsHeadersWithoutGridLayoutInvalidation()
Assert.Equal(frozenHeaderX, grid.ColumnHeadersForTesting[0].LayoutSlot.X);
Assert.True(grid.ColumnHeadersForTesting[1].LayoutSlot.X < scrollingHeaderX);
Assert.Equal(gridMeasureInvalidationsBefore, grid.MeasureInvalidationCount);
Assert.True(grid.ArrangeInvalidationCount > gridArrangeInvalidationsBefore);
Assert.Equal(gridArrangeInvalidationsBefore, grid.ArrangeInvalidationCount);
Assert.Equal(rootMeasureInvalidationsBefore, uiRoot.MeasureInvalidationCount);
Assert.True(uiRoot.ArrangeInvalidationCount > rootArrangeInvalidationsBefore);
Assert.Equal(rootArrangeInvalidationsBefore, uiRoot.ArrangeInvalidationCount);
}

[Fact]
Expand Down
4 changes: 2 additions & 2 deletions InkkSlinger.Tests/DirtyBoundsEdgeRegressionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@ namespace InkkSlinger.Tests;
public sealed class DirtyBoundsEdgeRegressionTests
{
[Fact]
public void RenderInvalidation_WithNullSource_EscalatesToFullFrameDirty()
public void RenderInvalidation_WithNullSource_DoesNotEscalateToFullFrameDirty()
{
var uiRoot = new UiRoot(new Panel());
uiRoot.SetDirtyRegionViewportForTests(new LayoutRect(0f, 0f, 200f, 200f));
uiRoot.ResetDirtyStateForTests();

uiRoot.NotifyInvalidation(UiInvalidationType.Render, null);

Assert.True(uiRoot.IsFullDirtyForTests());
Assert.False(uiRoot.IsFullDirtyForTests());
}

[Fact]
Expand Down
38 changes: 38 additions & 0 deletions InkkSlinger.Tests/Instrumentation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using System.Diagnostics;

namespace InkkSlinger.Tests;

public static class Instrumentation
{
/// <summary>
/// Writes a line to the test output that can be captured by InstrumentationCapture
/// and also appears in the test console output.
/// </summary>
[DebuggerStepThrough]
public static void Trace(string message)
{
var line = $"[INSTRUMENT] {message}";
System.Diagnostics.Trace.WriteLine(line);
Console.Out.WriteLine(line);
}

/// <summary>
/// Writes a timing entry in the standard format parsed by InstrumentationCapture.TryParseTiming.
/// Example: "MyMethod took 1234us"
/// </summary>
[DebuggerStepThrough]
public static void TraceTiming(string method, long microseconds)
{
Trace($"{method} took {microseconds}us");
}

/// <summary>
/// Writes a counter entry in the standard format parsed by InstrumentationCapture.TryParseCounter.
/// Example: "MyMethod #42"
/// </summary>
[DebuggerStepThrough]
public static void TraceCounter(string method, int count)
{
Trace($"{method} #{count}");
}
}
32 changes: 32 additions & 0 deletions InkkSlinger.Tests/InstrumentationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using Xunit;

namespace InkkSlinger.Tests;

public sealed class InstrumentationTests
{
[Fact]
public void Trace_WritesLineToConsole()
{
Instrumentation.Trace("Test message from InstrumentationTests");
}

[Fact]
public void TraceTiming_WritesTimingLine()
{
Instrumentation.TraceTiming("FakeMethod", 12345);
}

[Fact]
public void TraceCounter_WritesCounterLine()
{
Instrumentation.TraceCounter("FakeMethod", 42);
}

[Fact]
public void Trace_MultipleLines_AreOrdered()
{
Instrumentation.Trace("Line 1");
Instrumentation.Trace("Line 2");
Instrumentation.Trace("Line 3");
}
}
Loading