Merge claude/hopeful-maxwell-214a12 — D.2b UI Studio + faithful importer + Character window

UI Studio (preview panels through the production renderer), importer dat-fidelity (Fix A/B/C/4/5:
the importer carries dat font/justification/color; boundary look=importer / state=runtime), and the
Character window Attributes tab (reads as retail). 3062 tests green.
Handoff: docs/research/2026-06-26-mockup-stage-handoff.md.

# Conflicts:
#	docs/ISSUES.md
#	docs/architecture/retail-divergence-register.md
This commit is contained in:
Erik 2026-06-26 12:33:51 +02:00
commit ca94b479bf
61 changed files with 125881 additions and 44 deletions

View file

@ -0,0 +1,41 @@
using System.Numerics;
using AcDream.App.Rendering;
using Xunit;
namespace AcDream.App.Tests.Rendering;
public class DollCameraTests
{
[Fact]
public void Eye_position_is_retail_verbatim()
{
var cam = new DollCamera { Aspect = 100f / 214f };
Assert.True(Matrix4x4.Invert(cam.View, out var inv));
var eye = inv.Translation;
// Retail UIElement_Viewport::SetCamera position (decomp 0x004a5a51-0x004a5a61).
Assert.Equal(0.12f, eye.X, 3);
Assert.Equal(-2.4f, eye.Y, 3);
Assert.Equal(0.88f, eye.Z, 3);
}
[Fact]
public void Look_axis_is_pure_plus_y_zero_yaw()
{
// retail SetCameraDirection(0,0,0) ⇒ IDENTITY view frame ⇒ camera looks straight down +Y.
// System.Numerics CreateLookAt: forward = -(M13, M23, M33). Any yaw (Target.x≠Eye.x) would put a
// non-zero X here and turn the doll's face away — the bug this guards against.
var cam = new DollCamera { Aspect = 100f / 214f };
var forward = -new Vector3(cam.View.M13, cam.View.M23, cam.View.M33);
Assert.Equal(0f, forward.X, 4);
Assert.Equal(1f, forward.Y, 4);
Assert.Equal(0f, forward.Z, 4);
}
[Fact]
public void Projection_is_finite_and_uses_aspect()
{
var cam = new DollCamera { Aspect = 1.5f };
Assert.True(float.IsFinite(cam.Projection.M11));
Assert.NotEqual(0f, cam.Projection.M34); // perspective w = -z term
}
}

View file

@ -0,0 +1,84 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.Core.World;
using Xunit;
namespace AcDream.App.Tests.Rendering;
public class DollEntityBuilderTests
{
[Fact]
public void Builds_doll_entity_with_synthetic_guid_and_player_setup()
{
// SubPaletteRange uses: SubPaletteId, Offset (byte), Length (byte)
var doll = DollEntityBuilder.Build(
setupId: 0x0200_0001u,
meshRefs: new List<MeshRef>(),
basePaletteId: 0x04000ABCu,
subPalettes: new (uint SubPaletteId, byte Offset, byte Length)[] { (0x0F00_0001u, 0, 8) },
partOverrides: new (byte PartIndex, uint GfxObjId)[] { (2, 0x0100_0042u) });
Assert.Equal(0x0200_0001u, doll.SourceGfxObjOrSetupId);
Assert.Equal(DollEntityBuilder.DollServerGuid, doll.ServerGuid);
Assert.NotEqual(0u, doll.ServerGuid);
Assert.Single(doll.PartOverrides);
Assert.Equal((byte)2, doll.PartOverrides[0].PartIndex);
Assert.Equal(0x0100_0042u, doll.PartOverrides[0].GfxObjId);
Assert.NotNull(doll.PaletteOverride);
Assert.Equal(0x04000ABCu, doll.PaletteOverride!.BasePaletteId);
Assert.Single(doll.PaletteOverride.SubPalettes);
Assert.Equal(0x0F00_0001u, doll.PaletteOverride.SubPalettes[0].SubPaletteId);
}
[Fact]
public void Null_overrides_give_empty_collections_not_null()
{
var doll = DollEntityBuilder.Build(0x0200_0001u, new List<MeshRef>(), null, null, null);
// No subpalettes => no PaletteOverride (mirrors GameWindow: only built when Count > 0)
Assert.Null(doll.PaletteOverride);
Assert.Empty(doll.PartOverrides);
}
[Fact]
public void Empty_subpalettes_give_null_palette_override()
{
// Mirror GameWindow: SubPalettes with Count == 0 => no override
var doll = DollEntityBuilder.Build(
0x0200_0001u,
new List<MeshRef>(),
basePaletteId: 0x04000001u,
subPalettes: System.Array.Empty<(uint, byte, byte)>(),
partOverrides: null);
Assert.Null(doll.PaletteOverride);
}
[Fact]
public void Heading_is_normalized_quaternion_facing_viewer()
{
var doll = DollEntityBuilder.Build(0x0200_0001u, new List<MeshRef>(), null, null, null);
Assert.True(System.MathF.Abs(doll.Rotation.LengthSquared() - 1f) < 1e-3f);
}
[Fact]
public void Position_is_world_origin()
{
var doll = DollEntityBuilder.Build(0x0200_0001u, new List<MeshRef>(), null, null, null);
Assert.Equal(Vector3.Zero, doll.Position);
}
[Fact]
public void ParentCellId_is_null_for_doll_scene()
{
var doll = DollEntityBuilder.Build(0x0200_0001u, new List<MeshRef>(), null, null, null);
Assert.Null(doll.ParentCellId);
}
[Fact]
public void MeshRefs_are_passed_through()
{
var refs = new List<MeshRef> { new MeshRef(0x01000001u, Matrix4x4.Identity) };
var doll = DollEntityBuilder.Build(0x0200_0001u, refs, null, null, null);
Assert.Same(refs, doll.MeshRefs);
}
}

View file

@ -0,0 +1,118 @@
using AcDream.App.Studio;
namespace AcDream.App.Tests.Studio;
/// <summary>
/// Pure-math tests for the canvas → panel-local coordinate mapping used by
/// <see cref="StudioInspector.DrawCanvas"/>.
///
/// <para>No GL context required — we're just verifying the formula:
/// panel_local = (raw_mouse_screen) - (image_screen_top_left)</para>
///
/// <para>The image_screen_top_left is what ImGui.GetItemRectMin() returns after
/// ImGui.Image: the sub-window top-left + title-bar height + inner padding + any
/// scrolling. We model it as a constant offset in these tests.</para>
///
/// <para>V-flip: the image is drawn with uv0=(0,1) / uv1=(1,0) so GL's bottom-left
/// origin is flipped to top-left on screen. After the flip, screen Y=0 (top of image)
/// = panel Y=0 (top of the UI), so NO additional Y inversion is applied.</para>
/// </summary>
public class CanvasCoordMappingTests
{
// Simulates the mapping DrawCanvas performs:
// panel pixel = mouse_screen - image_rectMin
// Returns null when the result falls outside [0, width) x [0, height).
private static (int px, int py)? Map(
float mouseScreenX, float mouseScreenY,
float imageOriginX, float imageOriginY,
int panelWidth, int panelHeight)
{
int ix = (int)(mouseScreenX - imageOriginX);
int iy = (int)(mouseScreenY - imageOriginY);
if (ix < 0 || ix >= panelWidth || iy < 0 || iy >= panelHeight)
return null;
return (ix, iy);
}
[Fact]
public void TopLeft_of_image_maps_to_panel_origin()
{
// The canvas image starts at screen (300, 50) (after sub-window chrome).
// A click exactly at the image's screen top-left → panel (0, 0).
var result = Map(mouseScreenX: 300f, mouseScreenY: 50f,
imageOriginX: 300f, imageOriginY: 50f,
panelWidth: 1280, panelHeight: 720);
Assert.Equal((0, 0), result);
}
[Fact]
public void Interior_point_maps_correctly()
{
// Image origin at screen (300, 50). Mouse at screen (780, 230).
// Expected panel coord: (780-300, 230-50) = (480, 180).
var result = Map(mouseScreenX: 780f, mouseScreenY: 230f,
imageOriginX: 300f, imageOriginY: 50f,
panelWidth: 1280, panelHeight: 720);
Assert.Equal((480, 180), result);
}
[Fact]
public void Bottom_right_corner_maps_to_last_valid_pixel()
{
// Image is 1280×720, origin at screen (300, 50).
// Last pixel in bottom-right is panel (1279, 719) → screen (1579, 769).
var result = Map(mouseScreenX: 1579f, mouseScreenY: 769f,
imageOriginX: 300f, imageOriginY: 50f,
panelWidth: 1280, panelHeight: 720);
Assert.Equal((1279, 719), result);
}
[Fact]
public void Mouse_on_ImGui_chrome_above_image_returns_null()
{
// The ImGui window title-bar / padding is above rectMin, i.e. at screenY < 50.
// The mouse there should NOT produce a panel event.
var result = Map(mouseScreenX: 400f, mouseScreenY: 40f, // 10px above image origin
imageOriginX: 300f, imageOriginY: 50f,
panelWidth: 1280, panelHeight: 720);
Assert.Null(result);
}
[Fact]
public void Mouse_below_image_returns_null()
{
// screenY = 50 + 720 = 770 → iy = 720 which is >= panelHeight (720).
var result = Map(mouseScreenX: 400f, mouseScreenY: 770f,
imageOriginX: 300f, imageOriginY: 50f,
panelWidth: 1280, panelHeight: 720);
Assert.Null(result);
}
[Fact]
public void Y_is_not_inverted_after_vflip()
{
// Confirm the "no extra Y inversion" contract:
// The image is V-flipped in ImGui (uv0.Y=1, uv1.Y=0), so screen top row = panel Y=0.
// A click near the TOP of the image should give a SMALL panel Y, not a large one.
// Image origin at (300, 50). Click at (400, 55) → panel (100, 5). Y is small (near top).
var result = Map(mouseScreenX: 400f, mouseScreenY: 55f,
imageOriginX: 300f, imageOriginY: 50f,
panelWidth: 1280, panelHeight: 720);
Assert.Equal((100, 5), result);
// NOT (100, 715) — which would be the result if Y were incorrectly inverted.
Assert.True(result!.Value.py < 720 / 2, "Y near screen top should map to small panel Y, not near bottom");
}
[Fact]
public void LargeChrome_offset_is_fully_absorbed()
{
// Simulate a canvas sub-window with large chrome: ImGui title (20px) + padding (8px)
// puts the image origin at screenY = menuBar(22) + titleBar(20) + padding(8) = 50.
// Also a wide tree pane puts imageOriginX = 280 + padding.
// A click at screen (400, 110) with origin (290, 50) → panel (110, 60).
var result = Map(mouseScreenX: 400f, mouseScreenY: 110f,
imageOriginX: 290f, imageOriginY: 50f,
panelWidth: 1280, panelHeight: 720);
Assert.Equal((110, 60), result);
}
}

