acdream/tests/AcDream.App.Tests/UI/MossTankMarkupBuildOverRealFilesTests.cs
Erik 2d626971d9 fix(vtank): slice 7 round D item 4 — resizable panel, larger default
Owner live report 2026-09-07: "The size of the entire window needs to
be enlarged for default and should also be resizeable." mosstank.xml's
main panel is now resizable="true" with minw/minh floored at the
PRE-round-D authored size (856x236) and a new default authored size
~15% larger in both directions (984x271) — every one of the nine tab
groups grows by the identical 128px-wide/35px-tall delta (848x194 ->
976x229), preserving the original 0px group-to-panel margin design
exactly.

Every tab <group> gets anchor="left top right bottom" so a future
manual drag-resize keeps growing it. The two single-list tabs with
nothing beside their list (Monsters, Meta) give their list the SAME
full stretch, widened AND heightened by the same 128x35 delta at
author time — anchors only react to CHANGE from their first-draw
baseline, so bumping only the outer group while leaving the list's own
geometry untouched would leave it visually unchanged at the new
default (the growth invisibly reserved as margin until a further
manual resize). Every other tab's list(s) grow in HEIGHT only (+35),
pinned to whichever horizontal edge doesn't have a sibling list/button
column in the way (Items/Consumables/Buffs/Route all have one) — a
right-pinned sibling (Consumables' Excluded Scarab list, Buffs'
Blacklisted Buff Families list) is repositioned +128 at author time so
it already sits flush against the enlarged default's right edge.
Trailing "Add" rows/status labels below a taller list shift down by
the same +35 and get anchor="bottom" to keep tracking. Options/
Profiles/Vitals need no internal changes — only their own group grows.

Deviations, both documented in mosstank.xml's own Round D item 4
comment: Monsters' extra 128px of list width lands in its LAST column
(the MoveDown icon, which always absorbs the remainder per the markup
grammar) — a wider icon cell, not a redesigned grid; seven of nine
tabs' groups grow wider than their content uses, leaving harmless
empty space on the right at the enlarged default rather than
redesigning nine tabs' pixel geometry in one pass (a deliberate,
minimal-risk choice — nothing moved INTO another control's space, only
into previously-empty margin, so every existing AssertNoSiblingOverlap/
AssertWithinParent guarantee still holds).

Tests added:
- MossTankMarkupContractTests.AuthoredShellFitsTheMinimumCanvasAnd...
  updated to the new 984x271 default.
- PanelIsResizableFlooredAtThePreRoundDAuthoredSize (resizable="true",
  minw/minh = 856/236).
- EveryStretchingListDeclaresARealAnchor (contract pin: every list in
  Monsters/Items/Consumables/Buffs/Route/Meta declares a real anchor
  attribute, not the silent left-top default).
- MossTankMarkupBuildOverRealFilesTests.
  WideningTheRealMainPanelWidensTheRealMonstersList (App.Tests): builds
  the REAL mosstank.xml against a real MossTankPanel, widens the built
  root by 100px, and confirms the Monsters list's own Width grows in
  turn — the same mechanism MarkupResizableAnchorTests already proves
  against synthetic markup, now proven against the shipped file.

Mutation shown to fail: temporarily removing the Monsters list's own
anchor attribute failed the new re-layout test (976 -> 976, no growth);
restored and confirmed green. A build-copy gotcha surfaced while
writing these tests: dotnet build's default incremental copy did NOT
refresh the test projects' deployed mosstank.xml after editing the
source file (PreserveNewest apparently didn't see it as newer under
normal incremental evaluation) — dotnet build --no-incremental was
needed to get a fresh copy into bin/ before the size/anchor tests
would actually exercise the new markup instead of a stale cached copy.

Verified: dotnet build AcDream.slnx -c Release (clean, --no-incremental)
green; MossTank suite 715/715 (713 -> 715, two new markup contract
pins); App markup/plugin filter 243/243 (242 -> 243, one new re-layout
test).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 17:24:31 +02:00

170 lines
7 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}");
}
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;
}
}