using AcDream.App.Studio; using AcDream.App.UI; using AcDream.App.UI.Layout; using System.Numerics; 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("Female Aluvian Adventurer", heritage.LinesProvider()[0].Text); Assert.Equal("Non-Player Killer", pk.LinesProvider()[0].Text); } [Fact] public void Bind_WindowChromeButton_InvokesCloseCallback() { var close = MakeButton(WindowChromeController.CharacterCloseButtonId); var layout = Fake((WindowChromeController.CharacterCloseButtonId, close)); int closes = 0; CharacterStatController.Bind(layout, SampleData.SampleCharacter, onClose: () => closes++); close.OnEvent(new UiEvent(0u, close, UiEventType.Click)); Assert.Equal(1, closes); } [Fact] public void CharacterIdentityText_StatHeaderLine_ComposesRetailGenderHeritageTitle() { var sheet = new CharacterSheet { Gender = "Female", Heritage = "Aluvian", Title = "the Adventurer", }; Assert.Equal("Female Aluvian Adventurer", CharacterIdentityText.StatHeaderLine(sheet)); } [Theory] [InlineData(1, "Male")] [InlineData(2, "Female")] [InlineData(0, null)] public void CharacterIdentityText_GenderDisplayName_UsesRetailEnum(int value, string? expected) => Assert.Equal(expected, CharacterIdentityText.GenderDisplayName(value)); [Theory] [InlineData(1, "Aluvian")] [InlineData(2, "Gharu'ndim")] [InlineData(5, "Umbraen")] [InlineData(13, "Olthoi")] [InlineData(99, null)] public void CharacterIdentityText_HeritageGroupDisplayName_UsesRetailEnum(int value, string? expected) => Assert.Equal(expected, CharacterIdentityText.HeritageGroupDisplayName(value)); [Fact] public void Bind_HeaderElements_UseVisibleAttributesPageWhenIdsAreDuplicated() { var root = new UiPanel { Width = 300, Height = 600 }; var attrPage = MakeDatElement(CharacterStatController.AttributesPageId, top: 25, width: 300, height: 575); var hiddenPage = MakeDatElement(CharacterStatController.SkillsPageId, top: 25, width: 300, height: 575); var visibleName = new UiText { ElementId = CharacterStatController.NameId }; var hiddenName = new UiText { ElementId = CharacterStatController.NameId }; var visibleHeritage = new UiText { ElementId = CharacterStatController.HeritageId }; var hiddenHeritage = new UiText { ElementId = CharacterStatController.HeritageId }; var visibleLevel = new UiText { ElementId = CharacterStatController.LevelId }; var hiddenLevel = new UiText { ElementId = CharacterStatController.LevelId }; var visibleTotalXp = new UiText { ElementId = CharacterStatController.TotalXpId }; var hiddenTotalXp = new UiText { ElementId = CharacterStatController.TotalXpId }; var visibleTotalXpLabel = new UiText { ElementId = CharacterStatController.TotalXpLabelId }; var hiddenTotalXpLabel = new UiText { ElementId = CharacterStatController.TotalXpLabelId }; var visibleMeter = new UiMeter { ElementId = CharacterStatController.XpMeterId }; var hiddenMeter = new UiMeter { ElementId = CharacterStatController.XpMeterId }; var visibleXpNext = new UiText { ElementId = CharacterStatController.XpNextValueId }; var hiddenXpNext = new UiText { ElementId = CharacterStatController.XpNextValueId }; visibleMeter.AddChild(visibleXpNext); hiddenMeter.AddChild(hiddenXpNext); attrPage.AddChild(visibleName); attrPage.AddChild(visibleHeritage); attrPage.AddChild(visibleLevel); attrPage.AddChild(visibleTotalXpLabel); attrPage.AddChild(visibleTotalXp); attrPage.AddChild(visibleMeter); hiddenPage.AddChild(hiddenName); hiddenPage.AddChild(hiddenHeritage); hiddenPage.AddChild(hiddenLevel); hiddenPage.AddChild(hiddenTotalXpLabel); hiddenPage.AddChild(hiddenTotalXp); hiddenPage.AddChild(hiddenMeter); root.AddChild(attrPage); root.AddChild(hiddenPage); var layout = new ImportedLayout(root, new Dictionary { [CharacterStatController.NameId] = hiddenName, [CharacterStatController.HeritageId] = hiddenHeritage, [CharacterStatController.LevelId] = hiddenLevel, [CharacterStatController.TotalXpLabelId] = hiddenTotalXpLabel, [CharacterStatController.TotalXpId] = hiddenTotalXp, [CharacterStatController.XpMeterId] = hiddenMeter, [CharacterStatController.XpNextValueId] = hiddenXpNext, }); CharacterStatController.Bind(layout, SampleData.SampleCharacter); Assert.Equal("Studio Player", visibleName.LinesProvider()[0].Text); Assert.Equal("Female Aluvian Adventurer", visibleHeritage.LinesProvider()[0].Text); Assert.Equal("126", visibleLevel.LinesProvider()[0].Text); Assert.Equal("Total Experience (XP):", visibleTotalXpLabel.LinesProvider()[0].Text); Assert.Equal((1_250_000_000L).ToString("N0"), visibleTotalXp.LinesProvider()[0].Text); Assert.Equal((42_000_000L).ToString("N0"), visibleXpNext.LinesProvider()[0].Text); Assert.Empty(hiddenName.LinesProvider()); Assert.Empty(hiddenXpNext.LinesProvider()); } // ── 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_OnlyOneAffordable_SplitsOneAndTenStates() { var btn1 = MakeButton(); var btn10 = MakeButton(); var list = new UiPanel(); var layout = Fake( (CharacterStatController.RaiseOneId, btn1), (CharacterStatController.RaiseTenId, btn10), (CharacterStatController.ListBoxId, list)); var sheet = new CharacterSheet { UnassignedXp = 500L, AttributeRaiseCosts = new long[] { 0L, 0L, 0L, 0L, 110L, 0L, 0L, 0L, 0L }, AttributeRaise10Costs = new long[] { 0L, 0L, 0L, 0L, 1_100L, 0L, 0L, 0L, 0L }, }; CharacterStatController.Bind(layout, () => sheet); list.Children.OfType().ToList()[4].OnClick!(); Assert.Equal("Normal", btn1.ActiveState); Assert.Equal("Ghosted", 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_ClickAffordableAttribute_EmitsRaiseRequest() { var btn1 = MakeButton(); var list = new UiPanel(); var layout = Fake( (CharacterStatController.RaiseOneId, btn1), (CharacterStatController.ListBoxId, list)); var requests = new List(); CharacterStatController.Bind(layout, SampleData.SampleCharacter, onRaiseRequest: requests.Add); list.Children.OfType().ToList()[4].OnClick!(); btn1.OnClick!(); var request = Assert.Single(requests); Assert.Equal(CharacterStatController.RaiseTargetKind.Attribute, request.Kind); Assert.Equal(5u, request.StatId); Assert.Equal(110L, request.Cost); Assert.Equal(1, request.Amount); } [Fact] public void RaiseButtons_ClickAffordableAttribute_KeepsNormalStateUntilCostsRefresh() { var btn1 = MakeButton(); var list = new UiPanel(); var layout = Fake( (CharacterStatController.RaiseOneId, btn1), (CharacterStatController.ListBoxId, list)); var requests = new List(); CharacterStatController.Bind(layout, SampleData.SampleCharacter, onRaiseRequest: requests.Add); list.Children.OfType().ToList()[4].OnClick!(); Assert.Equal("Normal", btn1.ActiveState); btn1.OnClick!(); Assert.Single(requests); Assert.Equal("Normal", btn1.ActiveState); } [Fact] public void RaiseButtons_ClickAffordableVital_EmitsMaxVitalId() { var btn1 = MakeButton(); var list = new UiPanel(); var layout = Fake( (CharacterStatController.RaiseOneId, btn1), (CharacterStatController.ListBoxId, list)); var requests = new List(); CharacterStatController.Bind(layout, SampleData.SampleCharacter, onRaiseRequest: requests.Add); list.Children.OfType().ToList()[6].OnClick!(); btn1.OnClick!(); var request = Assert.Single(requests); Assert.Equal(CharacterStatController.RaiseTargetKind.Vital, request.Kind); Assert.Equal(1u, request.StatId); Assert.Equal(90L, request.Cost); Assert.Equal(1, request.Amount); } [Fact] public void RaiseButtons_ClickUnaffordableTen_DoesNotEmitRequest() { var btn10 = MakeButton(); var list = new UiPanel(); var layout = Fake( (CharacterStatController.RaiseTenId, btn10), (CharacterStatController.ListBoxId, list)); var requests = new List(); var sheet = new CharacterSheet { UnassignedXp = 500L, AttributeRaiseCosts = new long[] { 0L, 0L, 0L, 0L, 110L, 0L, 0L, 0L, 0L }, AttributeRaise10Costs = new long[] { 0L, 0L, 0L, 0L, 1_100L, 0L, 0L, 0L, 0L }, }; CharacterStatController.Bind(layout, () => sheet, onRaiseRequest: requests.Add); list.Children.OfType().ToList()[4].OnClick!(); btn10.OnClick!(); Assert.Empty(requests); } [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 SkillsTab_Click_RebuildsListWithRetailBucketsAndRows() { var list = new UiPanel { Width = 300 }; var layout = Fake((CharacterStatController.ListBoxId, list)); CharacterStatController.Bind(layout, SampleData.SampleCharacter, spriteResolve: id => (id, 16, 16)); ClickTab(layout, left: 92f); var headerPanels = SkillHeaders(list); var headers = headerPanels .Select(c => c.Children.OfType().First().LinesProvider()[0].Text) .ToList(); Assert.Equal(new[] { "Specialized Skills", "Trained Skills", "Untrained Skills", "Unusable Skills", }, headers); Assert.Equal(new[] { 0x06000F90u, 0x06000F86u, 0x06000F98u, 0x06000F89u }, headerPanels.Select(h => h.BackgroundSprite).ToArray()); Assert.All(headerPanels, h => Assert.Equal(Vector4.One, h.Children.OfType().First().LinesProvider()[0].Color)); var rows = SkillRows(list); Assert.Equal(12, rows.Count); Assert.All(rows, row => { Assert.Equal(Vector4.Zero, row.BackgroundColor); Assert.Equal(0u, row.BackgroundSprite); Assert.True(row.UseSelectionBars); }); var rowNames = rows .Select(r => r.Children.OfType().ToList()[1].LinesProvider()[0].Text) .ToList(); Assert.Equal(new[] { "Melee Defense", "War Magic", "Arcane Lore", "Life Magic", "Missile Weapons", "Healing", "Jump", "Loyalty", "Run", "Alchemy", "Cooking", "Fletching", }, rowNames); var meleeTexts = rows[0].Children.OfType().ToList(); Assert.Equal(Vector4.One, meleeTexts[1].LinesProvider()[0].Color); Assert.Equal(new Vector4(0.55f, 1f, 0.55f, 1f), meleeTexts[2].LinesProvider()[0].Color); var healingTexts = rows[5].Children.OfType().ToList(); Assert.Equal(Vector4.One, healingTexts[1].LinesProvider()[0].Color); Assert.Equal(Vector4.One, healingTexts[2].LinesProvider()[0].Color); } [Fact] public void SkillsTab_ClickThenAttributesTab_RestoresAttributeRows() { var list = new UiPanel { Width = 300 }; var layout = Fake((CharacterStatController.ListBoxId, list)); CharacterStatController.Bind(layout, SampleData.SampleCharacter, spriteResolve: id => (id, 16, 16)); ClickTab(layout, left: 92f); Assert.Equal(12, SkillRows(list).Count); ClickTab(layout, left: 0f); var rows = list.Children.OfType().ToList(); Assert.Equal(9, rows.Count); Assert.Equal("Strength", rows[0].Children.OfType().ToList()[1].LinesProvider()[0].Text); } [Fact] public void SkillsTab_MouseClick_RebuildsVisibleListWhenListIdIsDuplicated() { var root = new UiPanel { Width = 300, Height = 600 }; var attrPage = MakeDatElement(CharacterStatController.AttributesPageId, top: 25, width: 300, height: 575); var hiddenPage = MakeDatElement(CharacterStatController.SkillsPageId, top: 25, width: 300, height: 575); var name = new UiText(); var visibleList = MakeDatElement(CharacterStatController.ListBoxId, top: 112, width: 300, height: 398); var hiddenDuplicateList = MakeDatElement(CharacterStatController.ListBoxId, top: 112, width: 300, height: 398); attrPage.AddChild(name); attrPage.AddChild(visibleList); hiddenPage.AddChild(hiddenDuplicateList); root.AddChild(attrPage); root.AddChild(hiddenPage); var layout = new ImportedLayout(root, new Dictionary { [CharacterStatController.NameId] = name, // Mirrors the real import: the id dictionary can point at a hidden duplicate. [CharacterStatController.ListBoxId] = hiddenDuplicateList, }); CharacterStatController.Bind(layout, SampleData.SampleCharacter, spriteResolve: id => (id, 16, 16)); Assert.Equal("Strength", FirstRowName(visibleList)); Assert.Equal("", FirstRowName(hiddenDuplicateList)); var ui = new UiRoot { Width = 300, Height = 600 }; ui.AddChild(root); Assert.IsType(ui.Pick(132, 12)); ui.OnMouseDown(UiMouseButton.Left, 132, 12); ui.OnMouseUp(UiMouseButton.Left, 132, 12); Assert.Equal("Melee Defense", FirstRowName(visibleList)); Assert.Equal("", FirstRowName(hiddenDuplicateList)); } [Fact] public void SkillsTab_SelectWarMagic_ShowsTrainedSkillFooter() { var list = new UiPanel { Width = 300 }; var title = new UiText(); var l1Label = new UiText(); var l1Value = new UiText(); var l2Label = new UiText(); var l2Value = new UiText(); var layout = Fake( (CharacterStatController.ListBoxId, list), (CharacterStatController.FooterTitleId, title), (CharacterStatController.FooterLine1Label, l1Label), (CharacterStatController.FooterLine1Value, l1Value), (CharacterStatController.FooterLine2Label, l2Label), (CharacterStatController.FooterLine2Value, l2Value)); CharacterStatController.Bind(layout, SampleData.SampleCharacter, spriteResolve: id => (id, 16, 16)); ClickTab(layout, left: 92f); var rows = SkillRows(list); rows[1].OnClick!(); Assert.Equal("War Magic: 285", title.LinesProvider()[0].Text); Assert.Equal("Experience To Raise:", l1Label.LinesProvider()[0].Text); Assert.Equal((11_100_000L).ToString("N0"), l1Value.LinesProvider()[0].Text); Assert.Equal("Unassigned Experience:", l2Label.LinesProvider()[0].Text); Assert.Equal((87_757_321_741L).ToString("N0"), l2Value.LinesProvider()[0].Text); Assert.Equal(0x06001397u, rows[1].BackgroundSprite); Assert.True(rows[1].UseSelectionBars); Assert.Equal(Vector4.Zero, rows[1].BackgroundColor); Assert.Equal(0u, rows[0].BackgroundSprite); Assert.True(rows[0].UseSelectionBars); Assert.Equal(Vector4.Zero, rows[0].BackgroundColor); } [Fact] public void SkillsTab_ClickRaiseTen_EmitsSkillRaiseRequest() { var list = new UiPanel { Width = 300 }; var btn10 = MakeButton(); var layout = Fake( (CharacterStatController.ListBoxId, list), (CharacterStatController.RaiseTenId, btn10)); var requests = new List(); CharacterStatController.Bind(layout, SampleData.SampleCharacter, spriteResolve: id => (id, 16, 16), onRaiseRequest: requests.Add); ClickTab(layout, left: 92f); SkillRows(list)[1].OnClick!(); btn10.OnClick!(); var request = Assert.Single(requests); Assert.Equal(CharacterStatController.RaiseTargetKind.Skill, request.Kind); Assert.Equal(34u, request.StatId); Assert.Equal(111_000_000L, request.Cost); Assert.Equal(10, request.Amount); } [Fact] public void SkillsTab_SelectHealing_ShowsUntrainedSkillFooter() { var list = new UiPanel { Width = 300 }; var title = new UiText(); var l1Label = new UiText(); var l1Value = new UiText(); var l2Label = new UiText(); var l2Value = new UiText(); var btn1 = MakeButton(); var layout = Fake( (CharacterStatController.ListBoxId, list), (CharacterStatController.FooterTitleId, title), (CharacterStatController.FooterLine1Label, l1Label), (CharacterStatController.FooterLine1Value, l1Value), (CharacterStatController.FooterLine2Label, l2Label), (CharacterStatController.FooterLine2Value, l2Value), (CharacterStatController.RaiseOneId, btn1)); CharacterStatController.Bind(layout, SampleData.SampleCharacter, spriteResolve: id => (id, 16, 16)); ClickTab(layout, left: 92f); SkillRows(list)[5].OnClick!(); Assert.Equal("Healing", title.LinesProvider()[0].Text); Assert.Equal("Skill Credits To Raise:", l1Label.LinesProvider()[0].Text); Assert.Equal("6", l1Value.LinesProvider()[0].Text); Assert.Equal("Skill Credits Available:", l2Label.LinesProvider()[0].Text); Assert.Equal("96", l2Value.LinesProvider()[0].Text); Assert.True(btn1.Visible); Assert.Equal("Normal", btn1.ActiveState); } [Fact] public void SkillsTab_ClickUntrainedSkill_EmitsTrainSkillRequest() { var list = new UiPanel { Width = 300 }; var btn1 = MakeButton(); var layout = Fake( (CharacterStatController.ListBoxId, list), (CharacterStatController.RaiseOneId, btn1)); var requests = new List(); CharacterStatController.Bind(layout, SampleData.SampleCharacter, spriteResolve: id => (id, 16, 16), onRaiseRequest: requests.Add); ClickTab(layout, left: 92f); SkillRows(list)[5].OnClick!(); btn1.OnClick!(); var request = Assert.Single(requests); Assert.Equal(CharacterStatController.RaiseTargetKind.TrainSkill, request.Kind); Assert.Equal(21u, request.StatId); Assert.Equal(6L, request.Cost); Assert.Equal(1, request.Amount); } [Fact] public void SkillsTab_ClickTrain_RebuildsSelectedSkillAsTrained() { var list = new UiPanel { Width = 300 }; var btn1 = MakeButton(); var btn10 = MakeButton(); CharacterSheet sheet = new() { SkillCredits = 10, UnassignedXp = 1_000, Skills = new[] { new CharacterSkill(100u, "Train Me", 0x06000001u, CharacterSkillAdvancementClass.Untrained, BaseLevel: 5, CurrentLevel: 5, UsableUntrained: true, TrainedCost: 4, SpecializedCost: 0, RaiseCost: 0, Raise10Cost: 0), }, }; var layout = Fake( (CharacterStatController.ListBoxId, list), (CharacterStatController.RaiseOneId, btn1), (CharacterStatController.RaiseTenId, btn10)); var requests = new List(); CharacterStatController.Bind(layout, () => sheet, spriteResolve: id => (id, 16, 16), onRaiseRequest: request => { requests.Add(request); sheet = new CharacterSheet { SkillCredits = 6, UnassignedXp = 1_000, Skills = new[] { new CharacterSkill(100u, "Train Me", 0x06000001u, CharacterSkillAdvancementClass.Trained, BaseLevel: 5, CurrentLevel: 5, UsableUntrained: true, TrainedCost: 4, SpecializedCost: 0, RaiseCost: 10, Raise10Cost: 100), }, }; }); ClickTab(layout, left: 92f); SkillRows(list).Single().OnClick!(); Assert.False(btn10.Visible); btn1.OnClick!(); var request = Assert.Single(requests); Assert.Equal(CharacterStatController.RaiseTargetKind.TrainSkill, request.Kind); Assert.Equal("Train Me", SkillRows(list).Single().Children.OfType().ToList()[1].LinesProvider()[0].Text); Assert.True(btn1.Visible); Assert.True(btn10.Visible); Assert.Equal("Normal", btn1.ActiveState); Assert.Equal("Normal", btn10.ActiveState); } [Fact] public void SkillsTab_BindsCharacterScrollbarToScrollableViewport() { var root = new UiPanel { Width = 300, Height = 600 }; var page = new UiPanel { Width = 300, Height = 600 }; var name = new UiText(); var list = MakeDatElement(CharacterStatController.ListBoxId, top: 137, width: 300, height: 80); var scrollbarShell = MakeDatElement(CharacterStatController.ListScrollbarId, top: 137, width: 16, height: 80); scrollbarShell.Left = 281; page.AddChild(name); page.AddChild(list); page.AddChild(scrollbarShell); root.AddChild(page); var layout = new ImportedLayout(root, new Dictionary { [CharacterStatController.NameId] = name, [CharacterStatController.ListBoxId] = list, [CharacterStatController.ListScrollbarId] = scrollbarShell, }); CharacterStatController.Bind(layout, SampleData.SampleCharacter, spriteResolve: id => (id, 16, 16)); ClickTab(layout, left: 92f); var bar = page.Children.OfType().Single(); Assert.True(bar.Visible); Assert.NotNull(bar.Model); Assert.Contains(list.Children, c => c is UiScrollablePanel); Assert.False(scrollbarShell.Visible); } [Fact] public void GetRaiseCost_Index4Focus_Returns110() { var sheet = SampleData.SampleCharacter(); Assert.Equal(110L, CharacterStatController.GetRaiseCost(sheet, 4)); } [Fact] public void GetRaiseCost_Amount10Index4Focus_Returns1100() { var sheet = SampleData.SampleCharacter(); Assert.Equal(1_100L, CharacterStatController.GetRaiseCost(sheet, 4, amount: 10)); } [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_AttributeRaise10Costs_HasNineEntries() { var costs = SampleData.SampleCharacter().AttributeRaise10Costs; 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_AttributeRaise10Costs_FocusAt1100() => Assert.Equal(1_100L, SampleData.SampleCharacter().AttributeRaise10Costs[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 ────────────────────────────────────────────────────────────── private static void ClickTab(ImportedLayout layout, float left) { var tab = layout.Root.Children.OfType() .Single(p => System.Math.Abs(p.Left - left) < 0.5f && System.Math.Abs(p.Top) < 0.5f && System.Math.Abs(p.Height - 25f) < 0.5f); tab.OnClick!(); } private static string FirstRowName(UiElement list) { var row = Descendants(list).OfType().FirstOrDefault(); if (row is null) return ""; var texts = row.Children.OfType().ToList(); return texts.Count > 1 ? texts[1].LinesProvider()[0].Text : ""; } private static List SkillRows(UiElement list) => Descendants(list).OfType().ToList(); private static List SkillHeaders(UiElement list) => Descendants(list) .Where(c => c is UiPanel and not UiClickablePanel && c.Children.OfType().Any()) .Cast() .ToList(); private static IEnumerable Descendants(UiElement root) { foreach (var child in root.Children) { yield return child; foreach (var nested in Descendants(child)) yield return nested; } } private static UiDatElement MakeDatElement(uint id, float top, float width, float height) { var info = new ElementInfo { Id = id, Type = 3, Y = top, Width = width, Height = height, }; return new UiDatElement(info, static _ => (0u, 0, 0)) { Top = top, Width = width, Height = height, }; } /// Manufacture a minimal UiButton with a fake ElementInfo (no dat sprites). private static UiButton MakeButton(uint id = 0u) { var info = new ElementInfo { Id = id, 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); } }