View file

@ -0,0 +1,188 @@
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}");
}
}
}

View file

@ -0,0 +1,160 @@
using AcDream.App.Studio;
using AcDream.Core.Items;
namespace AcDream.App.Tests.Studio;
/// <summary>
/// Unit tests for <see cref="FixtureProvider"/> and <see cref="SampleData"/>.
/// These tests have NO GL/dat dependency — they only exercise the in-memory
/// ClientObjectTable population that FixtureProvider uses.
/// </summary>
public class FixtureProviderTests
{
/// <summary>
/// SampleData.BuildObjectTable() must place at least 6 items in the
/// player's main pack (ContainerId == PlayerGuid) and the player object
/// itself must exist with the right capacities.
/// </summary>
[Fact]
public void SampleTable_hasPackContents()
{
var t = SampleData.BuildObjectTable();
// Player object must exist.
var player = t.Get(SampleData.PlayerGuid);
Assert.NotNull(player);
Assert.Equal(102, player!.ItemsCapacity);
Assert.Equal(7, player.ContainersCapacity);
// At least 6 loose items must be in the main pack.
var contents = t.GetContents(SampleData.PlayerGuid);
Assert.True(contents.Count >= 6,
$"Expected >= 6 items in player pack; got {contents.Count}");
// Verify the first item has a non-zero IconId and a recognised Type.
var firstId = contents[0];
var first = t.Get(firstId);
Assert.NotNull(first);
Assert.NotEqual(0u, first!.IconId);
Assert.NotEqual(ItemType.None, first.Type);
}
/// <summary>
/// Spot-check that the sample table also seeds a sword-like item
/// (MeleeWeapon) and a piece of armor so icon resolution has
/// recognisable item types to work with.
/// </summary>
[Fact]
public void SampleTable_hasWeaponAndArmor()
{
var t = SampleData.BuildObjectTable();
var contents = t.GetContents(SampleData.PlayerGuid);
bool hasMelee = false, hasArmor = false;
foreach (var guid in contents)
{
var obj = t.Get(guid);
if (obj is null) continue;
if ((obj.Type & ItemType.MeleeWeapon) != 0) hasMelee = true;
if ((obj.Type & ItemType.Armor) != 0) hasArmor = true;
}
Assert.True(hasMelee, "Expected at least one MeleeWeapon in sample pack");
Assert.True(hasArmor, "Expected at least one Armor item in sample pack");
}
/// <summary>
/// Sample table must include at least 1 equipped item whose
/// CurrentlyEquippedLocation is non-None.
/// </summary>
[Fact]
public void SampleTable_hasEquippedItems()
{
var t = SampleData.BuildObjectTable();
bool anyEquipped = false;
foreach (var obj in t.Objects)
{
if (obj.CurrentlyEquippedLocation != EquipMask.None)
{ anyEquipped = true; break; }
}
Assert.True(anyEquipped, "Expected at least one equipped item in sample table");
}
/// <summary>
/// Sample table must include at least 2 side-bag containers in the
/// player's pack (ContainerId == PlayerGuid, Type has Container bit).
/// </summary>
[Fact]
public void SampleTable_hasSideBags()
{
var t = SampleData.BuildObjectTable();
int bagCount = 0;
foreach (var guid in t.GetContents(SampleData.PlayerGuid))
{
var obj = t.Get(guid);
if (obj is not null && (obj.Type & ItemType.Container) != 0)
bagCount++;
}
Assert.True(bagCount >= 2,
$"Expected >= 2 side-bag containers in player pack; got {bagCount}");
}
/// <summary>
/// Side bags must match the InventoryController filter: ItemType.Container OR ItemsCapacity &gt; 0.
/// This ensures they appear in the side-bag column even if the exact Type flag changes.
/// </summary>
[Fact]
public void SampleTable_sideBags_matchInventoryControllerFilter()
{
var t = SampleData.BuildObjectTable();
int bagCount = 0;
foreach (var guid in t.GetContents(SampleData.PlayerGuid))
{
var obj = t.Get(guid);
if (obj is null) continue;
// InventoryController.Populate line ~203: Type.HasFlag(Container) OR ItemsCapacity > 0
bool isBag = obj.Type.HasFlag(ItemType.Container) || obj.ItemsCapacity > 0;
if (isBag) bagCount++;
}
Assert.True(bagCount >= 2,
$"Expected >= 2 items matching the side-bag filter; got {bagCount}");
}
/// <summary>
/// Equipped items must BOTH (a) retain CurrentlyEquippedLocation != None after
/// seeding AND (b) appear in GetContents(PlayerGuid) so PaperdollController.Populate
/// can find them. These are the two conditions PaperdollController checks on every
/// equipped item (PaperdollController.Populate: ContainerId == playerGuid AND
/// CurrentlyEquippedLocation != None).
/// </summary>
[Fact]
public void SampleTable_equippedItems_retainLocationAndAreInContents()
{
var t = SampleData.BuildObjectTable();
var contents = new System.Collections.Generic.HashSet<uint>(t.GetContents(SampleData.PlayerGuid));
int equippedCount = 0;
foreach (var obj in t.Objects)
{
if (obj.CurrentlyEquippedLocation == EquipMask.None) continue;
equippedCount++;
// Must appear in GetContents so the controller can pick them up.
Assert.True(contents.Contains(obj.ObjectId),
$"Equipped item 0x{obj.ObjectId:X8} ('{obj.Name}', loc={obj.CurrentlyEquippedLocation}) " +
$"is NOT in GetContents(PlayerGuid). PaperdollController will miss it.");
// The equip location must survive the MoveItem call.
Assert.NotEqual(EquipMask.None, obj.CurrentlyEquippedLocation);
}
Assert.True(equippedCount >= 3,
$"Expected >= 3 equipped items in sample table; got {equippedCount}");
}
}

View file

@ -0,0 +1,60 @@
using AcDream.App.Studio;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using DatReaderWriter;
using DatReaderWriter.Options;
namespace AcDream.App.Tests.Studio;
/// <summary>
/// Unit tests for <see cref="LayoutSource"/>. The dat-backed test skips cleanly
/// when the real dats are not present (CI / dev machines without AC installed).
/// </summary>
public class LayoutSourceTests
{
private static (uint handle, int width, int height) NoTex(uint _) => (1u, 1, 1);
/// <summary>Resolve the client dat directory, or null if unavailable (skip the test).</summary>
private static string? ResolveDatDir()
{
var fromEnv = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv))
return fromEnv;
var def = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
return Directory.Exists(def) ? def : null;
}
/// <summary>
/// Load the vitals LayoutDesc (0x2100006C) from the real dats and verify
/// that LayoutSource returns a non-null root with the layout id findable.
/// Skips when the dats are unavailable.
///
/// Note: the spec asserts FindElement(0x2100006Cu) — that is the layout dat
/// id, not an element id in the widget tree. The actual vitals root element id
/// is 0x100005F9 (confirmed from vitals_2100006C.json fixture). We assert
/// FindElement(0x100005F9) here which verifies the same integration path: the
/// layout was successfully loaded and the element dict was populated.
/// </summary>
[Fact]
public void LoadsDatLayout_byId()
{
var dir = ResolveDatDir();
if (dir is null)
return; // Skip: dats not available on this machine / CI.
using var dats = new DatCollection(dir, DatAccessType.Read);
var src = new LayoutSource(dats, NoTex, datFont: null);
var root = src.Load(new StudioOptions(dir, 0x2100006Cu, null));
// root must be non-null: Import found the LayoutDesc and built the tree.
Assert.NotNull(root);
Assert.NotNull(src.CurrentLayout);
// The vitals root element id is 0x100005F9 (layout dat id 0x2100006C ≠ element id).
// Asserting FindElement verifies the byId dict was populated by the importer.
Assert.NotNull(src.CurrentLayout!.FindElement(0x100005F9u));
Assert.Equal(LayoutSourceKind.DatLayout, src.Kind);
}
}

View file

