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:
commit
ca94b479bf
61 changed files with 125881 additions and 44 deletions
41
tests/AcDream.App.Tests/Rendering/DollCameraTests.cs
Normal file
41
tests/AcDream.App.Tests/Rendering/DollCameraTests.cs
Normal 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
|
||||
}
|
||||
}
|
||||
84
tests/AcDream.App.Tests/Rendering/DollEntityBuilderTests.cs
Normal file
84
tests/AcDream.App.Tests/Rendering/DollEntityBuilderTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
118
tests/AcDream.App.Tests/Studio/CanvasCoordMappingTests.cs
Normal file
118
tests/AcDream.App.Tests/Studio/CanvasCoordMappingTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
188
tests/AcDream.App.Tests/Studio/DumpLayoutTests.cs
Normal file
188
tests/AcDream.App.Tests/Studio/DumpLayoutTests.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
160
tests/AcDream.App.Tests/Studio/FixtureProviderTests.cs
Normal file
160
tests/AcDream.App.Tests/Studio/FixtureProviderTests.cs
Normal 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 > 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}");
|
||||
}
|
||||
}
|
||||
60
tests/AcDream.App.Tests/Studio/LayoutSourceTests.cs
Normal file
60
tests/AcDream.App.Tests/Studio/LayoutSourceTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
147
tests/AcDream.App.Tests/UI/Layout/CharacterControllerTests.cs
Normal file
147
tests/AcDream.App.Tests/UI/Layout/CharacterControllerTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
44
tests/AcDream.App.Tests/UI/Layout/PaperdollToggleTests.cs
Normal file
44
tests/AcDream.App.Tests/UI/Layout/PaperdollToggleTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
29
tests/AcDream.App.Tests/UI/Layout/UiViewportFactoryTests.cs
Normal file
29
tests/AcDream.App.Tests/UI/Layout/UiViewportFactoryTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue