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); // CT4 fix round (BLOCKER 2): retail AppendText's the title VERBATIM // (@0x004f0990) — SampleData's "the Adventurer" keeps its lowercase // article, unlike the pre-fix-round stripped "Adventurer". Assert.Equal("Female Aluvian the 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", }; // CT4 fix round (BLOCKER 2): AppendText @0x004f0990 is verbatim — no // leading-article stripping. Assert.Equal("Female Aluvian the Adventurer", CharacterIdentityText.StatHeaderLine(sheet)); } /// CT4 fix round (BLOCKER 2): 26 real ACE /// CharacterTitle entries begin with "The" (capital T, e.g. /// "The Noob") — retail's verbatim AppendText must not mangle them the /// way the deleted StripLeadingArticle (which only matched a /// lowercase "the ") would have left half-stripped anyway. [Fact] public void CharacterIdentityText_StatHeaderLine_KeepsCapitalTheTitleUnmangled() { var sheet = new CharacterSheet { Gender = "Male", Heritage = "Aluvian", Title = "The Noob", }; Assert.Equal("Male Aluvian The Noob", 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 visiblePk = new UiText { ElementId = CharacterStatController.PkStatusId }; var hiddenPk = new UiText { ElementId = CharacterStatController.PkStatusId }; 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(visiblePk); attrPage.AddChild(visibleLevel); attrPage.AddChild(visibleTotalXpLabel); attrPage.AddChild(visibleTotalXp); attrPage.AddChild(visibleMeter); hiddenPage.AddChild(hiddenName); hiddenPage.AddChild(hiddenHeritage); hiddenPage.AddChild(hiddenPk); 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.PkStatusId] = hiddenPk, [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 the Adventurer", visibleHeritage.LinesProvider()[0].Text); // CT4 fix round item 7: this test predates CT4 and did not cover // PkStatusId — extended here to prove the visible-page-scoping rule // (Bind reads/writes the ATTRIBUTES-page copy, not the last- // registered duplicate in _byId) also holds for the PK line. Assert.Equal("Non-Player Killer", visiblePk.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()); Assert.Empty(hiddenPk.LinesProvider()); } // ── Campaign CT slice CT4: header identity block ─────────────────────── /// CT4 item 2: the heritage line composes Gender + Heritage + /// " " + the resolved display title (CharacterIdentityText.StatHeaderLine), /// and — because the label's text() provider re-reads data() /// on every draw — reflects a live title change with no rebind, exactly /// how CharacterSheetProvider's own DisplayTitleChanged/ /// TableReplaced subscription drives a real sheet rebuild in /// production. [Fact] public void Bind_HeritageLine_ComposesGenderHeritageTitle_AndUpdatesLiveOnDisplayTitleChange() { var heritage = new UiText(); var layout = Fake((CharacterStatController.HeritageId, heritage)); CharacterSheet sheet = new() { Gender = "Female", Heritage = "Aluvian" }; CharacterStatController.Bind(layout, () => sheet); Assert.Equal("Female Aluvian", heritage.LinesProvider()[0].Text); // Simulates RuntimeCharacterTitleState.DisplayTitleChanged firing and // CharacterSheetProvider rebuilding the sheet with the newly resolved // title — CharacterStatController never rebinds, the label's own // provider just re-reads the (reassigned) sheet. sheet = new CharacterSheet { Gender = "Female", Heritage = "Aluvian", Title = "War Mage" }; Assert.Equal("Female Aluvian War Mage", heritage.LinesProvider()[0].Text); } /// CT4 item 1 ruling: the Name line ships the PLAIN-NAME case /// only — retail's allegiance rank-title prefix /// (AllegianceData::GetFullName @0x005b6950) needs a ~200-string /// 22-function heritage×gender table judged out of reasonable size for /// this slice (AP-109). The Name label must show exactly /// , with no rank prefix synthesized /// from anywhere. [Fact] public void Bind_NameLine_ShowsPlainNameOnly_NoRankPrefix() { var name = new UiText(); var layout = Fake((CharacterStatController.NameId, name)); CharacterStatController.Bind(layout, () => new CharacterSheet { Name = "Dww" }); Assert.Equal("Dww", name.LinesProvider()[0].Text); } /// CT4 item 4: the level shows a bare "%d"-formatted /// integer when is present, and the /// PE-recovered literal "???" when it is null (retail InqInt(0x19) /// absent). Both cases use the WIDGET's own authored /// — not a hardcoded constant — proving /// the CT1 "authored color/font wins" fix (the former "Gold" constant is /// deleted from the controller entirely). [Theory] [InlineData(126, "126")] [InlineData(0, "0")] [InlineData(null, "???")] public void Bind_LevelLine_FormatsIntegerOrShowsQuestionMarks_InAuthoredColor(int? level, string expectedText) { var authoredColor = new Vector4(0.11f, 0.22f, 0.33f, 1f); var levelText = new UiText { DefaultColor = authoredColor }; var layout = Fake((CharacterStatController.LevelId, levelText)); CharacterStatController.Bind(layout, () => new CharacterSheet { Level = level }); UiText.Line line = Assert.Single(levelText.LinesProvider()); Assert.Equal(expectedText, line.Text); Assert.Equal(authoredColor, line.Color); } /// CT4 item 3: the PK line shows exactly whatever /// carries (the resolved /// StringTable text — see CharacterSheetProviderTests for the /// three-key resolution itself) in the widget's own authored color, not /// the deleted parchment "Body" constant. [Theory] [InlineData("Player Killer")] [InlineData("Player Killer Lite")] [InlineData("Non-Player Killer")] public void Bind_PkStatusLine_ShowsResolvedText_InAuthoredColor(string resolvedText) { var authoredColor = new Vector4(0.4f, 0.5f, 0.6f, 1f); var pk = new UiText { DefaultColor = authoredColor }; var layout = Fake((CharacterStatController.PkStatusId, pk)); CharacterStatController.Bind(layout, () => new CharacterSheet { PkStatus = resolvedText }); UiText.Line line = Assert.Single(pk.LinesProvider()); Assert.Equal(resolvedText, line.Text); Assert.Equal(authoredColor, line.Color); } /// An unresolved PK key (CT4 contract: "no invented English; if /// a key fails to resolve, show nothing") shows an empty line rather /// than a fabricated English fallback. [Fact] public void Bind_PkStatusLine_NullPkStatus_ShowsEmptyText() { var pk = new UiText(); var layout = Fake((CharacterStatController.PkStatusId, pk)); CharacterStatController.Bind(layout, () => new CharacterSheet { PkStatus = null }); Assert.Equal(string.Empty, pk.LinesProvider()[0].Text); } /// CT4 item 5, re-bound at the CT4 fix round (BLOCKER 1): the /// luminance pair (0x100005C5/0x100005C6) shows/hides its TEXT per /// retail's exact gate — UpdateExperience (0x004f0a70): /// "InqInt(0x19) < 200 || MaximumLuminance == 0" empties both /// elements' LinesProvider (retail's ClearAllText, /// @0x004f0e31/@0x004f0e3c) rather than toggling /// Visible — see Bind_LuminancePair_ShowsBoundTextWhenGateIsOpen /// for the actual bound content. [Theory] [InlineData(126, 0L, false)] // below level 200 — hidden regardless of luminance [InlineData(200, 0L, false)] // level gate met, but MaximumLuminance == 0 — hidden [InlineData(200, 1_000_000L, true)] // both conditions met — visible [InlineData(275, 500L, true)] [InlineData(null, 500L, false)] // absent level — treated as "not level 200+" public void Bind_LuminancePair_TogglesContentPerRetailGate(int? level, long maxLuminance, bool expectedVisible) { var label = new UiText { ElementId = CharacterStatController.LuminanceLabelId }; var value = new UiText { ElementId = CharacterStatController.LuminanceValueId }; var layout = Fake( (CharacterStatController.LuminanceLabelId, label), (CharacterStatController.LuminanceValueId, value)); CharacterStatController.Bind(layout, () => new CharacterSheet { Level = level, MaximumLuminance = maxLuminance, }); Assert.Equal(expectedVisible, label.LinesProvider().Count > 0); Assert.Equal(expectedVisible, value.LinesProvider().Count > 0); // ClearAllText leaves layout untouched — Visible is never written by // this binding at all (retail doesn't touch it either). Assert.True(label.Visible); Assert.True(value.Visible); } /// CT4 fix round (BLOCKER 1): caption "Luminance:" (UTF-16 /// PE-recovered @0x007c3dd4) and value "<available> / <maximum>" /// (narrow "%s / %s" @0x007c3dcc), each number formatted through the /// same N0/InvariantCulture helper Total XP uses, in the widget's own /// authored color. [Fact] public void Bind_LuminancePair_ShowsBoundTextWhenGateIsOpen() { var authoredColor = new Vector4(0.9f, 0.9f, 0.9f, 1f); var label = new UiText { ElementId = CharacterStatController.LuminanceLabelId, DefaultColor = authoredColor }; var value = new UiText { ElementId = CharacterStatController.LuminanceValueId, DefaultColor = authoredColor }; var layout = Fake( (CharacterStatController.LuminanceLabelId, label), (CharacterStatController.LuminanceValueId, value)); CharacterStatController.Bind(layout, () => new CharacterSheet { Level = 200, AvailableLuminance = 1_500_000L, MaximumLuminance = 25_000_000L, }); UiText.Line labelLine = Assert.Single(label.LinesProvider()); Assert.Equal("Luminance:", labelLine.Text); Assert.Equal(authoredColor, labelLine.Color); UiText.Line valueLine = Assert.Single(value.LinesProvider()); Assert.Equal("1,500,000 / 25,000,000", valueLine.Text); Assert.Equal(authoredColor, valueLine.Color); } // ── 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); // CT6: rows now stack inside a UiScrollablePanel viewport child of // the ListBox (same shrink-and-scroll contract Skills already had), // so Descendants (not Children) finds them. var rows = Descendants(list).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 = Descendants(list).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 = Descendants(list).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 = Descendants(list).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 = Descendants(list).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 = Descendants(list).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 = Descendants(list).OfType().ToList(); Assert.Equal(9, rows.Count); foreach (var row in rows) { var iconEl = row.Children.OfType().First(); Assert.Equal(0u, iconEl.BackgroundSprite); } } // ── CT5 fix round SHOULD-FIX 2: production BUILT-row geometry ──────────── // Load-bearing evidence for AddRow's own "hand-built-vs-template ruling" // doc comment: these tests assert the authored template 0x10000248 pixel // geometry against Bind's ACTUAL BUILT rows (not the DAT pins // CharacterPanelLiveDatTests.AttributeRowTemplate_... already covers — // those pin the template itself, these pin what the controller DOES // with it). [Fact] public void Bind_AttributeRow_MatchesAuthoredTemplateGeometry() { var list = new UiPanel(); var layout = Fake((CharacterStatController.ListBoxId, list)); CharacterStatController.Bind(layout, SampleData.SampleCharacter); var row = Descendants(list).OfType().First(); AssertRowGeometry(row, expectedWidth: 282f); } /// Vital rows (Health/Stamina/Mana) are appended after the 6 /// attribute rows by BuildAttributeRows — index 6 is the first /// vital row. [Fact] public void Bind_VitalRow_MatchesAuthoredTemplateGeometry() { var list = new UiPanel(); var layout = Fake((CharacterStatController.ListBoxId, list)); CharacterStatController.Bind(layout, SampleData.SampleCharacter); var row = Descendants(list).OfType().ToList()[6]; AssertRowGeometry(row, expectedWidth: 282f); } /// /// Unlike the attribute/vital test above (a plain /// list with no dat Width, which falls back to the raw 282px template /// ceiling — see RowContentWidth's doc comment), this test /// reproduces the REAL production geometry: the ListBox's authored /// scrollbar (0x1000023E) sits at X=281 /// (), /// so SkillViewportWidth measures the viewport as /// scrollbar.Left − list.Left = 281 and skill rows clamp one /// pixel narrower than the attribute/vital rows' 282px ceiling — the /// documented "282px row template overlaps the 281px scrollbar band by /// 1px" fact from the ground-truth doc, asserted against the actual /// mechanism instead of a synthetic width. /// [Fact] public void Bind_SkillRow_ClampsToAuthoredScrollbarGutterWidth() { 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: 112, width: 300, height: 398); var scrollbarShell = MakeDatElement(CharacterStatController.ListScrollbarId, top: 112, width: 16, height: 398); scrollbarShell.Left = 281; page.AddChild(name); page.AddChild(list); page.AddChild(scrollbarShell); root.AddChild(page); var skillsTab = MakeTab(CharacterStatController.TabSkillsId, left: 92f); root.AddChild(skillsTab); var layout = new ImportedLayout(root, new Dictionary { [CharacterStatController.NameId] = name, [CharacterStatController.ListBoxId] = list, [CharacterStatController.ListScrollbarId] = scrollbarShell, [CharacterStatController.TabSkillsId] = skillsTab, }); CharacterStatController.Bind(layout, SampleData.SampleCharacter, spriteResolve: id => (id, 16, 16)); ClickTab(layout, left: 92f); var row = SkillRows(list).First(); AssertRowGeometry(row, expectedWidth: 281f); } private static void AssertRowGeometry(UiPanel row, float expectedWidth) { Assert.Equal(expectedWidth, row.Width); Assert.Equal(20f, row.Height); var texts = row.Children.OfType().ToList(); Assert.True(texts.Count >= 3, "row must have icon + name + value children"); UiText icon = texts[0]; UiText name = texts[1]; UiText value = texts[2]; Assert.Equal(0f, icon.Left); Assert.Equal(20f, icon.Width); Assert.Equal(25f, name.Left); Assert.Equal(150f, name.Width); // CT5 fix round (SHOULD-FIX 3): the authored template's name column // carries no margin — Padding=0 (the former Padding=1f re-created // the X=26 glyph start this slice existed to remove). Assert.Equal(0f, name.Padding); Assert.Equal(175f, value.Left); Assert.Equal(100f, value.Width); Assert.True(value.RightAligned); } // ── 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 = Descendants(list).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); Descendants(list).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); Descendants(list).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); Descendants(list).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); Descendants(list).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 = Descendants(list).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 = Descendants(list).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 = Descendants(list).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 = Descendants(list).OfType().ToList(); rows[2].OnClick!(); // select rows[2].OnClick!(); // deselect Assert.Equal(0f, rows[2].BackgroundColor.W); } [Fact] public void RowClick_WithSpriteResolve_SelectedRowHasHighlightSprite() { // CT5 SEALED VERDICT (docs/research/2026-08-24-campaign-ct-dat-ground-truth.md // §2): the row template's Highlight-state media is 0x06000F93 // (gmAttributeUI::UpdateSelection @0x0049DEE0 SetState(6) -> // InfoRegion::SetState @0x004F0EE0 on template 0x10000248) — NOT // 0x06001397, which belongs to the spellbook row's separate // selected-overlay mechanism (SpellbookRowStyle.cs, untouched here). 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 = Descendants(list).OfType().ToList(); rows[2].OnClick!(); Assert.Equal(0x06000F93u, rows[2].BackgroundSprite); // selected → sprite Assert.Equal(0f, rows[2].BackgroundColor.W); // no tint // CT5 fix round (SHOULD-FIX 1): unselected rows draw the row // template's Normal-state media (0x06004CC2) instead of nothing. Assert.Equal(0x06004CC2u, rows[0].BackgroundSprite); // others: Normal-state media Assert.Equal(0x06004CC2u, 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 = Descendants(list).OfType().ToList(); rows[2].OnClick!(); // select rows[2].OnClick!(); // deselect // CT5 fix round (SHOULD-FIX 1): deselecting returns to the // unselected Normal-state media (0x06004CC2), not zero. Assert.Equal(0x06004CC2u, 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); Descendants(list).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); Descendants(list).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); Descendants(list).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: (request, completed) => { requests.Add(request); completed(); }); Descendants(list).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: (request, completed) => { requests.Add(request); completed(); }); Descendants(list).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: (request, completed) => { requests.Add(request); completed(); }); Descendants(list).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: (request, completed) => { requests.Add(request); completed(); }); Descendants(list).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 = Descendants(list).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: DAT-authored tab states ─────────────────────────────────────── [Fact] public void CharacterTabs_UseImportedChromeWithoutSyntheticRootChildren() { var layout = FixtureLoader.LoadCharacter(); int rootChildCount = layout.Root.Children.Count; var attributes = Assert.IsType(layout.FindElement(CharacterStatController.TabAttribId)); var skills = Assert.IsType(layout.FindElement(CharacterStatController.TabSkillsId)); var titles = Assert.IsType(layout.FindElement(CharacterStatController.TabTitlesId)); Assert.Equal(3, attributes.Children.Count); Assert.Equal(3, skills.Children.Count); Assert.Equal(3, titles.Children.Count); CharacterStatController.Bind(layout, SampleData.SampleCharacter, spriteResolve: id => (id, 16, 16)); Assert.Equal(rootChildCount, layout.Root.Children.Count); Assert.Equal(RetailUiStateIds.Open, attributes.ActiveRetailStateId); Assert.Equal(RetailUiStateIds.Closed, skills.ActiveRetailStateId); Assert.Equal(RetailUiStateIds.Closed, titles.ActiveRetailStateId); Assert.Equal( new uint[] { 0x06005D92u, 0x06005D94u, 0x06005D96u }, attributes.Children.Cast().Select(child => child.ActiveMedia().File)); Assert.Equal( new uint[] { 0x06005D93u, 0x06005D95u, 0x06005D97u }, skills.Children.Cast().Select(child => child.ActiveMedia().File)); // CT3 (2026-08-24): the Titles tab is no longer inert — it switches // to a real page (previously AP-109's known gap). Assert.False(titles.ClickThrough); Assert.NotNull(titles.OnClick); } [Fact] public void CharacterTabs_ClickUsesRetailStateColorAndPropagatesToChrome() { var layout = FixtureLoader.LoadCharacter(); var attributes = Assert.IsType(layout.FindElement(CharacterStatController.TabAttribId)); var skills = Assert.IsType(layout.FindElement(CharacterStatController.TabSkillsId)); CharacterStatController.Bind(layout, SampleData.SampleCharacter, spriteResolve: id => (id, 16, 16)); skills.OnClick!(); Assert.Equal(RetailUiStateIds.Closed, attributes.ActiveRetailStateId); Assert.Equal(RetailUiStateIds.Open, skills.ActiveRetailStateId); Assert.Equal(127f / 255f, attributes.DefaultColor.X, 5); Assert.Equal(204f / 255f, skills.DefaultColor.X, 5); Assert.All(attributes.Children, child => Assert.Equal(RetailUiStateIds.Closed, Assert.IsAssignableFrom(child).ActiveRetailStateId)); Assert.All(skills.Children, child => Assert.Equal(RetailUiStateIds.Open, Assert.IsAssignableFrom(child).ActiveRetailStateId)); } /// /// CT3 (2026-08-24): unlike Attributes/Skills (which share ONE mounted /// page and only rebind its content), the Titles page (0x10000539) is a /// genuinely separate, non-duplicated subtree (CT1 ground truth §3) that /// must actually be shown/hidden on tab switch. /// [Fact] public void TitlesTab_Click_ShowsTitlesPageAndHidesAttributesSkillsContent() { var layout = FixtureLoader.LoadCharacter(); var titlesTab = Assert.IsType(layout.FindElement(CharacterStatController.TabTitlesId)); var attributesTab = Assert.IsType(layout.FindElement(CharacterStatController.TabAttribId)); var titlesPage = layout.FindElement(CharacterStatController.TitlesPageId); var attributesPage = layout.FindElement(CharacterStatController.AttributesPageId); Assert.NotNull(titlesPage); Assert.NotNull(attributesPage); CharacterStatController.Bind(layout, SampleData.SampleCharacter, spriteResolve: id => (id, 16, 16)); Assert.True(attributesPage!.Visible); Assert.False(titlesPage!.Visible); titlesTab.OnClick!(); Assert.True(titlesPage.Visible); Assert.False(attributesPage.Visible); Assert.Equal(RetailUiStateIds.Open, titlesTab.ActiveRetailStateId); Assert.Equal(RetailUiStateIds.Closed, attributesTab.ActiveRetailStateId); // Switching back to Attributes restores the shared content page and // re-hides Titles. attributesTab.OnClick!(); Assert.True(attributesPage.Visible); Assert.False(titlesPage.Visible); Assert.Equal(RetailUiStateIds.Closed, titlesTab.ActiveRetailStateId); } // ── 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); // CT5 fix round (SHOULD-FIX 1): fresh unselected rows draw the // row template's Normal-state media (0x06004CC2) up front. Assert.Equal(0x06004CC2u, row.BackgroundSprite); }); 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(0f, 1f, 0f, 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 DataChangedRefresh_MovesATrainedSkillToItsSection_WithoutAClick() { // #431 CA5-gate bug: a TrainSkill's authoritative record updated the // sheet, but rows only rebuilt on CLICKS — the skill sat in the // untrained section (credits visibly debited) until the user clicked // train AGAIN. Bind now returns the data-changed refresh; invoking // it must re-bucket the rows, mirroring retail's quality-change // broadcast (InfoRegion::OnQualityChanged @ 0x004F0EB0). var list = new UiPanel { Width = 300 }; var layout = Fake((CharacterStatController.ListBoxId, list)); CharacterSheet sheet = SampleData.SampleCharacter(); Action refresh = CharacterStatController.Bind(layout, () => sheet, spriteResolve: id => (id, 16, 16)); ClickTab(layout, left: 92f); var untrained = sheet.Skills.First( s => s.AdvancementClass == CharacterSkillAdvancementClass.Untrained); int trainedBefore = sheet.Skills.Count( s => s.AdvancementClass == CharacterSkillAdvancementClass.Trained); Assert.Equal(trainedBefore, RowsUnderHeader(list, "Trained Skills")); // The server record lands: the skill is now Trained. CharacterSheet // is a plain class (init-only), so rebuild it like the provider does. CharacterSheet updated = SampleData.SampleCharacter(); updated.GetType(); // (no-op guard for clarity) sheet = new CharacterSheet { Name = sheet.Name, UnassignedXp = sheet.UnassignedXp, SkillCredits = sheet.SkillCredits, Skills = sheet.Skills .Select(s => s.Id == untrained.Id ? s with { AdvancementClass = CharacterSkillAdvancementClass.Trained } : s) .ToList(), }; refresh(); Assert.Equal(trainedBefore + 1, RowsUnderHeader(list, "Trained Skills")); } [Fact] public void Rows_CarryTheSharedTooltipPopupLocatorAndDescriptionText() { // #430: runtime-built rows author no popup locator, and the tooltip // presenter refuses a widget without one — so the row descriptions // could never mount. Rows must carry the shared popup skin (the only // locator pair the character layout itself references; the same // inference UiItemSlot ships) alongside their runtime text. var list = new UiPanel { Width = 300 }; var layout = Fake((CharacterStatController.ListBoxId, list)); CharacterStatController.Bind(layout, SampleData.SampleCharacter, spriteResolve: id => (id, 16, 16)); var attributeRows = Descendants(list).OfType().ToList(); Assert.NotEmpty(attributeRows); Assert.All(attributeRows, row => { Assert.Equal( RetailTooltipPresenter.SharedPopupSkinRootElementId, row.AuthoredTooltipRootElementId); Assert.Equal( RetailTooltipPresenter.SharedPopupSkinLayoutDid, row.AuthoredTooltipLayoutDid); }); // The six attribute rows carry the retail descriptions. Assert.All( attributeRows.Take(6), row => Assert.False(string.IsNullOrEmpty(row.GetTooltipText()))); } private static int RowsUnderHeader(UiElement list, string header) { // The rows live inside a nested content container; find the // container whose ordered children interleave section headers and // rows, then count rows between the named header and the next one. static bool IsHeader(UiElement c, string text) => c is UiPanel and not UiClickablePanel && c.Children.OfType().Any( x => x.LinesProvider()[0].Text == text); UiElement? container = new[] { list }.Concat(Descendants(list)) .FirstOrDefault(e => e.Children.Any(c => IsHeader(c, header))); Assert.NotNull(container); var children = container!.Children.ToList(); int start = children.FindIndex(c => IsHeader(c, header)); Assert.True(start >= 0, $"header '{header}' not found"); int count = 0; for (int i = start + 1; i < children.Count; i++) { if (children[i] is UiClickablePanel) count++; else if (children[i] is UiPanel p && p.Children.OfType().Any()) break; } return count; } [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 = Descendants(list).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 skillsTab = MakeTab(CharacterStatController.TabSkillsId, left: 92f); root.AddChild(skillsTab); 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.TabSkillsId] = skillsTab, }); 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.Same(skillsTab, 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!(); // SampleData's War Magic row is intentionally Base=280/Current=285 (an // illustrative buffed-skill sample — see SampleData.cs comment); issue // #267 wires the retail "(+5)" buff-delta parenthetical onto that gap, // which was previously computed but never surfaced in the title text. Assert.Equal("War Magic: 285 (+5)", 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); // CT5: RowHighlightSprite corrected to 0x06000F93 (see the sealed // verdict on RowClick_WithSpriteResolve_SelectedRowHasHighlightSprite // above); UseSelectionBars was retired outright (retail's actual // Highlight state is a full-row background swap, which the // inherited UiPanel.OnDraw already renders — no bars-only mode // needed). Assert.Equal(0x06000F93u, rows[1].BackgroundSprite); Assert.Equal(Vector4.Zero, rows[1].BackgroundColor); // CT5 fix round (SHOULD-FIX 1): the unselected row draws the row // template's Normal-state media (0x06004CC2), not zero. Assert.Equal(0x06004CC2u, rows[0].BackgroundSprite); 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: (request, completed) => { requests.Add(request); completed(); }); 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: (request, completed) => { requests.Add(request); completed(); }); 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, completed) => { 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), }, }; completed(); }); 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_DeferredTrainRefreshesOnlyWhenRequestCompletes() { var list = new UiPanel { Width = 300 }; var btn1 = MakeButton(); var btn10 = MakeButton(); CharacterSheet sheet = TrainingSheet(CharacterSkillAdvancementClass.Untrained); var layout = Fake( (CharacterStatController.ListBoxId, list), (CharacterStatController.RaiseOneId, btn1), (CharacterStatController.RaiseTenId, btn10)); Action? completeRaise = null; CharacterStatController.Bind(layout, () => sheet, spriteResolve: id => (id, 16, 16), onRaiseRequest: (_, completed) => completeRaise = completed); ClickTab(layout, left: 92f); SkillRows(list).Single().OnClick!(); btn1.OnClick!(); sheet = TrainingSheet(CharacterSkillAdvancementClass.Trained); Assert.NotNull(completeRaise); Assert.False(btn10.Visible); completeRaise!(); Assert.True(btn10.Visible); static CharacterSheet TrainingSheet(CharacterSkillAdvancementClass advancement) => new() { SkillCredits = advancement == CharacterSkillAdvancementClass.Untrained ? 10 : 6, UnassignedXp = 1_000, Skills = [ new CharacterSkill( 100u, "Train Me", 0x06000001u, advancement, BaseLevel: 5, CurrentLevel: 5, UsableUntrained: true, TrainedCost: 4, SpecializedCost: 0, RaiseCost: advancement == CharacterSkillAdvancementClass.Untrained ? 0 : 10, Raise10Cost: advancement == CharacterSkillAdvancementClass.Untrained ? 0 : 100), ], }; } [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 skillsTab = MakeTab(CharacterStatController.TabSkillsId, left: 92f); root.AddChild(skillsTab); var layout = new ImportedLayout(root, new Dictionary { [CharacterStatController.NameId] = name, [CharacterStatController.ListBoxId] = list, [CharacterStatController.ListScrollbarId] = scrollbarShell, [CharacterStatController.TabSkillsId] = skillsTab, }); 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)); // ── Issue #267 — vitae/buff delta parenthetical ────────────────────────── // Retail format cited from gmAttributeUI::DisplaySelectionFooter_Attribute // (0x0049d280, " (%s%d)") and gmSkillUI::DisplaySelectionFooter_Trained // (0x0049b860, vitae segment " (%d)" + buff segment " (%s%d)"). [Fact] public void GetAttributeDelta_ComputesEffectiveMinusBase() { var sheet = new CharacterSheet { Strength = 220, AttributeBaseValues = [200, 0, 0, 0, 0, 0] }; Assert.Equal(20, CharacterStatController.GetAttributeDelta(sheet, 0)); } [Fact] public void GetAttributeDelta_VitalRowIndex_ReturnsZero() { // Vitals (rows 6-8) use a "cur/max" footer format in retail // (DisplaySelectionFooter_Vital), not this delta pattern. var sheet = new CharacterSheet { AttributeBaseValues = [200, 0, 0, 0, 0, 0] }; Assert.Equal(0, CharacterStatController.GetAttributeDelta(sheet, 6)); } [Fact] public void GetAttributeDelta_EmptyBaseValues_ReturnsZero() { // SampleData / older sheets that don't populate AttributeBaseValues // must not throw or fabricate a delta. var sheet = new CharacterSheet { Strength = 220 }; Assert.Equal(0, CharacterStatController.GetAttributeDelta(sheet, 0)); } [Fact] public void GetSkillBuffOnlyDelta_IsolatesBuffFromVitae() { // CurrentLevel=251 already includes vitae (-99); the buff-only // residual is (251 - (-99)) - 300 = 50. var skill = new CharacterSkill(1u, "S", 0u, CharacterSkillAdvancementClass.Trained, BaseLevel: 300, CurrentLevel: 251, UsableUntrained: true, TrainedCost: 0, SpecializedCost: 0, RaiseCost: 0, VitaeModifier: -99); Assert.Equal(50, CharacterStatController.GetSkillBuffOnlyDelta(skill)); } [Fact] public void SkillValueColor_SubtractsVitaeBeforeChoosingFontState() { var vitaeOnly = new CharacterSkill( 1u, "S", 0u, CharacterSkillAdvancementClass.Trained, BaseLevel: 300, CurrentLevel: 201, UsableUntrained: true, TrainedCost: 0, SpecializedCost: 0, RaiseCost: 0, VitaeModifier: -99); var vitaeAndBuff = vitaeOnly with { CurrentLevel = 211 }; Assert.Equal(Vector4.One, CharacterStatController.SkillValueColor(vitaeOnly)); Assert.Equal( new Vector4(0f, 1f, 0f, 1f), CharacterStatController.SkillValueColor(vitaeAndBuff)); } [Fact] public void AttributeAndVitalValueColors_FollowRetailResidualComparison() { var sheet = new CharacterSheet { Strength = 220, AttributeBaseValues = [200, 0, 0, 0, 0, 0], HealthMax = 170, VitalBaseMaxValues = [200, 0, 0], VitalVitaeModifiers = [-40, 0, 0], }; Assert.Equal( new Vector4(0f, 1f, 0f, 1f), CharacterStatController.AttributeValueColor(sheet, 0)); Assert.Equal( new Vector4(0f, 1f, 0f, 1f), CharacterStatController.VitalValueColor(sheet, 0)); } [Fact] public void RowClick_AttributeWithBuff_FooterTitleShowsPositiveDelta() { var title = new UiText(); var list = new UiPanel(); var layout = Fake( (CharacterStatController.FooterTitleId, title), (CharacterStatController.ListBoxId, list)); CharacterSheet Sheet() => new() { Strength = 220, AttributeBaseValues = [200, 0, 0, 0, 0, 0] }; CharacterStatController.Bind(layout, Sheet); Descendants(list).OfType().ToList()[0].OnClick!(); // Strength = index 0 Assert.Equal("Strength: 220 (+20)", title.LinesProvider()[0].Text); } [Fact] public void RowClick_AttributeWithDebuff_FooterTitleShowsNegativeDelta() { var title = new UiText(); var list = new UiPanel(); var layout = Fake( (CharacterStatController.FooterTitleId, title), (CharacterStatController.ListBoxId, list)); CharacterSheet Sheet() => new() { Endurance = 180, AttributeBaseValues = [0, 200, 0, 0, 0, 0] }; CharacterStatController.Bind(layout, Sheet); Descendants(list).OfType().ToList()[1].OnClick!(); // Endurance = index 1 Assert.Equal("Endurance: 180 (-20)", title.LinesProvider()[0].Text); } [Fact] public void RowClick_AttributeZeroDelta_NoParenthetical() { var title = new UiText(); var list = new UiPanel(); var layout = Fake( (CharacterStatController.FooterTitleId, title), (CharacterStatController.ListBoxId, list)); CharacterSheet Sheet() => new() { Strength = 200, AttributeBaseValues = [200, 0, 0, 0, 0, 0] }; CharacterStatController.Bind(layout, Sheet); Descendants(list).OfType().ToList()[0].OnClick!(); Assert.Equal("Strength: 200", title.LinesProvider()[0].Text); } [Fact] public void SkillClick_VitaeOnly_FooterTitleShowsVitaeParenthetical() { // User-reported oracle (ISSUES.md #267): a base-303 skill under 33% // vitae shows the current (reduced) level with "(-100)". var list = new UiPanel { Width = 300 }; var title = new UiText(); var layout = Fake( (CharacterStatController.ListBoxId, list), (CharacterStatController.FooterTitleId, title)); CharacterSheet Sheet() => VitaeSkillSheet(currentLevel: 203, baseLevel: 303, vitaeModifier: -100); CharacterStatController.Bind(layout, Sheet, spriteResolve: id => (id, 16, 16)); ClickTab(layout, left: 92f); SkillRows(list)[0].OnClick!(); Assert.Equal("Test Skill: 203 (-100)", title.LinesProvider()[0].Text); } [Fact] public void SkillClick_VitaePlusBuff_FooterTitleShowsBothParentheticals() { var list = new UiPanel { Width = 300 }; var title = new UiText(); var layout = Fake( (CharacterStatController.ListBoxId, list), (CharacterStatController.FooterTitleId, title)); // CurrentLevel=251, base=300, vitae modifier=-99 (300*0.67-300). // Buff-only delta = (251 - (-99)) - 300 = 50. CharacterSheet Sheet() => VitaeSkillSheet(currentLevel: 251, baseLevel: 300, vitaeModifier: -99); CharacterStatController.Bind(layout, Sheet, spriteResolve: id => (id, 16, 16)); ClickTab(layout, left: 92f); SkillRows(list)[0].OnClick!(); Assert.Equal("Test Skill: 251 (-99) (+50)", title.LinesProvider()[0].Text); } [Fact] public void SkillFooter_UsesAuthoredPaletteForVitaeAndBuffRuns() { Vector4 normal = new(0.1f, 0.1f, 0.1f, 1f); Vector4 buff = new(0.2f, 0.3f, 0.4f, 1f); Vector4 debuff = new(0.5f, 0.6f, 0.7f, 1f); Vector4 vitae = new(127f / 255f, 1f, 1f, 1f); var list = new UiPanel { Width = 300 }; var title = new UiText { FontColorPalette = [normal, buff, debuff, vitae], }; var layout = Fake( (CharacterStatController.ListBoxId, list), (CharacterStatController.FooterTitleId, title)); CharacterSheet Sheet() => VitaeSkillSheet( currentLevel: 251, baseLevel: 300, vitaeModifier: -99); CharacterStatController.Bind( layout, Sheet, spriteResolve: id => (id, 16, 16)); ClickTab(layout, left: 92f); SkillRows(list)[0].OnClick!(); IReadOnlyList runs = title.RunsProvider!(); Assert.Equal(3, runs.Count); Assert.Equal(("Test Skill: 251", normal), (runs[0].Text, runs[0].Color)); Assert.Equal((" (-99)", vitae), (runs[1].Text, runs[1].Color)); Assert.Equal((" (+50)", buff), (runs[2].Text, runs[2].Color)); } [Fact] public void SkillRow_ValueAndColorFollowLiveSheetWithoutTabRebuild() { var list = new UiPanel { Width = 300 }; var layout = Fake((CharacterStatController.ListBoxId, list)); CharacterSheet sheet = VitaeSkillSheet( currentLevel: 300, baseLevel: 300, vitaeModifier: 0); CharacterStatController.Bind( layout, () => sheet, spriteResolve: id => (id, 16, 16)); ClickTab(layout, left: 92f); UiText value = SkillRows(list)[0].Children.OfType().ToList()[2]; Assert.Equal("300", value.LinesProvider()[0].Text); Assert.Equal(Vector4.One, value.LinesProvider()[0].Color); sheet = VitaeSkillSheet( currentLevel: 350, baseLevel: 300, vitaeModifier: 0); Assert.Equal("350", value.LinesProvider()[0].Text); Assert.Equal(new Vector4(0f, 1f, 0f, 1f), value.LinesProvider()[0].Color); } [Fact] public void SkillClick_ZeroDelta_NoParentheticals() { var list = new UiPanel { Width = 300 }; var title = new UiText(); var layout = Fake( (CharacterStatController.ListBoxId, list), (CharacterStatController.FooterTitleId, title)); CharacterSheet Sheet() => VitaeSkillSheet(currentLevel: 300, baseLevel: 300, vitaeModifier: 0); CharacterStatController.Bind(layout, Sheet, spriteResolve: id => (id, 16, 16)); ClickTab(layout, left: 92f); SkillRows(list)[0].OnClick!(); Assert.Equal("Test Skill: 300", title.LinesProvider()[0].Text); } private static CharacterSheet VitaeSkillSheet(int currentLevel, int baseLevel, int vitaeModifier) => new() { SkillCredits = 0, UnassignedXp = 1_000_000, Skills = [ new CharacterSkill( 200u, "Test Skill", 0x06000001u, CharacterSkillAdvancementClass.Trained, BaseLevel: baseLevel, CurrentLevel: currentLevel, UsableUntrained: true, TrainedCost: 0, SpecializedCost: 0, RaiseCost: 100, Raise10Cost: 1000, VitaeModifier: vitaeModifier), ], }; // ── 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)"); } [Fact] public void Bind_TotalXpValue_IsRightAligned() { var value = new UiText { Centered = true }; var layout = Fake((CharacterStatController.TotalXpId, value)); CharacterStatController.Bind(layout, SampleData.SampleCharacter); Assert.Equal((1_250_000_000L).ToString("N0"), value.LinesProvider()[0].Text); Assert.False(value.Centered); Assert.True(value.RightAligned); } // ── 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); Descendants(list).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); Descendants(list).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()); } [Fact] public void ProductionFixture_MountedPagesUseRetailListFooterAndScrollbarReflow() { var layout = FixtureLoader.LoadCharacter(); CharacterStatController.Bind( layout, SampleData.SampleCharacter, spriteResolve: id => (id, 16, 16)); ApplyLayoutPass(layout.Root); var page = layout.Root.Children.Single( e => e.DatElementId == CharacterStatController.AttributesPageId); var list = Descendants(page).Single( e => e.DatElementId == CharacterStatController.ListBoxId); var statLayout = list.Parent!; var scrollbar = statLayout.Children.OfType().Single( e => e.DatElementId == CharacterStatController.ListScrollbarId); var divider = statLayout.Children.Single( e => e.DatElementId == CharacterStatController.ListDividerId); var footers = statLayout.Children.Where( e => e.DatElementId is CharacterStatController.FooterStateAId or CharacterStatController.FooterStateBId or CharacterStatController.FooterStateCId).ToList(); Assert.Equal((112f, 398f), (list.Top, list.Height)); Assert.Equal((112f, 398f), (scrollbar.Top, scrollbar.Height)); Assert.Equal((510f, 7f), (divider.Top, divider.Height)); Assert.Equal(3, footers.Count); Assert.All(footers, footer => Assert.Equal((520f, 55f), (footer.Top, footer.Height))); // CT6 (2026-08-25): the shared scrollbar is now always BOUND // (.Model set, .Visible = true), on Attributes as well as Skills — // retail's authored gutter (0x1000023E) is reserved regardless of // content, so no per-tab visibility toggle is needed. CT6 fix round // (S2 correction): 0x1000023E authors 0x79 (HideWhenDisabled) TRUE // (fixture-verified), so a FITTING list actually HIDES the bar // entirely via IsPresentationVisible — it does not draw a full-track // "disabled" thumb the way the previous wording here implied. This // fixture mounts the raw 600px-tall content directly (no // RetailWindowFrame.Mount, no S4 372px default), so the 9 // attribute/vital rows (180px content) fit comfortably inside its // 398px view and HasOverflow is false here — a shrunk window (or the // real S4-corrected 372px mount default) is what flips it true and // makes the bar presentation-visible (see the window-level resize // test, which asserts IsPresentationVisible both ways). Assert.True( scrollbar.Visible, $"attributes-tab scrollbar: model={scrollbar.Model is not null}, " + $"resolve={scrollbar.SpriteResolve is not null}, track=0x{scrollbar.TrackSprite:X8}"); Assert.NotNull(scrollbar.Model); ClickTab(layout, left: 92f); Assert.True(scrollbar.Visible); Assert.NotNull(scrollbar.Model); Assert.NotNull(scrollbar.SpriteResolve); Assert.Equal(0x06004C5Fu, scrollbar.TrackSprite); // Retail seating (2026-08-24): top = the INCREMENT designee's // UP-arrow art, bottom = the DECREMENT designee's DOWN-arrow. Assert.Equal(RetailScrollbarChrome.UpNormal, scrollbar.UpSprite); Assert.Equal(RetailScrollbarChrome.DownNormal, scrollbar.DownSprite); Assert.Equal(RetailScrollbarChrome.UpRollover, scrollbar.UpRolloverSprite); Assert.Equal(RetailScrollbarChrome.ThumbMidRollover, scrollbar.ThumbRolloverSprite); } /// /// CT6 (2026-08-25): the window-level resize contract. Live-probed ground /// truth (against layout 0x2100006E, host 0x100005FE): /// UIElement_Resizebar::StartMouseResizing @0x0046B7E0 calls /// UIElement::StartResizing(this->GetParent(), ...) — the bottom /// Resizebar (0x10000660) is a DIRECT CHILD of the shared /// gmPanelUI host, not of the content parent — and /// UIElement::MouseResizeElement @0x00461130 reads /// GetAttribute_Int(this, 0x3C..0x3F) off that SAME element on every /// mouse-move. The host authors MinWidth=310 MinHeight=372 MaxWidth=310 /// (fixed — no horizontal Resizebar authored) MaxHeight=1000. This test /// mounts the real character content through /// with a synthetic /// carrying exactly those four probed values (the same synthetic- /// pattern already uses), matching /// production's actual wire-up in RetailUiRuntime.MountCharacter /// (which sources the SAME host element live from the DAT). /// /// CT6 fix round (2026-08-25, BLOCKER B1): the host source IS /// retail's own outer frame — its authored 310/372/310/1000 are already /// chrome-inclusive (5px bevel + 300x362 content + 5px = 310x372) — so /// this test now sets /// the same way production's MountCharacter does, and the mounted /// clamp equals the host's four raw values EXACTLY, with no chrome inset /// composed on top. /// [Fact] public void CharacterWindow_ResizesYWithinAuthoredHostClamp_AndReflowsListAndScrollbar() { var layout = FixtureLoader.LoadCharacter(); CharacterStatController.Bind( layout, SampleData.SampleCharacter, spriteResolve: id => (id, 16, 16)); // Tall enough that ConstrainResizeToParent (production's own setting) // never becomes the binding constraint below — this test isolates the // AUTHORED host clamp, not the desktop-edge clamp. var root = new UiRoot { Width = 1280, Height = 1400 }; RetailWindowHandle handle = RetailWindowFrame.Mount( root, layout.Root, id => (id, 16, 16), new RetailWindowFrame.Options { WindowName = WindowNames.Character, Chrome = RetailWindowChrome.NineSlice, Left = 540f, Top = 18f, ResizeX = false, ResizeY = true, ResizableEdges = ResizeEdges.Bottom, ConstrainResizeToParent = true, DatConstraintSource = HostConstraints(), DatConstraintSourceIsOuterFrame = true, ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom, }); ApplyLayoutPass(handle.OuterFrame); var page = layout.Root.Children.Single( e => e.DatElementId == CharacterStatController.AttributesPageId); var list = Descendants(page).Single( e => e.DatElementId == CharacterStatController.ListBoxId); var statLayout = list.Parent!; var scrollbar = statLayout.Children.OfType().Single( e => e.DatElementId == CharacterStatController.ListScrollbarId); var footer = statLayout.Children.First( e => e.DatElementId == CharacterStatController.FooterStateAId); var viewport = Assert.IsType(list.Children.Single()); // CT6 fix round (BLOCKER B1): the host source IS the outer frame // already (DatConstraintSourceIsOuterFrame = true above), so no // chrome inset is composed on top — the mounted clamp is EXACTLY the // DAT's own 310/372/310/1000. Assert.Equal(310f, handle.OuterFrame.MinWidth); Assert.Equal(310f, handle.OuterFrame.MaxWidth); Assert.Equal(372f, handle.OuterFrame.MinHeight); Assert.Equal(1000f, handle.OuterFrame.MaxHeight); float originalOuterHeight = handle.Height; float originalListHeight = list.Height; float originalFooterBottomGap = statLayout.Height - (footer.Top + footer.Height); Assert.NotNull(scrollbar.Model); viewport.LayoutScrollableChildren(); Assert.False( scrollbar.Model!.HasOverflow, "the fixture's authored default height fits all 9 rows without scrolling"); // Request far below the authored minimum — the clamp must hold at // 372, not the requested value. handle.ResizeTo(handle.Width, 50f); Assert.Equal(372f, handle.Height); ApplyLayoutPass(handle.OuterFrame); viewport.LayoutScrollableChildren(); Assert.True(list.Height < originalListHeight, "the stat list must shrink with the window"); Assert.Equal((int)MathF.Floor(viewport.Height), scrollbar.Model!.ViewHeight); Assert.True( scrollbar.Model!.HasOverflow, "9 rows (180px content) must overflow the shrunk view"); // S2 correction: 0x1000023E authors 0x79 (HideWhenDisabled) TRUE — // an overflowing list must show its scrollbar VISIBLE and // interactive, not merely bound-but-invisible. Assert.True( scrollbar.IsPresentationVisible, "an overflowing list's scrollbar must be presentation-visible (interactive), not hidden"); // The footer stays bottom-docked: same distance from the stat // layout's own bottom edge before and after the shrink. float shrunkFooterBottomGap = statLayout.Height - (footer.Top + footer.Height); Assert.Equal(originalFooterBottomGap, shrunkFooterBottomGap, precision: 2); // Request far above the authored maximum — the clamp must hold at // 1000, not the requested value. handle.ResizeTo(handle.Width, 5000f); Assert.Equal(1000f, handle.Height); ApplyLayoutPass(handle.OuterFrame); viewport.LayoutScrollableChildren(); Assert.False( scrollbar.Model!.HasOverflow, "growing well past the content height restores no-overflow"); // S2 correction: HideWhenDisabled TRUE means a fitting list HIDES its // scrollbar entirely — it does NOT leave a full-track "disabled" // thumb visible. Assert.False( scrollbar.IsPresentationVisible, "content that fits after growing large must HIDE the scrollbar (0x79 HideWhenDisabled), not show a disabled thumb"); // Growing back to the ORIGINAL authored size restores the original // list height and the no-overflow state. handle.ResizeTo(handle.Width, originalOuterHeight); ApplyLayoutPass(handle.OuterFrame); viewport.LayoutScrollableChildren(); Assert.Equal(originalOuterHeight, handle.Height); Assert.Equal(originalListHeight, list.Height, precision: 2); Assert.False(scrollbar.Model!.HasOverflow); } /// Synthetic authored 0x3C..0x3F constraints matching the /// 2026-08-25 live probe of the shared gmPanelUI host /// (0x100005FE in LayoutDesc 0x2100006E): MinWidth=310 /// MinHeight=372 MaxWidth=310 (fixed — no horizontal Resizebar authored) /// MaxHeight=1000. Same synthetic- shape as /// 's own private helper. private static ElementInfo HostConstraints() { var info = new ElementInfo(); var direct = new UiStateInfo { Id = UiStateInfo.DirectStateId }; direct.Properties.Values[0x3Fu] = new UiPropertyValue { Kind = UiPropertyKind.Integer, IntegerValue = 310, }; direct.Properties.Values[0x3Eu] = new UiPropertyValue { Kind = UiPropertyKind.Integer, IntegerValue = 372, }; direct.Properties.Values[0x3Du] = new UiPropertyValue { Kind = UiPropertyKind.Integer, IntegerValue = 310, }; direct.Properties.Values[0x3Cu] = new UiPropertyValue { Kind = UiPropertyKind.Integer, IntegerValue = 1000, }; info.States[UiStateInfo.DirectStateId] = direct; return info; } /// /// 2026-08-24 owner report: "hovering the scrollbar and arrows does /// nothing" — root-level hover through the REAL mounted character /// fixture (production element tree, overlays and z-order included). /// [Fact] public void ProductionFixture_HoveringTheSkillScrollbar_SelectsRolloverMedia() { var layout = FixtureLoader.LoadCharacter(); CharacterStatController.Bind( layout, SampleData.SampleCharacter, spriteResolve: id => (id, 16, 16)); var root = new UiRoot { Width = 800f, Height = 600f }; root.AddChild(layout.Root); ApplyLayoutPass(layout.Root); ClickTab(layout, left: 92f); var page = layout.Root.Children.Single( e => e.DatElementId == CharacterStatController.AttributesPageId); var list = Descendants(page).Single( e => e.DatElementId == CharacterStatController.ListBoxId); var scrollbar = list.Parent!.Children.OfType().Single( e => e.DatElementId == CharacterStatController.ListScrollbarId); Assert.True(scrollbar.Visible); // The headless harness never measures row content; give the REAL // bound model an overflowing extent so the bar is enabled the way // a populated skills list is in production. Assert.NotNull(scrollbar.Model); scrollbar.Model!.ContentHeight = 800; scrollbar.Model.ViewHeight = 398; Assert.False(scrollbar.IsModelDisabled); var screen = scrollbar.ScreenPosition; // Over the up arrow (5px into the 16px top button). root.OnMouseMove((int)(screen.X + 8f), (int)(screen.Y + 5f)); Assert.Equal( RetailScrollbarChrome.UpRollover, scrollbar.ActiveStartSpriteForTest); // Over the thumb (just below the up button; thumb starts at track top). root.OnMouseMove((int)(screen.X + 8f), (int)(screen.Y + 24f)); Assert.Equal( RetailScrollbarChrome.ThumbMidRollover, scrollbar.ActiveThumbSpriteForTest); } // ── Helpers ────────────────────────────────────────────────────────────── private static void ClickTab(ImportedLayout layout, float left) { uint id = left switch { 0f => CharacterStatController.TabAttribId, 92f => CharacterStatController.TabSkillsId, _ => CharacterStatController.TabTitlesId, }; var tab = Assert.IsType(layout.FindElement(id)); tab.OnClick!(); } private static void ApplyLayoutPass(UiElement parent) { foreach (var child in parent.Children) { child.ApplyAnchor(parent.Width, parent.Height); ApplyLayoutPass(child); } } 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 retail normal/ghosted states. private static UiButton MakeButton(uint id = 0u) { var info = new ElementInfo { Id = id, Type = 1 }; info.StateMedia["Normal"] = (1u, 1); info.StateMedia["Ghosted"] = (2u, 1); return new UiButton(info, static _ => (0u, 0, 0)); } private static UiText MakeTab(uint id, float left) { var info = new ElementInfo { Id = id, Type = 12, X = left, Width = 92f, Height = 25f, DefaultStateId = RetailUiStateIds.Closed, DefaultStateName = "Closed", }; info.States[RetailUiStateIds.Closed] = new UiStateInfo { Id = RetailUiStateIds.Closed, Name = "Closed", PassToChildren = true, }; info.States[RetailUiStateIds.Open] = new UiStateInfo { Id = RetailUiStateIds.Open, Name = "Open", PassToChildren = true, }; return Assert.IsType(DatWidgetFactory.Create(info, static _ => (0u, 0, 0), null)); } 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; } foreach ((uint id, float left) in new[] { (CharacterStatController.TabAttribId, 0f), (CharacterStatController.TabSkillsId, 92f), (CharacterStatController.TabTitlesId, 184f), }) { if (dict.ContainsKey(id)) continue; UiText tab = MakeTab(id, left); root.AddChild(tab); dict[id] = tab; } return new ImportedLayout(root, dict); } }