diff --git a/.github/instructions/testing.instructions.md b/.github/instructions/testing.instructions.md index aaf0eed..6b8e6e7 100644 --- a/.github/instructions/testing.instructions.md +++ b/.github/instructions/testing.instructions.md @@ -84,35 +84,27 @@ public class DataGridSourceSortingTests --- -## `FakeDrawingContext` — test double for renderers +## `RecordingDrawingContext` — test double for rendering -When testing `ICellRenderer` or `DrawnComponent`, create a no-op `IDrawingContext`: +Rendering tests run without SkiaSharp against `tests/KumikoUI.Core.Tests/TestHelpers/RecordingDrawingContext.cs`. +It records every `IDrawingContext` call as a `DrawOp` in call order (so index = z-order), in viewport +coordinates with the active clip, and measures text monospace (8 px per character, 14 px tall). +`DrawOp.Visible` is the part of an op its clip lets reach the screen. + +For `DataGridRenderer`, use `RenderHarness` (same folder): it builds a four-column grid of people, gives +every `GridColor` on the style a distinct value (so a recorded colour identifies its style role) and +renders into a fresh recording context: ```csharp -// tests/KumikoUI.Core.Tests/TestHelpers/FakeDrawingContext.cs -using KumikoUI.Core.Rendering; +var h = RenderHarness.Create(width: 300); +h.Column("Name").FreezeMode = ColumnFreezeMode.Left; +h.Scroll.OffsetX = 50; -namespace KumikoUI.Core.Tests.TestHelpers; +var ctx = h.Render(); -internal sealed class FakeDrawingContext : IDrawingContext -{ - public void DrawRect(GridRect r, GridPaint p) { } - public void FillRect(GridRect r, GridPaint p) { } - public void DrawRoundRect(GridRect r, float cr, GridPaint p) { } - public void FillRoundRect(GridRect r, float cr, GridPaint p) { } - public void DrawLine(float x1, float y1, float x2, float y2, GridPaint p) { } - public void DrawText(string t, float x, float y, GridPaint p) { } - public void DrawTextInRect(string t, GridRect r, GridPaint p, - GridTextAlignment h = GridTextAlignment.Left, - GridVerticalAlignment v = GridVerticalAlignment.Center, - bool clip = true) { } - public GridSize MeasureText(string t, GridPaint p) => new GridSize(t.Length * 8, 14); - public GridFontMetrics GetFontMetrics(GridPaint p) => new GridFontMetrics(14, 0, 2); - public void ClipRect(GridRect r) { } - public void Save() { } - public void Restore() { } - public void Translate(float dx, float dy) { } - public void DrawImage(object image, GridRect dest) { } - public void Dispose() { } -} +var cell = ctx.SingleText("P00"); +Assert.Equal(new GridRect(0, 0, 150, 400), cell.Clip); ``` + +Known renderer defects are pinned as `[Fact(Skip = ", fixed by #: …")]` tests in +`tests/KumikoUI.Core.Tests/Rendering/`; the fix removes the `Skip`. diff --git a/src/KumikoUI.Core/DataGridRenderer.cs b/src/KumikoUI.Core/DataGridRenderer.cs index 2779cb2..f80f952 100644 --- a/src/KumikoUI.Core/DataGridRenderer.cs +++ b/src/KumikoUI.Core/DataGridRenderer.cs @@ -307,11 +307,6 @@ public void Render( popupManager?.DrawPopups(ctx); } - private static float ColumnScreenX(float colContentX, ScrollState scroll, bool isFrozen) - { - return isFrozen ? colContentX : colContentX - scroll.OffsetX; - } - /// /// Returns screen X for a column, handling left-frozen, right-frozen, and scrollable. /// For right-frozen columns, the X is already an absolute screen position from GetVisibleRightFrozenColumns. @@ -326,13 +321,6 @@ private static float ColumnScreenX(float colContentX, ScrollState scroll, Column }; } - private void DrawFrozenBackground( - IDrawingContext ctx, float frozenWidth, ScrollState scroll, DataGridStyle style) - { - ctx.FillRect(new GridRect(0, 0, frozenWidth, scroll.ViewportHeight), - _paintCache.FrozenColumnBackground); - } - private void DrawRightFrozenBackground( IDrawingContext ctx, float rightFrozenLeft, float rightFrozenWidth, ScrollState scroll, DataGridStyle style) { diff --git a/src/KumikoUI.Core/Rendering/Primitives.cs b/src/KumikoUI.Core/Rendering/Primitives.cs index 476aba1..6ddc047 100644 --- a/src/KumikoUI.Core/Rendering/Primitives.cs +++ b/src/KumikoUI.Core/Rendering/Primitives.cs @@ -139,6 +139,9 @@ public GridRect Offset(float dx, float dy) /// A zero-size rectangle at the origin. public static GridRect Empty => new(0, 0, 0, 0); + + /// + public override string ToString() => FormattableString.Invariant($"GridRect({X}, {Y}, {Width}, {Height})"); } /// diff --git a/tests/KumikoUI.Core.Tests/PinnedBottomSummaryTests.cs b/tests/KumikoUI.Core.Tests/PinnedBottomSummaryTests.cs index 147c553..716a396 100644 --- a/tests/KumikoUI.Core.Tests/PinnedBottomSummaryTests.cs +++ b/tests/KumikoUI.Core.Tests/PinnedBottomSummaryTests.cs @@ -312,14 +312,14 @@ public void Render_PinnedFooter_IsDrawnAfterDragHandleGrips() grid.Style.ShowRowDragHandle = true; grid.Style.RowDragHandlePosition = DragHandlePosition.Left; - var fills = grid.Render().Fills; + var fills = grid.Render().Fills.ToList(); int footerIndex = fills.FindLastIndex(f => f.Color == grid.Style.SummaryRowBackgroundColor && Math.Abs(f.Rect.Y - grid.FooterTop) < 0.01f && f.Rect.Width >= ViewportWidth); int lastGripIndex = fills.FindLastIndex(f => - f.Kind == "FillRoundRect" && f.Color == grid.Style.RowDragHandleColor); + f.Kind == DrawOpKind.FillRoundRect && f.Color == grid.Style.RowDragHandleColor); Assert.True(footerIndex >= 0, "pinned footer background was not drawn"); Assert.True(lastGripIndex >= 0, "drag handle grips were not drawn"); @@ -332,7 +332,7 @@ public void Render_Unpinned_SummaryIsDrawnAfterLastRow() var grid = CreateGrid(rowCount: 3, pinned: false); float expectedY = grid.Style.HeaderHeight + 3 * grid.Style.RowHeight; - var fills = grid.Render().Fills; + var fills = grid.Render().Fills.ToList(); Assert.Contains(fills, f => f.Color == grid.Style.SummaryRowBackgroundColor && Math.Abs(f.Rect.Y - expectedY) < 0.01f); Assert.DoesNotContain(fills, f => f.Color == grid.Style.SummaryRowBackgroundColor && Math.Abs(f.Rect.Y - grid.FooterTop) < 0.01f); diff --git a/tests/KumikoUI.Core.Tests/Rendering/DataGridRendererOverlayTests.cs b/tests/KumikoUI.Core.Tests/Rendering/DataGridRendererOverlayTests.cs new file mode 100644 index 0000000..bb20a73 --- /dev/null +++ b/tests/KumikoUI.Core.Tests/Rendering/DataGridRendererOverlayTests.cs @@ -0,0 +1,418 @@ +using KumikoUI.Core.Components; +using KumikoUI.Core.Editing; +using KumikoUI.Core.Models; +using KumikoUI.Core.Rendering; +using KumikoUI.Core.Tests.TestHelpers; +using static KumikoUI.Core.Tests.TestHelpers.RenderHarness; + +namespace KumikoUI.Core.Tests.Rendering; + +/// +/// 's full-width layers: the grouping panel, group header and group +/// summary rows, table summary rows, column and row drag overlays, the editor and popups — plus +/// known defects pinned as skipped tests until their fixes land. +/// +public class DataGridRendererOverlayTests +{ + // ── Grouping panel ──────────────────────────────────────────── + + [Fact] + public void GroupingPanel_IsDrawnAboveTheHeader_AndShiftsTheGridDown() + { + var h = Create(); + h.Source.AddGroupDescription(new GroupDescription("City")); + + var ctx = h.Render(); + + Assert.Contains(ctx.Fills, f => f.Color == h.Style.GroupPanelBackgroundColor && f.Rect.Equals(new GridRect(0, 0, 600, 40))); + Assert.Contains(ctx.Lines, l => l.Color == h.Style.GridLineColor && Near(l.Y1, 40) && Near(l.Y2, 40) && Near(l.X2, 600)); + // Everything below the panel is translated by its height. + Assert.Contains(ctx.Fills, f => f.Color == h.Style.HeaderBackgroundColor && f.Rect.Equals(new GridRect(0, 40, 600, 40))); + Assert.Equal(40, ctx.SingleText("Name").Rect.Y); + Assert.Equal(40 + 40 + 36, ctx.SingleText("P00").Rect.Y); + } + + [Fact] + public void GroupingPanel_DrawsTheLabelThenAChipPerGroup() + { + var h = Create(); + h.Source.AddGroupDescription(new GroupDescription("City")); + h.Source.AddGroupDescription(new GroupDescription("Age", "Years")); + + var ctx = h.Render(); + + var label = ctx.SingleText("Grouped by:"); + Assert.Equal(new GridRect(10, 0, 11 * RecordingDrawingContext.CharWidth, 40), label.Rect); + Assert.Equal(h.Style.GroupPanelLabelColor, label.Color); + + // Chip text uses the column header unless the group names its own. + var city = ctx.SingleText("City", h.Style.GroupPanelChipTextColor); + var years = ctx.SingleText("Years", h.Style.GroupPanelChipTextColor); + Assert.Equal(10 + 88 + 10 + 4 + 10, city.Rect.X); + Assert.True(years.Rect.X > city.Rect.Right); + Assert.Equal(2, ctx.Fills.Count(f => f.Kind == DrawOpKind.FillRoundRect && f.Color == h.Style.GroupPanelChipBackgroundColor)); + // One hierarchy chevron (two strokes) between the chips. + Assert.Equal(2, ctx.Lines.Count(l => l.Color == h.Style.GroupPanelLabelColor)); + } + + [Theory] + [InlineData(SortDirection.Ascending, 16f)] // arrow head at the top of the 26 px chip (centre 20) + [InlineData(SortDirection.Descending, 24f)] // arrow head at the bottom + public void GroupingPanel_ChipArrow_ShowsTheGroupSortDirection(SortDirection direction, float headY) + { + var h = Create(); + h.Source.AddGroupDescription(new GroupDescription("City") { GroupSortDirection = direction }); + + var arrow = h.Render().Lines.Where(l => l.Color == h.Style.GroupPanelChipAccentColor).ToList(); + + Assert.Equal(3, arrow.Count); + var shaft = Assert.Single(arrow, l => Near(l.X1, l.X2)); + Assert.Equal(8, shaft.Rect.Height); + Assert.All(arrow.Where(l => l != shaft), l => Assert.Equal(headY, l.Y2)); + } + + // ── Group header rows ───────────────────────────────────────── + + [Fact] + public void GroupHeader_SpansTheViewport_WithTitleCountAndChevron() + { + var h = Create(); + h.Source.AddGroupDescription(new GroupDescription("City")); + + var ctx = h.Render(); + + // Row 0 is the Oslo header: below the 40 px panel and the 40 px header. + Assert.Contains(ctx.Fills, f => f.Color == h.Style.GroupHeaderBackgroundColor && f.Rect.Equals(new GridRect(0, 80, 600, 36))); + var title = Assert.Single(ctx.Texts, t => t.Color == h.Style.GroupHeaderTextColor && Near(t.Rect.Y, 80)); + Assert.Equal("City: Oslo (10)", title.Text); + Assert.Equal(8 + 14 + 8, title.Rect.X); + // Full-width rows are clipped to the data area, below the header. + Assert.Equal(new GridRect(0, 80, 600, 320), title.Clip); + Assert.Contains(ctx.Fills, f => f.Kind == DrawOpKind.FillRoundRect && f.Color == h.Style.GroupChevronBackgroundColor && f.Rect.Top > 80 && f.Rect.Bottom < 116); + } + + [Theory] + [InlineData(false, true)] // expanded ▼: has a horizontal top edge + [InlineData(true, false)] // collapsed ▶: has a vertical left edge + public void GroupHeader_ChevronShape_FollowsExpansion(bool collapse, bool expectHorizontalEdge) + { + var h = Create(); + h.Source.AddGroupDescription(new GroupDescription("City")); + if (collapse) h.Source.ToggleGroupExpansion(0); + + var chevron = h.Render().Lines.Where(l => l.Color == h.Style.GroupChevronColor && l.Rect.Top >= 80 && l.Rect.Bottom <= 116).ToList(); + + Assert.Equal(3, chevron.Count); + Assert.Equal(expectHorizontalEdge, chevron.Any(l => Near(l.Y1, l.Y2))); + Assert.Equal(!expectHorizontalEdge, chevron.Any(l => Near(l.X1, l.X2))); + } + + [Fact] + public void GroupHeader_NestedLevel_IsIndented() + { + var h = Create(); + h.Source.AddGroupDescription(new GroupDescription("City")); + h.Source.AddGroupDescription(new GroupDescription("Age")); + + var titles = h.Render().Texts.Where(t => t.Color == h.Style.GroupHeaderTextColor).OrderBy(t => t.Rect.Y).ToList(); + + Assert.StartsWith("City: Oslo", titles[0].Text); + Assert.StartsWith("Age: ", titles[1].Text); + Assert.Equal(titles[0].Rect.X + h.Style.GroupIndentWidth, titles[1].Rect.X); + } + + [Fact] + public void GroupHeader_AppendsTheCaptionSummary() + { + var h = Create(); + h.Source.CaptionSummary = new CaptionSummaryDescription { Columns = { new SummaryColumnDescription("Salary", SummaryType.Sum) } }; + h.Source.AddGroupDescription(new GroupDescription("City")); + + var title = h.Render().Texts.First(t => t.Color == h.Style.GroupHeaderTextColor); + + Assert.StartsWith("City: Oslo (10) — ", title.Text); + Assert.EndsWith(h.Source.GetCaptionSummaryText(h.Source.GetGroupHeaderInfo(0)!.GroupPath)!, title.Text); + } + + [Fact] + public void GroupRows_AreSkippedByTheColumnPasses() + { + var h = Create(rows: 4); + h.Source.AddGroupDescription(new GroupDescription("City")); + + var ctx = h.Render(); + + // The Oslo header row (y 80) gets only the full-width group background, no data-row background. + var atHeader = RowBackgrounds(ctx, 80, 600).ToList(); + Assert.Equal(new[] { h.Style.GroupHeaderBackgroundColor }, atHeader.Select(f => f.Color)); + } + + // ── Group summary rows ──────────────────────────────────────── + + [Fact] + public void GroupSummary_FollowsTheGroupsRows_WithTitleAndValueInItsColumn() + { + var h = Create(rows: 4); + h.Source.AddGroupSummaryRow(new GroupSummaryRow("Sub") { Title = "Subtotal", Columns = { new SummaryColumnDescription("Age", SummaryType.Sum) } }); + h.Source.AddGroupDescription(new GroupDescription("City")); + + var ctx = h.Render(); + + // Oslo header, P00, P02, then the summary at row 3: 80 + 3 × 36. + const float y = 188; + Assert.True(h.Source.IsGroupSummaryRow(3)); + Assert.Contains(ctx.Fills, f => f.Color == h.Style.GroupSummaryRowBackgroundColor && f.Rect.Equals(new GridRect(0, y, 600, 36))); + // Each group gets its own summary: Oslo's at row 3, Rome's at row 7. + var titles = ctx.TextsEqual("Subtotal").OrderBy(t => t.Rect.Y).ToList(); + Assert.Equal(2, titles.Count); + Assert.Equal(new GridRect(16, y, 200, 36), titles[0].Rect); + Assert.Equal(y + 4 * 36, titles[1].Rect.Y); + var value = Assert.Single(ctx.Texts, t => t.Color == h.Style.GroupSummaryRowTextColor && Near(t.Rect.Y, y) && t.Text != "Subtotal"); + Assert.Equal(new GridRect(158, y, 84, 36), value.Rect); + } + + [Fact] + public void GroupSummary_ValueInANarrowColumn_OverflowsAfterTheTitle() + { + var h = Create(rows: 4); + h.Column("Age").Width = 80; + h.Source.AddGroupSummaryRow(new GroupSummaryRow("Sub") { Title = "Subtotal", Columns = { new SummaryColumnDescription("Age", SummaryType.Sum) } }); + h.Source.AddGroupDescription(new GroupDescription("City")); + + var ctx = h.Render(); + + var overflow = ctx.Texts.Where(t => t.Text!.StartsWith(" | ", StringComparison.Ordinal)).OrderBy(t => t.Rect.Y).ToList(); + Assert.Equal(2, overflow.Count); + Assert.Equal(new GridRect(16 + 8 * RecordingDrawingContext.CharWidth, 188, 600 / 3f - 80 - 8, 36), overflow[0].Rect); + Assert.Equal(h.Style.GroupSummaryRowTextColor, overflow[0].Color); + } + + [Fact] + public void GroupSummary_WithoutATitle_DrawsOnlyTheValues() + { + var h = Create(rows: 4); + h.Column("Age").Width = 80; + h.Source.AddGroupSummaryRow(new GroupSummaryRow("Sub") { Columns = { new SummaryColumnDescription("Age", SummaryType.Sum), new SummaryColumnDescription("Salary", SummaryType.Sum) } }); + h.Source.AddGroupDescription(new GroupDescription("City")); + + var ctx = h.Render(); + + // Age is too narrow and there is no title to flow after, so only Salary's value is drawn. + var values = ctx.Texts.Where(t => t.Color == h.Style.GroupSummaryRowTextColor).ToList(); + Assert.Equal(2, values.Count); + Assert.All(values, v => Assert.Equal(388, v.Rect.X)); + } + + // ── Table summary rows ──────────────────────────────────────── + + [Fact] + public void TopSummary_SitsBelowTheHeader_AndPushesRowsDown() + { + var h = Create(); + h.Source.AddTableSummaryRow(new TableSummaryRow("Totals", SummaryPosition.Top) + { + Title = "Totals", + Columns = { new SummaryColumnDescription("Age", SummaryType.Sum) }, + }); + + var ctx = h.Render(); + + var background = Assert.Single(ctx.Fills, f => f.Color == h.Style.SummaryRowBackgroundColor); + Assert.Equal(new GridRect(0, 40, 600, 36), background.Rect); + Assert.Equal(new GridRect(16, 40, 200, 36), ctx.SingleText("Totals").Rect); + var value = ctx.SingleText(h.Source.ComputedTopSummaries[0].Values["Age"]); + Assert.Equal(new GridRect(158, 40, 84, 36), value.Rect); + Assert.Equal(h.Style.SummaryRowTextColor, value.Color); + Assert.Equal(76, ctx.SingleText("P00").Rect.Y); + Assert.True(background.Index > ctx.SingleText("P00").Index, "rows are painted over the top summary"); + } + + [Fact] + public void Summary_ValueInANarrowColumn_OverflowsAfterTheTitle() + { + var h = Create(); + h.Column("Age").Width = 80; // 64 px usable: below the 70 px threshold + h.Source.AddTableSummaryRow(new TableSummaryRow("Totals", SummaryPosition.Top) + { + Title = "Totals", + Columns = { new SummaryColumnDescription("Age", SummaryType.Sum) }, + }); + + var ctx = h.Render(); + + string value = h.Source.ComputedTopSummaries[0].Values["Age"]; + Assert.Empty(ctx.TextsEqual(value)); + var overflow = ctx.SingleText(" | " + value); + Assert.Equal(16 + 6 * RecordingDrawingContext.CharWidth, overflow.Rect.X); + Assert.Equal(40, overflow.Rect.Y); + } + + // ── Column drag overlay ─────────────────────────────────────── + + [Fact] + public void ColumnDrag_DrawsAGhostHeaderAtThePointer_OverTheHeader() + { + var h = Create(); + + var ctx = h.Render(dragColumnIndex: 1, dragColumnScreenX: 220); + + var ghost = Assert.Single(ctx.Fills, f => f.Color == h.Style.HeaderBackgroundColor.WithAlpha(180)); + Assert.Equal(new GridRect(220, 0, 100, 40), ghost.Rect); + Assert.Contains(ctx.Ops, o => o.Kind == DrawOpKind.DrawRect && o.Color == h.Style.CurrentCellBorderColor && o.Rect.Equals(ghost.Rect) && Near(o.StrokeWidth, 2)); + var titles = ctx.TextsEqual("Age").OrderBy(t => t.Index).ToList(); + Assert.Equal(2, titles.Count); + Assert.Equal(228, titles[1].Rect.X); + Assert.True(ghost.Index > titles[0].Index); + } + + [Fact] + public void ColumnDrag_OutOfRangeIndex_DrawsNothing() + { + var h = Create(); + + var ctx = h.Render(dragColumnIndex: 9, dragColumnScreenX: 220); + + Assert.DoesNotContain(ctx.Fills, f => f.Color == h.Style.HeaderBackgroundColor.WithAlpha(180)); + } + + // ── Row drag overlay ────────────────────────────────────────── + + [Fact] + public void RowDrag_DrawsAGhostRowAndADropIndicatorAtTheInsertionPoint() + { + var h = Create(); + + var ctx = h.Render(dragRowIndex: 2, dragRowScreenY: 150); + + var ghost = Assert.Single(ctx.Fills, f => f.Color == h.Style.RowDragOverlayColor); + Assert.Equal(new GridRect(0, 150, 600, 36), ghost.Rect); + Assert.Contains(ctx.Ops, o => o.Kind == DrawOpKind.DrawRect && o.Color == h.Style.RowDragBorderColor && o.Rect.Equals(ghost.Rect)); + var ghostText = Assert.Single(ctx.TextsEqual("P02"), t => Near(t.Rect.Y, 150)); + Assert.Equal(h.Style.CellTextColor.WithAlpha(200), ghostText.Color); + + // Ghost centre 168 → drop before row 3, whose top edge is 40 + 3 × 36 = 148. + var indicator = ctx.Lines.Where(l => l.Color == h.Style.RowDropIndicatorColor).ToList(); + Assert.Contains(indicator, l => Near(l.Y1, 148) && Near(l.Y2, 148) && Near(l.X1, 0) && Near(l.X2, 600)); + Assert.Equal(2, indicator.Count(l => Near(l.X1, l.X2))); // end caps + } + + [Fact] + public void RowDrag_OverTheFrozenBand_DropsBetweenFrozenRows() + { + var h = Create(); + h.Source.FrozenRowCount = 3; + h.Scroll.OffsetY = 100; + + var ctx = h.Render(dragRowIndex: 5, dragRowScreenY: 60); + + // Ghost centre 78 lies in frozen row 1 (76–112): the indicator marks its top edge, ignoring the scroll. + Assert.Contains(ctx.Lines, l => l.Color == h.Style.RowDropIndicatorColor && Near(l.Y1, 76) && Near(l.Y2, 76) && Near(l.X2, 600)); + Assert.Single(ctx.TextsEqual("P05"), t => Near(t.Rect.Y, 60)); + } + + [Fact] + public void RowDrag_GroupRow_DrawsAnEmptyGhost() + { + var h = Create(); + h.Source.AddGroupDescription(new GroupDescription("City")); + + var ctx = h.Render(dragRowIndex: 0, dragRowScreenY: 150); + + Assert.Single(ctx.Fills, f => f.Color == h.Style.RowDragOverlayColor); + Assert.DoesNotContain(ctx.Texts, t => t.Color == h.Style.CellTextColor.WithAlpha(200)); + } + + [Fact] + public void RowDrag_WithLeftDragHandle_StartsTheGhostAfterTheHandle() + { + var h = Create(); + h.Style.ShowRowDragHandle = true; + + var ctx = h.Render(dragRowIndex: 2, dragRowScreenY: 150); + + var ghost = Assert.Single(ctx.Fills, f => f.Color == h.Style.RowDragOverlayColor); + Assert.Equal(new GridRect(32, 150, 568, 36), ghost.Rect); + Assert.Contains(ctx.Lines, l => l.Color == h.Style.RowDropIndicatorColor && Near(l.Y1, l.Y2) && Near(l.X1, 32) && Near(l.X2, 600)); + } + + // ── Editor and popups ───────────────────────────────────────── + + [Fact] + public void PopupsAreDrawnLast_AboveTheEditor_Unclipped() + { + var h = Create(); + h.Source.AddGroupDescription(new GroupDescription("City")); + var edit = new EditSession { Style = h.Style }; + Assert.True(edit.BeginEdit(1, 0, h.Column("Name"), h.Source, new GridRect(0, 116, 150, 36))); + var popups = new PopupManager(); + var probe = new ProbeComponent { Bounds = new GridRect(0, 0, 80, 60) }; + popups.Show(probe, new GridRect(100, 100, 50, 20)); + + var ctx = h.Render(editSession: edit, popupManager: popups); + + var popup = ctx.Ops[^1]; + Assert.Equal(ProbeComponent.ProbeColor, popup.Color); + Assert.Equal(new GridRect(100, 120, 80, 60), popup.Rect); + Assert.Null(popup.Clip); + int editorBackground = ctx.Ops.FindLastIndex(o => o.Kind == DrawOpKind.FillRect && o.Rect.Equals(edit.ActiveEditor!.Bounds)); + Assert.True(editorBackground > 0 && editorBackground < popup.Index); + // The editor is drawn in viewport coordinates, outside the grouping panel's translation. + Assert.Equal(116, ctx.Ops[editorBackground].Rect.Y); + } + + // ── Known defects (un-skip when the fix lands) ──────────────── + + [Fact(Skip = "CORE-03, fixed by #22: the row-style text override is written into the column's shared CellStyle.")] + public void RowStyleTextOverride_DoesNotLeakIntoTheColumnStyleOrOtherRows() + { + var h = Create(); + var name = h.Column("Name"); + name.CellStyle = new CellStyle { Padding = 4 }; + var red = new GridColor(255, 0, 0); + h.Style.RowStyleResolver = item => ((RenderHarness.Person)item!).Name == "P00" ? new RowStyle { TextColor = red } : null; + + var ctx = h.Render(); + + Assert.Equal(red, ctx.SingleText("P00").Color); + Assert.Equal(h.Style.CellTextColor, ctx.SingleText("P01").Color); + Assert.Null(name.CellStyle.TextColor); + } + + [Fact(Skip = "CORE-25, fixed by #27: summary values of scrollable columns are drawn outside the scroll clip.")] + public void TableSummary_ScrollableColumnValue_NeverOverlapsTheFrozenPane() + { + // Name frozen (0–150); scrolled 50 px, Age spans 100–200 and slides under the frozen pane. + var h = Create(width: 300); + h.Column("Name").FreezeMode = ColumnFreezeMode.Left; + h.Source.AddTableSummaryRow(new TableSummaryRow("Totals") { Columns = { new SummaryColumnDescription("Age", SummaryType.Sum) } }); + h.Scroll.OffsetX = 50; + + var ctx = h.Render(); + + var value = ctx.SingleText(h.Source.ComputedBottomSummaries[0].Values["Age"]); + Assert.Equal(0, RectMath.HorizontalOverlap(value.Visible ?? GridRect.Empty, 0, 150)); + } + + [Fact(Skip = "CORE-25, fixed by #27: group summary values of scrollable columns are drawn outside the scroll clip.")] + public void GroupSummary_ScrollableColumnValue_NeverOverlapsTheFrozenPane() + { + var h = Create(rows: 4, width: 300); + h.Column("Name").FreezeMode = ColumnFreezeMode.Left; + h.Source.AddGroupSummaryRow(new GroupSummaryRow("Sub") { Columns = { new SummaryColumnDescription("Age", SummaryType.Sum) } }); + h.Source.AddGroupDescription(new GroupDescription("City")); + h.Scroll.OffsetX = 50; + + var ctx = h.Render(); + + var values = ctx.Texts.Where(t => t.Color == h.Style.GroupSummaryRowTextColor).ToList(); + Assert.NotEmpty(values); + Assert.All(values, v => Assert.Equal(0, RectMath.HorizontalOverlap(v.Visible ?? GridRect.Empty, 0, 150))); + } +} + +internal static class ColoredTextAssertions +{ + /// The single text op with this text drawn in . + public static DrawOp SingleText(this RecordingDrawingContext ctx, string text, GridColor color) => + Assert.Single(ctx.TextsEqual(text), o => o.Color == color); +} diff --git a/tests/KumikoUI.Core.Tests/Rendering/DataGridRendererPaneTests.cs b/tests/KumikoUI.Core.Tests/Rendering/DataGridRendererPaneTests.cs new file mode 100644 index 0000000..fb0b20a --- /dev/null +++ b/tests/KumikoUI.Core.Tests/Rendering/DataGridRendererPaneTests.cs @@ -0,0 +1,371 @@ +using KumikoUI.Core.Models; +using KumikoUI.Core.Rendering; +using KumikoUI.Core.Tests.TestHelpers; +using static KumikoUI.Core.Tests.TestHelpers.RenderHarness; + +namespace KumikoUI.Core.Tests.Rendering; + +/// +/// 's three column passes (scrollable, left-frozen, right-frozen), +/// frozen rows and the row drag-handle column: what each pass draws, where, and what its clip lets +/// reach the screen. +/// +public class DataGridRendererPaneTests +{ + private static readonly string[] ScrollableCellTexts = { "20", "21", "22", "23", "24", "Oslo", "Rome" }; + + // ── Left-frozen columns ─────────────────────────────────────── + + /// Name frozen left (0–150); 300 px viewport scrolled 50 px: Age at 100–200, City at 200–350. + private static RenderHarness LeftFrozen() + { + var h = Create(width: 300); + h.Column("Name").FreezeMode = ColumnFreezeMode.Left; + h.Scroll.OffsetX = 50; + return h; + } + + [Fact] + public void LeftFrozen_CellsStayPut_InsideTheFrozenClip() + { + var h = LeftFrozen(); + + var ctx = h.Render(); + + var name = ctx.SingleText("P00"); + Assert.Equal(8, name.Rect.X); + Assert.Equal(new GridRect(0, 0, 150, 400), name.Clip); + Assert.Equal(8, ctx.SingleText("Name").Rect.X); + } + + [Fact] + public void LeftFrozen_ScrollableCellsScroll_AndAreClippedAtThePaneEdge() + { + var h = LeftFrozen(); + + var ctx = h.Render(); + + var age = ctx.SingleText("20", row: 0, h); + Assert.Equal(150 - 50 + 8, age.Rect.X); + Assert.Equal(new GridRect(150, 0, 150, 400), age.Clip); + Assert.Equal(150, age.Visible!.Value.Left); + } + + [Fact] + public void LeftFrozen_NoScrollableCellReachesTheFrozenPane() + { + var h = LeftFrozen(); + + var ctx = h.Render(); + + var cells = ctx.Texts.Where(o => ScrollableCellTexts.Contains(o.Text)).ToList(); + Assert.NotEmpty(cells); + Assert.All(cells, o => Assert.Equal(0, RectMath.HorizontalOverlap(o.Visible ?? GridRect.Empty, 0, 150))); + } + + [Fact] + public void LeftFrozen_PaneHasItsBackgroundAndADivider() + { + var h = LeftFrozen(); + + var ctx = h.Render(); + + Assert.Contains(ctx.Fills, f => f.Color == h.Style.FrozenColumnBackgroundColor && f.Rect.Equals(new GridRect(0, 0, 150, 400))); + var divider = Assert.Single(ctx.Lines, l => l.Color == h.Style.FrozenColumnDividerColor); + Assert.Equal((150f, 0f, 150f, 400f), (divider.X1, divider.Y1, divider.X2, divider.Y2)); + Assert.Null(divider.Clip); + } + + [Fact] + public void LeftFrozen_CurrentCellBorder_IsDrawnByThePaneThatOwnsTheColumn() + { + var h = LeftFrozen(); + h.Selection.CurrentCell = new CellPosition(1, 0); + + var border = Assert.Single(h.Render().Ops, o => o.Kind == DrawOpKind.DrawRect && o.Color == h.Style.CurrentCellBorderColor); + + Assert.Equal(new GridRect(0, h.RowTop(1), 150, 36), border.Rect); + Assert.Equal(new GridRect(0, 0, 150, 400), border.Clip); + } + + [Fact] + public void LeftFrozen_WithLeftDragHandle_BothPanesStartAfterTheHandle() + { + var h = Create(); + h.Column("Name").FreezeMode = ColumnFreezeMode.Left; + h.Style.ShowRowDragHandle = true; + + var ctx = h.Render(); + + var name = ctx.SingleText("P00"); + Assert.Equal(32 + 8, name.Rect.X); + Assert.Equal(new GridRect(32, 0, 150, 400), name.Clip); + var age = ctx.SingleText("20", row: 0, h); + Assert.Equal(32 + 150 + 8, age.Rect.X); + Assert.Equal(new GridRect(182, 0, 418, 400), age.Clip); + Assert.Contains(ctx.Lines, l => l.Color == h.Style.FrozenColumnDividerColor && Near(l.X1, 182)); + } + + // ── Right-frozen columns ────────────────────────────────────── + + /// Salary frozen right (350–500) in a 500 px viewport scrolled 50 px. + private static RenderHarness RightFrozen() + { + var h = Create(width: 500); + h.Column("Salary").FreezeMode = ColumnFreezeMode.Right; + h.Scroll.OffsetX = 50; + return h; + } + + [Fact] + public void RightFrozen_CellsAreAnchoredToTheRightEdge_InsideTheirClip() + { + var h = RightFrozen(); + + var ctx = h.Render(); + + var salary = ctx.SingleText("1000"); + Assert.Equal(358, salary.Rect.X); + Assert.Equal(new GridRect(350, 0, 150, 400), salary.Clip); + Assert.Equal(358, ctx.SingleText("Salary").Rect.X); + } + + [Fact] + public void RightFrozen_ScrollableCellsAreClippedBeforeThePane() + { + var h = RightFrozen(); + + var ctx = h.Render(); + + var cells = ctx.Texts.Where(o => ScrollableCellTexts.Contains(o.Text)).ToList(); + Assert.NotEmpty(cells); + Assert.All(cells, o => Assert.Equal(new GridRect(0, 0, 350, 400), o.Clip)); + Assert.Equal(250 - 50 + 8, ctx.SingleText("Oslo", row: 0, h).Rect.X); + } + + [Fact] + public void RightFrozen_PaneHasItsBackgroundAndADivider() + { + var h = RightFrozen(); + + var ctx = h.Render(); + + Assert.Contains(ctx.Fills, f => f.Color == h.Style.RightFrozenColumnBackgroundColor && f.Rect.Equals(new GridRect(350, 0, 150, 400))); + var divider = Assert.Single(ctx.Lines, l => l.Color == h.Style.RightFrozenColumnDividerColor); + Assert.Equal((350f, 0f, 350f, 400f), (divider.X1, divider.Y1, divider.X2, divider.Y2)); + } + + [Fact] + public void RightFrozen_WithFrozenRows_KeepsFrozenRowsInThePane() + { + var h = RightFrozen(); + h.Source.FrozenRowCount = 2; + h.Scroll.OffsetY = 180; + + var ctx = h.Render(); + + var salary = ctx.SingleText("1000"); + Assert.Equal(new GridRect(358, 40, 134, 36), salary.Rect); + Assert.Equal(new GridRect(350, 0, 150, 400), salary.Clip); + } + + // ── Frozen rows ─────────────────────────────────────────────── + + /// Two frozen rows (40–112); the scrollable rows below are scrolled 180 px. + private static RenderHarness FrozenRows() + { + var h = Create(); + h.Source.FrozenRowCount = 2; + h.Scroll.OffsetY = 180; + return h; + } + + [Fact] + public void FrozenRows_StayBelowTheHeader_WhileTheRestScrolls() + { + var h = FrozenRows(); + + var ctx = h.Render(); + + Assert.Equal(40, ctx.SingleText("P00").Rect.Y); + Assert.Equal(76, ctx.SingleText("P01").Rect.Y); + // Scrollable rows start below the frozen band: row 7 = 112 + 5 × 36 − 180. + Assert.Equal(112, ctx.SingleText("P07").Rect.Y); + } + + [Fact] + public void FrozenRows_UseTheFrozenRowBackground_AndADividerBelowThem() + { + var h = FrozenRows(); + + var ctx = h.Render(); + + Assert.Contains(RowBackgrounds(ctx, 40, 600), f => f.Color == h.Style.FrozenRowBackgroundColor); + Assert.Contains(RowBackgrounds(ctx, 76, 600), f => f.Color == h.Style.AlternateRowColor); + var divider = Assert.Single(ctx.Lines, l => l.Color == h.Style.FrozenRowDividerColor); + Assert.Equal((0f, 112f, 600f, 112f), (divider.X1, divider.Y1, divider.X2, divider.Y2)); + } + + [Fact] + public void FrozenRows_AreDrawnAfterTheScrollableRows_SoScrolledRowsPassUnderThem() + { + var h = FrozenRows(); + + var ctx = h.Render(); + + // Row 4 has scrolled up to y = 4, under the header and the frozen band. + var scrolledUnder = ctx.SingleText("P04"); + Assert.Equal(4, scrolledUnder.Rect.Y); + int frozenBackground = RowBackgrounds(ctx, 40, 600).Single(f => f.Color == h.Style.FrozenRowBackgroundColor).Index; + Assert.True(frozenBackground > scrolledUnder.Index, "scrolled rows are painted over the frozen rows"); + } + + [Fact] + public void FrozenRows_CurrentCellBorder_IgnoresTheVerticalScroll() + { + var h = FrozenRows(); + h.Selection.CurrentCell = new CellPosition(1, 0); + + var border = Assert.Single(h.Render().Ops, o => o.Kind == DrawOpKind.DrawRect && o.Color == h.Style.CurrentCellBorderColor); + + Assert.Equal(new GridRect(0, 76, 150, 36), border.Rect); + } + + [Fact] + public void FrozenRows_WithFrozenColumns_AreDrawnInEveryPane() + { + var h = FrozenRows(); + h.Column("Name").FreezeMode = ColumnFreezeMode.Left; + + var ctx = h.Render(); + + Assert.Equal(new GridRect(0, 0, 150, 400), ctx.SingleText("P00").Clip); + var salary = ctx.SingleText("1000"); + Assert.Equal(40, salary.Rect.Y); + Assert.Equal(new GridRect(150, 0, 450, 400), salary.Clip); + } + + [Fact] + public void FrozenRows_HonourSelectionFocusAndStyles() + { + var h = Create(); + h.Source.FrozenRowCount = 3; + var highlight = new GridColor(200, 10, 10); + var rowText = new GridColor(10, 200, 10); + var warn = new GridColor(250, 120, 0); + var tint = new GridColor(9, 99, 199); + h.Selection.HandleRowSelection(0, shift: false, ctrl: false, totalRows: 20); + h.Selection.CurrentCell = new CellPosition(1, 0); + h.Style.RowStyleResolver = item => ((RenderHarness.Person)item!).Name == "P02" + ? new RowStyle { BackgroundColor = highlight, TextColor = rowText } + : null; + h.Style.CellStyleResolver = (item, column) => + column.PropertyName == "Age" && ((RenderHarness.Person)item!).Name == "P00" ? new CellStyle { TextColor = warn } : null; + h.Column("City").CellStyle = new CellStyle { BackgroundColor = tint }; + h.Scroll.OffsetY = 100; + + var ctx = h.Render(); + + Assert.Contains(RowBackgrounds(ctx, 40, 600), f => f.Color == h.Style.SelectionColor); + Assert.Contains(RowBackgrounds(ctx, 76, 600), f => f.Color == h.Style.FocusedRowColor); + Assert.Contains(RowBackgrounds(ctx, 112, 600), f => f.Color == highlight); + Assert.Contains(ctx.Fills, f => f.Color == tint && f.Rect.Equals(new GridRect(250, 112, 150, 36))); + Assert.Equal(rowText, ctx.SingleText("P02").Color); + Assert.Equal(warn, Assert.Single(ctx.TextsEqual("20"), t => Near(t.Rect.Y, 40)).Color); + } + + [Fact] + public void FrozenRows_CellBeingEdited_IsSkipped() + { + var h = Create(); + h.Source.FrozenRowCount = 2; + var edit = new KumikoUI.Core.Editing.EditSession { Style = h.Style }; + Assert.True(edit.BeginEdit(1, 0, h.Column("Name"), h.Source, new GridRect(0, 76, 150, 36))); + + var ctx = h.Render(editSession: edit); + + Assert.DoesNotContain(ctx.TextsEqual("P01"), o => o.Rect.Equals(new GridRect(8, 76, 134, 36))); + Assert.Single(ctx.TextsEqual("P00")); + } + + // ── Row drag-handle column ──────────────────────────────────── + + [Fact] + public void DragHandleLeft_ShiftsColumnsRight_AndClipsTheScrollablePaneAfterIt() + { + var h = Create(); + h.Style.ShowRowDragHandle = true; + + var ctx = h.Render(); + + var name = ctx.SingleText("P00"); + Assert.Equal(32 + 8, name.Rect.X); + Assert.Equal(new GridRect(32, 0, 568, 400), name.Clip); + Assert.Equal(40, ctx.SingleText("Name").Rect.X); + Assert.Equal(550 + 32, h.Scroll.ContentWidth); + } + + [Fact] + public void DragHandleLeft_DrawsHeaderBodyDividerAndSixGripDotsPerRow() + { + var h = Create(); + h.Style.ShowRowDragHandle = true; + + var ctx = h.Render(); + + Assert.Contains(ctx.Fills, f => f.Color == h.Style.RowDragHandleHeaderBackgroundColor && f.Rect.Equals(new GridRect(0, 0, 32, 40))); + Assert.Contains(ctx.Fills, f => f.Color == h.Style.RowDragHandleBackgroundColor && f.Rect.Equals(new GridRect(0, 40, 32, 360))); + Assert.Contains(ctx.Lines, l => l.Color == h.Style.GridLineColor && Near(l.X1, 32) && Near(l.X2, 32) && Near(l.Y1, 0) && Near(l.Y2, 400)); + + var row0Grips = ctx.Fills.Where(f => f.Color == h.Style.RowDragHandleColor && f.Rect.Top >= 40 && f.Rect.Bottom <= 76).ToList(); + Assert.Equal(6, row0Grips.Count); + Assert.All(row0Grips, g => Assert.True(g.Rect.Left >= 0 && g.Rect.Right <= 32)); + // 2 × 3 dots centred in the 32 × 36 cell: centres at x 13.5 / 18.5, y 53 / 58 / 63. + Assert.Equal(new[] { 12f, 17f }, row0Grips.Select(g => g.Rect.X).Distinct().OrderBy(x => x)); + Assert.Equal(new[] { 51.5f, 56.5f, 61.5f }, row0Grips.Select(g => g.Rect.Y).Distinct().OrderBy(y => y)); + } + + [Fact] + public void DragHandleRight_SitsAtTheRightEdge_AndColumnsStartAtZero() + { + var h = Create(); + h.Style.ShowRowDragHandle = true; + h.Style.RowDragHandlePosition = DragHandlePosition.Right; + + var ctx = h.Render(); + + Assert.Equal(8, ctx.SingleText("P00").Rect.X); + Assert.Equal(new GridRect(0, 0, 568, 400), ctx.SingleText("P00").Clip); + Assert.Contains(ctx.Fills, f => f.Color == h.Style.RowDragHandleHeaderBackgroundColor && f.Rect.Equals(new GridRect(568, 0, 32, 40))); + Assert.All(ctx.Fills.Where(f => f.Color == h.Style.RowDragHandleColor), g => Assert.True(g.Rect.Left >= 568)); + } + + [Fact] + public void DragHandle_FrozenRowsGetGripsAtTheirFixedPositions() + { + var h = Create(); + h.Style.ShowRowDragHandle = true; + h.Source.FrozenRowCount = 2; + h.Scroll.OffsetY = 180; + + var grips = h.Render().Fills.Where(f => f.Color == h.Style.RowDragHandleColor).ToList(); + + Assert.Equal(6, grips.Count(g => g.Rect.Top >= 40 && g.Rect.Bottom <= 76)); + Assert.Equal(6, grips.Count(g => g.Rect.Top >= 76 && g.Rect.Bottom <= 112)); + // Scrollable rows hidden under the frozen band get no grips. + Assert.DoesNotContain(grips, g => g.Rect.Bottom <= 40); + } + + [Fact] + public void DragHandle_GripsSkipGroupRows() + { + var h = Create(rows: 4); + h.Style.ShowRowDragHandle = true; + h.Source.AddGroupDescription(new GroupDescription("City")); + + var ctx = h.Render(); + + // Rows: Oslo header, P00, P02, Rome header, P01, P03 → 4 data rows × 6 dots. + Assert.Equal(24, ctx.Fills.Count(f => f.Color == h.Style.RowDragHandleColor)); + } +} diff --git a/tests/KumikoUI.Core.Tests/Rendering/DataGridRendererTests.cs b/tests/KumikoUI.Core.Tests/Rendering/DataGridRendererTests.cs new file mode 100644 index 0000000..4d36f29 --- /dev/null +++ b/tests/KumikoUI.Core.Tests/Rendering/DataGridRendererTests.cs @@ -0,0 +1,543 @@ +using KumikoUI.Core.Editing; +using KumikoUI.Core.Models; +using KumikoUI.Core.Rendering; +using KumikoUI.Core.Tests.TestHelpers; +using static KumikoUI.Core.Tests.TestHelpers.RenderHarness; + +namespace KumikoUI.Core.Tests.Rendering; + +/// +/// Golden-command tests for : frame structure, header, data rows, +/// cell renderers, grid lines, the current-cell border, sort indicators and filter icons. +/// Geometry is described on . +/// +public class DataGridRendererTests +{ + // ── Frame ───────────────────────────────────────────────────── + + [Fact] + public void Render_FirstOp_ClearsTheViewportWithTheBackground() + { + var h = Create(); + + var first = h.Render().Ops[0]; + + Assert.Equal(DrawOpKind.FillRect, first.Kind); + Assert.Equal(new GridRect(0, 0, 600, 400), first.Rect); + Assert.Equal(h.Style.BackgroundColor, first.Color); + Assert.Null(first.Clip); + } + + [Fact] + public void Render_ComputesContentSize() + { + var h = Create(); + h.Source.AddTableSummaryRow(new TableSummaryRow("Totals", SummaryPosition.Top) + { + Columns = { new SummaryColumnDescription("Age", SummaryType.Sum) }, + }); + + h.Render(); + + Assert.Equal(550, h.Scroll.ContentWidth); + Assert.Equal(40 + 36 + 20 * 36, h.Scroll.ContentHeight); + } + + public static TheoryData Configurations => new() + { + "plain", "frozen-left", "frozen-right", "frozen-rows", "grouped", "summaries", + "drag-handle-left", "drag-handle-right", "column-drag", "row-drag", + }; + + [Theory] + [MemberData(nameof(Configurations))] + public void Render_SaveAndRestore_AreBalanced(string configuration) + { + var h = Create(width: 500); + int dragColumn = -1, dragRow = -1; + switch (configuration) + { + case "frozen-left": h.Column("Name").FreezeMode = ColumnFreezeMode.Left; break; + case "frozen-right": h.Column("Salary").FreezeMode = ColumnFreezeMode.Right; break; + case "frozen-rows": h.Source.FrozenRowCount = 2; break; + case "grouped": h.Source.AddGroupDescription(new GroupDescription("City")); break; + case "summaries": + h.Source.AddTableSummaryRow(new TableSummaryRow("Top", SummaryPosition.Top) { Columns = { new SummaryColumnDescription("Age", SummaryType.Sum) } }); + h.Source.AddTableSummaryRow(new TableSummaryRow("Bottom") { Columns = { new SummaryColumnDescription("Age", SummaryType.Sum) } }); + break; + case "drag-handle-left": h.Style.ShowRowDragHandle = true; break; + case "drag-handle-right": + h.Style.ShowRowDragHandle = true; + h.Style.RowDragHandlePosition = DragHandlePosition.Right; + break; + case "column-drag": dragColumn = 1; break; + case "row-drag": dragRow = 2; break; + } + + var ctx = h.Render(dragColumnIndex: dragColumn, dragColumnScreenX: 100, dragRowIndex: dragRow, dragRowScreenY: 150); + + Assert.Equal(0, ctx.SaveDepth); + Assert.Equal(0, ctx.UnbalancedRestores); + Assert.True(ctx.Ops.Count > 0); + } + + // ── Header ──────────────────────────────────────────────────── + + [Fact] + public void Header_FillsTheBandAndDrawsTheBottomBorder() + { + var h = Create(); + + var ctx = h.Render(); + + Assert.Contains(ctx.Fills, f => f.Color == h.Style.HeaderBackgroundColor && f.Rect.Equals(new GridRect(0, 0, 600, 40))); + Assert.Contains(ctx.Lines, l => l.Color == h.Style.HeaderBorderColor && Near(l.Y1, 40) && Near(l.Y2, 40) && Near(l.X1, 0) && Near(l.X2, 600)); + } + + [Fact] + public void Header_DrawsEachTitleInItsColumn_ReservingSortAndFilterSpace() + { + var h = Create(); + + var ctx = h.Render(); + + // Sorting (12 px) and filtering (14 px) are allowed by default: 26 px reserved on the right. + var expected = new[] { ("Name", 0f, 150f), ("Age", 150f, 100f), ("City", 250f, 150f), ("Salary", 400f, 150f) }; + foreach (var (title, x, width) in expected) + { + var op = ctx.SingleText(title); + Assert.Equal(new GridRect(x + 8, 0, width - 16 - 26, 40), op.Rect); + Assert.Equal(h.Style.HeaderTextColor, op.Color); + Assert.Same(h.Style.HeaderFont, op.Font); + } + } + + [Theory] + [InlineData(false, false, 0f)] + [InlineData(true, false, 12f)] + [InlineData(false, true, 14f)] + [InlineData(true, true, 26f)] + public void Header_TitleWidth_ReservesRoomOnlyForEnabledIcons(bool sorting, bool filtering, float reserved) + { + var h = Create(); + var age = h.Column("Age"); + age.AllowSorting = sorting; + age.AllowFiltering = filtering; + + var op = h.Render().SingleText("Age"); + + Assert.Equal(100 - 16 - reserved, op.Rect.Width); + } + + [Fact] + public void Header_PerColumnHeaderStyle_OverridesBackgroundTextAndPadding() + { + var h = Create(); + var background = new GridColor(10, 20, 30); + var text = new GridColor(40, 50, 60); + h.Column("City").HeaderCellStyle = new CellStyle { BackgroundColor = background, TextColor = text, Padding = 2 }; + + var ctx = h.Render(); + + Assert.Contains(ctx.Fills, f => f.Color == background && f.Rect.Equals(new GridRect(250, 0, 150, 40))); + var title = ctx.SingleText("City"); + Assert.Equal(text, title.Color); + Assert.Equal(252, title.Rect.X); + Assert.Equal(h.Style.HeaderTextColor, ctx.SingleText("Age").Color); + } + + [Fact] + public void Header_IsDrawnAfterTheRows_SoRowsScrolledUpwardPassUnderIt() + { + var h = Create(); + h.Scroll.OffsetY = 50; + + var ctx = h.Render(); + + int headerFill = ctx.Ops.FindIndex(o => o.Color == h.Style.HeaderBackgroundColor); + int lastRowText = ctx.Ops.FindLastIndex(o => o.IsText && o.Text!.StartsWith('P')); + Assert.True(headerFill > lastRowText, "rows are painted over the header"); + } + + // ── Rows ────────────────────────────────────────────────────── + + [Fact] + public void Rows_OnlyVisibleRowsAreDrawn() + { + var h = Create(); + + var ctx = h.Render(); + + // 400 px viewport, 40 px header, 36 px rows: rows 0–9 are on screen. + for (int row = 0; row <= 9; row++) + Assert.Single(ctx.TextsEqual($"P{row:D2}")); + for (int row = 12; row < 20; row++) + Assert.Empty(ctx.TextsEqual($"P{row:D2}")); + } + + [Fact] + public void Rows_CellTextSitsInItsColumnInsideTheCellPadding() + { + var h = Create(); + + var ctx = h.Render(); + + var name = ctx.SingleText("P03"); + Assert.Equal(new GridRect(8, h.RowTop(3), 150 - 16, 36), name.Rect); + Assert.Equal(h.Style.CellTextColor, name.Color); + Assert.Same(h.Style.CellFont, name.Font); + + var salary = ctx.SingleText("1030"); + Assert.Equal(new GridRect(408, h.RowTop(3), 150 - 16, 36), salary.Rect); + } + + [Fact] + public void Rows_TextUsesTheColumnAlignment() + { + var h = Create(); + h.Column("Salary").TextAlignment = GridTextAlignment.Right; + + var ctx = h.Render(); + + Assert.Equal(GridTextAlignment.Right, ctx.SingleText("1000").HAlign); + Assert.Equal(GridTextAlignment.Left, ctx.SingleText("P00").HAlign); + } + + [Fact] + public void Rows_ScrolledVertically_MoveUpByTheOffset() + { + var h = Create(); + h.Scroll.OffsetY = 72; + + var ctx = h.Render(); + + Assert.Equal(40, ctx.SingleText("P02").Rect.Y); + } + + [Fact] + public void Rows_ScrolledHorizontally_MoveLeftByTheOffset() + { + var h = Create(width: 300); + h.Scroll.OffsetX = 100; + + var ctx = h.Render(); + + Assert.Equal(8 - 100, ctx.SingleText("P00").Rect.X); + Assert.Equal(250 - 100 + 8, ctx.SingleText("Oslo", row: 0, h).Rect.X); + } + + [Theory] + [InlineData(true, 1, "AlternateRowColor")] + [InlineData(true, 2, "BackgroundColor")] + [InlineData(false, 1, "BackgroundColor")] + public void Rows_Background_AlternatesWhenEnabled(bool alternate, int row, string expectedColor) + { + var h = Create(); + h.Style.AlternateRowBackground = alternate; + + var ctx = h.Render(); + + var expected = (GridColor)typeof(DataGridStyle).GetProperty(expectedColor)!.GetValue(h.Style)!; + Assert.Contains(RowBackgrounds(ctx, h.RowTop(row), 600), f => f.Color == expected); + } + + [Fact] + public void Rows_SelectedRowBeatsFocusedRow_AndFocusedRowIsTinted() + { + var h = Create(); + h.Selection.HandleRowSelection(2, shift: false, ctrl: false, totalRows: 20); + h.Selection.CurrentCell = new CellPosition(4, 0); + + var ctx = h.Render(); + + Assert.Contains(RowBackgrounds(ctx, h.RowTop(2), 600), f => f.Color == h.Style.SelectionColor); + Assert.Contains(RowBackgrounds(ctx, h.RowTop(4), 600), f => f.Color == h.Style.FocusedRowColor); + + h.Selection.CurrentCell = new CellPosition(2, 0); + Assert.Contains(RowBackgrounds(h.Render(), h.RowTop(2), 600), f => f.Color == h.Style.SelectionColor); + } + + [Fact] + public void Rows_RowStyleResolver_SetsTheRowBackground() + { + var h = Create(); + var highlight = new GridColor(200, 10, 10); + h.Style.RowStyleResolver = item => ((RenderHarness.Person)item!).Age == 22 ? new RowStyle { BackgroundColor = highlight } : null; + + var ctx = h.Render(); + + Assert.Contains(RowBackgrounds(ctx, h.RowTop(2), 600), f => f.Color == highlight); + Assert.DoesNotContain(RowBackgrounds(ctx, h.RowTop(3), 600), f => f.Color == highlight); + } + + [Fact] + public void Rows_RowStyleResolver_TextColorAppliesToThatRowOnly() + { + var h = Create(); + var red = new GridColor(255, 0, 0); + h.Style.RowStyleResolver = item => ((RenderHarness.Person)item!).Name == "P00" ? new RowStyle { TextColor = red } : null; + + var ctx = h.Render(); + + Assert.Equal(red, ctx.SingleText("P00").Color); + Assert.Equal(red, ctx.SingleText("1000").Color); + Assert.Equal(h.Style.CellTextColor, ctx.SingleText("P01").Color); + } + + [Fact] + public void Rows_ColumnCellStyle_FillsTheCellBackground() + { + var h = Create(); + var tint = new GridColor(9, 99, 199); + h.Column("City").CellStyle = new CellStyle { BackgroundColor = tint }; + + var ctx = h.Render(); + + Assert.Contains(ctx.Fills, f => f.Color == tint && f.Rect.Equals(new GridRect(250, h.RowTop(1), 150, 36))); + } + + [Fact] + public void Rows_CellStyleResolver_OverridesTextColorForMatchingCells() + { + var h = Create(); + var warn = new GridColor(250, 120, 0); + h.Style.CellStyleResolver = (item, column) => + column.PropertyName == "Age" && ((RenderHarness.Person)item!).Age == 21 ? new CellStyle { TextColor = warn } : null; + + var ctx = h.Render(); + + Assert.NotEmpty(ctx.TextsEqual("21")); + Assert.All(ctx.TextsEqual("21"), op => Assert.Equal(warn, op.Color)); + Assert.NotEmpty(ctx.TextsEqual("20")); + Assert.All(ctx.TextsEqual("20"), op => Assert.Equal(h.Style.CellTextColor, op.Color)); + } + + [Fact] + public void Rows_CellBeingEdited_IsSkipped_AndTheEditorIsDrawnLast() + { + var h = Create(); + var edit = new EditSession { Style = h.Style }; + var cell = new GridRect(0, h.RowTop(1), 150, 36); + Assert.True(edit.BeginEdit(1, 0, h.Column("Name"), h.Source, cell)); + + var ctx = h.Render(editSession: edit); + + // The renderer's own cell text for P01 sits at the padded cell rect; the editor draws elsewhere. + Assert.DoesNotContain(ctx.TextsEqual("P01"), o => o.Rect.Equals(new GridRect(8, h.RowTop(1), 134, 36))); + int editorBackground = ctx.Ops.FindLastIndex(o => o.Kind == DrawOpKind.FillRect && o.Rect.Equals(edit.ActiveEditor!.Bounds)); + int lastGridOp = ctx.Ops.FindLastIndex(o => o.Color == h.Style.HeaderBackgroundColor); + Assert.True(editorBackground > lastGridOp, "editor is drawn under the grid"); + Assert.Null(ctx.Ops[editorBackground].Clip); + } + + [Fact] + public void Rows_CustomCellRenderer_ReceivesThePaddedCellRectValueAndText() + { + var h = Create(rows: 3); + var probe = new CapturingCellRenderer(); + h.Column("Salary").CustomCellRenderer = probe; + + h.Render(); + + Assert.Equal(3, probe.Calls.Count); + var (rect, value, text) = probe.Calls[1]; + Assert.Equal(new GridRect(408, h.RowTop(1), 134, 36), rect); + Assert.Equal(1010m, value); + Assert.Equal("1010", text); + } + + [Fact] + public void SetCellRenderer_ReplacesTheRendererForEveryColumnOfThatType() + { + var h = Create(rows: 2); + var probe = new CapturingCellRenderer(); + h.Renderer.SetCellRenderer(DataGridColumnType.Numeric, probe); + + var ctx = h.Render(); + + Assert.Same(probe, h.Renderer.GetCellRenderer(DataGridColumnType.Numeric)); + Assert.Equal(4, probe.Calls.Count); // Age and Salary, two rows each + Assert.Empty(ctx.TextsEqual("1000")); + Assert.Single(ctx.TextsEqual("P00")); + } + + [Fact] + public void GetCellRenderer_UnregisteredType_FallsBackToTheTextRenderer() + { + var renderer = new DataGridRenderer(); + + Assert.IsType(renderer.GetCellRenderer((DataGridColumnType)999)); + Assert.IsType(renderer.GetCellRenderer(DataGridColumnType.Boolean)); + Assert.IsType(renderer.GetCellRenderer(DataGridColumnType.Image)); + } + + [Fact] + public void Rows_BooleanColumn_DrawsACheckboxAndACheckmarkOnlyWhenTrue() + { + var h = Create(rows: 2); + h.Source.SetColumns(new[] { new DataGridColumn { Header = "Active", PropertyName = "Active", Width = 100, ColumnType = DataGridColumnType.Boolean } }); + + var ctx = h.Render(); + + var boxes = ctx.Ops.Where(o => o.Kind == DrawOpKind.DrawRect && o.Color == h.Style.GridLineColor).ToList(); + Assert.Equal(2, boxes.Count); + // 16 px box centred in the padded cell (8..92 x row). + Assert.Equal(new GridRect(8 + (84 - 16) / 2f, h.RowTop(0) + 10, 16, 16), boxes[0].Rect); + var checkmarks = ctx.Lines.Where(l => l.Color == h.Style.AccentColor).ToList(); + Assert.Equal(2, checkmarks.Count); // row 0 is Active, row 1 is not + Assert.All(checkmarks, l => Assert.True(l.Rect.Y >= h.RowTop(0) && l.Rect.Bottom <= h.RowTop(1))); + } + + // ── Grid lines ──────────────────────────────────────────────── + + [Fact] + public void GridLines_HorizontalAtEveryRowBoundary_VerticalAtEveryColumnEdge() + { + var h = Create(rows: 3); + + var lines = h.Render().Lines.Where(l => l.Color == h.Style.GridLineColor).ToList(); + + var horizontalYs = lines.Where(l => Near(l.Y1, l.Y2)).Select(l => l.Y1).Distinct().OrderBy(y => y); + Assert.Equal(new[] { 40f, 76f, 112f, 148f }, horizontalYs); + var vertical = lines.Where(l => Near(l.X1, l.X2)).ToList(); + Assert.Equal(new[] { 150f, 250f, 400f, 550f }, vertical.Select(l => l.X1).OrderBy(x => x)); + // Vertical lines stop at the last row instead of running to the viewport bottom. + Assert.All(vertical, l => Assert.Equal(148f, l.Y2)); + } + + [Fact] + public void GridLines_CanBeTurnedOff() + { + var h = Create(rows: 3); + h.Style.ShowHorizontalGridLines = false; + h.Style.ShowVerticalGridLines = false; + + Assert.DoesNotContain(h.Render().Lines, l => l.Color == h.Style.GridLineColor); + } + + // ── Current cell ────────────────────────────────────────────── + + [Fact] + public void CurrentCell_IsOutlined() + { + var h = Create(); + h.Selection.CurrentCell = new CellPosition(2, 1); + + var border = Assert.Single(h.Render().Ops, o => o.Kind == DrawOpKind.DrawRect && o.Color == h.Style.CurrentCellBorderColor); + + Assert.Equal(new GridRect(150, h.RowTop(2), 100, 36), border.Rect); + Assert.Equal(h.Style.CurrentCellBorderWidth, border.StrokeWidth); + } + + [Fact] + public void CurrentCell_NoBorderWithoutACurrentCell() + { + var h = Create(); + + Assert.DoesNotContain(h.Render().Ops, o => o.Color == h.Style.CurrentCellBorderColor); + } + + [Fact] + public void CurrentCell_NoBorderOnAGroupHeaderRow() + { + var h = Create(); + h.Source.AddGroupDescription(new GroupDescription("City")); + Assert.True(h.Source.IsGroupHeaderRow(0)); + h.Selection.CurrentCell = new CellPosition(0, 0); + + Assert.DoesNotContain(h.Render().Ops, o => o.Kind == DrawOpKind.DrawRect && o.Color == h.Style.CurrentCellBorderColor); + } + + // ── Sort indicators ─────────────────────────────────────────── + + [Theory] + [InlineData(1, 17f)] // ascending: apex at the top + [InlineData(2, 23f)] // descending: apex at the bottom + public void SortIndicator_IsATriangleAtTheHeaderRight_PointingInTheSortDirection(int toggles, float apexY) + { + var h = Create(); + for (int i = 0; i < toggles; i++) h.Source.ToggleSort(h.Column("Name")); + + var arrow = h.Render().Lines.Where(l => l.Color == h.Style.SortIndicatorColor).ToList(); + + Assert.Equal(3, arrow.Count); + // Name is 150 px wide: arrow spans 134–142 (width − padding − 8), centred on the 40 px header. + Assert.Equal(134, arrow.Min(l => l.Rect.Left)); + Assert.Equal(142, arrow.Max(l => l.Rect.Right)); + var apex = arrow.SelectMany(l => new[] { (l.X1, l.Y1), (l.X2, l.Y2) }).Where(p => Near(p.Item1, 138f)).ToList(); + Assert.NotEmpty(apex); + Assert.All(apex, p => Assert.Equal(apexY, p.Item2)); + } + + [Fact] + public void SortIndicator_NotDrawnForUnsortedColumns() + { + var h = Create(); + + Assert.DoesNotContain(h.Render().Lines, l => l.Color == h.Style.SortIndicatorColor); + } + + [Fact] + public void SortIndicator_MultiSort_DrawsOrderBadges() + { + var h = Create(); + h.Column("City").SortDirection = SortDirection.Ascending; + h.Column("City").SortOrder = 1; + h.Column("Age").SortDirection = SortDirection.Descending; + h.Column("Age").SortOrder = 2; + + var ctx = h.Render(); + + var badges = ctx.Ops.Where(o => o.Kind == DrawOpKind.Text && o.Color == h.Style.SortIndicatorColor).ToList(); + Assert.Equal(new[] { "1", "2" }, badges.Select(b => b.Text).OrderBy(t => t)); + Assert.Equal(6, ctx.Lines.Count(l => l.Color == h.Style.SortIndicatorColor)); + } + + // ── Filter icons ────────────────────────────────────────────── + + [Fact] + public void FilterIcon_DrawnOnlyForFilterableColumns() + { + var h = Create(); + h.Column("Age").AllowFiltering = false; + + var funnel = h.Render().Lines.Where(l => l.Color == h.Style.FilterIconColor).ToList(); + + // Six strokes per funnel, three filterable columns; none inside Age (150–250). + Assert.Equal(18, funnel.Count); + Assert.DoesNotContain(funnel, l => l.Rect.Left >= 150 && l.Rect.Right <= 250); + // Name's funnel sits left of the sort-arrow slot: 150 − 8 − 12 − 10 = 120. + Assert.Equal(120, funnel.Where(l => l.Rect.Right <= 150).Min(l => l.Rect.Left)); + } + + [Fact] + public void FilterIcon_ActiveFilter_UsesTheActiveColourAndAFilledDot() + { + var h = Create(); + var city = h.Column("City"); + h.Source.SetColumnFilter(city, new FilterDescription(city) { SelectedValues = new HashSet { "Oslo" } }); + + var ctx = h.Render(); + + Assert.Equal(6, ctx.Lines.Count(l => l.Color == h.Style.FilterActiveIconColor)); + var dot = Assert.Single(ctx.Fills, f => f.Color == h.Style.FilterActiveIconColor); + Assert.Equal(5, dot.Rect.Width); + Assert.True(dot.Rect.Left >= 250 && dot.Rect.Right <= 400); + } + + private sealed class CapturingCellRenderer : ICellRenderer + { + public List<(GridRect Rect, object? Value, string Text)> Calls { get; } = new(); + + public void Render(IDrawingContext ctx, GridRect cellRect, object? value, string displayText, + DataGridColumn column, DataGridStyle style, bool isSelected, CellStyle? cellStyle = null) => + Calls.Add((cellRect, value, displayText)); + } +} + +internal static class RecordingAssertions +{ + /// The single text op with this text whose rectangle lies in . + public static DrawOp SingleText(this RecordingDrawingContext ctx, string text, int row, RenderHarness h) => + Assert.Single(ctx.TextsEqual(text), o => RenderHarness.Near(o.Rect.Y, h.RowTop(row))); +} diff --git a/tests/KumikoUI.Core.Tests/TestHelpers/RecordingDrawingContext.cs b/tests/KumikoUI.Core.Tests/TestHelpers/RecordingDrawingContext.cs index 08a94ea..22b98b5 100644 --- a/tests/KumikoUI.Core.Tests/TestHelpers/RecordingDrawingContext.cs +++ b/tests/KumikoUI.Core.Tests/TestHelpers/RecordingDrawingContext.cs @@ -2,38 +2,196 @@ namespace KumikoUI.Core.Tests.TestHelpers; +/// The kind of drawing call a recorded. +internal enum DrawOpKind +{ + FillRect, + DrawRect, + FillRoundRect, + DrawRoundRect, + Line, + Text, + TextInRect, + Image, +} + /// -/// An that records fill operations in call order so tests can assert -/// what was drawn and in which z-order, without SkiaSharp. Text measures as 8 px per character. +/// One recorded drawing call. Geometry is in viewport coordinates (every active +/// applied). Paint values are copied at call time, so a +/// paint the renderer reuses or mutates later does not change what was recorded. +/// +/// Position in the call sequence (z-order: higher draws on top). +/// The drawing call. +/// The target rectangle; a line's bounding box; a positioned text's measured box. +/// Paint colour. +/// Paint style (fill or stroke). +/// Paint stroke width. +/// Paint font, if any. +/// +/// Intersection of every active ; null when unclipped. A +/// call's own rectangle clip is not folded in (see ). +/// +/// The text, for text calls. +/// Horizontal alignment, for . +/// Whether a call clips to its own rectangle. +/// Line start X (viewport). +/// Line start Y (viewport). +/// Line end X (viewport). +/// Line end Y (viewport). +internal sealed record DrawOp( + int Index, + DrawOpKind Kind, + GridRect Rect, + GridColor Color, + PaintStyle Style, + float StrokeWidth, + GridFont? Font, + GridRect? Clip, + string? Text = null, + GridTextAlignment HAlign = GridTextAlignment.Left, + bool ClipsToRect = false, + float X1 = 0, float Y1 = 0, float X2 = 0, float Y2 = 0) +{ + /// + /// The part of that can reach the screen: intersected with + /// . Null when the op is clipped away entirely. + /// + public GridRect? Visible => Clip is { } clip ? RectMath.Intersect(Rect, clip) : Rect; + + public bool IsText => Kind is DrawOpKind.Text or DrawOpKind.TextInRect; + + public override string ToString() => + $"#{Index} {Kind} {Text} ({Rect.X:0.#},{Rect.Y:0.#} {Rect.Width:0.#}x{Rect.Height:0.#}) {Color}" + + (Clip is { } c ? $" clip({c.X:0.#},{c.Y:0.#} {c.Width:0.#}x{c.Height:0.#})" : ""); +} + +/// Rectangle helpers for clip assertions. +internal static class RectMath +{ + /// The overlap of two rectangles, or null when they do not overlap (touching edges do not count). + public static GridRect? Intersect(GridRect a, GridRect b) + { + float left = Math.Max(a.Left, b.Left); + float top = Math.Max(a.Top, b.Top); + float right = Math.Min(a.Right, b.Right); + float bottom = Math.Min(a.Bottom, b.Bottom); + // Lines have a zero-width or zero-height bounding box; keep them when they lie inside the clip. + if (right < left || bottom < top) return null; + if ((right <= left && a.Width > 0) || (bottom <= top && a.Height > 0)) return null; + return new GridRect(left, top, right - left, bottom - top); + } + + /// The horizontal overlap of a rectangle with the band [left, right). + public static float HorizontalOverlap(GridRect r, float left, float right) => + Math.Max(0, Math.Min(r.Right, right) - Math.Max(r.Left, left)); +} + +/// +/// An that records every drawing call, with the clip and translation +/// in effect, so tests can assert what was drawn, where, in which z-order and what reached the +/// screen — without SkiaSharp. Text measures monospace: px per character, +/// px tall. /// internal sealed class RecordingDrawingContext : IDrawingContext { - /// A recorded fill, in viewport coordinates after any calls. - public readonly record struct FillOp(string Kind, GridRect Rect, GridColor Color); + public const float CharWidth = 8f; + public const float TextHeight = 14f; - private readonly Stack<(float X, float Y)> _saved = new(); + private readonly Stack<(float Tx, float Ty, GridRect? Clip)> _saved = new(); private float _tx, _ty; + private GridRect? _clip; + + /// Every recorded call, in call order. + public List Ops { get; } = new(); + + /// Highest nesting reached. + public int MaxSaveDepth { get; private set; } + + /// calls without a matching . + public int UnbalancedRestores { get; private set; } - public List Fills { get; } = new(); + /// Current nesting; zero after a balanced frame. + public int SaveDepth => _saved.Count; + + // ── Queries ─────────────────────────────────────────────────── + + public IEnumerable Fills => Ops.Where(o => o.Kind is DrawOpKind.FillRect or DrawOpKind.FillRoundRect); + public IEnumerable Lines => Ops.Where(o => o.Kind == DrawOpKind.Line); + public IEnumerable Texts => Ops.Where(o => o.IsText); + + /// Text ops whose text equals . + public IEnumerable TextsEqual(string text) => Texts.Where(o => o.Text == text); + + /// The single text op with this exact text; fails when there is none or several. + public DrawOp SingleText(string text) + { + var matches = TextsEqual(text).ToList(); + Assert.True(matches.Count == 1, + $"expected one '{text}' text op, found {matches.Count}:{Environment.NewLine}{string.Join(Environment.NewLine, matches)}"); + return matches[0]; + } + + // ── IDrawingContext ─────────────────────────────────────────── private GridRect Map(GridRect r) => new(r.X + _tx, r.Y + _ty, r.Width, r.Height); - public void FillRect(GridRect rect, GridPaint paint) => Fills.Add(new("FillRect", Map(rect), paint.Color)); - public void FillRoundRect(GridRect rect, float cornerRadius, GridPaint paint) => Fills.Add(new("FillRoundRect", Map(rect), paint.Color)); - public void DrawRect(GridRect rect, GridPaint paint) { } - public void DrawRoundRect(GridRect rect, float cornerRadius, GridPaint paint) { } - public void DrawLine(float x1, float y1, float x2, float y2, GridPaint paint) { } - public void DrawText(string text, float x, float y, GridPaint paint) { } + private void Record(DrawOpKind kind, GridRect rect, GridPaint paint, string? text = null, + GridTextAlignment hAlign = GridTextAlignment.Left, bool clipsToRect = false, + float x1 = 0, float y1 = 0, float x2 = 0, float y2 = 0) + { + Ops.Add(new DrawOp(Ops.Count, kind, rect, paint.Color, paint.Style, paint.StrokeWidth, paint.Font, _clip, + text, hAlign, clipsToRect, x1, y1, x2, y2)); + } + + public void FillRect(GridRect rect, GridPaint paint) => Record(DrawOpKind.FillRect, Map(rect), paint); + public void DrawRect(GridRect rect, GridPaint paint) => Record(DrawOpKind.DrawRect, Map(rect), paint); + public void FillRoundRect(GridRect rect, float cornerRadius, GridPaint paint) => Record(DrawOpKind.FillRoundRect, Map(rect), paint); + public void DrawRoundRect(GridRect rect, float cornerRadius, GridPaint paint) => Record(DrawOpKind.DrawRoundRect, Map(rect), paint); + + public void DrawLine(float x1, float y1, float x2, float y2, GridPaint paint) + { + x1 += _tx; x2 += _tx; y1 += _ty; y2 += _ty; + var box = new GridRect(Math.Min(x1, x2), Math.Min(y1, y2), Math.Abs(x2 - x1), Math.Abs(y2 - y1)); + Record(DrawOpKind.Line, box, paint, x1: x1, y1: y1, x2: x2, y2: y2); + } + + public void DrawText(string text, float x, float y, GridPaint paint) + { + var size = MeasureText(text, paint); + // y is the baseline; the measured box sits above it. + Record(DrawOpKind.Text, Map(new GridRect(x, y - size.Height, size.Width, size.Height)), paint, text); + } + public void DrawTextInRect(string text, GridRect rect, GridPaint paint, GridTextAlignment hAlign = GridTextAlignment.Left, GridVerticalAlignment vAlign = GridVerticalAlignment.Center, - bool clip = true) { } - public GridSize MeasureText(string text, GridPaint paint) => new(text.Length * 8, 14); - public GridFontMetrics GetFontMetrics(GridPaint paint) => new(14, 0, 2); - public void ClipRect(GridRect rect) { } - public void Save() => _saved.Push((_tx, _ty)); - public void Restore() { if (_saved.Count > 0) (_tx, _ty) = _saved.Pop(); } + bool clip = true) => + Record(DrawOpKind.TextInRect, Map(rect), paint, text, hAlign, clip); + + public GridSize MeasureText(string text, GridPaint paint) => new(text.Length * CharWidth, TextHeight); + + public GridFontMetrics GetFontMetrics(GridPaint paint) => new(-11, 3, 2); + + public void ClipRect(GridRect rect) + { + var mapped = Map(rect); + _clip = _clip is { } c ? RectMath.Intersect(c, mapped) ?? GridRect.Empty : mapped; + } + + public void Save() + { + _saved.Push((_tx, _ty, _clip)); + MaxSaveDepth = Math.Max(MaxSaveDepth, _saved.Count); + } + + public void Restore() + { + if (_saved.Count == 0) { UnbalancedRestores++; return; } + (_tx, _ty, _clip) = _saved.Pop(); + } + public void Translate(float dx, float dy) { _tx += dx; _ty += dy; } - public void DrawImage(object image, GridRect destRect) { } - public void Dispose() { } + + public void DrawImage(object image, GridRect destRect) => + Record(DrawOpKind.Image, Map(destRect), new GridPaint()); } diff --git a/tests/KumikoUI.Core.Tests/TestHelpers/RenderHarness.cs b/tests/KumikoUI.Core.Tests/TestHelpers/RenderHarness.cs new file mode 100644 index 0000000..c0033d0 --- /dev/null +++ b/tests/KumikoUI.Core.Tests/TestHelpers/RenderHarness.cs @@ -0,0 +1,116 @@ +using System.Reflection; +using KumikoUI.Core.Components; +using KumikoUI.Core.Editing; +using KumikoUI.Core.Models; +using KumikoUI.Core.Rendering; + +namespace KumikoUI.Core.Tests.TestHelpers; + +/// +/// A grid wired to a and a . +/// Every on the style is given a distinct value, so a recorded colour +/// identifies the style role that produced it. +/// +/// +/// Default geometry (DataGridStyle defaults): header 40 px, rows 36 px, summary rows 36 px, +/// grouping panel 40 px, cell and header padding 8 px. Default columns: Name 150 px (x 0), +/// Age 100 px (x 150), City 150 px (x 250), Salary 150 px (x 400) — 550 px of content. +/// +internal sealed class RenderHarness +{ + public sealed class Person + { + public string Name { get; set; } = ""; + public int Age { get; set; } + public string City { get; set; } = ""; + public decimal Salary { get; set; } + public bool Active { get; set; } + } + + public DataGridSource Source { get; } = new(); + public DataGridStyle Style { get; } = WithDistinctColors(new DataGridStyle()); + public ScrollState Scroll { get; } + public SelectionModel Selection { get; } = new(); + public DataGridRenderer Renderer { get; } = new(); + public List People { get; } + + private RenderHarness(float width, float height, List people) + { + Scroll = new ScrollState { ViewportWidth = width, ViewportHeight = height }; + People = people; + } + + /// + /// Creates a grid with people (Name "P00", "P01", …; Age 20–24 cycling; + /// City alternating Oslo/Rome; Salary 1000 + 10·i; Active every third row). + /// + public static RenderHarness Create(int rows = 20, float width = 600, float height = 400, + Action? configure = null) + { + var people = Enumerable.Range(0, rows).Select(i => new Person + { + Name = $"P{i:D2}", + Age = 20 + i % 5, + City = i % 2 == 0 ? "Oslo" : "Rome", + Salary = 1000m + i * 10m, + Active = i % 3 == 0, + }).ToList(); + + var h = new RenderHarness(width, height, people); + h.Source.SetColumns(new[] + { + new DataGridColumn { Header = "Name", PropertyName = "Name", Width = 150 }, + new DataGridColumn { Header = "Age", PropertyName = "Age", Width = 100, ColumnType = DataGridColumnType.Numeric }, + new DataGridColumn { Header = "City", PropertyName = "City", Width = 150 }, + new DataGridColumn { Header = "Salary", PropertyName = "Salary", Width = 150, ColumnType = DataGridColumnType.Numeric }, + }); + h.Source.SetItems(people); + configure?.Invoke(h); + return h; + } + + public DataGridColumn Column(string propertyName) => + Source.Columns.Single(c => c.PropertyName == propertyName); + + public RecordingDrawingContext Render( + int dragColumnIndex = -1, float dragColumnScreenX = 0, + int dragRowIndex = -1, float dragRowScreenY = 0, + EditSession? editSession = null, PopupManager? popupManager = null) + { + var ctx = new RecordingDrawingContext(); + Renderer.Render(ctx, Source, Scroll, Selection, Style, + dragColumnIndex, dragColumnScreenX, dragRowIndex, dragRowScreenY, editSession, popupManager); + return ctx; + } + + /// Viewport Y of a scrollable row's top edge (no grouping panel, frozen rows or top summaries). + public float RowTop(int row) => Style.HeaderHeight + row * Style.RowHeight - Scroll.OffsetY; + + /// The full-width row background fill at , drawn by the pass with that clip. + public static IEnumerable RowBackgrounds(RecordingDrawingContext ctx, float y, float viewportWidth) => + ctx.Ops.Where(o => o.Kind == DrawOpKind.FillRect && Near(o.Rect.Y, y) && Near(o.Rect.X, 0) && o.Rect.Width >= viewportWidth); + + public static bool Near(float a, float b) => Math.Abs(a - b) < 0.01f; + + private static DataGridStyle WithDistinctColors(DataGridStyle style) + { + byte n = 1; + foreach (var p in typeof(DataGridStyle).GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.PropertyType == typeof(GridColor) && p.CanWrite) + .OrderBy(p => p.Name, StringComparer.Ordinal)) + { + p.SetValue(style, new GridColor(n, (byte)(255 - n), 77)); + n++; + } + return style; + } +} + +/// A drawn component that fills its bounds, to observe where and when it is drawn. +internal sealed class ProbeComponent : DrawnComponent +{ + public static readonly GridColor ProbeColor = new(1, 2, 3); + + public override void OnDraw(IDrawingContext ctx) => + ctx.FillRect(Bounds, new GridPaint { Color = ProbeColor }); +}