@ -0,0 +1,147 @@
using AcDream.App.Studio;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Unit tests for <see cref="CharacterController"/> and <see cref="SampleData.SampleCharacter"/>.
/// The controller CREATES the m_pMainText report element (0x1000011d) at bind time — retail's
/// gm*UI builds it at runtime; it is not a static dat element — and attaches it to the layout root.
/// No dats, no GL — pure data-wiring + report-content tests.
/// </summary>
public class CharacterControllerTests
{
/// <summary>Bind on an empty layout, then return the created m_pMainText element.</summary>
private static UiText BindAndGetText(System.Func<CharacterSheet> data)
{
var layout = FakeLayout();
CharacterController.Bind(layout, data);
return layout.Root.Children.OfType<UiText>()
.First(t => t.EventId == CharacterController.MainTextId);
}
private static string Report(System.Func<CharacterSheet> data)
=> string.Join("\n", BindAndGetText(data).LinesProvider().Select(l => l.Text));
// ── Test 1: Bind creates m_pMainText with a non-empty report ──────────────
[Fact]
public void Bind_CreatesMainTextWithReport()
{
var text = BindAndGetText(SampleData.SampleCharacter);
Assert.True(text.LinesProvider().Count > 0, "Expected non-empty report after Bind");
}
// ── Test 2: Report contains all six attribute names (decomp order 1,2,4,3,5,6) ───
[Fact]
public void Bind_ReportContainsAllSixAttributes()
{
var allText = Report(SampleData.SampleCharacter);
Assert.Contains("Strength", allText);
Assert.Contains("Endurance", allText);
Assert.Contains("Quickness", allText);
Assert.Contains("Coordination", allText);
Assert.Contains("Focus", allText);
Assert.Contains("Self", allText);
}
// ── Test 3: Report contains vitals section ────────────────────────────────
[Fact]
public void Bind_ReportContainsVitals()
{
var allText = Report(SampleData.SampleCharacter);
Assert.Contains("Health", allText);
Assert.Contains("Stamina", allText);
Assert.Contains("Mana", allText);
}
// ── Test 4: Report contains encumbrance / burden section ─────────────────
[Fact]
public void Bind_ReportContainsBurden()
{
var allText = Report(SampleData.SampleCharacter);
Assert.Contains("Burden", allText);
Assert.Contains("Capacity", allText);
}
// ── Test 5: Report contains the sample character's name ──────────────────
[Fact]
public void Bind_ReportContainsSampleName()
=> Assert.Contains("Studio Player", Report(SampleData.SampleCharacter));
// ── Test 6: Bind on a layout WITHOUT the element creates it (no throw) ────
[Fact]
public void Bind_OnEmptyLayout_CreatesElement()
{
var layout = FakeLayout(); // no MainTextId present
CharacterController.Bind(layout, SampleData.SampleCharacter);
Assert.Contains(layout.Root.Children.OfType<UiText>(),
t => t.EventId == CharacterController.MainTextId);
}
// ── Test 7: The created element carries the m_pMainText id ───────────────
[Fact]
public void Bind_CreatedElementHasMainTextId()
=> Assert.Equal(CharacterController.MainTextId, BindAndGetText(SampleData.SampleCharacter).EventId);
// ── Test 8: SampleCharacter fixture has non-zero attribute values ─────────
[Fact]
public void SampleCharacter_AttributesAreNonZero()
{
var s = SampleData.SampleCharacter();
Assert.True(s.Strength > 0, "Strength must be > 0");
Assert.True(s.Endurance > 0, "Endurance must be > 0");
Assert.True(s.Quickness > 0, "Quickness must be > 0");
Assert.True(s.Coordination > 0, "Coordination must be > 0");
Assert.True(s.Focus > 0, "Focus must be > 0");
Assert.True(s.Self > 0, "Self must be > 0");
}
// ── Test 9: SampleCharacter fixture has coherent vitals ──────────────────
[Fact]
public void SampleCharacter_VitalsAreCoherent()
{
var s = SampleData.SampleCharacter();
Assert.True(s.HealthCurrent > 0 && s.HealthCurrent <= s.HealthMax, "Health cur must be in [1, max]");
Assert.True(s.StaminaCurrent > 0 && s.StaminaCurrent <= s.StaminaMax, "Stamina cur must be in [1, max]");
Assert.True(s.ManaCurrent > 0 && s.ManaCurrent <= s.ManaMax, "Mana cur must be in [1, max]");
}
// ── Test 10: Report contains attribute VALUES from the fixture ────────────
[Fact]
public void Bind_ReportContainsSampleAttributeValues()
{
var sheet = SampleData.SampleCharacter();
var allText = Report(() => sheet);
Assert.Contains(sheet.Strength.ToString(), allText);
Assert.Contains(sheet.Endurance.ToString(), allText);
Assert.Contains(sheet.Quickness.ToString(), allText);
Assert.Contains(sheet.Coordination.ToString(), allText);
Assert.Contains(sheet.Focus.ToString(), allText);
Assert.Contains(sheet.Self.ToString(), allText);
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static ImportedLayout FakeLayout(params (uint id, UiElement e)[] items)
{
var dict = new Dictionary<uint, UiElement>();
var root = new UiPanel();
foreach (var (id, e) in items)
{
root.AddChild(e);
dict[id] = e;
}
return new ImportedLayout(root, dict);
}
}

View file

@ -0,0 +1,920 @@
using AcDream.App.Studio;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Unit tests for <see cref="CharacterStatController"/> — the Attributes-tab controller that
/// binds the REAL importer-mounted header + 9-row list + footer elements (no created overlay).
/// Pure data wiring against fake layouts; no dats, no GL.
///
/// <para>Pass 1 tests verify header/XP meter/attribute list/footer State-A binding.
/// Pass 2 tests verify: (a) row click → selection toggle; (b) footer State-B content;
/// (c) raise-button affordability; (d) tab button states.</para>
/// </summary>
public class CharacterStatControllerTests
{
// ── Header labels bind to the sheet ──────────────────────────────────────
[Fact]
public void Bind_SetsNameLabel()
{
var name = new UiText();
var layout = Fake((CharacterStatController.NameId, name));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
Assert.Equal("Studio Player", name.LinesProvider()[0].Text);
}
[Fact]
public void Bind_SetsLevelLabel()
{
var level = new UiText();
var layout = Fake((CharacterStatController.LevelId, level));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
Assert.Equal("126", level.LinesProvider()[0].Text);
}
[Fact]
public void Bind_SetsHeritageAndPkLabels()
{
var heritage = new UiText();
var pk = new UiText();
var layout = Fake((CharacterStatController.HeritageId, heritage),
(CharacterStatController.PkStatusId, pk));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
Assert.Equal("Aluvian Heritage", heritage.LinesProvider()[0].Text);
Assert.Equal("Non-Player Killer", pk.LinesProvider()[0].Text);
}
// ── XP meter fill ────────────────────────────────────────────────────────
[Fact]
public void Bind_SetsXpMeterFill()
{
var meter = new UiMeter();
var layout = Fake((CharacterStatController.XpMeterId, meter));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
var fill = meter.Fill();
Assert.NotNull(fill);
Assert.True(System.MathF.Abs(fill!.Value - 0.63f) < 0.001f, $"expected ~0.63, got {fill}");
}
// ── Attribute list — 9 rows in list box 0x1000023D ───────────────────────
[Fact]
public void Bind_AttributeList_Has9Rows()
{
var list = new UiPanel();
var layout = Fake((CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
// Rows are UiClickablePanel which inherits UiPanel, so OfType<UiPanel> matches.
var rows = list.Children.OfType<UiPanel>().ToList();
Assert.Equal(9, rows.Count);
}
[Fact]
public void Bind_AttributeList_RowsAreClickablePanels()
{
var list = new UiPanel();
var layout = Fake((CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
var rows = list.Children.OfType<UiClickablePanel>().ToList();
Assert.Equal(9, rows.Count);
// All rows must have an OnClick wired (not null) and ClickThrough = false.
foreach (var row in rows)
{
Assert.NotNull(row.OnClick);
Assert.False(row.ClickThrough, "clickable row must accept pointer hits");
}
}
[Fact]
public void Bind_AttributeList_EachRowHasRightAlignedValueLabel()
{
var list = new UiPanel();
var layout = Fake((CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
var rows = list.Children.OfType<UiPanel>().ToList();
Assert.Equal(9, rows.Count);
foreach (var row in rows)
{
var texts = row.Children.OfType<UiText>().ToList();
Assert.True(texts.Count >= 2, "each row must have at least name + value UiText");
var valueEl = texts[^1];
Assert.True(valueEl.RightAligned, "value label must be RightAligned");
}
}
[Fact]
public void Bind_AttributeList_RowNamesInRetailOrder()
{
var list = new UiPanel();
var layout = Fake((CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
var rows = list.Children.OfType<UiPanel>().ToList();
Assert.Equal(9, rows.Count);
string[] expectedNames =
{
"Strength", "Endurance", "Coordination", "Quickness", "Focus", "Self",
"Health", "Stamina", "Mana",
};
for (int i = 0; i < 9; i++)
{
var texts = rows[i].Children.OfType<UiText>().ToList();
Assert.True(texts.Count >= 2, $"row {i} must have name + value");
string rowName = texts[1].LinesProvider()[0].Text;
Assert.Equal(expectedNames[i], rowName);
}
}
[Fact]
public void Bind_AttributeList_RowValues_AttributeIntegersAndVitalsCurMax()
{
var list = new UiPanel();
var layout = Fake((CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
var rows = list.Children.OfType<UiPanel>().ToList();
string ValueOf(UiPanel row) => row.Children.OfType<UiText>().ToList()[^1].LinesProvider()[0].Text;
Assert.Equal("200", ValueOf(rows[0])); // Strength
Assert.Equal("10", ValueOf(rows[1])); // Endurance
Assert.Equal("10", ValueOf(rows[2])); // Coordination
Assert.Equal("200", ValueOf(rows[3])); // Quickness
Assert.Equal("10", ValueOf(rows[4])); // Focus
Assert.Equal("10", ValueOf(rows[5])); // Self
Assert.Equal("5/5", ValueOf(rows[6])); // Health
Assert.Equal("10/10", ValueOf(rows[7])); // Stamina
Assert.Equal("10/10", ValueOf(rows[8])); // Mana
}
[Fact]
public void Bind_AttributeList_IconHasBackgroundSpriteWhenResolverProvided()
{
var list = new UiPanel();
var layout = Fake((CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
spriteResolve: id => (id, 16, 16));
var rows = list.Children.OfType<UiPanel>().ToList();
Assert.Equal(9, rows.Count);
var iconEl = rows[0].Children.OfType<UiText>().First();
Assert.Equal(0x060002C8u, iconEl.BackgroundSprite);
var healthIcon = rows[6].Children.OfType<UiText>().First();
Assert.Equal(0x06004C3Bu, healthIcon.BackgroundSprite);
}
[Fact]
public void Bind_AttributeList_IconBackgroundSprite_ZeroWhenNoResolver()
{
var list = new UiPanel();
var layout = Fake((CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter, spriteResolve: null);
var rows = list.Children.OfType<UiPanel>().ToList();
Assert.Equal(9, rows.Count);
foreach (var row in rows)
{
var iconEl = row.Children.OfType<UiText>().First();
Assert.Equal(0u, iconEl.BackgroundSprite);
}
}
// ── Footer State A ────────────────────────────────────────────────────────
[Fact]
public void Bind_FooterStateA_TitleIsSelectPrompt()
{
var title = new UiText();
var layout = Fake((CharacterStatController.FooterTitleId, title));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
// Initial state (nothing selected): State-A title.
Assert.Equal("Select an Attribute to Improve", title.LinesProvider()[0].Text);
}
[Fact]
public void Bind_FooterStateA_Line1LabelIsSkillCreditsAvailable()
{
var lbl = new UiText();
var layout = Fake((CharacterStatController.FooterLine1Label, lbl));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
Assert.Equal("Skill Credits Available:", lbl.LinesProvider()[0].Text);
}
[Fact]
public void Bind_FooterStateA_Line1ValueIsSkillCredits()
{
var val = new UiText();
var layout = Fake((CharacterStatController.FooterLine1Value, val));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
Assert.Equal("96", val.LinesProvider()[0].Text);
}
[Fact]
public void Bind_FooterStateA_Line2LabelIsUnassignedExperience()
{
var lbl = new UiText();
var layout = Fake((CharacterStatController.FooterLine2Label, lbl));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
Assert.Equal("Unassigned Experience:", lbl.LinesProvider()[0].Text);
}
[Fact]
public void Bind_FooterStateA_Line2ValueIsUnassignedXp()
{
var val = new UiText();
var layout = Fake((CharacterStatController.FooterLine2Value, val));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
var expected = (87_757_321_741L).ToString("N0");
Assert.Equal(expected, val.LinesProvider()[0].Text);
}
// ── Pass 2: Row selection → Footer State B ───────────────────────────────
[Fact]
public void RowClick_SelectRow4Focus_FooterStateBShowsFocusTitle()
{
// Focus is index 4 in AttrRows. SampleData Focus = 10. Cost = 110.
var title = new UiText();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.FooterTitleId, title),
(CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
// Simulate a click on row 4 (Focus).
var rows = list.Children.OfType<UiClickablePanel>().ToList();
Assert.Equal(9, rows.Count);
rows[4].OnClick!();
// Footer title should now be "Focus: 10".
Assert.Equal("Focus: 10", title.LinesProvider()[0].Text);
}
[Fact]
public void RowClick_SelectRow4Focus_FooterLine1LabelIsExperienceToRaise()
{
var lbl = new UiText();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.FooterLine1Label, lbl),
(CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
Assert.Equal("Experience To Raise:", lbl.LinesProvider()[0].Text);
}
[Fact]
public void RowClick_SelectRow4Focus_FooterLine1ValueIsRaiseCost()
{
var val = new UiText();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.FooterLine1Value, val),
(CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
// Focus raise cost = 110 (SampleData fixture).
Assert.Equal((110L).ToString("N0"), val.LinesProvider()[0].Text);
}
[Fact]
public void RowClick_SelectRow4Focus_FooterLine2LabelIsUnassignedExperience()
{
var lbl = new UiText();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.FooterLine2Label, lbl),
(CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
Assert.Equal("Unassigned Experience:", lbl.LinesProvider()[0].Text);
}
[Fact]
public void RowClick_SelectRow4Focus_FooterLine2ValueIsUnassignedXp()
{
var val = new UiText();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.FooterLine2Value, val),
(CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
// UnassignedXp = 87_757_321_741L
var expected = (87_757_321_741L).ToString("N0");
Assert.Equal(expected, val.LinesProvider()[0].Text);
}
// ── Pass 2: Toggle deselects ──────────────────────────────────────────────
[Fact]
public void RowClick_ToggleSameRow_ReturnsToFooterStateA()
{
var title = new UiText();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.FooterTitleId, title),
(CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
var rows = list.Children.OfType<UiClickablePanel>().ToList();
// Select Focus (row 4).
rows[4].OnClick!();
Assert.Equal("Focus: 10", title.LinesProvider()[0].Text);
// Click the same row again → deselect.
rows[4].OnClick!();
Assert.Equal("Select an Attribute to Improve", title.LinesProvider()[0].Text);
}
[Fact]
public void RowClick_SwitchRow_UpdatesToNewRow()
{
var title = new UiText();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.FooterTitleId, title),
(CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
var rows = list.Children.OfType<UiClickablePanel>().ToList();
// Select Endurance (row 1, value=10).
rows[1].OnClick!();
Assert.Equal("Endurance: 10", title.LinesProvider()[0].Text);
// Select Self (row 5, value=10).
rows[5].OnClick!();
Assert.Equal("Self: 10", title.LinesProvider()[0].Text);
}
// ── Pass 2: Row highlight ─────────────────────────────────────────────────
[Fact]
public void RowClick_SelectRow_HighlightsSelectedAndClearsOthers()
{
var list = new UiPanel();
var layout = Fake((CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
var rows = list.Children.OfType<UiClickablePanel>().ToList();
// All rows start transparent.
Assert.All(rows, r => Assert.Equal(0f, r.BackgroundColor.W));
// Select row 2 (Coordination).
rows[2].OnClick!();
Assert.NotEqual(0f, rows[2].BackgroundColor.W); // highlighted
Assert.Equal(0f, rows[0].BackgroundColor.W); // others cleared
Assert.Equal(0f, rows[1].BackgroundColor.W);
}
[Fact]
public void RowClick_Deselect_ClearsHighlight()
{
var list = new UiPanel();
var layout = Fake((CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
var rows = list.Children.OfType<UiClickablePanel>().ToList();
rows[2].OnClick!(); // select
rows[2].OnClick!(); // deselect
Assert.Equal(0f, rows[2].BackgroundColor.W);
}
[Fact]
public void RowClick_WithSpriteResolve_SelectedRowHasHighlightSprite()
{
// When spriteResolve is provided, the selected row must use sprite 0x06001397
// (retail Button-state-6 dark bar) instead of the translucent gold BackgroundColor.
var list = new UiPanel();
var layout = Fake((CharacterStatController.ListBoxId, list));
// Minimal sprite resolver — returns a fake non-zero handle so UiPanel draws it.
static (uint, int, int) FakeResolve(uint id) => (1u, 32, 8);
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
spriteResolve: FakeResolve);
var rows = list.Children.OfType<UiClickablePanel>().ToList();
rows[2].OnClick!();
Assert.Equal(0x06001397u, rows[2].BackgroundSprite); // selected → sprite
Assert.Equal(0f, rows[2].BackgroundColor.W); // no tint
Assert.Equal(0u, rows[0].BackgroundSprite); // others cleared
Assert.Equal(0u, rows[1].BackgroundSprite);
}
[Fact]
public void RowClick_WithSpriteResolve_Deselect_ClearsSprite()
{
var list = new UiPanel();
var layout = Fake((CharacterStatController.ListBoxId, list));
static (uint, int, int) FakeResolve(uint id) => (1u, 32, 8);
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
spriteResolve: FakeResolve);
var rows = list.Children.OfType<UiClickablePanel>().ToList();
rows[2].OnClick!(); // select
rows[2].OnClick!(); // deselect
Assert.Equal(0u, rows[2].BackgroundSprite);
}
// ── Pass 2: Raise button affordability ───────────────────────────────────
[Fact]
public void RaiseButtons_InitiallyHidden()
{
var btn1 = MakeButton();
var btn10 = MakeButton();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.RaiseOneId, btn1),
(CharacterStatController.RaiseTenId, btn10),
(CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
Assert.False(btn1.Visible, "raise×1 must start hidden");
Assert.False(btn10.Visible, "raise×10 must start hidden");
}
[Fact]
public void RaiseButtons_AffordableRow_ShowsNormalState()
{
// Focus row (index 4) cost=110, UnassignedXp=87_757_321_741 → affordable.
var btn1 = MakeButton();
var btn10 = MakeButton();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.RaiseOneId, btn1),
(CharacterStatController.RaiseTenId, btn10),
(CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!(); // select Focus
Assert.True(btn1.Visible, "raise×1 visible on selection");
Assert.True(btn10.Visible, "raise×10 visible on selection");
Assert.Equal("Normal", btn1.ActiveState);
Assert.Equal("Normal", btn10.ActiveState);
}
[Fact]
public void RaiseButtons_MaxedRow_ShowsGhostedState()
{
// Strength row (index 0) cost=0 → disabled. UnassignedXp is irrelevant.
var btn1 = MakeButton();
var btn10 = MakeButton();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.RaiseOneId, btn1),
(CharacterStatController.RaiseTenId, btn10),
(CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
list.Children.OfType<UiClickablePanel>().ToList()[0].OnClick!(); // select Strength (cost=0)
Assert.True(btn1.Visible, "raise button visible even when disabled");
Assert.Equal("Ghosted", btn1.ActiveState);
Assert.Equal("Ghosted", btn10.ActiveState);
}
[Fact]
public void RaiseButtons_Deselect_HidesButtons()
{
var btn1 = MakeButton();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.RaiseOneId, btn1),
(CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
var rows = list.Children.OfType<UiClickablePanel>().ToList();
rows[4].OnClick!(); // select
Assert.True(btn1.Visible);
rows[4].OnClick!(); // deselect
Assert.False(btn1.Visible, "raise button hidden after deselect");
}
// ── Pass 2: Tab button states ─────────────────────────────────────────────
// Tab sprites are added to layout.Root (NOT to the tab group elements), so
// the tab groups keep Children.Count==0 and survive the page-visibility pass.
// AddTabSpritesToRoot() adds 3 sprite UiTexts + 1 label UiText per tab to the
// root when a spriteResolve is provided; nothing is added when spriteResolve=null.
[Fact]
public void TabButtons_NoSpriteResolve_AddsNoSpriteChildrenToRoot()
{
// When spriteResolve is null, AddTabSpritesToRoot is not called — no sprites added.
var layout = Fake(); // empty fake layout with just the root UiPanel
int rootChildCountBefore = layout.Root.Children.Count;
CharacterStatController.Bind(layout, SampleData.SampleCharacter, spriteResolve: null);
// No extra children added to root when spriteResolve is null.
Assert.Equal(rootChildCountBefore, layout.Root.Children.Count);
}
[Fact]
public void TabButtons_AttributesGroup_AddsOpenSpriteIdsToRoot()
{
// Attributes tab (isOpen=true): 3 sprite children with Open sprite ids on ROOT.
// The tab group element itself has 0 children (sprites go to root, not the group).
var layout = Fake(); // minimal fake
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
spriteResolve: id => (id, 16, 16));
// Root should contain the Open sprite ids among all tab sprite children.
var sprites = layout.Root.Children.OfType<UiText>()
.Where(t => t.BackgroundSprite != 0u).ToList();
Assert.Contains(sprites, t => t.BackgroundSprite == 0x06005D92u); // Open left-cap
Assert.Contains(sprites, t => t.BackgroundSprite == 0x06005D94u); // Open center
Assert.Contains(sprites, t => t.BackgroundSprite == 0x06005D96u); // Open right-cap
}
[Fact]
public void TabButtons_SkillsAndTitles_AddClosedSpriteIdsToRoot()
{
// Skills + Titles tabs (isOpen=false): Closed sprite ids appear on root.
var layout = Fake();
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
spriteResolve: id => (id, 16, 16));
var sprites = layout.Root.Children.OfType<UiText>()
.Where(t => t.BackgroundSprite != 0u).ToList();
Assert.Contains(sprites, t => t.BackgroundSprite == 0x06005D93u); // Closed left-cap
Assert.Contains(sprites, t => t.BackgroundSprite == 0x06005D95u); // Closed center
Assert.Contains(sprites, t => t.BackgroundSprite == 0x06005D97u); // Closed right-cap
}
[Fact]
public void TabButtons_WithSpriteResolve_AddsAllThreeTabsToRoot()
{
// With spriteResolve: all 3 tabs inject 4 children each (3 sprites + 1 label)
// = 12 sprite children total across 3 tabs on the root.
var layout = Fake();
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
spriteResolve: id => (id, 16, 16));
// 3 tabs × 3 sprites = 9 sprite UiTexts on root.
var sprites = layout.Root.Children.OfType<UiText>()
.Where(t => t.BackgroundSprite != 0u).ToList();
Assert.Equal(9, sprites.Count);
}
// ── Affordability helpers (GetRaiseCost) ──────────────────────────────────
[Fact]
public void GetRaiseCost_Index4Focus_Returns110()
{
var sheet = SampleData.SampleCharacter();
Assert.Equal(110L, CharacterStatController.GetRaiseCost(sheet, 4));
}
[Fact]
public void GetRaiseCost_Index0Strength_Returns0()
{
var sheet = SampleData.SampleCharacter();
Assert.Equal(0L, CharacterStatController.GetRaiseCost(sheet, 0));
}
[Fact]
public void GetRaiseCost_OutOfRange_Returns0()
{
var sheet = SampleData.SampleCharacter();
Assert.Equal(0L, CharacterStatController.GetRaiseCost(sheet, 99));
}
// ── GetRowName helper ─────────────────────────────────────────────────────
[Fact]
public void GetRowName_Index0_ReturnsStrength()
=> Assert.Equal("Strength", CharacterStatController.GetRowName(0));
[Fact]
public void GetRowName_Index4_ReturnsFocus()
=> Assert.Equal("Focus", CharacterStatController.GetRowName(4));
[Fact]
public void GetRowName_Index6_ReturnsHealth()
=> Assert.Equal("Health", CharacterStatController.GetRowName(6));
[Fact]
public void GetRowName_NegativeIndex_ReturnsEmpty()
=> Assert.Equal(string.Empty, CharacterStatController.GetRowName(-1));
// ── SampleData sanity ─────────────────────────────────────────────────────
[Fact]
public void SampleCharacter_SkillCredits_Is96()
=> Assert.Equal(96, SampleData.SampleCharacter().SkillCredits);
[Fact]
public void SampleCharacter_UnassignedXp_IsSet()
=> Assert.Equal(87_757_321_741L, SampleData.SampleCharacter().UnassignedXp);
[Fact]
public void SampleCharacter_AttributeRaiseCosts_HasNineEntries()
{
var costs = SampleData.SampleCharacter().AttributeRaiseCosts;
Assert.NotNull(costs);
Assert.Equal(9, costs.Length);
}
[Fact]
public void SampleCharacter_AttributeRaiseCosts_FocusAt110()
=> Assert.Equal(110L, SampleData.SampleCharacter().AttributeRaiseCosts[4]);
[Fact]
public void SampleCharacter_AttributeRaiseCosts_StrengthAt0()
=> Assert.Equal(0L, SampleData.SampleCharacter().AttributeRaiseCosts[0]);
// ── UiText flag sanity ────────────────────────────────────────────────────
[Fact]
public void UiText_RightAligned_DefaultFalse()
{
var t = new UiText();
Assert.False(t.RightAligned);
}
[Fact]
public void UiText_RightAligned_CanBeSetTrue()
{
var t = new UiText { RightAligned = true };
Assert.True(t.RightAligned);
}
// ── Header captions (new — LevelCaptionId + TotalXpLabelId) ─────────────
[Fact]
public void Bind_LevelCaptionId_SetsCharacterLevelText()
{
var caption = new UiText();
var layout = Fake((CharacterStatController.LevelCaptionId, caption));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
// 2-line caption: "Character" (top) / "Level" (bottom) so it fits the 65px element.
var lines = caption.LinesProvider();
Assert.True(lines.Count >= 1, "LevelCaption must provide at least one line");
Assert.Equal("Character", lines[0].Text);
Assert.False(caption.Centered, "LevelCaption must be left-justified (Centered=false)");
Assert.False(caption.RightAligned, "LevelCaption must be left-justified (RightAligned=false)");
}
[Fact]
public void Bind_TotalXpLabelId_SetsTotalExperienceXpText()
{
var lbl = new UiText();
var layout = Fake((CharacterStatController.TotalXpLabelId, lbl));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
Assert.Equal("Total Experience (XP):", lbl.LinesProvider()[0].Text);
Assert.False(lbl.Centered, "TotalXpLabel must be left-justified (Centered=false)");
Assert.False(lbl.RightAligned, "TotalXpLabel must be left-justified (RightAligned=false)");
}
// ── Polish Commit 1: name white, Infinity!, white footer title ─────────────
[Fact]
public void Bind_NameColor_IsWhite()
{
// Retail: name "Horan" is WHITE, not gold. (2026-06-26 ref)
var name = new UiText();
var layout = Fake((CharacterStatController.NameId, name));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
var color = name.LinesProvider()[0].Color;
Assert.Equal(1f, color.X, precision: 3);
Assert.Equal(1f, color.Y, precision: 3);
Assert.Equal(1f, color.Z, precision: 3);
Assert.Equal(1f, color.W, precision: 3);
}
[Fact]
public void RowClick_MaxedRow_FooterLine1ValueIsInfinity()
{
// Strength (index 0) cost=0 → "Infinity!" per retail spec.
var val = new UiText();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.FooterLine1Value, val),
(CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
list.Children.OfType<UiClickablePanel>().ToList()[0].OnClick!(); // Strength
Assert.Equal("Infinity!", val.LinesProvider()[0].Text);
}
[Fact]
public void RowClick_SelectedFooterTitle_IsWhite()
{
// State B footer title must be WHITE (retail 2026-06-26 ref).
var title = new UiText();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.FooterTitleId, title),
(CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!(); // Focus
var color = title.LinesProvider()[0].Color;
Assert.Equal(1f, color.X, precision: 3);
Assert.Equal(1f, color.Y, precision: 3);
Assert.Equal(1f, color.Z, precision: 3);
Assert.Equal(1f, color.W, precision: 3);
}
[Fact]
public void Bind_FooterStateA_TitleColor_IsBodyNotWhite()
{
// State A title is body (parchment), not white.
var title = new UiText();
var layout = Fake((CharacterStatController.FooterTitleId, title));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
// Body = (0.92, 0.90, 0.82, 1.0) — check it's not pure white.
var color = title.LinesProvider()[0].Color;
Assert.True(color.X < 1f || color.Y < 1f || color.Z < 1f,
"State-A title should be body/parchment color, not pure white");
}
[Fact]
public void Bind_LevelCaptionId_SetsTwoLines()
{
// Retail: level caption is "Character" / "Level" on two lines (not truncated).
var caption = new UiText();
var layout = Fake((CharacterStatController.LevelCaptionId, caption));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
var lines = caption.LinesProvider();
Assert.Equal(2, lines.Count);
Assert.Equal("Character", lines[0].Text);
Assert.Equal("Level", lines[1].Text);
}
// ── Robustness ────────────────────────────────────────────────────────────
[Fact]
public void Bind_MissingElements_DoesNotThrow()
=> CharacterStatController.Bind(Fake(), SampleData.SampleCharacter);
// ── Fix 5: XP meter text children bound via FindElement ──────────────────
/// <summary>
/// Fix 5: the XP label (0x10000237) is a dat-origin UiText child of the XP meter
/// (now built by the importer). After Bind(), FindElement must return a UiText with
/// a LinesProvider that emits "XP for next level:".
/// </summary>
[Fact]
public void Bind_XpMeter_XpNextLabel_IsBoundViaFindElement()
{
var meter = new UiMeter();
var xpLabel = new UiText();
// Attach the label as a child of the meter so it matches the real importer layout.
meter.AddChild(xpLabel);
var layout = Fake(
(CharacterStatController.XpMeterId, meter),
(CharacterStatController.XpNextLabelId, xpLabel));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
Assert.NotNull(xpLabel.LinesProvider);
var lines = xpLabel.LinesProvider();
Assert.Single(lines);
Assert.Equal("XP for next level:", lines[0].Text);
}
/// <summary>
/// Fix 5: the XP value (0x10000238) is bound to the XpToNextLevel string.
/// ClickThrough=true and RightAligned=true must be set by the controller.
/// </summary>
[Fact]
public void Bind_XpMeter_XpNextValue_IsBoundViaFindElement()
{
var meter = new UiMeter();
var xpValue = new UiText();
meter.AddChild(xpValue);
var layout = Fake(
(CharacterStatController.XpMeterId, meter),
(CharacterStatController.XpNextValueId, xpValue));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
Assert.NotNull(xpValue.LinesProvider);
Assert.True(xpValue.ClickThrough, "XP value overlay must be ClickThrough");
Assert.True(xpValue.RightAligned, "XP value overlay must be RightAligned");
var lines = xpValue.LinesProvider();
Assert.Single(lines);
// XpToNextLevel from SampleData = 42_000_000L formatted as "42,000,000"
Assert.Equal((42_000_000L).ToString("N0"), lines[0].Text);
}
/// <summary>
/// Fix 5: if XpNextLabelId / XpNextValueId are absent from the layout (the old
/// ConsumesDatChildren path, or a test layout that doesn't include them), Bind()
/// must not throw — the meter Fill is still bound.
/// </summary>
[Fact]
public void Bind_XpMeter_MissingTextChildren_DoesNotThrow()
{
var meter = new UiMeter();
var layout = Fake((CharacterStatController.XpMeterId, meter));
// No XpNextLabelId or XpNextValueId in the layout.
var ex = Record.Exception(() =>
CharacterStatController.Bind(layout, SampleData.SampleCharacter));
Assert.Null(ex);
// Fill must still be bound.
Assert.NotNull(meter.Fill());
}
// ── Helpers ──────────────────────────────────────────────────────────────
/// <summary>Manufacture a minimal UiButton with a fake ElementInfo (no dat sprites).</summary>
private static UiButton MakeButton()
{
var info = new ElementInfo { Id = 0, Type = 1 };
return new UiButton(info, static _ => (0u, 0, 0));
}
private static ImportedLayout Fake(params (uint id, UiElement e)[] items)
{
var dict = new Dictionary<uint, UiElement>();
var root = new UiPanel();
foreach (var (id, e) in items)
{
root.AddChild(e);
dict[id] = e;
}
return new ImportedLayout(root, dict);
}
}

View file

@ -0,0 +1,194 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Unit tests for Fix C: per-element dat FontDid resolver in <see cref="DatWidgetFactory"/>.
///
/// <para>
/// <see cref="UiDatFont"/> cannot be constructed in pure unit tests (GL + dat required),
/// so these tests verify the plumbing using null/non-null identity and sentinel resolvers:
/// <list type="bullet">
/// <item>Null resolver → DatFont unchanged (backward-compat: same as before Fix C).</item>
/// <item>Resolver present, FontDid == 0 → resolver NOT called; DatFont == global datFont.</item>
/// <item>Resolver returns null for an id (font missing) → DatFont falls back to global datFont.</item>
/// <item>Controller sets DatFont after build → controller value wins.</item>
/// <item><see cref="LayoutImporter.Build"/> threads fontResolve to the factory.</item>
/// </list>
/// </para>
/// </summary>
public class DatWidgetFactoryFontResolveTests
{
private static (uint, int, int) NoTex(uint _) => (0, 0, 0);
// ── Test 1: null fontResolve → DatFont == datFont (backward-compat) ─────
/// <summary>
/// When fontResolve is null (the live GameWindow path), BuildText must NOT
/// touch DatFont beyond setting it to the global datFont. Null in → null out.
/// </summary>
[Fact]
public void NullFontResolve_DatFont_EqualsGlobalFont()
{
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, FontDid = 0x40000001u };
// datFont=null, fontResolve=null → backward-compat: DatFont == null
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null, fontResolve: null));
Assert.Null(t.DatFont);
}
// ── Test 2: FontDid == 0 → resolver NOT called ────────────────────────────
/// <summary>
/// When the element has FontDid == 0, the fontResolve delegate must NOT be
/// invoked (the element has no dat font; fall through to global datFont).
/// </summary>
[Fact]
public void FontDidZero_ResolverNotCalled()
{
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, FontDid = 0u };
bool called = false;
UiDatFont? Resolver(uint id) { called = true; return null; }
// FontDid=0 → resolver should never fire, even though one is provided.
Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null, fontResolve: Resolver));
Assert.False(called, "fontResolve must NOT be called when FontDid == 0");
}
// ── Test 3: resolver returns null (font missing) → fallback to datFont ───
/// <summary>
/// When the element has a non-zero FontDid but the resolver returns null
/// (font not found in dats), DatFont must fall back to the shared global datFont.
/// Since datFont is null in unit tests, this verifies the fallback chain.
/// </summary>
[Fact]
public void ResolverReturnsNull_FallsBackToGlobalFont()
{
const uint SomeFontDid = 0x40000005u;
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, FontDid = SomeFontDid };
int callCount = 0;
UiDatFont? Resolver(uint id) { callCount++; return null; } // always returns null (font missing)
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, datFont: null, fontResolve: Resolver));
// Resolver was called exactly once for this element.
Assert.Equal(1, callCount);
// Fallback: DatFont == global datFont (null in unit tests).
Assert.Null(t.DatFont);
}
// ── Test 4: resolver called with correct FontDid ──────────────────────────
/// <summary>
/// When fontResolve is provided and FontDid is non-zero, the resolver is called
/// with the element's exact FontDid value.
/// </summary>
[Fact]
public void ResolverCalledWithElementFontDid()
{
const uint ExpectedFontDid = 0x40000002u;
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, FontDid = ExpectedFontDid };
uint? capturedId = null;
UiDatFont? Resolver(uint id) { capturedId = id; return null; }
DatWidgetFactory.Create(info, NoTex, datFont: null, fontResolve: Resolver);
Assert.Equal(ExpectedFontDid, capturedId);
}
// ── Test 5: controller DatFont override wins over build-time value ───────
/// <summary>
/// A controller that calls FindElement and sets DatFont afterward MUST override
/// whatever the factory set at build time. This is the backward-compat guarantee.
/// The DatFont property is settable — a controller can override it after build.
/// </summary>
[Fact]
public void ControllerDatFontOverride_WinsOverBuildTimeFont()
{
const uint SomeFontDid = 0x40000003u;
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, FontDid = SomeFontDid };
UiDatFont? Resolver(uint _) => null; // returns null → build-time DatFont == null
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, datFont: null, fontResolve: Resolver));
Assert.Null(t.DatFont); // build-time value set by factory
// Controller simulated override: set DatFont to null explicitly.
// The real guarantee is that DatFont is a settable property.
// (We can't construct a real UiDatFont here — it requires GL + dats.)
t.DatFont = null; // controller overrides after build
Assert.Null(t.DatFont);
// ✓ DatFont is settable — controller override mechanism is in place.
}
// ── Test 6: LayoutImporter.Build threads fontResolve to factory ──────────
/// <summary>
/// When <see cref="LayoutImporter.Build"/> is given a fontResolve, it must pass
/// it to <see cref="DatWidgetFactory.Create"/> for EVERY element in the tree.
/// Verified by checking that a Type-12 child with FontDid triggers the resolver.
/// </summary>
[Fact]
public void LayoutImporter_Build_ThreadsFontResolve_ToFactory()
{
const uint FontDid = 0x40000004u;
var root = new ElementInfo { Id = 1u, Type = 3, Width = 200, Height = 100 };
var child = new ElementInfo { Id = 2u, Type = 12, Width = 100, Height = 20, FontDid = FontDid };
root.Children.Add(child);
int resolveCallCount = 0;
UiDatFont? Resolver(uint id) { resolveCallCount++; return null; }
var layout = LayoutImporter.Build(root, NoTex, datFont: null, fontResolve: Resolver);
// The child element has a non-zero FontDid — resolver must have been called for it.
Assert.True(resolveCallCount > 0, "fontResolve was not called for the child element");
// The child widget was built (findable in the layout).
Assert.NotNull(layout.FindElement(2u));
}
// ── Test 7: Meter element also receives element font from resolver ────────
/// <summary>
/// Type-7 (UiMeter) elements with a FontDid must also go through the resolver.
/// Verified by checking the resolver fires for a meter element with a non-zero FontDid.
/// </summary>
[Fact]
public void FontResolve_CalledForMeter_WhenFontDidPresent()
{
const uint MeterFontDid = 0x40000006u;
var meter = new ElementInfo { Type = 7, Width = 150, Height = 16, FontDid = MeterFontDid };
int callCount = 0;
UiDatFont? Resolver(uint id) { callCount++; return null; }
var w = DatWidgetFactory.Create(meter, NoTex, datFont: null, fontResolve: Resolver);
var m = Assert.IsType<UiMeter>(w);
// Resolver was called for the meter element's FontDid.
Assert.True(callCount > 0, "fontResolve was not called for meter element");
// Meter DatFont: resolver returned null, so DatFont falls back to global datFont (null).
Assert.Null(m.DatFont);
}
// ── Test 8: LayoutImporter.BuildFromInfos signature is backward-compat ────
/// <summary>
/// <see cref="LayoutImporter.BuildFromInfos"/> without fontResolve (default null)
/// must still work for all callers that don't pass it — verifies the optional
/// parameter default is correct and no existing callers broke.
/// </summary>
[Fact]
public void BuildFromInfos_WithoutFontResolve_WorksAsBeforeFix()
{
var root = new ElementInfo { Id = 10u, Type = 3, Width = 200, Height = 100 };
var child = new ElementInfo { Id = 11u, Type = 12, Width = 100, Height = 20, FontDid = 0x40000001u };
// Call without fontResolve (the old signature shape).
var layout = LayoutImporter.BuildFromInfos(root, new[] { child }, NoTex, datFont: null);
var t = Assert.IsType<UiText>(layout.FindElement(11u));
// No resolver → DatFont == global datFont (null in unit tests).
Assert.Null(t.DatFont);
}
}

