using System.Numerics; using AcDream.App.UI; using AcDream.App.UI.Layout; using AcDream.Runtime; namespace AcDream.App.Tests.UI.Layout; /// /// Campaign CT slice CT3: hermetic (no DAT, no live runtime) tests for /// . Binding-seam coverage /// (feedback_test_the_binding_seam.md) uses the REAL committed /// character_2100002E.json fixture — verified (2026-08-24) to already /// carry the whole Titles page subtree, including the ListBox's own /// authored TemplateList entry pointing at (0x2100005E, /// 0x10000536) — so every element this controller binds except the row /// template ITSELF (a separate LayoutDesc CT1 could only reach via a live /// DAT probe, with no committed fixture yet) comes from the real imported /// tree, not a hand-built stand-in. /// public sealed class CharacterTitlesControllerTests { // Row template ground truth (docs/research/2026-08-24-campaign-ct-dat- // ground-truth.md §3): LayoutDesc 0x2100005E, element 0x10000536 — a // 270x24 Type-3 container with DirectState/Highlight media and one // Type-0xC text child (0x10000537). private const uint RowTemplateLayoutId = 0x2100005Eu; private const uint RowTemplateElementId = 0x10000536u; private const uint RowTextId = 0x10000537u; private const uint RowNormalSprite = 0x06004CCAu; private const uint RowHighlightSprite = 0x06001AAFu; private static ElementInfo BuildRowTemplateInfo() { var info = new ElementInfo { Id = RowTemplateElementId, Type = 3u, Width = 270f, Height = 24f, }; info.StateMedia[""] = (RowNormalSprite, 3); info.StateMedia["Highlight"] = (RowHighlightSprite, 1); info.Children.Add(new ElementInfo { Id = RowTextId, Type = 0xCu, Width = 270f, Height = 24f, HJustify = HJustify.Left, FontColor = Vector4.One, }); return info; } private static UiElement? FakeRowTemplateResolver(uint layoutId, uint elementId) => LayoutImporter.Build(BuildRowTemplateInfo(), static _ => (0u, 0, 0), null).Root; private sealed class Harness { public required ImportedLayout Layout; public required UiTemplateListBox ListBox; public required UiText DisplayText; public required UiButton SetDisplayButton; public required RuntimeCharacterTitleState Titles; public required Dictionary Names; public required List SentTitleIds; public required CharacterTitlesController Controller; public IReadOnlyList Rows => ListBox.ViewportForTest?.Children ?? []; public string RowText(UiElement row) => ((UiText)UiElement.FindDescendant(row, RowTextId)!).LinesProvider().Single().Text; public uint RowMedia(UiElement row) => ((UiDatElement)row).ActiveMedia().File; } // ── Binding seam ───────────────────────────────────────────────────── [Fact] public void Bind_FindsEveryTitlesPageElement_InTheRealImportedFixture() { Harness h = BindWithEarnedTitles([], displayTitleId: 0u); Assert.NotNull(h.Controller); Assert.Equal(CharacterTitlesController.TitleListBoxId, h.ListBox.DatElementId); Assert.Equal(CharacterTitlesController.CurrentDisplayTitleTextId, h.DisplayText.DatElementId); Assert.Equal(CharacterTitlesController.SetDisplayButtonId, h.SetDisplayButton.DatElementId); // The authored scrollbar (0x10000533, the ListBox's own // ScrollbarElementId) must actually be wired to the list's scroll // model, not merely present. var scrollbar = Assert.IsType( h.Layout.FindElement(h.ListBox.ScrollbarElementId)); Assert.Same(h.ListBox.Scroll, scrollbar.Model); } [Fact] public void Fixture_PageCaptions_ResolveToNonEmptyText() { // Pins this class's own remarks claim (CT3 fix round item 5b): // LayoutImporter.BuildText already resolves every element's // authored StringInfo caption at import time, so the Titles page's // two static captions (0x1000052E/0x10000531) must actually carry a // resolvable authored StringInfo -- not silently come through as // empty/missing text -- even though this controller never touches // either element itself. FixtureLoader.LoadCharacter() passes NO // string resolver (it needs no live DAT for structural conformance // checks elsewhere), so this test rebuilds the SAME committed // fixture with a stub resolver that stands in for // DatStringResolver.Resolve -- exercising the real // ResolveAuthoredString → stringResolve pipeline // (DatWidgetFactory.cs) without needing a live StringTable. static string? StubResolve(UiStringInfoValue info) => info.TableId != 0u && info.StringId != 0u ? "" : null; ImportedLayout layout = LayoutImporter.Build( FixtureLoader.LoadCharacterInfos(), static _ => (0u, 0, 0), null, stringResolve: StubResolve); var currentTitleCaption = Assert.IsType(layout.FindElement(0x1000052Eu)); var titlesEarnedCaption = Assert.IsType(layout.FindElement(0x10000531u)); Assert.Equal("", currentTitleCaption.LinesProvider()[0].Text); Assert.Equal("", titlesEarnedCaption.LinesProvider()[0].Text); } [Fact] public void Bind_MissingListBox_ReturnsNullWithoutThrowing() { var root = new UiPanel(); var titles = new RuntimeCharacterTitleState(); CharacterTitlesController? controller = CharacterTitlesController.Bind( root, titles, static _ => null, FakeRowTemplateResolver, static _ => new RuntimeCommandResult(RuntimeCommandStatus.Inactive, default)); Assert.Null(controller); } [Fact] public void RowTemplateResolver_ReceivesTheFixturesOwnAuthoredTemplateIds() { // The REAL fixture's ListBox (0x10000532) authors TemplateList = // [(0x2100005E, 0x10000536)] (dat property 0x64) — this proves the // controller's AddItemFromTemplateList(0) call actually reads that // authored entry rather than a hardcoded pair of its own. var seen = new List<(uint LayoutId, uint ElementId)>(); UiElement? Recording(uint layoutId, uint elementId) { seen.Add((layoutId, elementId)); return FakeRowTemplateResolver(layoutId, elementId); } BindWithEarnedTitles( [1u], displayTitleId: 0u, names: new() { [1u] = "Adventurer" }, rowResolver: Recording); (uint layoutId, uint elementId) = Assert.Single(seen); Assert.Equal(RowTemplateLayoutId, layoutId); Assert.Equal(RowTemplateElementId, elementId); } private static Harness BindWithEarnedTitles( IReadOnlyList earnedIds, uint displayTitleId, Dictionary? names = null, Func? rowResolver = null) { ImportedLayout layout = FixtureLoader.LoadCharacter(); var titles = new RuntimeCharacterTitleState(); titles.ReplaceTable(displayTitleId, earnedIds.ToArray()); Dictionary resolvedNames = names ?? new Dictionary(); var sent = new List(); RuntimeCommandResult SendSetTitle(uint id) { sent.Add(id); return new RuntimeCommandResult(RuntimeCommandStatus.Accepted, default); } string? ResolveTitle(uint id) => resolvedNames.TryGetValue(id, out string? name) ? name : null; CharacterTitlesController? controller = CharacterTitlesController.Bind( layout.Root, titles, ResolveTitle, rowResolver ?? FakeRowTemplateResolver, SendSetTitle); Assert.NotNull(controller); var listBox = Assert.IsType( layout.FindElement(CharacterTitlesController.TitleListBoxId)); var displayText = Assert.IsType( layout.FindElement(CharacterTitlesController.CurrentDisplayTitleTextId)); var button = Assert.IsType( layout.FindElement(CharacterTitlesController.SetDisplayButtonId)); return new Harness { Layout = layout, ListBox = listBox, DisplayText = displayText, SetDisplayButton = button, Titles = titles, Names = resolvedNames, SentTitleIds = sent, Controller = controller!, }; } // ── Row sort + content ────────────────────────────────────────────── [Fact] public void Rows_AreSortedAlphabeticallyByResolvedTitleText() { Harness h = BindWithEarnedTitles( [13u, 5u, 1u], displayTitleId: 0u, names: new() { [1u] = "Adventurer", [5u] = "Life Mage", [13u] = "War Mage", }); Assert.Equal(3, h.Rows.Count); Assert.Equal( ["Adventurer", "Life Mage", "War Mage"], h.Rows.Select(h.RowText)); } [Fact] public void Rows_UnresolvedTitle_ProducesNoRow() { // AddTitleToList @0x0049A840 early-outs (@0x0049a873/@0x0049a914): // retail never creates a row for an id GetCharacterTitleFromID // fails to resolve -- "Unknown" is exclusively the display-title // text's own Refresh fallback literal (below), never a row's // (CT3 fix round -- this was previously ported backwards). Harness h = BindWithEarnedTitles( [99u], displayTitleId: 0u, names: []); Assert.Empty(h.Rows); } [Fact] public void Rows_TitleIdZero_ProducesNoRow() { // Same early-out (@0x0049a873), the OTHER guarded case: retail // never creates a row for id 0 even if a resolver were somehow // willing to answer for it. Harness h = BindWithEarnedTitles( [0u, 1u], displayTitleId: 0u, names: new() { [0u] = "Should Never Appear", [1u] = "Adventurer" }); UiElement row = Assert.Single(h.Rows); Assert.Equal("Adventurer", h.RowText(row)); } // ── Selection + highlight ───────────────────────────────────────────── [Fact] public void SelectingARow_AppliesHighlightMedia_AndDeselectsTheOthers() { Harness h = BindWithEarnedTitles( [1u, 5u], displayTitleId: 0u, names: new() { [1u] = "Adventurer", [5u] = "Life Mage" }); UiElement first = h.Rows[0]; UiElement second = h.Rows[1]; ((UiDatElement)first).OnClick!(); Assert.Equal(RowHighlightSprite, h.RowMedia(first)); Assert.Equal(RowNormalSprite, h.RowMedia(second)); ((UiDatElement)second).OnClick!(); Assert.Equal(RowNormalSprite, h.RowMedia(first)); Assert.Equal(RowHighlightSprite, h.RowMedia(second)); } // ── Ghost truth table (UpdateButtons @0x0049A500, CORRECTED direction) ─ [Fact] public void Ghost_NoSelection_ButtonIsGhosted() { Harness h = BindWithEarnedTitles([1u], displayTitleId: 0u, names: new() { [1u] = "Adventurer" }); Assert.False(h.SetDisplayButton.Enabled); } [Fact] public void Ghost_SelectedRowEqualsCurrentDisplayTitle_ButtonIsGhosted() { Harness h = BindWithEarnedTitles( [1u, 5u], displayTitleId: 5u, names: new() { [1u] = "Adventurer", [5u] = "Life Mage" }); // Sorted order: Adventurer(1), Life Mage(5) — select the row whose // id equals the current display title (5). UiElement lifeMageRow = h.Rows.Single(r => h.RowText(r) == "Life Mage"); ((UiDatElement)lifeMageRow).OnClick!(); Assert.False(h.SetDisplayButton.Enabled); } [Fact] public void Ghost_SelectedRowDiffersFromCurrentDisplayTitle_ButtonIsNormal() { Harness h = BindWithEarnedTitles( [1u, 5u], displayTitleId: 5u, names: new() { [1u] = "Adventurer", [5u] = "Life Mage" }); UiElement adventurerRow = h.Rows.Single(r => h.RowText(r) == "Adventurer"); ((UiDatElement)adventurerRow).OnClick!(); Assert.True(h.SetDisplayButton.Enabled); } // ── Click -> SetTitle wire send ──────────────────────────────────────── [Fact] public void ClickingSetDisplay_SendsExactlyOneSetTitleWithTheSelectedId_AndMutatesNothingLocally() { Harness h = BindWithEarnedTitles( [1u, 13u], displayTitleId: 1u, names: new() { [1u] = "Adventurer", [13u] = "War Mage" }); UiElement warMageRow = h.Rows.Single(r => h.RowText(r) == "War Mage"); ((UiDatElement)warMageRow).OnClick!(); List rowsBeforeClick = h.Rows.ToList(); h.SetDisplayButton.OnClick!(); Assert.Equal([13u], h.SentTitleIds); // No optimistic local mutation — CT2's own contract (ACE sends no // echo when re-setting the current title; the display title only // ever changes from a DisplayTitleChanged event). Assert.Equal(1u, h.Titles.DisplayTitleId); Assert.Equal("Adventurer", h.DisplayText.LinesProvider().Single().Text); // The click is wire-only: no RebuildRows, no selection change. The // row set is the SAME instances in the same order, and War Mage // stays selected/highlighted/enabled exactly as before the click. Assert.Equal(rowsBeforeClick, h.Rows); Assert.Equal(RowHighlightSprite, h.RowMedia(warMageRow)); Assert.True(h.SetDisplayButton.Enabled); } [Fact] public void ClickingSetDisplay_WhileGhosted_SendsNothing() { Harness h = BindWithEarnedTitles([1u], displayTitleId: 0u, names: new() { [1u] = "Adventurer" }); // No row selected -> Ghosted. A direct OnClick invocation bypasses // UiButton's own Enabled-gated event routing, so this exercises the // controller's own belt-and-braces re-check. h.SetDisplayButton.OnClick!(); Assert.Empty(h.SentTitleIds); } // ── Wire events ───────────────────────────────────────────────────── [Fact] public void TableReplaced_RebuildsRows_AndClearsSelection() { Harness h = BindWithEarnedTitles( [1u], displayTitleId: 0u, names: new() { [1u] = "Adventurer", [13u] = "War Mage" }); ((UiDatElement)h.Rows[0]).OnClick!(); Assert.True(h.SetDisplayButton.Enabled); // selected, differs from display(0) h.Titles.ReplaceTable(0u, [13u]); Assert.Equal(["War Mage"], h.Rows.Select(h.RowText)); // The previously-selected row no longer exists post-rebuild -> back // to the no-selection Ghosted state. Assert.False(h.SetDisplayButton.Enabled); } [Fact] public void TableReplaced_ClearsSelection_EvenWhenTheSelectedIdIsStillEarned() { // BLOCKER fix (CT3 fix round): Refresh @0x0049abc0 calls // SetSelectedItem(nullptr, 1) (@0x0049ac5a) UNCONDITIONALLY, before // it repopulates the list -- a still-earned selected id is no // defense. A byte-identical resend must still dark out the row and // re-ghost the button (this test used to assert the OPPOSITE and // was wrong). Harness h = BindWithEarnedTitles( [1u, 5u], displayTitleId: 0u, names: new() { [1u] = "Adventurer", [5u] = "Life Mage" }); UiElement lifeMageRow = h.Rows.Single(r => h.RowText(r) == "Life Mage"); ((UiDatElement)lifeMageRow).OnClick!(); Assert.True(h.SetDisplayButton.Enabled); // Same table resent (retail's own Refresh() is unconditional — // CT2 review anchor 1) — the id (5) is STILL earned afterward, yet // the selection must still be dropped. h.Titles.ReplaceTable(0u, [1u, 5u]); UiElement rebuiltLifeMageRow = h.Rows.Single(r => h.RowText(r) == "Life Mage"); Assert.Equal(RowNormalSprite, h.RowMedia(rebuiltLifeMageRow)); Assert.False(h.SetDisplayButton.Enabled); } [Fact] public void DisplayTitleChanged_ClearsSelection_EvenWhenTheSelectedIdIsStillEarned() { // The display-change twin of the TableReplaced test above: 0x002B's // display half is retail's OTHER Refresh() call site, so it carries // the same unconditional SetSelectedItem(nullptr, 1) (@0x0049ac5a). // This handler never calls RebuildRows (the row SET does not // change), so it specifically proves the highlight is re-applied // via ApplyRowHighlights even without a rebuild. Selecting a // DIFFERENT id than the one becoming the new display title isolates // this from the already-covered "selection == new display title" // ghost case (DisplayTitleChanged_UpdatesTextAndReevaluatesGhost): // id 1 remains earned and still differs from the new display id 5, // yet selection must still clear. Harness h = BindWithEarnedTitles( [1u, 5u], displayTitleId: 0u, names: new() { [1u] = "Adventurer", [5u] = "Life Mage" }); UiElement adventurerRow = h.Rows.Single(r => h.RowText(r) == "Adventurer"); ((UiDatElement)adventurerRow).OnClick!(); Assert.Equal(RowHighlightSprite, h.RowMedia(adventurerRow)); Assert.True(h.SetDisplayButton.Enabled); // selected(1) != display(0) h.Titles.ApplyUpdateTitle(5u, setAsDisplay: true); Assert.Equal(RowNormalSprite, h.RowMedia(adventurerRow)); Assert.False(h.SetDisplayButton.Enabled); } [Fact] public void TitleAdded_PreservesSelection() { // RecvNotice_AddCharacterTitle @0x0049a990 splices the new row into // mTitleList without ever touching m_pSelectedItem -- a DIFFERENT // retail method from Refresh, and the one case that must NOT clear // selection. This is the case most at risk from the blocker fix // above (it would be trivial to over-clear on every wire event). Harness h = BindWithEarnedTitles( [1u], displayTitleId: 0u, names: new() { [1u] = "Adventurer", [13u] = "War Mage" }); UiElement adventurerRow = Assert.Single(h.Rows); ((UiDatElement)adventurerRow).OnClick!(); Assert.Equal(RowHighlightSprite, h.RowMedia(adventurerRow)); Assert.True(h.SetDisplayButton.Enabled); h.Titles.ApplyUpdateTitle(13u, setAsDisplay: false); UiElement rebuiltAdventurerRow = h.Rows.Single(r => h.RowText(r) == "Adventurer"); Assert.Equal(RowHighlightSprite, h.RowMedia(rebuiltAdventurerRow)); Assert.True(h.SetDisplayButton.Enabled); } [Fact] public void TitleAdded_InsertsExactlyOneRow_PreservingExistingRowsInSortedOrder() { Harness h = BindWithEarnedTitles( [1u], displayTitleId: 0u, names: new() { [1u] = "Adventurer", [5u] = "Life Mage" }); Assert.Single(h.Rows); h.Titles.ApplyUpdateTitle(5u, setAsDisplay: false); Assert.Equal(["Adventurer", "Life Mage"], h.Rows.Select(h.RowText)); } [Fact] public void DisplayTitleChanged_UpdatesTextAndReevaluatesGhost() { Harness h = BindWithEarnedTitles( [1u, 5u], displayTitleId: 0u, names: new() { [1u] = "Adventurer", [5u] = "Life Mage" }); UiElement lifeMageRow = h.Rows.Single(r => h.RowText(r) == "Life Mage"); ((UiDatElement)lifeMageRow).OnClick!(); Assert.True(h.SetDisplayButton.Enabled); // selected(5) != display(0) // Simulates the server echo (0x002B UpdateTitle, setAsDisplay=true) // that a real Set-as-Display send would eventually produce. h.Titles.ApplyUpdateTitle(5u, setAsDisplay: true); Assert.Equal("Life Mage", h.DisplayText.LinesProvider().Single().Text); // Selection now equals the (new) current display title -> Ghosted. Assert.False(h.SetDisplayButton.Enabled); } [Fact] public void DisplayTitleText_UnresolvedId_ShowsRetailUnknownLiteral() { Harness h = BindWithEarnedTitles([1u], displayTitleId: 77u, names: new() { [1u] = "Adventurer" }); Assert.Equal("Unknown", h.DisplayText.LinesProvider().Single().Text); } [Fact] public void DisplayTitleText_NoDisplayTitleSet_ShowsRetailUnknownLiteral() { Harness h = BindWithEarnedTitles([1u], displayTitleId: 0u, names: new() { [1u] = "Adventurer" }); Assert.Equal("Unknown", h.DisplayText.LinesProvider().Single().Text); } // ── Lifecycle ─────────────────────────────────────────────────────── [Fact] public void Dispose_UnsubscribesFromTitleEvents() { Harness h = BindWithEarnedTitles([1u], displayTitleId: 0u, names: new() { [1u] = "Adventurer" }); h.Controller.Dispose(); // Must not throw, and must not rebuild the (now-orphaned) rows. h.Titles.ReplaceTable(0u, [1u, 5u]); Assert.Single(h.Rows); } }