From e178c6abacd2b6bfeefc0e156c30bad557f8f309 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 6 Sep 2026 21:28:10 +0200 Subject: [PATCH] =?UTF-8?q?fix(vt):=20list=20column=20fix=20round=2010/11?= =?UTF-8?q?=20=E2=80=94=20Meta/Monsters-shaped=20end-to-end=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two tests built through the full MarkupDocument.Build parse -> bind -> draw (recording renderer) -> click pipeline, transcribing the two real VTank list shapes named in the fix round brief (refs/vtank/uTank2.ViewXML.mainView.xml): - EndToEnd_MetaShapedSixColumnList: lstMetaRules's shape (3 icon columns, a 150px text column, then two width="*" text columns, the second with an onclick) at a list width (703) chosen so the two auto columns' 505px leftover does NOT divide evenly (252/253) — proving the last one absorbs the rounding slack. Asserts each column kind draws inside its own x-range and a click in each of the six columns reaches its own bound callback with the right row (icon columns fire their own onclick; the plain text column still selects; the onclick-bearing trailing text column fires that instead). - EndToEnd_MonstersShapedTwentyThreeColumnList: lstMonsters's shape (14 check columns, 7 text columns each given an onclick per fix item 1, 2 icon columns) built from real fixedwidth values with NO "*" anywhere, at a list width (724) equal to the exact sum of every column's declared width — the "no auto column, last absorbs the remainder as today" case. Asserts the column-kind counts, each drawn kind's x-range, and one representative click per kind reaches its own callback (other same-kind columns share a dummy callback, proving the bound-per-column dispatch, not just "some column of that kind fired"). Both use iconkind="item" for their icon columns (not the default "did") so the fake resolver's echoed id is the literal texture drawn, sidestepping PluginIcons.Normalize's did-namespace OR — an incidental discovery while writing these (the first draft asserted on raw ids and failed against the normalized 0x06000000-tagged textures). Verified meaningful two ways: both failed first against the pre-fix raw-id assumption (texture ids came back normalized), and — a stronger check specific to these integration tests, since the underlying mechanics were already covered by earlier fix-round commits — a temporary mutation of ColumnLayout's auto-column share computation (forcing share=0) made EndToEnd_MetaShapedSixColumnList fail exactly as expected before being reverted. Co-Authored-By: Claude Fable 5.1 --- .../UI/MarkupListColumnsTests.cs | 217 ++++++++++++++++++ 1 file changed, 217 insertions(+) diff --git a/tests/AcDream.App.Tests/UI/MarkupListColumnsTests.cs b/tests/AcDream.App.Tests/UI/MarkupListColumnsTests.cs index f372d2545..810022964 100644 --- a/tests/AcDream.App.Tests/UI/MarkupListColumnsTests.cs +++ b/tests/AcDream.App.Tests/UI/MarkupListColumnsTests.cs @@ -1182,4 +1182,221 @@ public sealed class MarkupListColumnsTests 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); + } }