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>
536 lines
26 KiB
C#
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 <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.
|
|
/// </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;
|
|
}
|
|
}
|