View file

@ -1,3 +1,4 @@
using System.Numerics;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
@ -228,4 +229,126 @@ public class DatWidgetFactoryTests
Assert.NotEqual(OverlayFile, m.FrontRight);
Assert.NotEqual(OverlayFile, m.FrontTile);
}
// ── Justification build-time application (new for importer Fix A) ────────
/// <summary>
/// A Type-12 text element with HJustify=Center (the default) must produce a
/// UiText with Centered=true and RightAligned=false at build time.
/// This proves BuildText applies the dat's HJustify at construction without a
/// controller binding step.
/// </summary>
[Fact]
public void BuildText_HJustifyCenter_SetsCentered()
{
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, HJustify = HJustify.Center };
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
Assert.True(t.Centered);
Assert.False(t.RightAligned);
Assert.Equal(VJustify.Center, t.VerticalJustify);
}
/// <summary>
/// A Type-12 text element with HJustify=Right must produce a UiText with
/// Centered=false and RightAligned=true at build time.
/// </summary>
[Fact]
public void BuildText_HJustifyRight_SetsRightAligned()
{
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, HJustify = HJustify.Right };
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
Assert.False(t.Centered);
Assert.True(t.RightAligned);
}
/// <summary>
/// A Type-12 text element with HJustify=Left must produce a UiText with
/// Centered=false and RightAligned=false at build time.
/// </summary>
[Fact]
public void BuildText_HJustifyLeft_SetsNeitherCenteredNorRight()
{
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, HJustify = HJustify.Left };
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
Assert.False(t.Centered);
Assert.False(t.RightAligned);
}
/// <summary>
/// A Type-12 text element with VJustify=Top must produce a UiText with
/// VerticalJustify=Top at build time.
/// </summary>
[Fact]
public void BuildText_VJustifyTop_SetsVerticalJustifyTop()
{
var info = new ElementInfo { Type = 12, Width = 100, Height = 55, VJustify = VJustify.Top };
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
Assert.Equal(VJustify.Top, t.VerticalJustify);
}
/// <summary>
/// A controller override: build-time sets Centered=true (HJustify=Center), then
/// a controller sets Centered=false afterward. The controller's override wins —
/// this is the backward-compatibility guarantee.
/// </summary>
[Fact]
public void BuildText_ControllerOverrideWinsAfterBuild()
{
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, HJustify = HJustify.Center };
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
Assert.True(t.Centered); // build-time value
t.Centered = false; // controller override
Assert.False(t.Centered); // override wins
}
// ── DefaultColor from dat FontColor (importer Fix B) ─────────────────────
/// <summary>
/// When ElementInfo.FontColor is set (dat carried 0x1B ColorBaseProperty),
/// DatWidgetFactory.BuildText must copy it onto UiText.DefaultColor.
/// </summary>
[Fact]
public void BuildText_FontColorPresent_SetsDefaultColor()
{
var gold = new Vector4(1f, 0.82f, 0.36f, 1f);
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, FontColor = gold };
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
Assert.Equal(gold, t.DefaultColor);
}
/// <summary>
/// When ElementInfo.FontColor is null (dat carried no 0x1B), DefaultColor must
/// stay at white (Vector4.One) — the backward-compatible default.
/// </summary>
[Fact]
public void BuildText_FontColorAbsent_DefaultColorIsWhite()
{
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, FontColor = null };
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
Assert.Equal(Vector4.One, t.DefaultColor);
}
/// <summary>
/// Backward-compat guarantee: a controller that sets LinesProvider with an
/// explicit per-line color AFTER build is unaffected by DefaultColor.
/// The controller's per-line color is stored in the Line record, not DefaultColor,
/// so DefaultColor changing from dat does not touch existing controller bindings.
/// </summary>
[Fact]
public void BuildText_ControllerExplicitLineColor_UnaffectedByDefaultColor()
{
// Dat sets a parchment color.
var parchment = new Vector4(0.92f, 0.90f, 0.82f, 1f);
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, FontColor = parchment };
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
// Controller overrides with an explicit gold color in the Line record.
var gold = new Vector4(1f, 0.82f, 0.36f, 1f);
t.LinesProvider = () => new[] { new UiText.Line("text", gold) };
// The line's color is gold (controller wins), NOT the dat parchment.
Assert.Equal(gold, t.LinesProvider()[0].Color);
// DefaultColor still holds the dat parchment (unmodified by the controller binding).
Assert.Equal(parchment, t.DefaultColor);
}
}

