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

@ -149,7 +149,7 @@
<field x="734" y="28" w="40" h="16" text="{PetCustomRangeValueText}"
onchange="{SetPetCustomRangeText}" maxlength="6" background="#E6000000"
tooltip="Custom pet summon range in meters." />
<label x="614" y="48" w="125" h="16" text="Pet Min. Monsters:" color="#FFE8DEC3" />
<label x="614" y="48" w="120" h="16" text="Pet Min. Monsters:" color="#FFE8DEC3" />
<field x="734" y="48" w="40" h="16" text="{PetDensityValueText}"
onchange="{SetPetDensityText}" maxlength="3" background="#E6000000"
tooltip="Minimum nearby monsters required before summoning a pet." />
@ -449,65 +449,75 @@
<label x="438" y="184" text="{ProfileNotice}" color="#FF9B9072" />
</group>
<!-- Buffs: every visible switch maps to the live buff plan, plus VTank's
own Extra Buff Spells / Blacklisted Buff Families lists (Campaign VT
S7.4, docs/research/vtank-kb/08-ui-views.md §1 "Tab: Buffs" — VTank's
REAL Buffs tab is only these two lists + two "Add..." buttons; the
category toggles/difficulty/rebuff/Buff-button below are MossTank's
own pre-existing extension, kept as a superset and compacted into
two columns to make room rather than removed). Any cell click on
either list removes that row (PluginCore.cs:7323-7355); "Add..."
opens the shared SelfBuffChoiceView-style picker
(mosstank-buffpicker.xml). List heights (84, not VTank's 116) and
widths (246/256, not VTank's 320) are narrowed to coexist with the
retained toggle columns in this tab's fixed 848x194 canvas — a
proportion-preserving compromise, not a pixel-for-pixel port
(owner's bar: "looks basically the same", never VVS pixels). -->
<!-- Buffs: VTank's REAL Buffs tab (docs/research/vtank-kb/08-ui-views.md
§1 "Tab: Buffs") is ONLY two lists + two "Add..." buttons — Extra Buff
Spells and Blacklisted Buff Families, each VTank's own 320x116, at the
far left and far right of the 848-wide canvas (PluginCore.cs:
7323-7355 — any cell click removes that row; "Add..." opens the
shared SelfBuffChoiceView-style picker, mosstank-buffpicker.xml).
Fix round B item 2 (owner's silhouette rule: VTank's own controls are
never shrunk/moved/reordered to make room for MossTank extras): the
category toggles / difficulty / rebuff / Buff-cast-now block below
(MossTank's own pre-existing extension, no VTank Buffs-tab
counterpart) used to live in the same two columns as the narrowed
lists, and its Difficulty "+" button (338,108) overlapped the Extra
Buff Spells "Add..." button (336,102) — a real sibling-overlap bug
the new AuthoredControlsInTheSameGroupNeverOverlap pin below now
catches. Both lists are restored to VTank's full 320x116 at the far
left (x=4) and far right (x=524, 848-4-320) with their own
120-wide "Add..." row below each list; the whole MossTank-extras
block moves into the EMPTY MIDDLE strip between the two lists
(x=332..516, the 184px VTank's own layout never uses), stacked
compactly: 7 toggles at a 16px pitch, then Difficulty +/-, then
Rebuff +/-, then the Buff button + status, then Coverage at y=178
(194-16, item 3's bottom-fit rule) — a proportion-preserving
compromise fitting a real 184x194 box, not a pixel-for-pixel VVS
port (owner's bar: "looks basically the same", never VVS pixels). -->
<group x="8" y="42" w="848" h="194" visible="{BuffsVisible}">
<toggle x="4" y="0" w="160" h="18" text="Trained skills only"
checked="{TrainedOnlyEnabled}" onclick="{ToggleTrainedOnly}" />
<toggle x="4" y="20" w="160" h="18" text="Attributes"
checked="{AttributesEnabled}" onclick="{ToggleAttributes}" />
<toggle x="4" y="40" w="160" h="18" text="Protections"
checked="{ProtectionsEnabled}" onclick="{ToggleProtections}" />
<toggle x="4" y="60" w="160" h="18" text="Weapon auras"
checked="{AurasEnabled}" onclick="{ToggleAuras}" />
<toggle x="168" y="0" w="160" h="18" text="Armor banes"
checked="{BanesEnabled}" onclick="{ToggleBanes}" />
<toggle x="168" y="20" w="160" h="18" text="Regeneration"
checked="{RegenerationEnabled}" onclick="{ToggleRegeneration}" />
<toggle x="168" y="40" w="160" h="18" text="Other self-spells"
checked="{OtherEnabled}" onclick="{ToggleOther}" />
<label x="336" y="0" w="246" text="Extra Buff Spells" color="#FFE8DEC3"
<label x="4" y="0" w="320" h="16" text="Extra Buff Spells" color="#FFE8DEC3"
tooltip="Named spell exemplars added beyond the school-driven picks (best similar will be used)." />
<list x="336" y="16" w="246" h="84" rowheight="17"
<list x="4" y="18" w="320" h="116" rowheight="17"
items="{ExtraBuffRows}" selected="{SelectedExtraBuffIndex}"
onchange="{DeleteExtraBuffAt}" tooltip="Click a row to remove it." />
<button x="336" y="102" w="120" h="16" text="Add..." onclick="{ShowExtraBuffPicker}" />
<button x="4" y="138" w="120" h="18" text="Add..." onclick="{ShowExtraBuffPicker}" />
<label x="590" y="0" w="256" text="Blacklisted Buff Families" color="#FFE8DEC3"
<label x="524" y="0" w="320" h="16" text="Blacklisted Buff Families" color="#FFE8DEC3"
tooltip="Named buff families never cast, even if otherwise wanted." />
<list x="590" y="16" w="256" h="84" rowheight="17"
<list x="524" y="18" w="320" h="116" rowheight="17"
items="{BlacklistedBuffFamilyRows}" selected="{SelectedBlacklistedBuffIndex}"
onchange="{DeleteBlacklistedBuffFamilyAt}" tooltip="Click a row to remove it." />
<button x="590" y="102" w="120" h="16" text="Add..." onclick="{ShowBlacklistedBuffPicker}" />
<button x="524" y="138" w="120" h="18" text="Add..." onclick="{ShowBlacklistedBuffPicker}" />
<label x="4" y="112" w="300" text="{DifficultyText}" color="#FFE8DEC3" />
<button x="308" y="108" w="26" h="20" text="-" onclick="{DifficultyDown}"
<toggle x="332" y="0" w="180" h="16" text="Trained skills only"
checked="{TrainedOnlyEnabled}" onclick="{ToggleTrainedOnly}" />
<toggle x="332" y="16" w="180" h="16" text="Attributes"
checked="{AttributesEnabled}" onclick="{ToggleAttributes}" />
<toggle x="332" y="32" w="180" h="16" text="Protections"
checked="{ProtectionsEnabled}" onclick="{ToggleProtections}" />
<toggle x="332" y="48" w="180" h="16" text="Weapon auras"
checked="{AurasEnabled}" onclick="{ToggleAuras}" />
<toggle x="332" y="64" w="180" h="16" text="Armor banes"
checked="{BanesEnabled}" onclick="{ToggleBanes}" />
<toggle x="332" y="80" w="180" h="16" text="Regeneration"
checked="{RegenerationEnabled}" onclick="{ToggleRegeneration}" />
<toggle x="332" y="96" w="180" h="16" text="Other self-spells"
checked="{OtherEnabled}" onclick="{ToggleOther}" />
<label x="332" y="116" w="140" text="{DifficultyText}" color="#FFE8DEC3" />
<button x="472" y="112" w="20" h="18" text="-" onclick="{DifficultyDown}"
tooltip="Decrease the skill-over-difficulty margin." />
<button x="338" y="108" w="26" h="20" text="+" onclick="{DifficultyUp}"
<button x="494" y="112" w="20" h="18" text="+" onclick="{DifficultyUp}"
tooltip="Increase the skill-over-difficulty margin." />
<label x="4" y="134" w="300" text="{RebuffText}" color="#FFE8DEC3" />
<button x="308" y="130" w="26" h="20" text="-" onclick="{RebuffDown}"
<label x="332" y="136" w="140" text="{RebuffText}" color="#FFE8DEC3" />
<button x="472" y="132" w="20" h="18" text="-" onclick="{RebuffDown}"
tooltip="Rebuff later." />
<button x="338" y="130" w="26" h="20" text="+" onclick="{RebuffUp}"
<button x="494" y="132" w="20" h="18" text="+" onclick="{RebuffUp}"
tooltip="Rebuff earlier." />
<button x="4" y="156" w="140" h="26" text="{BuffButtonText}" onclick="{Buff}" />
<label x="164" y="162" text="{BuffStatus}" color="#FFC7B98F" />
<label x="4" y="186" text="{Coverage}" color="#FF9B9072" />
<button x="332" y="154" w="110" h="18" text="{BuffButtonText}" onclick="{Buff}" />
<label x="446" y="158" w="70" text="{BuffStatus}" color="#FFC7B98F" />
<label x="332" y="178" w="184" h="16" text="{Coverage}" color="#FF9B9072" />
</group>
<!-- Route: VTank's navigation-waypoint editor and execution surface.

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