acdream/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs
Erik ad4ed51d6b feat(ui): importer Fix 5 — build meter text children; drop character XP injection hacks
LayoutImporter.BuildWidget gains a UiMeter-specific branch after the
ConsumesDatChildren gate: Type-3 slice containers (already consumed by
DatWidgetFactory.BuildMeter to extract sprite ids) are skipped, but all
other children (Type-12 UIElement_Text overlays) are built normally,
registered in byId, and attached as UiElement children of the meter.

This fixes the XP meter (0x10000236): its two Type-12 children
  0x10000237 "XP for next level:" label
  0x10000238 XP-to-next-level value
are now real UiText widgets in the tree, findable via FindElement.

CharacterStatController.Bind now uses FindElement + LinesProvider binding
instead of injecting runtime AddChild nodes. The two previously-injected
UiText overlays are removed; the controller binds Padding=0, ClickThrough,
RightAligned on the dat-origin widgets instead.

New constants XpNextLabelId + XpNextValueId replace the old comment block.

TAB GROUPS (SKIPPED — documented): UiText.ConsumesDatChildren flipping is
NOT safe globally (risks chat/vitals) and the page-visibility pass in
CharacterStatController depends on tab group Children.Count==0. Tab sprite
injection onto layout.Root stays and is explicitly commented as deliberate.

VITALS SAFE: health/stamina/mana meters have only Type-3 children → new
loop finds nothing → zero UiElement children added → byte-identical render.
VitalsController.BindMeter AddChild(number) pattern unchanged.

Tests: 3 new LayoutImporterTests (slice-only, Fix-5 text children,
vitals regression guard) + 3 CharacterStatControllerTests (label bound,
value bound, missing children no-throw). 715 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 11:35:19 +02:00

920 lines
35 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
}
}