View file

@ -1,3 +1,4 @@
using System.Numerics;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
@ -161,4 +162,98 @@ public class ElementReaderTests
Assert.Single(merged.Children);
Assert.Equal(0x2u, merged.Children[0].Id);
}
// ── HJustify / VJustify — Merge propagation ─────────────────────────────
/// <summary>
/// When the derived element has a non-Center HJustify, the derived value wins
/// (same "non-default wins" rule as FontDid).
/// </summary>
[Fact]
public void Merge_DerivedHJustifyRight_OverridesBaseCenter()
{
var base_ = new ElementInfo { HJustify = HJustify.Center };
var derived = new ElementInfo { HJustify = HJustify.Right };
var merged = ElementReader.Merge(base_, derived);
Assert.Equal(HJustify.Right, merged.HJustify);
}
/// <summary>
/// When the derived element has the default HJustify (Center), the base value
/// is inherited — Center from the derived does NOT override a Left base.
/// </summary>
[Fact]
public void Merge_DerivedHJustifyCenter_InheritsBaseLeft()
{
var base_ = new ElementInfo { HJustify = HJustify.Left };
var derived = new ElementInfo { HJustify = HJustify.Center }; // default — no explicit dat property
var merged = ElementReader.Merge(base_, derived);
Assert.Equal(HJustify.Left, merged.HJustify);
}
/// <summary>
/// VJustify=Top from the base propagates when the derived element has no explicit
/// (Center) vertical justification.
/// </summary>
[Fact]
public void Merge_BaseVJustifyTop_InheritedWhenDerivedIsCenter()
{
var base_ = new ElementInfo { VJustify = VJustify.Top };
var derived = new ElementInfo { VJustify = VJustify.Center }; // default
var merged = ElementReader.Merge(base_, derived);
Assert.Equal(VJustify.Top, merged.VJustify);
}
/// <summary>
/// VJustify=Bottom from the derived element wins over a Center base.
/// </summary>
[Fact]
public void Merge_DerivedVJustifyBottom_OverridesBaseCenter()
{
var base_ = new ElementInfo { VJustify = VJustify.Center };
var derived = new ElementInfo { VJustify = VJustify.Bottom };
var merged = ElementReader.Merge(base_, derived);
Assert.Equal(VJustify.Bottom, merged.VJustify);
}
// ── FontColor — Merge propagation (Fix B) ────────────────────────────────
/// <summary>
/// When the derived element has an explicit (non-null) FontColor, the derived value
/// wins in Merge — same "non-null derived wins" rule used for FontDid and HJustify.
/// </summary>
[Fact]
public void Merge_DerivedFontColor_OverridesBaseNull()
{
var base_ = new ElementInfo { FontColor = null };
var derived = new ElementInfo { FontColor = new Vector4(1f, 0f, 0f, 1f) };
var merged = ElementReader.Merge(base_, derived);
Assert.Equal(new Vector4(1f, 0f, 0f, 1f), merged.FontColor);
}
/// <summary>
/// When the derived element has no FontColor (null), the base's FontColor is inherited.
/// A null-derived must NOT override a non-null base.
/// </summary>
[Fact]
public void Merge_DerivedFontColorNull_InheritsBaseColor()
{
var gold = new Vector4(1f, 0.82f, 0.36f, 1f);
var base_ = new ElementInfo { FontColor = gold };
var derived = new ElementInfo { FontColor = null }; // default — no dat property
var merged = ElementReader.Merge(base_, derived);
Assert.Equal(gold, merged.FontColor);
}
/// <summary>
/// When neither base nor derived carries a FontColor, the merged result is null.
/// </summary>
[Fact]
public void Merge_BothFontColorNull_MergedIsNull()
{
var base_ = new ElementInfo { FontColor = null };
var derived = new ElementInfo { FontColor = null };
var merged = ElementReader.Merge(base_, derived);
Assert.Null(merged.FontColor);
}
}

