fix(vtank): slice 7 fix round B item 2 — restore VTank's Buffs silhouette, add sibling-overlap pin

Owner's silhouette rule: VTank's own controls are never shrunk/moved/
reordered to make room for MossTank extras. The Buffs tab's Difficulty "+"
button (338,108,26,20) overlapped the Extra Buff Spells "Add..." button
(336,102,120,16) because both VTank lists had been narrowed to 246/256x84
to coexist with MossTank's toggle/difficulty/rebuff/Buff-cast-now block in
the same two columns.

Fix: restore both lists to VTank's real 320x116 at the far left (x=4) and
far right (x=524) with their own 120-wide "Add..." row below each list.
Move the MossTank-only extras block (7 toggles, Difficulty +/-, Rebuff +/-,
the Buff button+status, Coverage) into the empty 184px middle strip between
the two lists that VTank's own layout never uses. Coverage moves to y=178
(194-16) per the upcoming bottom-fit rule.

Also fixed: a second real overlap the new pin found on the Options tab —
"Pet Min. Monsters:" (614,48,125,16) overlapped its own field
(734,48,40,16) by 5px; narrowed the label to the same 120px column width
every other label/field pair in that block uses.

Added AuthoredControlsInTheSameContainerNeverOverlapASibling (4 file cases)
plus a direct predicate pin (AssertNoSiblingOverlap_CatchesARealOverlap...)
proving the helper catches a real overlap, ignores touching edges, and
ignores two <group> tab-pages sharing one rectangle (mutually exclusive via
their own visible="{...}" binding) — the existing AssertWithinParent only
ever checked a child against its OWN parent's bounds, never against a
sibling.

Mutation check: setting DifficultyUp's button to (472,112) — the same rect
as DifficultyDown — turned AuthoredControlsInTheSameContainerNeverOverlapASibling
red against the real mosstank.xml ("<button> text='-' ... overlaps sibling
<button> text='+' ..."); reverting turns it green.

tests/AcDream.Plugins.MossTank.Tests: 665/665 (was 660/660, +5: the new
theory's 4 file cases + 1 predicate fact).
tests/AcDream.App.Tests --filter Markup|Plugin|UiMenu|Slider: 276/3 skipped/279 (unchanged).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-07 11:33:08 +02:00
parent 040d5f3d81
commit 0c2c3b572d
2 changed files with 155 additions and 46 deletions

View file

@ -307,6 +307,105 @@ public sealed class MossTankMarkupContractTests
AssertWithinParent(root);
}
/// <summary>
/// Fix round B item 2: <see cref="AssertWithinParent"/> only ever proved
/// a control fits inside its OWN parent's declared bounds — it never
/// caught two SIBLINGS overlapping each other while each individually
/// still fit. That's exactly how the Buffs tab's Difficulty "+" button
/// (338,108,26,20) ended up drawn on top of the Extra Buff Spells
/// "Add..." button (336,102,120,16) before this fix round, and how the
/// Options tab's "Pet Min. Monsters:" label (614,48,125,16) overlapped
/// the very field it labels (734,48,40,16) by 5px. This walks every
/// container in every plugin panel file and asserts no two positioned
/// children overlap — except two <c>&lt;group&gt;</c> elements (VTank
/// tab pages are mutually exclusive via their own <c>visible="{...}"</c>
/// binding, so sharing the same rectangle is by design) and
/// <c>&lt;column&gt;</c> (Campaign VT slice 1's <c>&lt;list&gt;&lt;column&gt;</c>
/// children have no <c>x</c>/<c>y</c> grammar at all — see
/// docs/plugin-ui-markup.md's "Columns" — so every column reads as
/// (0,0) and would trivially "overlap" every other column).
/// </summary>
[Theory]
[InlineData("mosstank.xml")]
[InlineData("mosstank-advanced.xml")]
[InlineData("mosstank-loot-editor.xml")]
[InlineData("mosstank-buffpicker.xml")]
public void AuthoredControlsInTheSameContainerNeverOverlapASibling(string fileName)
{
XDocument document = XDocument.Load(
Path.Combine(AppContext.BaseDirectory, fileName));
XElement root = Assert.IsType<XElement>(document.Root);
AssertNoSiblingOverlap(root);
}
/// <summary>Direct pin on the overlap PREDICATE itself (independent of
/// any real markup file): two rectangles that truly overlap must be
/// caught, touching-but-not-overlapping edges must not be a false
/// positive, and two sibling &lt;group&gt;s sharing one rectangle (the
/// normal VTank-tab-page shape) must be ignored.</summary>
[Fact]
public void AssertNoSiblingOverlap_CatchesARealOverlapAndIgnoresGroupPagesAndTouchingEdges()
{
var overlapping = new XElement("panel",
new XAttribute("w", "848"), new XAttribute("h", "194"),
new XElement("button", new XAttribute("x", "336"), new XAttribute("y", "102"),
new XAttribute("w", "120"), new XAttribute("h", "16")),
new XElement("button", new XAttribute("x", "338"), new XAttribute("y", "108"),
new XAttribute("w", "26"), new XAttribute("h", "20")));
Assert.Throws<Xunit.Sdk.TrueException>(() => AssertNoSiblingOverlap(overlapping));
var touchingEdges = new XElement("panel",
new XAttribute("w", "848"), new XAttribute("h", "194"),
new XElement("button", new XAttribute("x", "0"), new XAttribute("y", "0"),
new XAttribute("w", "100"), new XAttribute("h", "20")),
new XElement("button", new XAttribute("x", "100"), new XAttribute("y", "0"),
new XAttribute("w", "100"), new XAttribute("h", "20")));
AssertNoSiblingOverlap(touchingEdges); // must not throw
var twoGroupPages = new XElement("panel",
new XAttribute("w", "848"), new XAttribute("h", "236"),
new XElement("group", new XAttribute("x", "8"), new XAttribute("y", "42"),
new XAttribute("w", "848"), new XAttribute("h", "194")),
new XElement("group", new XAttribute("x", "8"), new XAttribute("y", "42"),
new XAttribute("w", "848"), new XAttribute("h", "194")));
AssertNoSiblingOverlap(twoGroupPages); // must not throw
}
private static void AssertNoSiblingOverlap(XElement container)
{
XElement[] children = container.Elements()
.Where(static child => child.Name.LocalName != "column")
.ToArray();
for (int i = 0; i < children.Length; i++)
{
for (int j = i + 1; j < children.Length; j++)
{
XElement a = children[i], b = children[j];
if (a.Name.LocalName == "group" && b.Name.LocalName == "group")
continue;
Assert.True(
!RectanglesOverlap(a, b),
$"<{a.Name}> text='{(string?)a.Attribute("text")}' @ "
+ $"({Number(a, "x")},{Number(a, "y")},{Number(a, "w")},{Number(a, "h")}) "
+ $"overlaps sibling <{b.Name}> text='{(string?)b.Attribute("text")}' @ "
+ $"({Number(b, "x")},{Number(b, "y")},{Number(b, "w")},{Number(b, "h")}).");
}
}
foreach (XElement child in children)
AssertNoSiblingOverlap(child);
}
private static bool RectanglesOverlap(XElement a, XElement b)
{
float aw = Number(a, "w"), ah = Number(a, "h");
float bw = Number(b, "w"), bh = Number(b, "h");
if (aw <= 0f || ah <= 0f || bw <= 0f || bh <= 0f)
return false; // an element with no declared size never "occupies" space
float ax = Number(a, "x"), ay = Number(a, "y");
float bx = Number(b, "x"), by = Number(b, "y");
return ax < bx + bw && bx < ax + aw && ay < by + bh && by < ay + ah;
}
/// <summary>
/// Fix round A (2026-09-07): the Advanced Options and Loot Editor popups
/// moved out of mosstank.xml into their own plugin panel files. This