acdream/tests/AcDream.App.Tests/UI/MossTankMarkupBuildOverRealFilesTests.cs
Erik 617a9c2218 fix(mosstank): drop tooltips from the Advanced Options popup
Owner's third live look (2026-09-07): "Also still have tooltips in the
advanced options, remove please." VTank's own AdvancedOptionsView has
no tooltip mechanism at all (docs/research/vtank-kb/08-ui-views.md
§1) — its own description readout (AdvancedOptionDescription, the
label under the field) is the retail in-popup info channel, not a
hover tooltip. Removes the three tooltip= attributes
mosstank-advanced.xml had grown (the option list, the category
checklist, the value field); nothing else in the file set one.

New test AdvancedOptionsPopupHasNoElementWithATooltip builds the real
mosstank-advanced.xml against a real MossTankPanel and walks the
WHOLE resolved UiElement tree checking RuntimeTooltipTextSource is
null everywhere, rather than re-checking the three known offenders —
MarkupDocument.Build only ever sets that field when the source element
declared a real tooltip= attribute, so a null source function means no
tooltip was ever bound. Shown to fail first against a targeted
mutation (a tooltip= temporarily reintroduced on the option list) —
failed with "UiMarkupList carries a tooltip" — then reverted and
restored green.

App markup/plugin filter: 261 -> 262 (one new Fact).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 21:12:15 +02:00

536 lines
26 KiB
C#