View file

@ -55,15 +55,15 @@ public class LayoutImporterTests
Assert.NotNull(tree.FindElement(0x20000002));
}
// ── Test 3: Meter consumes its children — child ids not in byId ──────────
// ── Test 3: Meter consumes Type-3 slice children — child ids not in byId ────
/// <summary>
/// A meter (Type 7) whose children are the 3-slice back/front containers.
/// The meter itself must be findable; its direct children must NOT appear as
/// separate nodes in the tree (meters own their children, not the generic tree).
/// A meter (Type 7) whose children are ONLY the 3-slice back/front containers (Type 3).
/// The meter itself must be findable; its Type-3 children must NOT appear as separate
/// nodes in the tree (Fix 5: only non-Type-3 children are built as separate widgets).
/// </summary>
[Fact]
public void BuildFromInfos_MeterWithChildren_MeterPresent_ChildrenNotInTree()
public void BuildFromInfos_MeterWithSliceChildren_MeterPresent_SliceChildrenNotInTree()
{
const uint MeterId = 0x100000E6u;
const uint BackLayerId = 0x100000E7u;
@ -85,10 +85,103 @@ public class LayoutImporterTests
// The meter widget is present.
Assert.IsType<UiMeter>(tree.FindElement(MeterId));
// The meter's dat-children are NOT separate UiElement nodes.
// The meter's Type-3 slice children are NOT separate UiElement nodes.
Assert.Null(tree.FindElement(BackLayerId));
Assert.Null(tree.FindElement(FrontLayerId));
// The UiMeter itself has no Ui children (meters consume their children internally).
// The UiMeter itself has no UiElement children (all children were Type-3, consumed).
var uiMeter = (UiMeter)tree.FindElement(MeterId)!;
Assert.Empty(uiMeter.Children);
}
// ── Test 5: Fix 5 — meter text children (Type-12) are built and registered ─
/// <summary>
/// Fix 5: a meter (Type 7) with BOTH Type-3 slice containers AND a Type-12 text
/// child. The Type-3 containers must be consumed (not in byId); the Type-12 text
/// child must be built as a UiText, registered in byId, and attached as a UiElement
/// child of the meter (so it renders as an overlay).
/// </summary>
[Fact]
public void BuildFromInfos_MeterWithTextChild_TextChildInTreeAsUiTextChildOfMeter()
{
const uint MeterId = 0x10000236u;
const uint BackId = 0x10000237u; // Type-3 slice
const uint FrontId = 0x10000238u; // Type-3 slice
const uint LabelId = 0x10000239u; // Type-12 text child
const uint ValueId = 0x1000023Au; // Type-12 text child
var backContainer = BuildSliceContainer(BackId, ReadOrder: 0,
l: 0x0600747Eu, t: 0x0600747Fu, r: 0x06007480u);
var frontContainer = BuildSliceContainer(FrontId, ReadOrder: 1,
l: 0x06007481u, t: 0x06007482u, r: 0x06007483u);
// Two Type-12 text children (the XP "label" and "value" overlay).
var labelInfo = new ElementInfo { Id = LabelId, Type = 12, X = 0, Y = 0, Width = 120, Height = 13 };
var valueInfo = new ElementInfo { Id = ValueId, Type = 12, X = 0, Y = 0, Width = 200, Height = 13,
HJustify = HJustify.Right };
var meter = new ElementInfo { Id = MeterId, Type = 7, Width = 200, Height = 13 };
meter.Children.Add(backContainer);
meter.Children.Add(frontContainer);
meter.Children.Add(labelInfo);
meter.Children.Add(valueInfo);
var root = new ElementInfo { Id = 0x100005F9, Type = 3, Width = 210, Height = 20 };
var tree = LayoutImporter.BuildFromInfos(root, new[] { meter }, NoTex, null);
// Meter is present.
Assert.IsType<UiMeter>(tree.FindElement(MeterId));
// Type-3 slice containers are NOT in byId (consumed by BuildMeter).
Assert.Null(tree.FindElement(BackId));
Assert.Null(tree.FindElement(FrontId));
// Type-12 text children ARE in byId as UiText.
Assert.IsType<UiText>(tree.FindElement(LabelId));
Assert.IsType<UiText>(tree.FindElement(ValueId));
// The UiMeter has exactly 2 UiElement children (the two text overlays).
var uiMeter = (UiMeter)tree.FindElement(MeterId)!;
Assert.Equal(2, uiMeter.Children.Count);
Assert.All(uiMeter.Children, c => Assert.IsType<UiText>(c));
// The value child should have RightAligned=true (HJustify.Right in the dat).
var valueWidget = (UiText)tree.FindElement(ValueId)!;
Assert.True(valueWidget.RightAligned, "Type-12 text child with HJustify.Right must build as RightAligned=true");
}
// ── Test 6: Fix 5 — vitals meters (Type-3 only) are unaffected ────────────
/// <summary>
/// Fix 5 regression guard: vitals health/stamina/mana meters have ONLY Type-3 slice
/// children (no Type-12 text children). The meter must have zero UiElement children
/// after build — VitalsController injects its number overlay at runtime, not the importer.
/// </summary>
[Fact]
public void BuildFromInfos_VitalsMeter_NoTextChildren_MeterHasNoUiChildren()
{
const uint MeterId = 0x100000E6u; // vitals health meter
const uint BackId = 0x100000E7u;
const uint FrontId = 0x100000E8u;
var backContainer = BuildSliceContainer(BackId, ReadOrder: 0,
l: 0x0600747Eu, t: 0x0600747Fu, r: 0x06007480u);
var frontContainer = BuildSliceContainer(FrontId, ReadOrder: 1,
l: 0x06007481u, t: 0x06007482u, r: 0x06007483u);
var meter = new ElementInfo { Id = MeterId, Type = 7, Width = 150, Height = 16 };
meter.Children.Add(backContainer);
meter.Children.Add(frontContainer);
var root = new ElementInfo { Id = 0x100005F9, Type = 3, Width = 160, Height = 58 };
var tree = LayoutImporter.BuildFromInfos(root, new[] { meter }, NoTex, null);
Assert.IsType<UiMeter>(tree.FindElement(MeterId));
// Vitals meter has no UiElement children — only Type-3 children were present,
// all consumed, none built as UiText overlays.
var uiMeter = (UiMeter)tree.FindElement(MeterId)!;
Assert.Empty(uiMeter.Children);
}

