diff --git a/src/AcDream.Plugins.MossTank/MossTankPanel.cs b/src/AcDream.Plugins.MossTank/MossTankPanel.cs index 9ae0b7e3..696e9a54 100644 --- a/src/AcDream.Plugins.MossTank/MossTankPanel.cs +++ b/src/AcDream.Plugins.MossTank/MossTankPanel.cs @@ -1542,6 +1542,26 @@ internal sealed partial class MossTankPanel public IReadOnlyList MonsterMoveDownIcons => _monsterMoveDownIconsColumn; public Action MoveMonsterRuleUpAt => row => MoveMonsterRuleAtCore(row, -1); public Action MoveMonsterRuleDownAt => row => MoveMonsterRuleAtCore(row, 1); + // Round F item 5 (resemblance re-check: "Monsters' move-down arrow + // drifted ~67 px from move-up because the last (auto) column + // absorbed the new width — add a trailing filler column"): + // docs/plugin-ui-markup.md's own "Width semantics" rule makes the + // LAST in a ALWAYS auto regardless of its own + // declared width, so MonsterMoveDownIcons — the grid's 23rd and, at + // the time, LAST column — silently absorbed every pixel of window + // growth instead of staying at its declared 23px pitch. A trailing + // filler column (empty items) now sits after it so MoveDownIcons + // keeps its own real width and this filler absorbs the remainder + // instead — the same "fixed icon + trailing auto filler" shape + // lstMetaRules already proves out + // (EndToEnd_MetaShapedSixColumnList_AutoColumnsShareRemainder). + // MonsterFillerClick is a real, bound no-op — MossTankMarkupContractTests' + // EveryInteractiveControlDeclaresARealHandlerBinding requires every + // interactive control (every included) to declare SOME + // handler; clicking blank filler space does nothing, but the binding + // itself is real, not omitted. + public IReadOnlyList MonsterFillerColumn => Array.Empty(); + public Action MonsterFillerClick => static _ => { }; /// Vitals line, using the same numbers the character panel shows. public string Vitals => _vitals; diff --git a/src/AcDream.Plugins.MossTank/mosstank.xml b/src/AcDream.Plugins.MossTank/mosstank.xml index b31c8546..7387a9c4 100644 --- a/src/AcDream.Plugins.MossTank/mosstank.xml +++ b/src/AcDream.Plugins.MossTank/mosstank.xml @@ -459,6 +459,15 @@ + + + /// Round F item 5 (resemblance re-check: "Monsters' move-down arrow + /// drifted ~67 px from move-up because the last (auto) column + /// absorbed the new width — add a trailing filler column"). + /// MonsterMoveDownIcons used to be the grid's LAST <column>, + /// and docs/plugin-ui-markup.md's own "Width semantics" rule makes + /// the LAST column ALWAYS auto regardless of its own declared width + /// — so it silently absorbed every pixel of window growth instead of + /// staying at its declared 23px pitch, dragging the down-arrow away + /// from the up-arrow as the panel widened. Proves the two icons stay + /// the same fixed ~23px apart at both the authored default (984) and + /// the D-6 enlarged size (1100), through the SAME real mosstank.xml + /// file and MossTankPanel this file's other real-file tests use. + /// + [Theory] + [InlineData(984f)] + [InlineData(1100f)] + public void MonstersMoveUpAndMoveDownIconsStayAdjacentAtEveryWidth(float width) + { + string xml = File.ReadAllText( + Path.Combine(MossTankMarkupDirectory, "mosstank.xml")); + var panel = new MossTankPanel(new StubHost()); + + // The MoveUp/MoveDown icon columns need a real IMarkupIconResolver + // wired (unlike every other test in this file, which only cares + // about geometry) — MarkupDocument.Build leaves + // silently undrawn without one, so an identity stub is required + // here to actually exercise DrawIconCell. + UiNineSlicePanel built = MarkupDocument.Build( + xml, panel, static id => (id, 32, 32), icons: new IdentityIconResolver()); + + UiPanel[] tabGroups = built.Children + .Where(static child => child.GetType() == typeof(UiPanel)) + .Cast() + .ToArray(); + Assert.Equal(9, tabGroups.Length); + foreach (UiPanel group in tabGroups) + group.Visible = false; + UiPanel monstersGroup = tabGroups[3]; + monstersGroup.Visible = true; + + var device = new RecordingGpuDevice(); + var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused"); + renderer.Begin(new Vector2(1400f, 900f)); + var ctx = new UiRenderContext(renderer, new Vector2(1400f, 900f)); + + // First draw at the authored default captures every anchored + // descendant's baseline margins; only THEN resize to the target + // width and redraw — the same two-draw shape every other + // real-file re-layout test in this file uses. Both draws record + // sprite calls into the SAME device, so each icon's quad appears + // TWICE (once per draw) — .Last() reads the final, resized-layout + // draw, not the stale authored-default one. + built.DrawSelfAndChildren(ctx); + built.Width = width; + built.DrawSelfAndChildren(ctx); + + var moveUpQuad = renderer.DebugSpriteSegmentVerts + .Last(static s => s.Texture == 0x060028FCu); + var moveDownQuad = renderer.DebugSpriteSegmentVerts + .Last(static s => s.Texture == 0x060028FDu); + + float gap = moveDownQuad.Verts[0] - moveUpQuad.Verts[0]; + Assert.True( + gap is >= 20f and <= 26f, + $"MoveUp/MoveDown icons are {gap}px apart at width {width} — " + + "expected VTank's fixed ~23px icon pitch, not the growing " + + "gap a still-last, still-auto MoveDown column would produce."); + } + private static void AssertResolvedNoSiblingOverlap(UiElement container) { // Same "invisible subtree never got a real anchor pass" reasoning @@ -351,6 +421,21 @@ public sealed class MossTankMarkupBuildOverRealFilesTests public IGpuFrame? CurrentFrame => null; } + /// Identity for icon-column + /// draw tests — every non-zero id "resolves" to itself as the texture + /// (16x16), matching MarkupListColumnsTests.FakeIconResolver's + /// own convention so a real DID like the Monsters grid's move-up/ + /// move-down pair (0x060028FC/FD) round-trips as its own texture id. + private sealed class IdentityIconResolver : IMarkupIconResolver + { + public (uint tex, int w, int h) ResolveDid(uint did) => + did == 0u ? (0u, 0, 0) : (did, 16, 16); + public (uint tex, int w, int h) ResolveSpell(uint spellId) => + spellId == 0u ? (0u, 0, 0) : (spellId, 16, 16); + public (uint tex, int w, int h) ResolveItem(uint objectId) => + objectId == 0u ? (0u, 0, 0) : (objectId, 16, 16); + } + private sealed class StubHost : IPluginHost { public bool HasUi => false; diff --git a/tests/AcDream.Plugins.MossTank.Tests/MossTankMarkupContractTests.cs b/tests/AcDream.Plugins.MossTank.Tests/MossTankMarkupContractTests.cs index 4ae496b9..5e1aa3b2 100644 --- a/tests/AcDream.Plugins.MossTank.Tests/MossTankMarkupContractTests.cs +++ b/tests/AcDream.Plugins.MossTank.Tests/MossTankMarkupContractTests.cs @@ -209,6 +209,14 @@ public sealed class MossTankMarkupContractTests // docs/research/vtank-kb/08-ui-views.md §1 "Tab: Monsters" (column // type/order) and the decompile's own tooltip strings // (refs/vtank/decompiled/uTank2/PluginCore.cs:1693-1707). + // + // Round F item 5 appended a 24th column: a genuinely inert + // trailing filler (empty items, no onclick) so the icon column + // before it (MoveDownIcons) is no longer the grid's LAST column + // and keeps its own declared 23px width instead of silently + // absorbing every pixel of window growth (docs/plugin-ui-markup.md's + // "Width semantics" — the last column is ALWAYS auto). It has no + // VTank counterpart; VTank's own 23-column shape is unchanged. XDocument document = XDocument.Load( Path.Combine(AppContext.BaseDirectory, "mosstank.xml")); XElement root = Assert.IsType(document.Root); @@ -217,14 +225,14 @@ public sealed class MossTankMarkupContractTests XElement list = Assert.Single(monstersGroup.Elements("list")); XElement[] columns = list.Elements("column").ToArray(); - Assert.Equal(23, columns.Length); + Assert.Equal(24, columns.Length); string[] expectedTypes = [ "check", "check", "check", "check", "check", "check", "check", "check", "check", "check", "check", "check", "check", "check", "text", "text", "text", "text", "text", "text", "text", - "icon", "icon", + "icon", "icon", "text", ]; Assert.Equal(expectedTypes, columns.Select(c => (string?)c.Attribute("type")));