using System.Numerics; using AcDream.App.Rendering; using AcDream.App.Rendering.Gpu; using AcDream.App.Tests.Rendering.Gpu; using AcDream.App.UI; using DatReaderWriter.Types; using Xunit; namespace AcDream.App.Tests.UI; /// /// Campaign VT slice 1 Part B (docs/plans/2026-09-06-campaign-vt-slice1-files-and-columns.md): /// multi-column <list><column> markup, VVS HudList /// parity per docs/research/vtank-kb/08-ui-views.md §2-3. /// /// /// Covers: parse + per-column binding-type validation (good markup binds; /// every documented throw case throws with the expected message), draw-level /// pins against the same recording-renderer apparatus MarkupIconTests /// uses (column x-offsets, the check glyph, the icon column, per-cell /// clipping), hit-test routing (text selects, check/icon fire their own /// per-row callback and do NOT change selection, past-the-end and scroll /// offset), and a backward-compatibility proof that a column-less /// <list> is unaffected. /// /// public sealed class MarkupListColumnsTests { private static (uint, int, int) Sprite(uint id) => (1u, 32, 32); // ── B1: parse + per-column binding-type validation ────────────────────── private sealed class ThreeColumnBinding { public IReadOnlyList Names { get; } = new[] { "Mosswart", "Drudge", "Rat" }; public IReadOnlyList Fester { get; } = new[] { true, false }; public IReadOnlyList Icons { get; } = new[] { 7735u, 0u, 42u }; public int Selected { get; set; } = -1; public int FesterToggled { get; private set; } = -1; public int IconClickedRow { get; private set; } = -1; public Action ToggleFester => row => FesterToggled = row; public Action ClickIcon => row => IconClickedRow = row; } private const string ThreeColumnXml = "" + "" + " " + " " + " " + "" + ""; private sealed class FakeIconResolver : IMarkupIconResolver { public readonly List<(string Method, uint Id)> Calls = new(); public (uint tex, int w, int h) ResolveDid(uint did) { Calls.Add(("did", did)); return did == 0u ? (0u, 0, 0) : (did, 16, 16); } public (uint tex, int w, int h) ResolveSpell(uint spellId) { Calls.Add(("spell", spellId)); return spellId == 0u ? (0u, 0, 0) : (spellId, 16, 16); } public (uint tex, int w, int h) ResolveItem(uint objectId) { Calls.Add(("item", objectId)); return objectId == 0u ? (0u, 0, 0) : (objectId, 16, 16); } } [Fact] public void Columns_TextCheckIcon_BindEachColumnsOwnPerRowSource() { var resolver = new FakeIconResolver(); var binding = new ThreeColumnBinding(); var panel = MarkupDocument.Build(ThreeColumnXml, binding, Sprite, icons: resolver); var list = Assert.IsType(panel.Children[0]); Assert.NotNull(list.Columns); Assert.Equal(3, list.Columns!.Count); Assert.Equal(UiMarkupListColumnKind.Text, list.Columns[0].Kind); Assert.Equal(UiMarkupListColumnKind.Check, list.Columns[1].Kind); Assert.Equal(UiMarkupListColumnKind.Icon, list.Columns[2].Kind); Assert.Equal(binding.Names, list.Columns[0].TextSource!()); Assert.Equal(binding.Fester, list.Columns[1].CheckSource!()); Assert.Equal(binding.Icons, list.Columns[2].IconValuesSource!()); // Row count is the longest bound column (Names has 3 rows; Fester // only 2) — short columns simply have nothing to draw for row 2, they // don't truncate the whole list. Assert.Equal(3, list.Columns.Max(c => c.RowCount())); // The legacy single-column fields stay at their untouched defaults — // proves MarkupDocument never populates both surfaces at once. Assert.Empty(list.ItemsSource()); Assert.Null(list.IconIdsSource); list.Columns[1].CheckChanged!(1); Assert.Equal(1, binding.FesterToggled); list.Columns[2].IconClicked!(0); Assert.Equal(0, binding.IconClickedRow); } [Fact] public void ColumnLessList_ColumnsPropertyStaysNull_LegacyPathUntouched() { var binding = new ThreeColumnBinding(); const string xml = "" + "" + ""; var panel = MarkupDocument.Build(xml, binding, Sprite); var list = Assert.IsType(panel.Children[0]); Assert.Null(list.Columns); Assert.Equal(binding.Names, list.ItemsSource()); } [Fact] public void Column_UnknownType_ThrowsAtBuild() { var binding = new ThreeColumnBinding(); const string xml = "" + "" + " " + ""; var ex = Assert.Throws( () => MarkupDocument.Build(xml, binding, Sprite)); Assert.Contains("bogus", ex.Message); Assert.Contains("column[0]", ex.Message); } [Fact] public void TextColumn_MissingItems_ThrowsAtBuild() { var binding = new ThreeColumnBinding(); const string xml = "" + "" + " " + ""; var ex = Assert.Throws(() => MarkupDocument.Build(xml, binding, Sprite)); Assert.Contains("column[0] type=\"text\" items", ex.Message); } [Fact] public void CheckColumn_MissingValues_ThrowsAtBuild() { var binding = new ThreeColumnBinding(); const string xml = "" + "" + " " + ""; var ex = Assert.Throws(() => MarkupDocument.Build(xml, binding, Sprite)); Assert.Contains("column[0] type=\"check\" values", ex.Message); } [Fact] public void CheckColumn_MissingOnchange_ThrowsAtBuild() { var binding = new ThreeColumnBinding(); const string xml = "" + "" + " " + ""; var ex = Assert.Throws(() => MarkupDocument.Build(xml, binding, Sprite)); Assert.Contains("column[0] type=\"check\" onchange", ex.Message); } [Fact] public void IconColumn_MissingValues_ThrowsAtBuild() { var binding = new ThreeColumnBinding(); const string xml = "" + "" + " " + ""; var ex = Assert.Throws(() => MarkupDocument.Build(xml, binding, Sprite)); Assert.Contains("column[0] type=\"icon\" values", ex.Message); } [Fact] public void IconColumn_MissingOnclick_ThrowsAtBuild() { var binding = new ThreeColumnBinding(); const string xml = "" + "" + " " + ""; var ex = Assert.Throws(() => MarkupDocument.Build(xml, binding, Sprite)); Assert.Contains("column[0] type=\"icon\" onclick", ex.Message); } [Fact] public void IconColumn_UnknownIconKind_ThrowsAtBuild_EvenWithNoResolverWired() { var binding = new ThreeColumnBinding(); const string xml = "" + "" + " " + ""; var ex = Assert.Throws(() => MarkupDocument.Build(xml, binding, Sprite)); Assert.Contains("column[0] type=\"icon\" iconkind", ex.Message); } [Fact] public void IconColumn_NoResolverWired_IconResolveStaysNull_ButValuesStillBind() { var binding = new ThreeColumnBinding(); const string xml = "" + "" + " " + ""; var panel = MarkupDocument.Build(xml, binding, Sprite); // no `icons:` resolver var list = Assert.IsType(panel.Children[0]); Assert.NotNull(list.Columns); Assert.Null(list.Columns![0].IconResolve); Assert.Equal(binding.Icons, list.Columns[0].IconValuesSource!()); } [Fact] public void Columns_CombinedWithLegacyItemsAttribute_ThrowsAtBuild() { var binding = new ThreeColumnBinding(); const string xml = "" + "" + " " + ""; var ex = Assert.Throws(() => MarkupDocument.Build(xml, binding, Sprite)); Assert.Contains("items/icons/colors", ex.Message); } [Fact] public void Columns_CombinedWithLegacyColorsAttribute_ThrowsAtBuild() { var binding = new ThreeColumnBinding(); const string xml = "" + "" + " " + ""; var ex = Assert.Throws(() => MarkupDocument.Build(xml, binding, Sprite)); Assert.Contains("items/icons/colors", ex.Message); } [Fact] public void Columns_CombinedWithLegacyIconsAttribute_ThrowsAtBuild() { var binding = new ThreeColumnBinding(); const string xml = "" + "" + " " + ""; var ex = Assert.Throws(() => MarkupDocument.Build(xml, binding, Sprite)); Assert.Contains("items/icons/colors", ex.Message); } [Fact] public void NonColumnChildOfList_ThrowsAtBuild() { var binding = new ThreeColumnBinding(); const string xml = "" + "" + " "; var ex = Assert.Throws( () => MarkupDocument.Build(xml, binding, Sprite)); Assert.Contains("label", ex.Message); } [Fact] public void TextColumn_OptionalColorsAttribute_BindsWhenPresent_NullWhenAbsent() { var binding = new ThreeColumnBinding(); const string withColors = "" + "" + " " + ""; var panelWith = MarkupDocument.Build(withColors, binding, Sprite); var listWith = Assert.IsType(panelWith.Children[0]); Assert.NotNull(listWith.Columns![0].ColorsSource); Assert.Equal(binding.Icons, listWith.Columns[0].ColorsSource!()); const string withoutColors = "" + "" + " " + ""; var panelWithout = MarkupDocument.Build(withoutColors, binding, Sprite); var listWithout = Assert.IsType(panelWithout.Children[0]); Assert.Null(listWithout.Columns![0].ColorsSource); } [Fact] public void TextColumn_MalformedColorsAttribute_ThrowsAtBuild() { var binding = new ThreeColumnBinding(); const string xml = "" + "" + " " + ""; var ex = Assert.Throws(() => MarkupDocument.Build(xml, binding, Sprite)); Assert.Contains("column[0] type=\"text\" colors", ex.Message); } // ── Fix round: text column optional onclick (fix item 1) ──────────────── [Fact] public void TextColumn_OptionalOnclickAttribute_BindsAndDoesNotBreakWithoutIt() { var binding = new ThreeColumnBinding(); const string withOnclick = "" + "" + " " + ""; var panelWith = MarkupDocument.Build(withOnclick, binding, Sprite); var listWith = Assert.IsType(panelWith.Children[0]); Assert.NotNull(listWith.Columns![0].TextClicked); const string withoutOnclick = "" + "" + " " + ""; var panelWithout = MarkupDocument.Build(withoutOnclick, binding, Sprite); var listWithout = Assert.IsType(panelWithout.Children[0]); Assert.Null(listWithout.Columns![0].TextClicked); } [Fact] public void TextColumn_MalformedOnclickAttribute_ThrowsAtBuild() { var binding = new ThreeColumnBinding(); const string xml = "" + "" + " " + ""; var ex = Assert.Throws(() => MarkupDocument.Build(xml, binding, Sprite)); Assert.Contains("column[0] type=\"text\" onclick", ex.Message); } [Fact] public void ClickInTextColumn_WithOnclick_FiresItInsteadOfSelecting() { var clicked = new List(); var selections = new List(); var list = new UiMarkupList { Width = 60f, Height = 200f, RowHeight = 20f, SelectedIndexSource = () => -1, SelectionChanged = row => selections.Add(row), Columns = new[] { UiMarkupListColumn.Text( 60f, () => new[] { "a", "b", "c" }, null, row => clicked.Add(row)), }, }; var (_, ctx) = MakeContext(200f, 200f); list.DrawSelfAndChildren(ctx); // Row 1 (y = 20..40). list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 10, Data2 = 25 }); Assert.Equal(new[] { 1 }, clicked); Assert.Empty(selections); } // ── Fix round: width semantics (fix item 2) ────────────────────────────── [Fact] public void NonLastColumn_MissingWidth_ThrowsAtBuild_NamingColumnIndexAndType() { var binding = new ThreeColumnBinding(); const string xml = "" + "" + " " + " " + ""; var ex = Assert.Throws(() => MarkupDocument.Build(xml, binding, Sprite)); Assert.Contains("column[0] type=\"text\" width", ex.Message); } [Fact] public void NonLastColumn_UnparseableWidth_ThrowsAtBuild() { var binding = new ThreeColumnBinding(); const string xml = "" + "" + " " + " " + ""; var ex = Assert.Throws(() => MarkupDocument.Build(xml, binding, Sprite)); Assert.Contains("column[0] type=\"text\" width", ex.Message); } [Fact] public void NonLastColumn_NonPositiveWidth_ThrowsAtBuild() { var binding = new ThreeColumnBinding(); const string xml = "" + "" + " " + " " + ""; var ex = Assert.Throws(() => MarkupDocument.Build(xml, binding, Sprite)); Assert.Contains("column[0] type=\"check\" width", ex.Message); } [Fact] public void NonLastColumn_WidthStar_DoesNotThrow_ParsesAsAutoWidth() { var binding = new ThreeColumnBinding(); const string xml = "" + "" + " " + " " + ""; var panel = MarkupDocument.Build(xml, binding, Sprite); var list = Assert.IsType(panel.Children[0]); Assert.NotNull(list.Columns); } [Fact] public void LastColumn_MissingOrInvalidWidth_DoesNotThrow_StillAbsorbsRemainder() { var binding = new ThreeColumnBinding(); const string xml = "" + "" + " " + " " + ""; var panel = MarkupDocument.Build(xml, binding, Sprite); var list = Assert.IsType(panel.Children[0]); Assert.NotNull(list.Columns); } [Fact] public void AutoWidthColumns_ShareRemainingWidthEqually_LastAbsorbsRoundingSlack() { // 3 columns, list width 100: col0 fixed 10, col1 auto ("*"), col2 // (last) auto too. Remaining = 90, split between 2 auto columns: // floor(90/2)=45 each with no slack to prove the interesting case, // so use 101 instead — remaining=91, share=floor(91/2)=45, and the // LAST auto column must absorb the odd pixel (46), not col1. var list = new UiMarkupList { Width = 101f, Height = 40f, RowHeight = 18f, SelectedIndexSource = () => -1, BackgroundColor = default, BorderColor = default, Columns = new[] { UiMarkupListColumn.Text(10f, () => new[] { "" }, null), UiMarkupListColumn.Icon( 0f, () => new uint[] { 20u }, id => (id, 16, 16), _ => { }, isAutoWidth: true), UiMarkupListColumn.Icon( 0f, () => new uint[] { 21u }, id => (id, 16, 16), _ => { }, isAutoWidth: true), }, }; var (renderer, ctx) = MakeContext(200f, 200f); list.DrawSelfAndChildren(ctx); var col1Quad = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == 20u); var col2Quad = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == 21u); // col1 (non-last auto): cell [10,55) — a 16x16 icon centered in a // 45px-wide cell must start at/after x=10 and stay inside the cell. Assert.True(col1Quad.Verts[0] >= 10f - 0.01f && col1Quad.Verts[8] <= 55f + 0.01f, $"expected col1's icon inside [10,55), got x=[{col1Quad.Verts[0]},{col1Quad.Verts[8]}]"); // col2 (last auto): cell [55,101) — the rounding slack (46px, not // 45px) landed here, so its full 16px icon must start at/after x=55. Assert.True(col2Quad.Verts[0] >= 55f - 0.01f, $"expected col2's icon at/after x=55 (10 + 45), got x={col2Quad.Verts[0]}"); } [Fact] public void DeclaredWidthsExceedingListWidth_ClampTheOverflowingColumn_LaterColumnsGetZero() { // col0 declares 80px in a 50px-wide list — clamps to 50. col1 (a // fixed, non-last, non-auto column) and col2 (last) both get 0: col1 // never draws (cellW<=0 skip), col2 (last, always-auto) absorbs // whatever's left, which is also 0. var list = new UiMarkupList { Width = 50f, Height = 40f, RowHeight = 18f, SelectedIndexSource = () => -1, BackgroundColor = default, BorderColor = default, Columns = new[] { UiMarkupListColumn.Icon( 80f, () => new uint[] { 7u }, id => (id, 16, 16), _ => { }), UiMarkupListColumn.Icon( 20f, () => new uint[] { 8u }, id => (id, 16, 16), _ => { }), UiMarkupListColumn.Icon( 20f, () => new uint[] { 9u }, id => (id, 16, 16), _ => { }), }, }; var (renderer, ctx) = MakeContext(200f, 200f); list.DrawSelfAndChildren(ctx); Assert.Contains(renderer.DebugSpriteSegmentVerts, s => s.Texture == 7u); Assert.DoesNotContain(renderer.DebugSpriteSegmentVerts, s => s.Texture == 8u); Assert.DoesNotContain(renderer.DebugSpriteSegmentVerts, s => s.Texture == 9u); var clampedQuad = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == 7u); Assert.True(clampedQuad.Verts[8] - clampedQuad.Verts[0] <= 50f + 0.01f, "expected col0's icon clamped inside the 50px list width"); } // ── Draw-level: column x-offsets, check glyph, icon, clipping ──────────── private static (TextRenderer renderer, UiRenderContext ctx) MakeContext(float w, float h) { var device = new RecordingGpuDevice(); var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused"); renderer.Begin(new Vector2(w, h)); var ctx = new UiRenderContext(renderer, new Vector2(w, h)); return (renderer, ctx); } private sealed class NullGpuFrameSource : ICurrentGpuFrameSource { public IGpuFrame? CurrentFrame => null; } // ── Fix round: try/finally around every per-cell clip (fix item 4) ────── [Fact] public void PerCellDrawException_StillBalancesTheClipStack() { // col0's icon resolver throws mid-draw (a hostile/buggy plugin // resolver) — the per-cell PushClip for that cell must still be // popped before the exception propagates, matching UiButton.cs // /UiElement.cs's own try/finally clip pattern. Without it, the one // leaked PushClip (plus UiElement's own outer ambient-clip pop // consuming the wrong stack entry on the way out) leaves the // context's clip stack permanently off by one. var list = new UiMarkupList { Width = 60f, Height = 40f, RowHeight = 18f, SelectedIndexSource = () => -1, Columns = new[] { UiMarkupListColumn.Icon( 60f, () => new uint[] { 99u }, _ => throw new InvalidOperationException("boom"), _ => { }), }, }; var (_, ctx) = MakeContext(200f, 200f); int depthBefore = ctx.ClipStackDepth; Assert.Throws(() => list.DrawSelfAndChildren(ctx)); Assert.Equal(depthBefore, ctx.ClipStackDepth); } [Fact] public void Columns_IconThenCheck_EachCellDrawsInsideItsOwnColumnBounds() { // col0 = icon, width 20; col1 (last) = check, absorbs the remainder // of a 100-wide list (80px). Fix round item 8: the check column is // now SECOND (not first, as the original draft had it) — a check // glyph in col0's [0,20) position would satisfy a ">=0 and <20" // assertion trivially even if the column-offset math were completely // broken (e.g. everything drawing at x=0); putting it second makes // the assertion a REAL pin — it can only pass if the glyph actually // moved to col1's [20,100) cell. var list = new UiMarkupList { Width = 100f, Height = 40f, RowHeight = 18f, SelectedIndexSource = () => -1, BackgroundColor = default, BorderColor = default, Columns = new[] { UiMarkupListColumn.Icon( 20f, () => new uint[] { 9u }, id => (id, 16, 16), _ => { }), UiMarkupListColumn.Check(20f, () => new[] { true }, _ => { }), }, }; var (renderer, ctx) = MakeContext(200f, 200f); list.DrawSelfAndChildren(ctx); // The check glyph's inner-lamp fill is a distinctive color (not the // default black/zero used by every other fill in this test) — // isolate its vertices by color match. One quad emits 6 vertices // sharing the same color, so take the first rather than requiring // exactly one match. var checkVertex = renderer.DebugSpriteSegmentVerts .SelectMany(s => Chunk(s.Verts)) .First(v => ColorMatches(v, UiCheckLamp.CheckedInner)); Assert.True(checkVertex[0] >= 20f - 0.01f && checkVertex[0] < 100f, $"expected the check glyph inside [20,100), got x={checkVertex[0]}"); // The icon column drew a real (non-zero-width) sprite quad on its // own resolved texture (9u), entirely inside col0's [0,20) cell. var iconQuad = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == 9u); Assert.True(iconQuad.Verts[0] < 20f, $"expected the icon column's sprite inside [0,20), got x={iconQuad.Verts[0]}"); float iconWidth = iconQuad.Verts[8] - iconQuad.Verts[0]; Assert.True(iconWidth > 0f, $"expected a non-zero icon quad width, got {iconWidth}"); } [Fact] public void CheckColumn_DrawsCheckedAndUncheckedGlyphsPerRow() { var list = new UiMarkupList { Width = 60f, Height = 60f, RowHeight = 18f, SelectedIndexSource = () => -1, BackgroundColor = default, BorderColor = default, Columns = new[] { UiMarkupListColumn.Check(60f, () => new[] { true, false }, _ => { }), }, }; var (renderer, ctx) = MakeContext(200f, 200f); list.DrawSelfAndChildren(ctx); var quads = renderer.DebugSpriteSegmentVerts.SelectMany(s => Chunk(s.Verts)).ToList(); Assert.Contains(quads, v => ColorMatches(v, UiCheckLamp.CheckedInner)); Assert.Contains(quads, v => ColorMatches(v, UiCheckLamp.UncheckedInner)); // Row 0's checked glyph sits above row 1's unchecked glyph. var checkedY = quads.First(v => ColorMatches(v, UiCheckLamp.CheckedInner))[1]; var uncheckedY = quads.First(v => ColorMatches(v, UiCheckLamp.UncheckedInner))[1]; Assert.True(uncheckedY > checkedY, $"expected row 1's glyph ({uncheckedY}) below row 0's ({checkedY})"); } // ── Fix round: short check columns draw the unchecked lamp past their // own row count (fix item 7) ──────────────────────────────────────────── [Fact] public void CheckColumn_DrawsUncheckedLamp_ForRowsPastItsOwnRowCount() { // col0 (text) has 3 rows, driving the overall row count. col1 (last, // check) only binds 1 row of its own — VVS materializes every cell // regardless (docs/plugin-ui-markup.md already documented this), so // rows 1 and 2 must STILL draw an unchecked lamp, not nothing. var list = new UiMarkupList { Width = 60f, Height = 60f, RowHeight = 18f, SelectedIndexSource = () => -1, BackgroundColor = default, BorderColor = default, Columns = new[] { UiMarkupListColumn.Text(20f, () => new[] { "a", "b", "c" }, null), UiMarkupListColumn.Check(20f, () => new[] { true }, _ => { }), }, }; var (renderer, ctx) = MakeContext(200f, 200f); list.DrawSelfAndChildren(ctx); var uncheckedQuads = renderer.DebugSpriteSegmentVerts .SelectMany(s => Chunk(s.Verts)) .Where(v => ColorMatches(v, UiCheckLamp.UncheckedInner)) .ToList(); // One unchecked lamp for row 1 (bool value false — not this test's // case) never applies here since col1's only row (0) is TRUE; rows 1 // and 2 have no bound value at all and must still draw UNCHECKED. Assert.True(uncheckedQuads.Count >= 2, $"expected an unchecked lamp for both row 1 and row 2 (past col1's own " + $"1-row data), got {uncheckedQuads.Count} unchecked lamp quad(s)"); } // ── Fix round: shared UiCheckLamp, centered check glyph (fix item 8) ──── [Fact] public void CheckColumn_GlyphIsHorizontallyCenteredInItsCell_LikeTheIconCellAlreadyIs() { // A single (last) check column absorbing a 50px-wide list: the old // flush-left placement put the lamp's inner fill at x=4 regardless // of cell width; centered (like DrawIconCell already centers its // sprite) it must sit near the cell's midpoint instead. var list = new UiMarkupList { Width = 50f, Height = 20f, RowHeight = 18f, SelectedIndexSource = () => -1, BackgroundColor = default, BorderColor = default, Columns = new[] { UiMarkupListColumn.Check(50f, () => new[] { true }, _ => { }), }, }; var (renderer, ctx) = MakeContext(200f, 200f); list.DrawSelfAndChildren(ctx); var innerVertex = renderer.DebugSpriteSegmentVerts .SelectMany(s => Chunk(s.Verts)) .First(v => ColorMatches(v, UiCheckLamp.CheckedInner)); // Flush-left would put this at x=4; centered in a 50px cell (extent // 48, lamp size 11) it lands at 1 + (48-11)/2 + 3 = 22.5. Assert.True(innerVertex[0] > 15f, $"expected the check glyph centered in its 50px cell (~22.5), got x={innerVertex[0]}"); } [Fact] public void IconColumn_DrawsResolvedRowIcon_AndSkipsAMissingOne() { var list = new UiMarkupList { Width = 60f, Height = 40f, RowHeight = 18f, SelectedIndexSource = () => -1, BackgroundColor = default, BorderColor = default, Columns = new[] { UiMarkupListColumn.Icon( 60f, () => new uint[] { 9u, 0u }, id => id == 0u ? (0u, 0, 0) : (id, 16, 16), _ => { }), }, }; var (renderer, ctx) = MakeContext(200f, 200f); list.DrawSelfAndChildren(ctx); Assert.Contains(renderer.DebugSpriteSegmentVerts, s => s.Texture == 9u); // Row 1's id (0u) resolves to nothing — there is exactly one drawn // icon quad total (6 vertices; AppendQuad's two-triangle layout). int iconVertexCount = renderer.DebugSpriteSegmentVerts .Where(s => s.Texture == 9u) .Sum(s => s.Verts.Count / 8); Assert.Equal(6, iconVertexCount); } [Fact] public void LastColumn_AbsorbsRemainingWidth_RegardlessOfItsOwnDeclaredWidth() { // col0 (text) declares 30px; col1 (icon, LAST) declares only 5px, but // being last it must absorb the true remainder of a 100px-wide list // (70px), not its own tiny declared value — a 16x16 icon fit into a // genuine 70px-wide cell scales up to its full 16px size, whereas a // 5px cell would force it down to ~3px (extentW=3, scale=3/16). var list = new UiMarkupList { Width = 100f, Height = 40f, RowHeight = 18f, SelectedIndexSource = () => -1, BackgroundColor = default, BorderColor = default, Columns = new[] { UiMarkupListColumn.Text(30f, () => new[] { "" }, null), UiMarkupListColumn.Icon(5f, () => new uint[] { 9u }, id => (id, 16, 16), _ => { }), }, }; var (renderer, ctx) = MakeContext(200f, 200f); list.DrawSelfAndChildren(ctx); var iconQuad = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == 9u); float drawnWidth = iconQuad.Verts[8] - iconQuad.Verts[0]; Assert.True(drawnWidth > 10f, $"expected the icon to render near its full 16px size in the ~70px remainder " + $"cell (not squeezed into its declared 5px), got width {drawnWidth}"); // Also at/after col0's 30px boundary — never inside col0's own cell. Assert.True(iconQuad.Verts[0] >= 30f - 0.01f); } [Fact] public void TextColumn_OverLongText_ClipsToItsOwnCellWidth() { var glyphs = new Dictionary { ['W'] = new FontCharDesc { Unicode = 'W', Width = 8, Height = 8 }, }; var font = new UiDatFont( fgTex: 1u, fgW: 32, fgH: 32, bgTex: 0, bgW: 0, bgH: 0, lineHeight: 16f, baselineOffset: 12f, glyphs); // A narrow (10px) first column with a long run of wide glyphs must // never draw past x=10 — the SECOND column starts immediately after // and must never see any of the first column's glyph geometry. var list = new UiMarkupList { Width = 100f, Height = 40f, RowHeight = 18f, DatFont = font, SelectedIndexSource = () => -1, BackgroundColor = default, BorderColor = default, Columns = new[] { UiMarkupListColumn.Text(10f, () => new[] { "WWWWWWWWWW" }, null), UiMarkupListColumn.Text(90f, () => new[] { "" }, null), }, }; var (renderer, ctx) = MakeContext(200f, 200f); list.DrawSelfAndChildren(ctx); var glyphVertices = renderer.DebugSpriteSegmentVerts .Where(s => s.Texture == 1u) .SelectMany(s => Chunk(s.Verts)) .ToList(); Assert.NotEmpty(glyphVertices); foreach (var vertex in glyphVertices) { float x = vertex[0]; Assert.True(x <= 10f + 0.01f, $"expected every glyph vertex clipped inside the 10px column, got x={x}"); } } /// Split a flat 8-floats/vertex, 6-vertices/quad buffer into per-vertex arrays. private static IEnumerable Chunk(IReadOnlyList verts) { for (int i = 0; i + 8 <= verts.Count; i += 8) yield return verts.Skip(i).Take(8).ToArray(); } private static bool ColorMatches(float[] vertex, Vector4 color) => MathF.Abs(vertex[4] - color.X) < 0.001f && MathF.Abs(vertex[5] - color.Y) < 0.001f && MathF.Abs(vertex[6] - color.Z) < 0.001f && MathF.Abs(vertex[7] - color.W) < 0.001f; // ── Hit-test: text selects, check/icon fire their own callback ─────────── private static UiMarkupList MakeHitTestList( out List selections, out List checkFires, out List iconFires, int rowCount = 3, float width = 60f) { var sel = new List(); var chk = new List(); var icn = new List(); selections = sel; checkFires = chk; iconFires = icn; var textRows = Enumerable.Range(0, rowCount).Select(i => $"row{i}").ToArray(); var checkRows = Enumerable.Range(0, rowCount).Select(i => i % 2 == 0).ToArray(); var iconRows = Enumerable.Range(0, rowCount).Select(i => (uint)(i + 1)).ToArray(); return new UiMarkupList { Width = width, Height = 200f, RowHeight = 20f, SelectedIndexSource = () => -1, SelectionChanged = row => sel.Add(row), Columns = new[] { UiMarkupListColumn.Text(20f, () => textRows, null), UiMarkupListColumn.Check(20f, () => checkRows, row => chk.Add(row)), UiMarkupListColumn.Icon(20f, () => iconRows, id => (id, 16, 16), row => icn.Add(row)), }, }; } [Fact] public void ClickInTextColumn_SelectsAndDoesNotFireCheckOrIcon() { var list = MakeHitTestList(out var sel, out var chk, out var icn); var (_, ctx) = MakeContext(200f, 200f); list.DrawSelfAndChildren(ctx); // frame order: draw, then handle input // Row 1 (y = 20..40), x = 10 (inside the text column [0,20)). list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 10, Data2 = 25 }); Assert.Equal(new[] { 1 }, sel); Assert.Empty(chk); Assert.Empty(icn); } [Fact] public void ClickInCheckColumn_FiresCheckCallbackWithRowIndex_AndDoesNotSelect() { var list = MakeHitTestList(out var sel, out var chk, out var icn); var (_, ctx) = MakeContext(200f, 200f); list.DrawSelfAndChildren(ctx); // Row 2 (y = 40..60), x = 30 (inside the check column [20,40)). list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 30, Data2 = 45 }); Assert.Equal(new[] { 2 }, chk); Assert.Empty(sel); Assert.Empty(icn); } [Fact] public void ClickInIconColumn_FiresIconCallbackWithRowIndex_AndDoesNotSelect() { var list = MakeHitTestList(out var sel, out var chk, out var icn); var (_, ctx) = MakeContext(200f, 200f); list.DrawSelfAndChildren(ctx); // Row 0 (y = 0..20), x = 50 (inside the icon column [40,60)). list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 50, Data2 = 5 }); Assert.Equal(new[] { 0 }, icn); Assert.Empty(sel); Assert.Empty(chk); } [Fact] public void ClickPastTheLastRow_DoesNothing_ButStillSwallowsThePress() { var list = MakeHitTestList(out var sel, out var chk, out var icn, rowCount: 2); var (_, ctx) = MakeContext(200f, 200f); list.DrawSelfAndChildren(ctx); // Only 2 rows (0..40); click at y=100 lands well past the data. bool handled = list.OnEvent( new UiEvent { Type = UiEventType.MouseDown, Data1 = 10, Data2 = 100 }); Assert.True(handled); Assert.Empty(sel); Assert.Empty(chk); Assert.Empty(icn); } // ── Fix round: per-column row-bound guard on callbacks (fix item 3) ───── [Fact] public void ClickInShortCheckColumn_PastItsOwnRowCount_FiresNothing() { // col0 = text, 3 rows (drives the overall row count). col1 (last) = // check, only 1 row of its own. A click on row 2 (index 2) lands // within the OVERALL row count (0..3) and inside col1's cell, but // col1's own CheckSource only covers row 0 — the click must fire // nothing, not throw and not fall back to some default value. var fired = new List(); var list = new UiMarkupList { Width = 60f, Height = 200f, RowHeight = 20f, SelectedIndexSource = () => -1, Columns = new[] { UiMarkupListColumn.Text(20f, () => new[] { "a", "b", "c" }, null), UiMarkupListColumn.Check(20f, () => new[] { true }, row => fired.Add(row)), }, }; var (_, ctx) = MakeContext(200f, 200f); list.DrawSelfAndChildren(ctx); // Row 2 (y = 40..60), x = 30 (inside col1's [20,60) cell). list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 30, Data2 = 45 }); Assert.Empty(fired); } [Fact] public void ClickInShortIconColumn_PastItsOwnRowCount_FiresNothing() { var fired = new List(); var list = new UiMarkupList { Width = 60f, Height = 200f, RowHeight = 20f, SelectedIndexSource = () => -1, Columns = new[] { UiMarkupListColumn.Text(20f, () => new[] { "a", "b", "c" }, null), UiMarkupListColumn.Icon( 20f, () => new uint[] { 9u }, id => (id, 16, 16), row => fired.Add(row)), }, }; var (_, ctx) = MakeContext(200f, 200f); list.DrawSelfAndChildren(ctx); list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 30, Data2 = 45 }); Assert.Empty(fired); } [Fact] public void ClickInShortTextColumn_WithOnclick_PastItsOwnRowCount_FiresNothing() { var fired = new List(); var selections = new List(); var list = new UiMarkupList { Width = 60f, Height = 200f, RowHeight = 20f, SelectedIndexSource = () => -1, SelectionChanged = row => selections.Add(row), Columns = new[] { UiMarkupListColumn.Text( 20f, () => new[] { "only-row-0" }, null, row => fired.Add(row)), UiMarkupListColumn.Text(20f, () => new[] { "a", "b", "c" }, null), }, }; var (_, ctx) = MakeContext(200f, 200f); list.DrawSelfAndChildren(ctx); // Row 2, x=10 (inside col0's onclick-bearing cell) — col0 only has // row 0, so nothing fires; the click must NOT fall back to // SelectionChanged either (that's a different column's affordance). list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 10, Data2 = 45 }); Assert.Empty(fired); Assert.Empty(selections); } // ── Fix round: kill per-frame/per-event allocations (fix item 6) ──────── [Fact] public void ClickEvent_DoesNotReinvokeColumnSourceFunctions_ReusesDrawSideMaterialization() { // Each column's source Func increments its own counter every time // it's invoked. One Draw call must invoke each exactly once // (materializing the frame's rows); any number of SUBSEQUENT click // events must invoke NONE of them again — the click path reuses the // draw-side materialized rows/row-count/layout rather than // re-invoking every source and re-laying-out on every event. int textCalls = 0, checkCalls = 0, iconCalls = 0; var list = new UiMarkupList { Width = 60f, Height = 200f, RowHeight = 20f, SelectedIndexSource = () => -1, Columns = new[] { UiMarkupListColumn.Text(20f, () => { textCalls++; return new[] { "a", "b", "c" }; }, null), UiMarkupListColumn.Check( 20f, () => { checkCalls++; return new[] { true, false, true }; }, _ => { }), UiMarkupListColumn.Icon( 20f, () => { iconCalls++; return new uint[] { 1u, 2u, 3u }; }, id => (id, 16, 16), _ => { }), }, }; var (_, ctx) = MakeContext(200f, 200f); list.DrawSelfAndChildren(ctx); Assert.Equal(1, textCalls); Assert.Equal(1, checkCalls); Assert.Equal(1, iconCalls); for (int i = 0; i < 5; i++) { list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 10, Data2 = 25 }); list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 30, Data2 = 45 }); list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 50, Data2 = 5 }); } Assert.Equal(1, textCalls); Assert.Equal(1, checkCalls); Assert.Equal(1, iconCalls); } [Fact] public void Scroll_OffsetIsRespectedBySubsequentHitTests() { var list = MakeHitTestList(out var sel, out _, out _, rowCount: 10, width: 60f); list.Height = 40f; // 2 visible rows of a 10-row list — forces real scrolling // Force a draw so the widget has computed VisibleRows/topRow state at // least once (mirrors normal frame order: draw, then handle input). var (_, ctx) = MakeContext(200f, 200f); list.DrawSelfAndChildren(ctx); // Scroll down 3 rows (negative wheel data = scroll down, matching the // legacy single-column list's own convention: _topRow -= Sign(Data0)). for (int i = 0; i < 3; i++) list.OnEvent(new UiEvent { Type = UiEventType.Scroll, Data0 = -1 }); list.DrawSelfAndChildren(ctx); // clamp/re-settle topRow like a real frame would // Click the FIRST visible row after scrolling 3 rows down — should // resolve to absolute row 3, not row 0. list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 10, Data2 = 5 }); Assert.Equal(new[] { 3 }, sel); } // ── Backward compatibility: column-less list is unaffected ────────────── private sealed class LegacyBinding { public IReadOnlyList Choices => new[] { "First", "Second" }; public IReadOnlyList ChoiceColors => new[] { 0xFF0000u, 0x00FF00u }; public int SelectedIndex { get; set; } = 1; public Action SelectIndex => _ => { }; } /// /// Regression pin for "without <column> children the element is /// byte-for-byte the old single-text-column list": builds the SAME /// column-less markup through (which now /// routes through the new if/else split in the "list" case) and compares /// its draw output against a constructed BY /// HAND with equivalent fields, bypassing /// entirely — i.e. against the widget's own unmodified /// OnDraw/legacy branch, not a snapshot captured from a different /// commit. Every vertex float must match exactly. /// [Fact] public void ColumnLessList_ProducesTheIdenticalDrawRecordToTheHandBuiltWidget() { var glyphs = new Dictionary { ['F'] = new FontCharDesc { Unicode = 'F', Width = 8, Height = 8 }, }; var font = new UiDatFont( fgTex: 1u, fgW: 32, fgH: 32, bgTex: 0, bgW: 0, bgH: 0, lineHeight: 16f, baselineOffset: 12f, glyphs); var binding = new LegacyBinding(); const string xml = "" + "" + ""; var panel = MarkupDocument.Build(xml, binding, Sprite); var viaMarkup = Assert.IsType(panel.Children[0]); viaMarkup.DatFont = font; // markup panels always get a host font; irrelevant to the pin itself viaMarkup.BackgroundColor = default; viaMarkup.BorderColor = default; var handBuilt = new UiMarkupList { Width = 180f, Height = 60f, RowHeight = 18f, DatFont = font, ItemsSource = () => binding.Choices, ItemColorsSource = () => binding.ChoiceColors, SelectedIndexSource = () => binding.SelectedIndex, BackgroundColor = default, BorderColor = default, }; var (rendererMarkup, ctxMarkup) = MakeContext(200f, 200f); viaMarkup.DrawSelfAndChildren(ctxMarkup); var (rendererHand, ctxHand) = MakeContext(200f, 200f); handBuilt.DrawSelfAndChildren(ctxHand); var markupVerts = rendererMarkup.DebugSpriteSegmentVerts.ToList(); var handVerts = rendererHand.DebugSpriteSegmentVerts.ToList(); Assert.Equal(handVerts.Count, markupVerts.Count); for (int i = 0; i < handVerts.Count; i++) { Assert.Equal(handVerts[i].Texture, markupVerts[i].Texture); Assert.Equal(handVerts[i].Verts.Count, markupVerts[i].Verts.Count); for (int j = 0; j < handVerts[i].Verts.Count; j++) Assert.Equal(handVerts[i].Verts[j], markupVerts[i].Verts[j], 4); } Assert.Null(viaMarkup.Columns); } // ── Fix round: end-to-end MarkupDocument builds (fix item 10) ─────────── private static UiDatFont MakeAsciiFont() { var glyphs = new Dictionary(); foreach (char c in "ABCDEFGH") glyphs[c] = new FontCharDesc { Unicode = c, Width = 6, Height = 8 }; return new UiDatFont( fgTex: 1u, fgW: 32, fgH: 32, bgTex: 0, bgW: 0, bgH: 0, lineHeight: 16f, baselineOffset: 12f, glyphs); } private sealed class MetaShapedBinding { public IReadOnlyList Delete { get; } = new uint[] { 101u, 102u }; public IReadOnlyList MoveUp { get; } = new uint[] { 201u, 202u }; public IReadOnlyList MoveDown { get; } = new uint[] { 301u, 302u }; public IReadOnlyList State { get; } = new[] { "A", "B" }; public IReadOnlyList Condition { get; } = new[] { "C", "D" }; public IReadOnlyList Action { get; } = new[] { "E", "F" }; public int Selected { get; set; } = -1; public List Selections { get; } = new(); public Action SelectRow => row => Selections.Add(row); public List DeleteClicks { get; } = new(); public Action ClickDelete => row => DeleteClicks.Add(row); public List MoveUpClicks { get; } = new(); public Action ClickMoveUp => row => MoveUpClicks.Add(row); public List MoveDownClicks { get; } = new(); public Action ClickMoveDown => row => MoveDownClicks.Add(row); public List ActionClicks { get; } = new(); public Action ClickAction => row => ActionClicks.Add(row); } /// /// VTank's Meta tab (refs/vtank/uTank2.ViewXML.mainView.xml's /// lstMetaRules, ~line 292): 3 icon columns (16px each), a 150px /// text column, then TWO trailing 0-width (auto) text columns — the real /// VVS shape our width="*" convention exists to express. The list /// width (703) is chosen so the two auto columns' shares DON'T divide /// evenly (505/2 = 252 remainder 1), proving the last one absorbs the /// rounding slack rather than both getting an identical share. /// [Fact] public void EndToEnd_MetaShapedSixColumnList_AutoColumnsShareRemainder_EachKindRoutesCorrectly() { var resolver = new FakeIconResolver(); var binding = new MetaShapedBinding(); const string xml = "" + "" + " " + " " + " " + " " + " " + " " + ""; var panel = MarkupDocument.Build(xml, binding, Sprite, datFont: MakeAsciiFont(), icons: resolver); var list = Assert.IsType(panel.Children[0]); Assert.Equal(6, list.Columns!.Count); Assert.Equal( new[] { UiMarkupListColumnKind.Icon, UiMarkupListColumnKind.Icon, UiMarkupListColumnKind.Icon, UiMarkupListColumnKind.Text, UiMarkupListColumnKind.Text, UiMarkupListColumnKind.Text, }, list.Columns.Select(c => c.Kind)); var (renderer, ctx) = MakeContext(800f, 200f); list.DrawSelfAndChildren(ctx); // Column ranges: [0,16) [16,32) [32,48) [48,198) [198,450) [450,703). // The two auto text columns split 505 leftover px as 252/253 — the // LAST one (Action) absorbs the extra pixel. var deleteQuad = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == 101u); Assert.True(deleteQuad.Verts[0] < 16f); var moveUpQuad = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == 201u); Assert.True(moveUpQuad.Verts[0] >= 16f && moveUpQuad.Verts[0] < 32f); var moveDownQuad = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == 301u); Assert.True(moveDownQuad.Verts[0] >= 32f && moveDownQuad.Verts[0] < 48f); var glyphs = renderer.DebugSpriteSegmentVerts .Where(s => s.Texture == 1u) .SelectMany(s => Chunk(s.Verts)) .ToList(); Assert.Contains(glyphs, v => v[0] >= 48f && v[0] < 198f); // State Assert.Contains(glyphs, v => v[0] >= 198f && v[0] < 450f); // Condition (auto, 252px) Assert.Contains(glyphs, v => v[0] >= 450f && v[0] < 703f); // Action (auto, 253px — the slack) // Click routing: each column kind reaches its own callback with the // right row; the plain text column (State) still selects. list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 8, Data2 = 0 }); // col0 row0 list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 24, Data2 = 18 }); // col1 row1 list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 40, Data2 = 0 }); // col2 row0 list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 60, Data2 = 18 }); // col3 (State) row1 list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 600, Data2 = 0 }); // col5 (Action) row0 Assert.Equal(new[] { 0 }, binding.DeleteClicks); Assert.Equal(new[] { 1 }, binding.MoveUpClicks); Assert.Equal(new[] { 0 }, binding.MoveDownClicks); Assert.Equal(new[] { 1 }, binding.Selections); // col3 click selected row1 Assert.Equal(new[] { 0 }, binding.ActionClicks); } private sealed class MonsterShapedBinding { public IReadOnlyList Checks { get; } = new[] { true, false }; public IReadOnlyList Texts { get; } = new[] { "A", "B" }; public IReadOnlyList Icons0 { get; } = new uint[] { 501u, 502u }; public IReadOnlyList Icons1 { get; } = new uint[] { 601u, 602u }; public int Selected { get; set; } = -1; public List<(int Column, int Row)> Check0Fires { get; } = new(); public Action OnCheck0 => row => Check0Fires.Add((0, row)); public Action OnCheckRest => _ => { }; public List<(int Column, int Row)> Text0Fires { get; } = new(); public Action OnText0 => row => Text0Fires.Add((0, row)); public Action OnTextRest => _ => { }; public List<(int Column, int Row)> Icon0Fires { get; } = new(); public Action OnIcon0 => row => Icon0Fires.Add((0, row)); public Action OnIcon1 => _ => { }; } /// /// VTank's Monsters tab (refs/vtank/uTank2.ViewXML.mainView.xml's /// lstMonsters, ~lines 111-140): 14 check columns (16px each, /// clFester..clCorrosion), 7 text columns (clMonName 120, clPriority 20, /// clDamageType 56, clExVulnType 56, clWeaponToUse 80, clOffHand 80, /// clPetDamageType 56), then 2 icon columns (clMoveUp/clMoveDown, 16px /// each). Every declared width is a plain positive number — no /// "*" anywhere, matching the real file — so the LAST column /// (clMoveDown) still absorbs the remainder "as today", which this test /// pins by sizing the list to the EXACT sum of every column's declared /// width (724px): the last column ends up with exactly its own declared /// 16px, not stretched or starved. /// [Fact] public void EndToEnd_MonstersShapedTwentyThreeColumnList_EachKindRoutesCorrectly() { var resolver = new FakeIconResolver(); var binding = new MonsterShapedBinding(); var columns = new System.Text.StringBuilder(); for (int i = 0; i < 14; i++) { string onchange = i == 0 ? "{OnCheck0}" : "{OnCheckRest}"; columns.Append( $""); } int[] textWidths = { 120, 20, 56, 56, 80, 80, 56 }; for (int i = 0; i < textWidths.Length; i++) { string onclick = i == 0 ? "{OnText0}" : "{OnTextRest}"; columns.Append( $""); } columns.Append(""); columns.Append(""); string xml = "" + "" + columns + ""; var panel = MarkupDocument.Build(xml, binding, Sprite, datFont: MakeAsciiFont(), icons: resolver); var list = Assert.IsType(panel.Children[0]); Assert.Equal(23, list.Columns!.Count); Assert.Equal(14, list.Columns.Count(c => c.Kind == UiMarkupListColumnKind.Check)); Assert.Equal(7, list.Columns.Count(c => c.Kind == UiMarkupListColumnKind.Text)); Assert.Equal(2, list.Columns.Count(c => c.Kind == UiMarkupListColumnKind.Icon)); var (renderer, ctx) = MakeContext(800f, 200f); list.DrawSelfAndChildren(ctx); // col0 (check): [0,16). col14 (first text, clMonName): [224,344). // col21 (first icon, clMoveUp): [692,708). col22 (LAST, clMoveDown): // [708,724) — exactly its own declared 16px since every width here // is a plain fixedwidth number (no "*"), the "no auto column" // no-op-through-ColumnLayout case. var checkGlyph = renderer.DebugSpriteSegmentVerts .SelectMany(s => Chunk(s.Verts)) .First(v => ColorMatches(v, UiCheckLamp.CheckedInner)); Assert.True(checkGlyph[0] < 16f); var textGlyphs = renderer.DebugSpriteSegmentVerts .Where(s => s.Texture == 1u) .SelectMany(s => Chunk(s.Verts)) .ToList(); Assert.Contains(textGlyphs, v => v[0] >= 224f && v[0] < 344f); var icon0Quad = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == 501u); // Both icon columns share texture 501u/502u pairs per row, but only // ONE quad per texture id total across both columns (each row binds // the SAME Icons source to both columns) — assert its x falls in // EITHER icon column's range, proving the icon kind draws there and // nowhere else. Assert.True(icon0Quad.Verts[0] >= 692f && icon0Quad.Verts[0] < 724f); // Click routing: one representative column per kind reaches its own // callback with the right row; the OTHER same-kind columns (sharing // a dummy callback) stay untouched. list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 8, Data2 = 18 }); // col0 (check) row1 list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 260, Data2 = 0 }); // col14 (text) row0 list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 700, Data2 = 18 }); // col21 (icon) row1 Assert.Equal(new[] { (0, 1) }, binding.Check0Fires); Assert.Equal(new[] { (0, 0) }, binding.Text0Fires); Assert.Equal(new[] { (0, 1) }, binding.Icon0Fires); } }