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 resize="none" attribute (MarkupDocument.cs: "resize" only ever means anything once resizable="true" already arms the master switch), so the claim was false and the popup was permanently fixed-size. resizable="true" now floors minw/minh at the pre-fix authored size (392x450 — never shrink below the tested layout, matching the main panel's own rule). lOptionList grows WIDTH ONLY: the value-edit field, description, and notice all sit directly below it with zero vertical slack and none of them anchor to shift down in lockstep, so any height growth here would walk the list straight into the field below — the same "grow only in the direction with nothing in the way" principle the main panel already applies to Items/Consumables/Buffs/Route's lists. lFilterList (the category checklist beside it) tracks the growing right edge via its own anchor="right" so the widening option list never walks into it, matching the main panel's own right-pinned-sibling pattern. AdvancedOptionDescription is declared anchor="left top right" for markup consistency, but documented as a real no-op: UiLabel.OnDraw (UiPanel.cs) always overwrites Width/Height to the current measured text extent on every draw, right after the anchor pass runs in the same frame, so nothing a label's anchor computes is ever visible — a genuinely working vertical anchor was deliberately avoided since the notice label directly below isn't anchored either and a large resize would walk description into it; fully anchoring the rest of the vertical stack is a larger redesign left for a future round. Mutation shown to fail first: reverting the markup (git checkout, patch saved and reapplied) failed at Assert.True(built.Resizable) — the popup was still fixed-size. App markup/plugin filter 243 -> 244; MossTank suite holds 721/721 (this item touches App-side markup only). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
238 lines
10 KiB
C#
238 lines
10 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);
|
|
Assert.Equal(450f, 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}).");
|
|
}
|
|
|
|
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
|
|
{
|
|
public IGpuFrame? CurrentFrame => null;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|