acdream/tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs
Erik 03e073b748 feat(ui): Campaign CT slice CT3 — Titles page live via standard GUI classes
Titles tab (AP-109's known-inert gap) now switches to a real page and
CharacterTitlesController binds it entirely through UiTemplateListBox/
UiScrollbar/UiButton — zero bespoke widgets, matching every other
social/options row-list page in this codebase.

Retail anchors: gmCharacterTitleUI::PostInit @0x0049A610; AddTitleToList
@0x0049A840 + FindSortedInsertPosition @0x0049A760 (rows sorted by
resolved display text — this port rebuilds the full sorted set on every
change rather than a positional splice, since UiTemplateListBox has no
insert-at-index primitive and no other consumer needs one either);
InfoRegion::SetState(selected?6:1) (row Highlight/DirectState swap, the
same mechanism CT1's SEALED VERDICT confirmed for the stat rows);
UpdateButtons @0x0049A500 CORRECTED direction (Ghosted unless a row is
selected whose id differs from the current display title — no selection
IS the Ghosted case); Refresh @0x0049abc0 (display-title text, including
the hardcoded "Unknown" fallback, refreshed on both TableReplaced and
DisplayTitleChanged per CT2's review anchor 1); Event_SetDisplayCharacterTitle
@0x006a5720 (wire-only TitleSet 0x002C send, no local mutation).

CharacterStatController.Bind now three-way switches Attributes/Skills/
Titles — Titles is a genuinely separate, non-duplicated page container
(CT1 ground truth §3), unlike Attributes/Skills which share one mounted
page and only rebind content.

The two page captions (0x1000052E/0x10000531) are left untouched:
LayoutImporter.BuildText already resolves every element's authored
StringInfo caption at import time, so no controller-side string lookup
was added.

New IGameRuntimeCommands.SetTitle seam on DeferredGameRuntimeStateCommands
(InteractionUiRuntimeSources.cs) mirrors the existing Advance() shape.
CharacterRuntimeBindings gains Titles/TitleResolver/SendSetTitle;
CharacterTitleResolver (CT2) is constructed once at composition time and
its .Resolve method group is passed to the controller as a delegate
(not the concrete DAT-backed type) so the controller stays hermetically
testable without a live IDatReaderWriter.

Tests (tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs):
binding-seam coverage against the REAL committed character_2100002E.json
fixture (verified this session to already carry the Titles page subtree,
including the ListBox's own authored TemplateList=[(0x2100005E,
0x10000536)] entry — RowTemplateResolver_ReceivesTheFixturesOwnAuthoredTemplateIds
proves the controller reads that authored pair, not a hardcoded one); a
hand-authored ElementInfo standing in only for the row template itself
(a separate LayoutDesc with no committed fixture yet — CT1 was a live-DAT
probe only); sorted-row order, Unknown fallback, row selection/highlight,
the ghost truth table (no selection / selected==display / selected!=
display), click-sends-exactly-one-SetTitle-and-mutates-nothing,
click-while-ghosted-sends-nothing, TableReplaced rebuild (including
selection survival when the id is still earned), TitleAdded single-row
growth, DisplayTitleChanged text+ghost refresh, and Dispose
unsubscription. CharacterStatControllerTests updated for the Titles tab
no longer being ClickThrough, plus a new tab-switch visibility test.

Register: amends AP-109 (docs/architecture/retail-divergence-register.md)
to record the Titles-page half as LIVE; the header identity block and
luminance fields remain open for CT4.

Suites: full solution 15,405 tests / 0 skips (App 6,130) green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 22:59:40 +02:00

414 lines
16 KiB
C#

using System.Numerics;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Runtime;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Campaign CT slice CT3: hermetic (no DAT, no live runtime) tests for
/// <see cref="CharacterTitlesController"/>. Binding-seam coverage
/// (<c>feedback_test_the_binding_seam.md</c>) uses the REAL committed
/// <c>character_2100002E.json</c> fixture — verified (2026-08-24) to already
/// carry the whole Titles page subtree, including the ListBox's own
/// authored <c>TemplateList</c> entry pointing at <c>(0x2100005E,
/// 0x10000536)</c> — 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.
/// </summary>
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<uint, string> Names;
public required List<uint> SentTitleIds;
public required CharacterTitlesController Controller;
public IReadOnlyList<UiElement> 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<UiScrollbar>(
h.Layout.FindElement(h.ListBox.ScrollbarElementId));
Assert.Same(h.ListBox.Scroll, scrollbar.Model);
}
[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<uint> earnedIds,
uint displayTitleId,
Dictionary<uint, string>? names = null,
Func<uint, uint, UiElement?>? rowResolver = null)
{
ImportedLayout layout = FixtureLoader.LoadCharacter();
var titles = new RuntimeCharacterTitleState();
titles.ReplaceTable(displayTitleId, earnedIds.ToArray());
Dictionary<uint, string> resolvedNames = names ?? new Dictionary<uint, string>();
var sent = new List<uint>();
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<UiTemplateListBox>(
layout.FindElement(CharacterTitlesController.TitleListBoxId));
var displayText = Assert.IsType<UiText>(
layout.FindElement(CharacterTitlesController.CurrentDisplayTitleTextId));
var button = Assert.IsType<UiButton>(
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_ShowsRetailUnknownLiteral()
{
Harness h = BindWithEarnedTitles(
[99u],
displayTitleId: 0u,
names: []);
UiElement row = Assert.Single(h.Rows);
Assert.Equal("Unknown", 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!();
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);
}
[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_SelectedTitleStillEarned_KeepsSelectionHighlighted()
{
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);
// A resend of the SAME table (retail's own Refresh() is
// unconditional — CT2 review anchor 1) must not silently desync the
// ghost state from the still-valid selection.
h.Titles.ReplaceTable(0u, [1u, 5u]);
UiElement rebuiltLifeMageRow = h.Rows.Single(r => h.RowText(r) == "Life Mage");
Assert.Equal(RowHighlightSprite, h.RowMedia(rebuiltLifeMageRow));
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);
}
}