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." docs/plugin-ui-markup.md's own "Width semantics" rule makes the LAST <column> in a <list> ALWAYS treated as auto, regardless of its own declared width — MonsterMoveDownIcons was the grid's 23rd and, until this commit, LAST column, so it silently absorbed every pixel of window growth instead of staying at its declared 23px pitch. The gap was already wrong (89.5px) at the authored 984-wide default, not just after a resize. Adds a 24th, trailing <column type="text"> with empty items (MonsterFillerColumn) so MoveDownIcons is no longer last and keeps its own real width; the filler absorbs the remainder instead — the same "fixed icon columns + trailing auto filler" shape lstMetaRules' three 16px icon columns + two auto text columns already prove out (MarkupListColumnsTests.EndToEnd_MetaShapedSixColumnList_...). The filler's onclick (MonsterFillerClick) is a real, bound no-op — MossTankMarkupContractTests.EveryInteractiveControlDeclaresARealHandlerBinding requires every <column> to declare SOME handler, so "no binding at all" (the other option this item's own spec offered) is not actually legal in this codebase; clicking blank filler space does nothing, but the binding itself is real. Mutation shown to fail first: the new MonstersMoveUpAndMoveDownIconsStayAdjacentAtEveryWidth theory (984 and 1100, MarkupDocument.Build over the real mosstank.xml with a real IMarkupIconResolver stub) failed at BOTH widths against the unmodified markup — 89.5px apart at 984, 147.5px at 1100 — then passed (fixed ~23px gap at both) once the filler column was added. MonstersGridHasVtanksTwentyThreeColumnsInOrderWithRetailHeaderTooltips' 23->24 column-count/type update is the other required change; VTank's own 23-column shape is unchanged, only a MossTank-only 24th filler is new. MossTank suite 724/724 (one test's expectations updated, no net count change); App markup/plugin filter 258 -> 260 (the new theory's two InlineData cases). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
488 lines
23 KiB
C#
488 lines
23 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);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|