Adds Task 4b: a second load path for the UI Studio that reads the committed retail UI layout dump (docs/research/2026-06-25-retail-ui-layout-dump.json) and renders any of the 26 retail windows as a static sprite hierarchy. New files: - src/AcDream.App/Studio/UiDumpModel.cs — POCOs + System.Text.Json parse of the dump (UiDump, DumpPanel, DumpNode, DumpRect, DumpStateSet, DumpImage, DumpState + UiDumpModel static helpers: Parse, ListSlugs, PickImageId). - src/AcDream.App/Studio/DumpLayout.cs — DumpLayout.Load(path, slug, resolve, out err): parses the dump, finds the panel by slug, builds a UiElement tree. Internal DumpSpriteElement draws its sprite via DrawSprite (not reusing UiDatElement — avoids the ElementInfo/StateMedia dat-import dependency for this static mockup). DumpGroupElement is a transparent container for Group nodes. Rect basis is ABSOLUTE in the dump (verified: inventory root at absolute x=500 and its children also start at x≈500 — child offset from parent is 0–50px, not 500px); DumpLayout subtracts parent rect to produce parent-relative Left/Top for each child. - tests/AcDream.App.Tests/Studio/DumpLayoutTests.cs — 5 tests covering: inventory load with 0x100001D5 check + >= 40 nodes, unknown slug → null+err, root at origin, children are parent-relative, all 26 slugs smoke-load. Modified files: - StudioOptions: adds DumpSlug + DumpFile fields; --dump <slug> and --dump-file <path> args; ResolveDumpFile() walks up to the solution root to find the default dump JSON (mirrors ConformanceDats.SolutionRoot()). --dump suppresses the default vitals layout so the two modes are exclusive. - StudioWindow.OnLoad: when DumpSlug is set, loads via DumpLayout (no FixtureProvider, no controllers — static structure only); else falls through to the existing LayoutSource + FixtureProvider path. Results: DumpLayoutTests 5/5 passed; full AcDream.App.Tests 609 passed, 2 skipped (same as before); dotnet build green, 0 warnings. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
188 lines
6.9 KiB
C#
188 lines
6.9 KiB
C#
using AcDream.App.Studio;
|
|
using AcDream.App.UI;
|
|
|
|
namespace AcDream.App.Tests.Studio;
|
|
|
|
/// <summary>
|
|
/// Tests for <see cref="DumpLayout"/> — parsing the retail UI dump JSON and
|
|
/// building a <see cref="UiElement"/> tree from it.
|
|
///
|
|
/// These tests load the real dump file from the source tree
|
|
/// (<c>docs/research/2026-06-25-retail-ui-layout-dump.json</c>). The test
|
|
/// skips cleanly when the file is absent (should not happen in a normal dev
|
|
/// checkout, but guards against stripped CI machines).
|
|
/// </summary>
|
|
public class DumpLayoutTests
|
|
{
|
|
private static string DumpPath()
|
|
{
|
|
// Walk up from the test output directory to the solution root,
|
|
// mirroring ConformanceDats.SolutionRoot().
|
|
var dir = AppContext.BaseDirectory;
|
|
while (!string.IsNullOrEmpty(dir))
|
|
{
|
|
if (File.Exists(Path.Combine(dir, "AcDream.slnx")))
|
|
return Path.Combine(dir, "docs", "research",
|
|
"2026-06-25-retail-ui-layout-dump.json");
|
|
dir = Path.GetDirectoryName(dir);
|
|
}
|
|
// Fallback: try a relative path (won't find it but skip rather than throw)
|
|
return Path.Combine(AppContext.BaseDirectory,
|
|
"docs", "research", "2026-06-25-retail-ui-layout-dump.json");
|
|
}
|
|
|
|
private static (uint, int, int) NoTex(uint _) => (1u, 1, 1);
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────
|
|
|
|
/// <summary>Depth-first search for an element with the given EventId.</summary>
|
|
private static UiElement? FindById(UiElement root, uint id)
|
|
{
|
|
if (root.EventId == id) return root;
|
|
foreach (var c in root.Children)
|
|
{
|
|
var found = FindById(c, id);
|
|
if (found is not null) return found;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// <summary>Count the total elements in the tree (self + all descendants).</summary>
|
|
private static int CountAll(UiElement root)
|
|
{
|
|
int n = 1;
|
|
foreach (var c in root.Children) n += CountAll(c);
|
|
return n;
|
|
}
|
|
|
|
// ── Tests ─────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Loading the "inventory" slug should succeed and the returned tree should
|
|
/// contain an element with EventId == 0x100001D5 (the doll viewport node)
|
|
/// and at least 40 elements in total.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Load_Inventory_ReturnsTreeWithDollViewport()
|
|
{
|
|
var path = DumpPath();
|
|
if (!File.Exists(path))
|
|
return; // Skip: dump not available.
|
|
|
|
var root = DumpLayout.Load(path, "inventory", NoTex, out var err);
|
|
|
|
Assert.NotNull(root);
|
|
Assert.Null(err);
|
|
|
|
// The doll viewport element must appear somewhere in the tree.
|
|
const uint dollViewportId = 0x100001D5u;
|
|
var found = FindById(root!, dollViewportId);
|
|
Assert.NotNull(found);
|
|
|
|
// The full tree must be reasonably deep — 59 dump nodes → >= 40 elements.
|
|
int total = CountAll(root!);
|
|
Assert.True(total >= 40,
|
|
$"Expected >= 40 elements in inventory tree; got {total}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Loading an unknown slug must return null and a non-empty error string.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Load_UnknownSlug_ReturnsNullWithError()
|
|
{
|
|
var path = DumpPath();
|
|
if (!File.Exists(path))
|
|
return; // Skip.
|
|
|
|
var root = DumpLayout.Load(path, "this_slug_does_not_exist", NoTex, out var err);
|
|
|
|
Assert.Null(root);
|
|
Assert.NotNull(err);
|
|
Assert.NotEmpty(err!);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The root element's Left/Top should be (0,0) (the panel's rect offset has
|
|
/// been stripped so the tree sits at the window origin), and its Width/Height
|
|
/// should match the dump panel dimensions.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Load_Inventory_RootAtOrigin()
|
|
{
|
|
var path = DumpPath();
|
|
if (!File.Exists(path))
|
|
return; // Skip.
|
|
|
|
var root = DumpLayout.Load(path, "inventory", NoTex, out _);
|
|
Assert.NotNull(root);
|
|
|
|
// Root always placed at (0,0) by DumpLayout (origin of the UiHost).
|
|
Assert.Equal(0f, root!.Left);
|
|
Assert.Equal(0f, root.Top);
|
|
// Width/Height come from the panel record in the dump.
|
|
Assert.True(root.Width > 0, "Root width must be > 0");
|
|
Assert.True(root.Height > 0, "Root height must be > 0");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Children must use parent-relative coordinates (the dump rects are absolute;
|
|
/// DumpLayout subtracts the parent rect to produce parent-local offsets).
|
|
/// Verify that at least the direct children of the root have Left/Top values
|
|
/// that are NOT equal to the absolute rect they had in the dump (since the root
|
|
/// was at x>0 in screen space but we place it at 0,0).
|
|
/// </summary>
|
|
[Fact]
|
|
public void Load_Inventory_ChildrenAreParentRelative()
|
|
{
|
|
var path = DumpPath();
|
|
if (!File.Exists(path))
|
|
return; // Skip.
|
|
|
|
var root = DumpLayout.Load(path, "inventory", NoTex, out _);
|
|
Assert.NotNull(root);
|
|
|
|
// If the dump has children at absolute x>=500 but the root is at 0,
|
|
// a correct parent-relative placement will give children x < 500.
|
|
// (The inventory panel root is at absolute x=500; children in the dump
|
|
// also start at x=500 — after subtraction they should land near x=0.)
|
|
bool anyChildAtAbsoluteX = false;
|
|
foreach (var child in root!.Children)
|
|
{
|
|
if (child.Left >= 490f) // would indicate absolute not relative
|
|
{
|
|
anyChildAtAbsoluteX = true;
|
|
break;
|
|
}
|
|
}
|
|
Assert.False(anyChildAtAbsoluteX,
|
|
"Children appear to have absolute coords (Left >= 490) — " +
|
|
"DumpLayout must subtract the parent rect.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Every panel slug known in the dump must load without error.
|
|
/// This is a smoke test that the JSON parse + tree build does not
|
|
/// crash on any of the 26 panels.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Load_AllSlugs_Succeed()
|
|
{
|
|
var path = DumpPath();
|
|
if (!File.Exists(path))
|
|
return; // Skip.
|
|
|
|
var slugs = UiDumpModel.ListSlugs(path);
|
|
Assert.True(slugs.Count >= 20,
|
|
$"Expected >= 20 panel slugs in dump; got {slugs.Count}");
|
|
|
|
foreach (var slug in slugs)
|
|
{
|
|
var root = DumpLayout.Load(path, slug, NoTex, out var err);
|
|
Assert.True(root is not null || err is not null,
|
|
$"Load('{slug}') returned both null root AND null error — one must be set.");
|
|
if (root is null)
|
|
Assert.Fail($"Slug '{slug}' failed with error: {err}");
|
|
}
|
|
}
|
|
}
|