fix(vtank): slice 7 round F item 6 — Buffs gap, Loot Editor Back, Route count column

Three small resemblance-re-check nits.

Buffs: the extras strip (toggles/Difficulty/Rebuff/Buff button, no VTank
counterpart) stayed at its pre-Round-D x=332 while Round D item 4's own
+128 right-anchor shift moved the Blacklisted Buff Families list from
x=524 to x=652 — an 8px left margin against a 138px right margin.
Discovered a hard geometric ceiling while fixing this: Extra Buff
Spells never moves (anchor="left top bottom", pinned x=4..324) but
Blacklisted Buff Families tracks the growing right edge, sitting at
x=524 at the panel's own tested FLOOR (856x236) and x=652 at the
984-wide enlarged default — a 128px swing. A single fixed-position
strip can only be centered against ONE of those widths; centering
against the 984 default (tried x=397 first) put the strip's right edge
55px inside where the list sits at the floor — a real overlap the
existing ResolvedMainPanelHasNoOverlapOrOutOfBoundsChildAtThisSize pin
(856x236 case) caught immediately. x=333 is the widest safe position
(9px margins against the floor's 324..524 gap) — 1px from where fix
round B item 2 already had it. The residual 984-default asymmetry
(~9/137, barely changed from ~8/138) is an accepted, unavoidable-
without-a-redesign limitation, the same "dead margin, polish later"
trade already accepted for six other tabs at this size. Coverage moved
from y=213 (anchored to the group's bottom edge, a real 55px empty
hole once the panel grew past its original 194-tall size) to sit
directly under the Buff button row (y=176, no anchor).

Loot Editor: dropped the leftover standalone "Back" button — the
window's own OS-level title bar already closes it, matching every
other plugin popup (Advanced Options included) since round D.
CloseLootEditor stays real, still called by ToggleLootEditorVisible.

Route: the waypoint grid's count column (declared width="30") was the
grid's LAST column, so docs/plugin-ui-markup.md's own "Width semantics"
rule made that declared width never validated/used — it silently
absorbed whatever remainder was left (33px normally, but only 17px once
the scrollbar's 16px reservation ate into the list's own 370px width,
since 337+30 already exceeded 370-16=354). A trailing filler column
alone wasn't sufficient — proved by a targeted mutation below — since
even non-last, a truly fixed 30px column still gets clamped by the
same 354px ceiling. The real text column narrows 337->324
(324+30=354, exactly matching the scrollbar-reduced width) so the
count column gets its full 30px whether or not the scrollbar shows.

Every new/changed pin shown to fail against a targeted mutation first:
LootEditorHasNoLeftoverBackButton failed with the button re-inserted,
then passed once removed. RouteShapedGrid_CountColumnStaysThirtyPxWhen
TheListScrolls (a synthetic list matching Route's real 370/324/30/*
shape, 9 rows forcing the scrollbar, reflecting into UiMarkupList's own
_cachedLayout) failed both against the original 2-column 337/30 shape
(17px, reproducing the reported squeeze) AND against filler-only-no-
narrowing (also 17px, proving the filler alone isn't the fix), then
passed at exactly 30px once both changes landed together.
BuffsExtrasStripIsCenteredAtThePanelsFloorAndCoverageSitsUnderTheButtonRow
and its App-side resolved-tree counterpart
BuffsExtrasStripStaysCenteredAndNonOverlappingAtTheFloor pin the
floor-centered position and the Coverage gap.

MossTank suite 725 -> 726 (one new pin); App markup/plugin filter
260 -> 262 (two new pins).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-07 19:28:30 +02:00
parent bae34aaa8b
commit 8376105936
6 changed files with 312 additions and 32 deletions

View file

@ -1460,6 +1460,58 @@ public sealed class MarkupListColumnsTests
Assert.Equal(new[] { 0 }, binding.ActionClicks);
}
private sealed class RouteShapedBinding
{
public IReadOnlyList<string> Names { get; } =
Enumerable.Range(0, 9).Select(static i => $"WP{i}").ToArray();
public IReadOnlyList<string> Counts { get; } =
Enumerable.Range(0, 9).Select(static i => i.ToString()).ToArray();
public IReadOnlyList<string> Filler { get; } = Array.Empty<string>();
public int Selected { get; set; } = -1;
public Action<int> Click => static _ => { };
}
/// <summary>
/// Round F item 6 (resemblance re-check: "Route's count column
/// squeezes to 17 when the list scrolls"). Mirrors the Route tab's
/// EXACT real shape (mosstank.xml: a 370px-wide list, columns
/// 324/30/*) with enough rows (9, past the 151/17≈8 visible-row
/// ceiling) to force the scrollbar — the count column's declared
/// 30px was previously only honored while the scrollbar was hidden;
/// with it showing, 337(the OLD text width)+30 already exceeded
/// 370-16=354, clamping the count column to 17px. Reflects into
/// UiMarkupList's own cached column layout (the same one
/// DrawColumns/OnEventColumns use) rather than measuring glyphs —
/// this is a pure geometry question, no font involved.
/// </summary>
[Fact]
public void RouteShapedGrid_CountColumnStaysThirtyPxWhenTheListScrolls()
{
var binding = new RouteShapedBinding();
const string xml =
"<panel x=\"0\" y=\"0\" w=\"400\" h=\"200\">" +
"<list x=\"0\" y=\"0\" w=\"370\" h=\"151\" rowheight=\"17\" " +
"selected=\"{Selected}\">" +
" <column type=\"text\" width=\"324\" items=\"{Names}\" onclick=\"{Click}\"/>" +
" <column type=\"text\" width=\"30\" items=\"{Counts}\" onclick=\"{Click}\"/>" +
" <column type=\"text\" width=\"*\" items=\"{Filler}\"/>" +
"</list></panel>";
var panel = MarkupDocument.Build(xml, binding, Sprite);
var list = Assert.IsType<UiMarkupList>(panel.Children[0]);
var (renderer, ctx) = MakeContext(800f, 400f);
list.DrawSelfAndChildren(ctx);
System.Reflection.FieldInfo layoutField = typeof(UiMarkupList).GetField(
"_cachedLayout",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
var layout = ((float x, float w)[])layoutField.GetValue(list)!;
Assert.Equal(3, layout.Length);
Assert.Equal(30f, layout[1].w, 3);
}
private sealed class MonsterShapedBinding
{
public IReadOnlyList<bool> Checks { get; } = new[] { true, false };

View file

@ -254,6 +254,73 @@ public sealed class MossTankMarkupBuildOverRealFilesTests
}
}
/// <summary>
/// Round F item 6 (resemblance re-check: "Buffs' 136 px middle gap").
/// <see cref="ResolvedMainPanelHasNoOverlapOrOutOfBoundsChildAtThisSize"/>
/// already proves the extras strip never overlaps the Blacklisted
/// Buff Families list at the floor or the enlarged size (that generic
/// sibling-overlap pass is exactly what caught the original x=397
/// attempt at true 984-default centering colliding with the list's
/// own floor position, x=524). This is the narrower, POSITIVE claim
/// that overlap pass alone doesn't make: the strip is actually
/// CENTERED at the panel's floor (856x236) — the one width at which
/// centering and non-overlap can both hold, since Extra Buff Spells
/// never moves but Blacklisted Buff Families tracks the growing
/// right edge.
/// </summary>
[Fact]
public void BuffsExtrasStripStaysCenteredAndNonOverlappingAtTheFloor()
{
string xml = File.ReadAllText(
Path.Combine(MossTankMarkupDirectory, "mosstank.xml"));
var panel = new MossTankPanel(new StubHost());
UiNineSlicePanel built = MarkupDocument.Build(xml, panel, static id => (id, 32, 32));
UiPanel[] tabGroups = built.Children
.Where(static child => child.GetType() == typeof(UiPanel))
.Cast<UiPanel>()
.ToArray();
Assert.Equal(9, tabGroups.Length);
foreach (UiPanel group in tabGroups)
group.Visible = false;
UiPanel buffsGroup = tabGroups[6];
buffsGroup.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 anchor baselines;
// only then resize down to the panel's own tested floor.
built.DrawSelfAndChildren(ctx);
built.Width = 856f;
built.Height = 236f;
built.DrawSelfAndChildren(ctx);
UiMarkupList[] lists = buffsGroup.Children.OfType<UiMarkupList>().ToArray();
Assert.Equal(2, lists.Length);
UiMarkupList leftList = lists.Single(static l => l.Left < 300f);
UiMarkupList rightList = lists.Single(static l => l.Left >= 300f);
UiMarkupToggle trainedToggle = Assert.Single(
buffsGroup.Children.OfType<UiMarkupToggle>(), static t => t.Text == "Trained skills only");
UiSimpleButton rebuffUpButton = Assert.Single(
buffsGroup.Children.OfType<UiSimpleButton>(), static b => b.Text == "+" && b.Top > 120f);
float leftMargin = trainedToggle.Left - (leftList.Left + leftList.Width);
float rightMargin = rightList.Left - (rebuffUpButton.Left + rebuffUpButton.Width);
Assert.True(
rightMargin >= 0f,
$"Extras strip (right edge {rebuffUpButton.Left + rebuffUpButton.Width}) "
+ $"overlaps the right list (left edge {rightList.Left}) at the floor.");
Assert.True(
MathF.Abs(leftMargin - rightMargin) <= 4f,
$"Extras strip not centered at the floor: left margin "
+ $"{leftMargin}px, right margin {rightMargin}px.");
}
private static void AssertResolvedWithinParent(UiElement parent)
{
foreach (UiElement child in parent.Children)

View file

@ -301,6 +301,102 @@ public sealed class MossTankMarkupContractTests
Assert.DoesNotContain("{ProfileNotice}", labelBindings);
}
/// <summary>
/// Round F item 6 (resemblance re-check nits: "Buffs' 136 px middle
/// gap and the orphaned Coverage label"). Coverage sits right under
/// the Buff button row instead of anchored to the group's bottom
/// edge, which used to leave a 55px empty hole above it as the panel
/// grew.
///
/// The middle-gap half has a hard geometric ceiling documented at
/// mosstank.xml's own Buffs comment: Extra Buff Spells never moves
/// (x=4..324) but Blacklisted Buff Families tracks the growing right
/// edge (anchor="right"), sitting at x=524 at the panel's own tested
/// FLOOR (856x236) and x=652 (its authored declaration below) at the
/// 984-wide enlarged default — a 128px swing this campaign's own
/// Round D item 4 introduced. A single FIXED-position strip can only
/// be centered relative to ONE of those two widths; centering
/// against the 984 default would put the strip's right edge INSIDE
/// where the list sits at the floor — a real overlap
/// ResolvedMainPanelHasNoOverlapOrOutOfBoundsChildAtThisSize's own
/// 856x236 case would catch. This pins centering against the FLOOR
/// value instead (declared x minus the 128px Round D growth delta —
/// 984-856) — the widest safe position a fixed strip can take,
/// verified against the real-file resolved tree at both sizes by
/// <c>MossTankMarkupBuildOverRealFilesTests.BuffsExtrasStripStaysCenteredAndNonOverlappingAtTheFloor</c>
/// in the App test project.
/// </summary>
[Fact]
public void BuffsExtrasStripIsCenteredAtThePanelsFloorAndCoverageSitsUnderTheButtonRow()
{
XDocument document = XDocument.Load(
Path.Combine(AppContext.BaseDirectory, "mosstank.xml"));
XElement root = Assert.IsType<XElement>(document.Root);
XElement buffsGroup = root.Elements("group")
.Single(static g => (string?)g.Attribute("visible") == "{BuffsVisible}");
XElement[] lists = buffsGroup.Elements("list").ToArray();
Assert.Equal(2, lists.Length);
float leftListRightEdge = Number(lists[0], "x") + Number(lists[0], "w");
// Round D item 4's own +128 growth: the declared x below is the
// 984-wide authored default; subtracting it back out gives the
// resolved position at the panel's tested floor (856x236).
const float roundDGrowthDelta = 984f - 856f;
float rightListLeftEdgeAtFloor = Number(lists[1], "x") - roundDGrowthDelta;
XElement trainedToggle = buffsGroup.Elements("toggle")
.Single(static t => (string?)t.Attribute("onclick") == "{ToggleTrainedOnly}");
XElement rebuffUpButton = buffsGroup.Elements("button")
.Single(static b => (string?)b.Attribute("onclick") == "{RebuffUp}");
float extrasLeft = Number(trainedToggle, "x");
float extrasRight = Number(rebuffUpButton, "x") + Number(rebuffUpButton, "w");
float leftMargin = extrasLeft - leftListRightEdge;
float rightMarginAtFloor = rightListLeftEdgeAtFloor - extrasRight;
Assert.True(
rightMarginAtFloor >= 0f,
$"Extras strip (right edge {extrasRight}) overlaps the "
+ $"Blacklisted Buff Families list at the panel's floor "
+ $"(left edge {rightListLeftEdgeAtFloor}).");
Assert.True(
MathF.Abs(leftMargin - rightMarginAtFloor) <= 4f,
$"Extras strip not centered at the panel's floor: left margin "
+ $"{leftMargin}px, right margin {rightMarginAtFloor}px.");
XElement buffButton = buffsGroup.Elements("button")
.Single(static b => (string?)b.Attribute("onclick") == "{Buff}");
XElement coverage = buffsGroup.Elements("label")
.Single(static l => (string?)l.Attribute("text") == "{Coverage}");
float rowBottom = Number(buffButton, "y") + Number(buffButton, "h");
float coverageGap = Number(coverage, "y") - rowBottom;
Assert.True(
coverageGap is >= 0f and <= 12f,
$"Coverage sits {coverageGap}px below the Buff button row — "
+ "expected <=12px, not the old 55px hole from anchoring it to "
+ "the group's bottom edge instead.");
}
/// <summary>
/// Round F item 6 (resemblance re-check: "Loot Editor: drop the
/// leftover 'Back' button — the window closes from its title bar
/// like the others"). The standalone button is gone;
/// CloseLootEditor stays a real, bound property (still called by
/// ToggleLootEditorVisible, the Profiles-tab checkbox's own close
/// path).
/// </summary>
[Fact]
public void LootEditorHasNoLeftoverBackButton()
{
XDocument document = XDocument.Load(
Path.Combine(AppContext.BaseDirectory, "mosstank-loot-editor.xml"));
XElement root = Assert.IsType<XElement>(document.Root);
Assert.DoesNotContain(
root.Elements("button"),
static b => (string?)b.Attribute("text") == "Back");
Assert.NotNull(typeof(MossTankPanel).GetProperty("CloseLootEditor"));
}
[Fact]
public void AuthoredShellFitsTheMinimumCanvasAndEverySizedChildFitsItsParent()
{