using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Tests.Rendering.Gpu;
using AcDream.App.UI;
using AcDream.Plugin.Abstractions;
using AcDream.Plugins.MossTank;
using Xunit;
namespace AcDream.App.Tests.UI;
/// <summary>
/// Fix round B item 15. Every existing MossTank markup pin
/// (<c>MossTankMarkupContractTests</c>) validates <c>mosstank*.xml</c>
/// against <see cref="MossTankPanel"/> through reflection alone — "does a
/// public property with this name and this CLR type exist" — never through
/// <see cref="MarkupDocument.Build"/> itself, the code that actually mounts
/// a plugin panel at runtime. <c>MarkupDocument.Build</c> has its own
/// validation a reflection-only check can't see (attribute-format
/// exceptions like <c>ValidateArtStyle</c>'s "must be plain or retail",
/// delegate-shape mismatches surfaced as thrown <see cref="FormatException"/>s
/// rather than a missing property, numeric-attribute parsing, column-type
/// dispatch). Before this test, a markup bug of that shape would throw
/// inside <c>RetailUiRuntime.MountPlugins</c>'s own try/catch and the panel
/// would simply not appear — no test failure, no visible error short of a
/// live client screenshot. This builds every real <c>mosstank*.xml</c> file
/// against a REAL <see cref="MossTankPanel"/> (a stub <see cref="IPluginHost"/>,
/// same shape as <c>MossTankMarkupContractTests.StubHost</c>) so a bad
/// attribute fails a test instead of dropping the panel silently at mount.
/// </summary>
public sealed class MossTankMarkupBuildOverRealFilesTests
{
private static string MossTankMarkupDirectory =>
Path.Combine(AppContext.BaseDirectory, "MossTank");
public static IEnumerable<object[]> MossTankMarkupFiles() =>
Directory.GetFiles(MossTankMarkupDirectory, "mosstank*.xml")
.OrderBy(static path => path, StringComparer.Ordinal)
.Select(static path => new object[] { path });
[Theory]
[MemberData(nameof(MossTankMarkupFiles))]
public void EveryMossTankPanelFileBuildsAgainstARealPanelWithNoException(string path)
{
string xml = File.ReadAllText(path);
var panel = new MossTankPanel(new StubHost());
UiNineSlicePanel built = MarkupDocument.Build(xml, panel, static id => (id, 32, 32));
Assert.NotNull(built);
Assert.NotEmpty(built.Children);
}
/// <summary>
/// Round D item 4's own re-layout proof: the Monsters tab's real
/// mosstank.xml list carries <c>anchor="left right top bottom"</c>
/// (EveryStretchingListDeclaresARealAnchor, MossTankMarkupContractTests,
/// pins the attribute is present; this proves the attribute actually
/// DOES something through the real anchor machinery) — widening the
/// built root panel widens the Monsters list in turn, the same
/// mechanism MarkupResizableAnchorTests.ResizingPanel_LeftRightList_
/// WidensWithThePanel proves against synthetic markup, now proven
/// against the real shipped file.
/// </summary>
[Fact]
public void WideningTheRealMainPanelWidensTheRealMonstersList()
{
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));
// Every tab's group is visibility-bound ("visible={XVisible}") to
// StubHost's own IsAvailable=false automation, so relying on the
// normal VisibleSource/TickSelfAndChildren reconciliation would
// hide every tab (including the root). This test cares about the
// anchor mechanism, not the tab-switching one — it makes the
// Monsters group (the 4th of the nine tab groups in file order:
// Options/Profiles/Vitals/Monsters/...) visible directly. Exact
// type match, not OfType<UiPanel>() — UiSimpleButton/
// UiMarkupTabButton (the tab strip) are ALSO UiPanel subtypes;
// only a bare <group> compiles to the base UiPanel type itself.
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 monstersGroup = tabGroups[3];
monstersGroup.Visible = true;
UiMarkupList monstersList = Assert.Single(monstersGroup.Children.OfType<UiMarkupList>());
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 (already-enlarged, 984 wide) default
// captures the list's anchor baseline.
built.DrawSelfAndChildren(ctx);
float widthAtAuthoredDefault = monstersList.Width;
// A live drag-resize (RetailWindowManager.ResizeTo) mutates Width
// directly; the next draw re-applies the captured margins against
// the NEW panel width.
built.Width += 100f;
built.DrawSelfAndChildren(ctx);
Assert.True(
monstersList.Width > widthAtAuthoredDefault,
$"Monsters list width did not grow: {widthAtAuthoredDefault} -> {monstersList.Width}");
}
/// <summary>
/// D-3 (round E architecture re-check): mosstank.xml's own top-of-file
/// comment already claimed the Advanced Options popup "gets its own
/// resizable=\"true\" + anchored lists" — the popup file itself still
/// carried the dead <c>resize="none"</c> attribute (only meaningful
/// once <c>resizable="true"</c> already arms the master switch,
/// MarkupDocument.cs), so the popup was permanently fixed-size. This
/// proves the fix through the real anchor machinery (the same
/// mechanism <see cref="WideningTheRealMainPanelWidensTheRealMonstersList"/>
/// proves for the main panel): lOptionList grows in WIDTH ONLY
/// (mosstank-advanced.xml's own comment explains why height growth is
/// unsafe here — the value field sits directly below with no slack),
/// and lFilterList tracks the growing right edge in lockstep so the
/// widening option list never walks into it (the same "right-pinned
/// sibling repositions" pattern the main panel already uses).
/// </summary>
[Fact]
public void WideningTheRealAdvancedOptionsPopupGrowsTheOptionListWithoutOverlappingItsSibling()
{
string xml = File.ReadAllText(
Path.Combine(MossTankMarkupDirectory, "mosstank-advanced.xml"));
var panel = new MossTankPanel(new StubHost());
UiNineSlicePanel built = MarkupDocument.Build(xml, panel, static id => (id, 32, 32));
Assert.True(built.Resizable);
Assert.Equal(392f, built.MinWidth);
// Round F item 1 shrunk the popup back to VTank's real
// AdvancedOptionsView footprint (392x300) after deleting the
// "Editing X." notice and the "MossTank Extras" section.
Assert.Equal(300f, built.MinHeight);
// Bypass the VisibleSource binding (bound to AdvancedOptionsVisible,
// false on the stub automation) the same way the Monsters test
// bypasses tab visibility — DrawSelfAndChildren's own anchor pass
// never runs for an invisible element.
built.Visible = true;
UiMarkupList optionList = Assert.Single(
built.Children.OfType<UiMarkupList>(), static list => list.Width == 256f);
UiMarkupList categoryList = Assert.Single(
built.Children.OfType<UiMarkupList>(), static list => list.Width == 120f);
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));
built.DrawSelfAndChildren(ctx);
float optionListWidthBefore = optionList.Width;
float optionListHeightBefore = optionList.Height;
float categoryListLeftBefore = categoryList.Left;
float categoryListWidthBefore = categoryList.Width;
built.Width += 100f;
built.DrawSelfAndChildren(ctx);
Assert.True(
optionList.Width > optionListWidthBefore,
$"Option list width did not grow: {optionListWidthBefore} -> {optionList.Width}");
Assert.Equal(optionListHeightBefore, optionList.Height); // height fixed
Assert.True(
categoryList.Left > categoryListLeftBefore,
$"Category list did not track the growing right edge: {categoryListLeftBefore} -> {categoryList.Left}");
Assert.Equal(categoryListWidthBefore, categoryList.Width); // width fixed, only repositions
Assert.True(
optionList.Left + optionList.Width <= categoryList.Left,
$"Widened option list (right edge {optionList.Left + optionList.Width}) overlaps "
+ $"the repositioned category list (left edge {categoryList.Left}).");
}
/// <summary>
/// Owner's third live look (2026-09-07): "Also still have tooltips in
/// the advanced options, remove please." VTank's own AdvancedOptionsView
/// has no tooltip mechanism at all — its description readout is the
/// retail in-popup info channel, not a hover tooltip. Builds the real
/// mosstank-advanced.xml against a real panel (the same shape every
/// other test in this file uses) and walks the WHOLE resolved tree —
/// not just the three elements that used to carry tooltip= — so a
/// tooltip added anywhere else in this popup in the future fails this
/// pin too. <see cref="MarkupDocument.Build"/> only ever sets
/// <see cref="UiElement.RuntimeTooltipTextSource"/> when the source
/// element declared a real tooltip= attribute (MarkupDocument.cs's own
/// ApplyCommonAttributes), so a null source function here means no
/// tooltip was ever bound for that element.
/// </summary>
[Fact]
public void AdvancedOptionsPopupHasNoElementWithATooltip()
{
string xml = File.ReadAllText(
Path.Combine(MossTankMarkupDirectory, "mosstank-advanced.xml"));
var panel = new MossTankPanel(new StubHost());
UiNineSlicePanel built = MarkupDocument.Build(xml, panel, static id => (id, 32, 32));
AssertNoElementHasATooltip(built);
}
private static void AssertNoElementHasATooltip(UiElement element)
{
Assert.True(
element.RuntimeTooltipTextSource is null,
$"{element.GetType().Name} carries a tooltip — the Advanced "
+ "Options popup must have none (owner's third live look).");
foreach (UiElement child in element.Children)
AssertNoElementHasATooltip(child);
}
/// <summary>
/// D-6 (round E architecture re-check): every real-file re-layout pin
/// so far only inspects ONE named element after a resize (the
/// Monsters list, the Advanced Options option/category lists). This
/// proves the whole resolved tree, at both ends of the main panel's
/// declared resize range — the floor (minw/minh, 856x236) and one
/// enlarged size (1100x320) past the 984x271 authored default — has
/// no sibling overlap and no child crossing its parent's edge, for
/// EACH of the nine tabs in turn (only the active tab's own subtree
/// gets a real anchor pass; DrawSelfAndChildren returns early for an
/// invisible element, matching <see cref="WideningTheRealMainPanelWidensTheRealMonstersList"/>'s
/// own reasoning). Reuses the same rectangle-overlap/parent-bounds
/// algorithm <c>MossTankMarkupContractTests</c>' AssertNoSiblingOverlap/
/// AssertWithinParent apply to the AUTHORED XElement tree, adapted
/// here to the RESOLVED UiElement tree (real Left/Top/Width/Height
/// post-anchor, not the raw x/y/w/h attributes) since a resize only
/// exists at this level.
/// </summary>
[Theory]
[InlineData(856f, 236f)] // the panel's own minw/minh floor
[InlineData(1100f, 320f)] // one enlarged size past the 984x271 default
public void ResolvedMainPanelHasNoOverlapOrOutOfBoundsChildAtThisSize(
float width, float height)
{
string xml = File.ReadAllText(
Path.Combine(MossTankMarkupDirectory, "mosstank.xml"));
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));
// A fresh MarkupDocument.Build per tab, not one shared tree reused
// across all nine: each element's anchor margins are captured ONCE,
// on its very first ApplyAnchor call, and never recomputed after —
// reusing one tree across tabs would let an earlier tab's resize
// leave the shared root already at the target size by the time a
// LATER tab's own descendants take their "first" (authored-default)
// capture, corrupting their baseline.
for (int tabIndex = 0; tabIndex < 9; tabIndex++)
{
var panel = new MossTankPanel(new StubHost());
UiNineSlicePanel built = MarkupDocument.Build(xml, panel, static id => (id, 32, 32));
built.Visible = true;
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;
tabGroups[tabIndex].Visible = true;
// First draw at the authored default captures every anchored
// descendant's baseline margins (matching a live window that
// just opened); only THEN does resizing to the target size
// reapply those captured margins against the new bounds —
// the same two-draw shape WideningTheRealMainPanelWidensTheRealMonstersList
// uses.
built.DrawSelfAndChildren(ctx);
built.Width = width;
built.Height = height;
built.DrawSelfAndChildren(ctx);
AssertResolvedWithinParent(built);
AssertResolvedNoSiblingOverlap(built);
}
}
// Owner's third live look (2026-09-07) deleted the MossTank-only extras
// strip that used to sit between the Buffs tab's two lists (7 category
// toggles, the difficulty/rebuff spinners, the Force/Stop buffing
// button + status, the coverage counts) outright — VTank's real Buffs
// tab has no such block (see MossTankMarkupContractTests'
// BuffsTabIsVtankOnlySixControls for the authored-tree pin). The
// former BuffsExtrasStripStaysCenteredAndNonOverlappingAtTheFloor test
// that proved the (now-deleted) strip stayed centered at the panel's
// floor is gone with it; the two remaining lists' own non-overlap/
// within-bounds behavior at both the floor and the enlarged size stays
// covered generically by ResolvedMainPanelHasNoOverlapOrOutOfBoundsChildAtThisSize.
private static void AssertResolvedWithinParent(UiElement parent)
{
foreach (UiElement child in parent.Children)
{
// An invisible child's OWN box (e.g. an inactive tab group,
// whose top-level anchor still tracks the resize regardless
// of visibility) is still worth checking, but DrawSelfAndChildren
// returns before applying anchor to ITS descendants while it
// stays invisible — recursing further would compare stale,
// never-resolved-for-this-size geometry and misreport it as
// broken. Skip descending past an invisible node; the tab it
// belongs to gets its own real pass in this test's own loop
// once it becomes the active one.
if (!child.Visible)
continue;
// UiLabel is excluded from the size checks (not from
// recursion): UiLabel.OnDraw (UiPanel.cs, the CT-GF1 fix)
// deliberately overwrites Width/Height to the CURRENT measured
// text extent on every draw, ignoring its own authored w/h —
// "a markup <label> authors position only". This test's stub
// renderer has no real DatFont, so a label falls through to
// OnDraw's crude "text.Length * 7f" placeholder, which reports
// a box far wider than the authored geometry (or the real DAT
// font) ever would and would misreport as crossing a parent
// edge or overlapping a sibling — noise from the test harness,
// not a real layout bug. Left/Top (position) are unaffected by
// OnDraw and still worth checking through the parent-bounds
// recursion.
if (child is not UiLabel)
{
if (child.Width > 0f)
{
Assert.True(
child.Left + child.Width <= parent.Width + 0.01f,
$"{child.GetType().Name} @ ({child.Left},{child.Top},{child.Width},"
+ $"{child.Height}) crosses the right edge of {parent.GetType().Name} "
+ $"(w={parent.Width}).");
}
if (child.Height > 0f)
{
Assert.True(
child.Top + child.Height <= parent.Height + 0.01f,
$"{child.GetType().Name} @ ({child.Left},{child.Top},{child.Width},"
+ $"{child.Height}) crosses the bottom edge of {parent.GetType().Name} "
+ $"(h={parent.Height}).");
}
}
AssertResolvedWithinParent(child);
}
}
/// <summary>
/// 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 &lt;column&gt;,
/// 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.
/// </summary>
[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 <column type="icon">
// 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<UiPanel>()
.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
// as AssertResolvedWithinParent — only compare/recurse into
// children that are actually visible.
UiElement[] children = container.Children
.Where(static child => child.Visible)
.ToArray();
for (int i = 0; i < children.Length; i++)
{
for (int j = i + 1; j < children.Length; j++)
{
UiElement a = children[i], b = children[j];
// Exact type match, not "is UiPanel": bare <group> tab
// pages deliberately occupy the identical rect (only one
// is Visible at a time) — subtypes like UiMarkupTabButton
// are real siblings that must not overlap. UiLabel is
// excluded for the same font-dependent-box reason
// AssertResolvedWithinParent excludes it (see its comment).
if (a.GetType() == typeof(UiPanel) && b.GetType() == typeof(UiPanel))
continue;
if (a is UiLabel || b is UiLabel)
continue;
Assert.True(
!ResolvedRectanglesOverlap(a, b),
$"{a.GetType().Name} @ ({a.Left},{a.Top},{a.Width},{a.Height}) overlaps "
+ $"sibling {b.GetType().Name} @ ({b.Left},{b.Top},{b.Width},{b.Height}).");
}
}
foreach (UiElement child in children)
AssertResolvedNoSiblingOverlap(child);
}
private static bool ResolvedRectanglesOverlap(UiElement a, UiElement b)
{
if (a.Width <= 0f || a.Height <= 0f || b.Width <= 0f || b.Height <= 0f)
return false; // an element with no resolved size never "occupies" space
return a.Left < b.Left + b.Width && b.Left < a.Left + a.Width
&& a.Top < b.Top + b.Height && b.Top < a.Top + a.Height;
}
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
{
public IGpuFrame? CurrentFrame => null;
}
/// <summary>Identity <see cref="IMarkupIconResolver"/> for icon-column
/// draw tests — every non-zero id "resolves" to itself as the texture
/// (16x16), matching <c>MarkupListColumnsTests.FakeIconResolver</c>'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.</summary>
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;
public IPluginLogger Log { get; } = new StubLogger();
public IGameState State { get; } = new StubState();
public IEvents Events { get; } = new StubEvents();
public ISelectionService Selection { get; } = new StubSelection();
public IUiRegistry Ui => NoOpUiRegistry.Instance;
public IAutomationSurface Automation => NoOpAutomationSurface.Instance;
}
private sealed class StubLogger : IPluginLogger
{
public void Info(string message) { }
public void Warn(string message) { }
public void Error(string message, Exception? exception = null) { }
}
private sealed class StubState : IGameState
{
public IReadOnlyList<WorldEntitySnapshot> Entities => [];
}
private sealed class StubEvents : IEvents
{
public event Action<WorldEntitySnapshot> EntitySpawned
{
add { }
remove { }
}
public event Action<double> Tick
{
add { }
remove { }
}
}
private sealed class StubSelection : ISelectionService
{
public uint? SelectedObjectId => null;
public uint? PreviousObjectId => null;
public event Action<SelectionChangedEvent> Changed
{
add { }
remove { }
}
public bool Select(uint objectId) => false;
public bool Clear() => false;
}
}