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>
555 lines
26 KiB
C#
555 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>
|
|
/// 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);
|
|
}
|
|
}
|
|
|
|
/// <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)
|
|
{
|
|
// 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;
|
|
}
|
|
}
|