using AcDream.App.Studio;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
///
/// Unit tests for — 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.
///
/// 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.
///
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 matches.
var rows = list.Children.OfType().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().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().ToList();
Assert.Equal(9, rows.Count);
foreach (var row in rows)
{
var texts = row.Children.OfType().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().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().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().ToList();
string ValueOf(UiPanel row) => row.Children.OfType().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().ToList();
Assert.Equal(9, rows.Count);
var iconEl = rows[0].Children.OfType().First();
Assert.Equal(0x060002C8u, iconEl.BackgroundSprite);
var healthIcon = rows[6].Children.OfType().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().ToList();
Assert.Equal(9, rows.Count);
foreach (var row in rows)
{
var iconEl = row.Children.OfType().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().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().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().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().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().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().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().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().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().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().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().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().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().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().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()
.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()
.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()
.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().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().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 ──────────────────
///
/// 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:".
///
[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);
}
///
/// Fix 5: the XP value (0x10000238) is bound to the XpToNextLevel string.
/// ClickThrough=true and RightAligned=true must be set by the controller.
///
[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);
}
///
/// 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.
///
[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 ──────────────────────────────────────────────────────────────
/// Manufacture a minimal UiButton with a fake ElementInfo (no dat sprites).
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();
var root = new UiPanel();
foreach (var (id, e) in items)
{
root.AddChild(e);
dict[id] = e;
}
return new ImportedLayout(root, dict);
}
}