View file

@ -0,0 +1,44 @@
using AcDream.App.UI.Layout;
using Xunit;
namespace AcDream.App.Tests.UI.Layout;
public class PaperdollToggleTests
{
// The 9 armor element-ids that gmPaperDollUI::ListenToElementMessage flips
// (decomp 175674-175706). Doll-view hides these; slot-view shows them.
private static readonly uint[] ArmorIds =
{
0x100005abu, 0x100005acu, 0x100005adu, 0x100005aeu, 0x100005afu,
0x100005b0u, 0x100005b1u, 0x100005b2u, 0x100005b3u,
};
[Fact]
public void ArmorSlotIds_match_the_decomp_nine()
{
Assert.Equal(ArmorIds, PaperdollController.ArmorSlotElementIds);
}
[Fact]
public void DollView_default_shows_doll_hides_armor()
{
var v = new PaperdollController.PaperdollViewState();
Assert.False(v.SlotView);
Assert.True(v.DollVisible);
Assert.False(v.ArmorSlotsVisible);
}
[Fact]
public void Toggle_to_slotview_hides_doll_shows_armor_and_back()
{
var v = new PaperdollController.PaperdollViewState();
v.Toggle();
Assert.True(v.SlotView);
Assert.False(v.DollVisible);
Assert.True(v.ArmorSlotsVisible);
v.Toggle();
Assert.False(v.SlotView);
Assert.True(v.DollVisible);
Assert.False(v.ArmorSlotsVisible);
}
}

View file

@ -0,0 +1,29 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
public class UiViewportFactoryTests
{
private static (uint, int, int) NoTex(uint _) => (0, 0, 0);
[Fact]
public void Factory_builds_UiViewport_for_dat_type_0xD()
{
var info = new ElementInfo { Type = 0xD, Width = 200, Height = 300 };
var widget = DatWidgetFactory.Create(info, NoTex, null);
var viewport = Assert.IsType<UiViewport>(widget);
Assert.True(viewport.ConsumesDatChildren);
}
[Fact]
public void UiViewport_rect_set_from_ElementInfo()
{
var info = new ElementInfo { Type = 0xD, X = 10, Y = 20, Width = 180, Height = 240 };
var widget = DatWidgetFactory.Create(info, NoTex, null)!;
Assert.Equal(10f, widget.Left);
Assert.Equal(20f, widget.Top);
Assert.Equal(180f, widget.Width);
Assert.Equal(240f, widget.Height);
}
}

View file

@ -14,6 +14,53 @@ public class UiRootInputTests
Assert.Equal(AnchorEdges.None, panel.Anchors);
}
private sealed class ClickRecorder : UiElement
{
private readonly bool _handlesClick;
public bool Clicked;
public ClickRecorder(bool handlesClick) => _handlesClick = handlesClick;
public override bool HandlesClick => _handlesClick;
public override bool OnEvent(in UiEvent e)
{
if (e.Type == UiEventType.Click) { Clicked = true; return true; }
return false;
}
}
[Fact]
public void HandlesClickWidget_insideDraggableWindow_stillEmitsClick()
{
// Regression (paperdoll "Slots" toggle): a HandlesClick widget (e.g. a UiButton) inside a
// whole-window-Draggable frame (the inventory window, IA-12 whole-window-drag) must still
// receive its Click. Before the fix the window-move branch captured the press and OnMouseUp
// returned before emitting Click, so the toggle button did nothing.
var root = new UiRoot { Width = 800, Height = 600 };
var frame = new UiPanel { Left = 10, Top = 300, Width = 200, Height = 60, Draggable = true };
var btn = new ClickRecorder(handlesClick: true) { Left = 5, Top = 5, Width = 120, Height = 14 };
frame.AddChild(btn);
root.AddChild(frame);
root.OnMouseDown(UiMouseButton.Left, 20, 310); // press over the button (screen rect 15,305..135,319)
root.OnMouseUp(UiMouseButton.Left, 20, 310); // release same spot → Click
Assert.True(btn.Clicked);
}
[Fact]
public void PlainWidget_insideDraggableWindow_doesNotEmitClick()
{
// Contrast that locks the distinction: a NON-HandlesClick child inside the Draggable frame is
// captured as a whole-window-drag, so no Click is emitted (the frame is draggable by it).
var root = new UiRoot { Width = 800, Height = 600 };
var frame = new UiPanel { Left = 10, Top = 300, Width = 200, Height = 60, Draggable = true };
var plain = new ClickRecorder(handlesClick: false) { Left = 5, Top = 5, Width = 120, Height = 14 };
frame.AddChild(plain);
root.AddChild(frame);
root.OnMouseDown(UiMouseButton.Left, 20, 310);
root.OnMouseUp(UiMouseButton.Left, 20, 310);
Assert.False(plain.Clicked);
}
private sealed class CoordRecorder : UiElement
{
public (int x, int y)? Down, Move;

View file

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI;
@ -140,4 +141,57 @@ public class UiTextTests
Assert.Equal(new UiText.Pos(1, 2), s2);
Assert.Equal(new UiText.Pos(1, 8), e2);
}
// ── VOffset: vertical positioning for single-line mode ───────────────────
/// <summary>
/// VJustify.Center: vertical offset = (height - lineHeight) / 2.
/// This is the original formula used by both the Centered and RightAligned paths
/// before VerticalJustify was introduced. Must remain unchanged (backward compat).
/// </summary>
[Fact]
public void VOffset_Center_ReturnsHalfHeightMinusLineHeight()
{
float y = UiText.VOffset(height: 55f, lineHeight: 12f, padding: 0f, VJustify.Center);
Assert.Equal((55f - 12f) * 0.5f, y);
}
/// <summary>
/// VJustify.Top: vertical offset = padding (top of the content area).
/// Used for footer title elements whose dat box is the full footer height (55 px)
/// but the text should render near the top.
/// </summary>
[Fact]
public void VOffset_Top_ReturnsPadding()
{
float y = UiText.VOffset(height: 55f, lineHeight: 12f, padding: 0f, VJustify.Top);
Assert.Equal(0f, y);
float yWithPadding = UiText.VOffset(height: 55f, lineHeight: 12f, padding: 4f, VJustify.Top);
Assert.Equal(4f, yWithPadding);
}
/// <summary>
/// VJustify.Bottom: vertical offset = height - lineHeight - padding.
/// </summary>
[Fact]
public void VOffset_Bottom_ReturnsHeightMinusLineHeightMinusPadding()
{
float y = UiText.VOffset(height: 55f, lineHeight: 12f, padding: 2f, VJustify.Bottom);
Assert.Equal(55f - 12f - 2f, y);
}
/// <summary>
/// Center with non-zero padding: padding does NOT affect the center formula —
/// VJustify.Center always uses (height - lineHeight) / 2 regardless of padding,
/// matching the original behavior.
/// </summary>
[Fact]
public void VOffset_Center_IgnoresPadding()
{
float yNoPad = UiText.VOffset(height: 40f, lineHeight: 12f, padding: 0f, VJustify.Center);
float yWithPad = UiText.VOffset(height: 40f, lineHeight: 12f, padding: 4f, VJustify.Center);
Assert.Equal(yNoPad, yWithPad); // Center is padding-independent
Assert.Equal((40f - 12f) * 0.5f, yNoPad);
}
}