acdream/tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs
Erik 025108a8aa fix(CT-GF1): review fix round — literal DrawHere clip shape, empty-clip cull, popup input routing
Applies all 11 items from the Opus dual-lens review of 989f6652 (0
blockers, 7 SHOULD-FIX, 4 NOTE):

- S2: UiElement.DrawSelfAndChildren now pushes the ambient clip right
  after PushAlpha and wraps OnDraw + the children walk +
  OnDrawAfterChildren in ONE block — the literal UIRegion::DrawHere
  @0x0069FA30 shape, which clips an element's OWN DrawSelf too, not
  just its children (UIElement_Text::DrawSelf @0x00467AA0 locks glyph
  blits to its own clipped surface rect; UIRegion::DrawSelf
  @0x0069F1A0 blits per clip rect). Deleted the two now-redundant
  ad-hoc self-clips this supersedes: UiText.DrawText and
  UiField.DrawMultiLine both pushed their own (0,0,Width,Height) —
  exactly what the new ambient clip already provides one level up.
  Kept UiButton.DrawBlockLabel's clip: it clips to LabelBox/ValueBox,
  an authored INNER sub-rect that can be smaller than and offset from
  the button's own full rect — a genuine narrower viewport, not a
  redundant duplicate.
- S3: deleted UiItemList's `ClipsChildren => CellWidth > 0f` override
  — correct under the old opt-in-false default, inverted under the
  new default-true (an unconfigured list would stop clipping instead
  of clipping like everything else).
- S4: pinned the escaped-popup input path end to end. New
  UiAncestorClipTests test mounts a menu inside a short window on a
  real UiRoot, opens it, and proves a click in the escaped popup
  region reaches the menu through UiRoot.PopupHit (a plain top-down
  walk is proven to reject the same point first). UiRoot.WantsMouse
  now also checks PopupHit — it previously only checked Captured/
  HitTestTopDown, so a game action could fire underneath an open
  dropdown's escaped region. OnMouseDown/OnScroll already routed
  through PopupHit first (#374); unchanged.
- S5: strengthened the Titles-divider regression test's positive
  half. The old assertion only checked SOME quad's Y fell in a band —
  vacuously true given other same-band content. Now asserts the
  divider's exact rect (X and Y), then diffs against the same rect
  with the divider hidden (Visible=false) to prove the quad was
  actually attributable to it.
- S1: added UiWindowDrawCaptureSweepTests — Character/Chat/Vendor/
  Options mounted through their real production Bind entry points
  with a non-zero sprite resolver, drawn via RecordingGpuDevice,
  asserting a per-window vertex floor (~40-45% of this session's
  observed baseline: Character 588, Chat 162, Vendor 54, Options 240)
  plus one key sprite id read LIVE off the bound controller/element
  (never hardcoded). Character's key sprite (RetailChromeSprites.
  TopEdge) specifically exercises OnDrawAfterChildren, the exact path
  S2's caution note flagged. Inventory/Paperdoll/social/map-house
  skipped — no single fixture-driven top-level Bind entry point.
- S6: added the CT-GF1 subsection to the campaign plan's ledger
  (989f6652 + this fix round; CT7 re-gate still owed).
- S7: UiRenderContext.PushClipUnbounded now resets to the CANVAS rect
  (0,0,ScreenSize), not null — retail's own popup region is
  SCREEN-clipped (UIElement_Menu::MakePopup spawns a top-level region
  bounded by the screen), not truly unbounded. AD-113 amended.
- N1: UiRoot overrides ClipsChildren => false — the root's own region
  IS the screen (the viewport already scissors it), so this is a
  safety net against a momentarily zero-sized root silently blanking
  the whole UI tree under the new ancestor-clip default.
- N2: added the empty-clip subtree cull (retail's var_24 gate
  @0x0069FB8E) to DrawSelfAndChildren only — DrawOverlays is a wholly
  separate traversal untouched by this change. New test proves a menu
  inside a fully-clipped (zero-width) window still draws its open
  popup via the overlay pass while the main pass draws nothing.
- N3: CT7 script §5 now names the collapsed-toolbar check and the
  four highest-overflow windows (combat/vitals bar, Options
  bottom-button row, map/house page, floaty chat) as explicit
  eyeball items for the re-gate.
- N4: verification below covers both the working tree and the clean
  committed tree.

Decomp anchors: UIRegion::DrawHere @0x0069FA30 (var_24 gate
@0x0069FB8E); UIElement_Text::DrawSelf @0x00467AA0 (self-clip);
UIRegion::DrawSelf @0x0069F1A0; UIElement_Menu::MakePopup (screen-
clipped popup region).

Verification (both runs green, --filter "Lane!=InstalledDat&
Lane!=PreparedPackage&Lane!=Live&Lane!=Manual&Lane!=Timing&
Lane!=Windows&Lane!=Linux&Lane!=SystemFont&Purpose!=Diagnostic&
Status!=KnownFailure"): full Release solution build green; working
tree 14,900+ tests across every project (one LandblockPresentation
PipelineTests flake reproduced ONLY under full-solution parallel
load, passes standalone and on rerun — unrelated to this change,
streaming domain); InstalledDat lane green (ACDREAM_RUN_INSTALLED_DAT
_TESTS=1, Status!=KnownFailure, 205+34+3+172 App/Content/Bake/Core
tests). Clean committed tree (git stash push -u the uncommitted
owner probe + docs files, rerun, stash pop) reported in the session
summary.

src/AcDream.App/UI/UiRoot.cs carries an unrelated, pre-existing
uncommitted owner probe (ACDREAM_PROBE_UI_HOVER) — staged selectively
(git add -p) so only this commit's own two hunks (ClipsChildren
override, WantsMouse) landed; the probe hunk is untouched and stays
uncommitted, same as before this fix round.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 07:11:42 +02:00

830 lines
36 KiB
C#

using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Tests.Rendering.Gpu;
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 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 ? "<resolved>" : null;
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCharacterInfos(),
static _ => (0u, 0, 0),
null,
stringResolve: StubResolve);
var currentTitleCaption = Assert.IsType<UiText>(layout.FindElement(0x1000052Eu));
var titlesEarnedCaption = Assert.IsType<UiText>(layout.FindElement(0x10000531u));
Assert.Equal("<resolved>", currentTitleCaption.LinesProvider()[0].Text);
Assert.Equal("<resolved>", 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<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_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<UiElement> 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);
}
// ── CT6 resize/scrollbar contract ──────────────────────────────────
/// <summary>
/// CT6 (2026-08-25): the Titles list (authored H=455 inside the 575px
/// page — CT1 ground truth §3) must shrink with the window and hand off
/// to its scrollbar the same way <c>CharacterStatController</c>'s
/// attribute/skill list does. Mounts the real fixture through
/// <see cref="RetailWindowFrame"/> (same shape as
/// <c>RetailUiRuntime.MountCharacter</c>) and shrinks the window well
/// below the list's authored height.
/// </summary>
[Fact]
public void TitlesList_ReflowsWithWindowResize_AndScrollbarOverflowFlips()
{
// 15 rows at the authored 24px pitch = 360px content — comfortably
// under the list's own authored 455px height (fits at the fixture's
// default mounted size, matching how CharacterStatController's own
// attribute list fits its default size), but well over what remains
// once the window is shrunk below.
uint[] earnedIds = Enumerable.Range(1, 15).Select(i => (uint)i).ToArray();
var names = earnedIds.ToDictionary(id => id, id => $"Title {id}");
Harness h = BindWithEarnedTitles(earnedIds, displayTitleId: 0u, names: names);
Assert.Equal(15, h.Rows.Count);
var root = new UiRoot { Width = 1280, Height = 1400 };
RetailWindowHandle handle = RetailWindowFrame.Mount(
root,
h.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,
// No DatConstraintSource: this test exercises the LIST'S OWN
// reflow/scrollbar contract given an already-permitted
// resize, not the authored host clamp value itself (that is
// CharacterStatControllerTests.
// CharacterWindow_ResizesYWithinAuthoredHostClamp_AndReflowsListAndScrollbar's
// job) — an explicit generous MinHeight lets the window
// actually shrink instead of defaulting to its own mounted
// height (RetailWindowFrame.Mount's fallback when neither
// MinHeight nor DatConstraintSource is supplied).
MinHeight = 40f,
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
});
ApplyAnchors(handle.OuterFrame);
var scrollbar = Assert.IsType<UiScrollbar>(
h.Layout.FindElement(h.ListBox.ScrollbarElementId));
Assert.Same(h.ListBox.Scroll, scrollbar.Model);
float originalListHeight = h.ListBox.Height;
h.ListBox.ViewportForTest!.LayoutScrollableChildren();
Assert.False(
h.ListBox.Scroll.HasOverflow,
"the fixture's authored default height fits all 15 rows without scrolling");
// Shrink the window well below the list's own authored 455px height.
// (CT6 fix round, S3 correction: BOTH the Titles page container
// 0x10000539 AND its own ListBox 0x10000532 carry a real authored
// LayoutPolicy — HasOriginalParentSize=true on both, pinned by
// CharacterPanelLiveDatTests.TitlesListAndPage_AuthorHasOriginalParentSize
// — that already stretches with the mounted content's height. The
// reflow this test proves comes entirely from that authored
// LayoutPolicy; CharacterTitlesController.Bind sets no Anchors of
// its own on the ListBox, since the "if LayoutPolicy is null"
// fallback it used to carry was dead code and has been deleted.)
handle.ResizeTo(handle.Width, 200f);
ApplyAnchors(handle.OuterFrame);
h.ListBox.ViewportForTest!.LayoutScrollableChildren();
Assert.True(h.ListBox.Height < originalListHeight, "the Titles list must shrink with the window");
Assert.True(h.ListBox.Scroll.HasOverflow, "360px of rows must overflow the shrunk view");
Assert.Equal((int)MathF.Floor(h.ListBox.ViewportForTest!.Height), h.ListBox.Scroll.ViewHeight);
// Growing back restores the original height and the no-overflow state.
handle.ResizeTo(handle.Width, handle.AuthoredHeight);
ApplyAnchors(handle.OuterFrame);
h.ListBox.ViewportForTest!.LayoutScrollableChildren();
Assert.Equal(originalListHeight, h.ListBox.Height, precision: 2);
Assert.False(h.ListBox.Scroll.HasOverflow);
}
private static void ApplyAnchors(UiElement parent)
{
foreach (UiElement child in parent.Children)
{
child.ApplyAnchor(parent.Width, parent.Height);
ApplyAnchors(child);
}
}
// ── CT-GF1 ancestor-clip gate repro ─────────────────────────────────
/// <summary>
/// OWNER GATE FINDING (screenshots on file, Campaign CT slice CT-GF1):
/// on the Titles tab, the page's authored divider 0x10000530 (300x9,
/// authored Y=60 in the 575px page, top-edge mode 2 = bottom-anchored at
/// 515px from the page bottom) escapes the window at the CT6-correct
/// 372px mounted default — the page is only ~337px tall there, so the
/// divider's bottom-anchor math computes a NEGATIVE Y and renders ABOVE
/// the window entirely. Retail clips this away
/// (<c>UIRegion::DrawHere @0x0069FA30</c>'s ancestor-clip rect
/// intersection, non-empty gate @0x0069FB8E); acdream drew it floating
/// above the window before this fix. Sibling divider 0x10000534
/// (authored Y=550) has the same shape but lands harmlessly at this
/// size — both share sprite 0x06001420, which is why the assertions
/// below key on Y-RANGE (this divider's own resolved screen position),
/// not texture.
///
/// Mounts the real fixture through the same <see cref="RetailWindowFrame"/>
/// production shape as <c>RetailUiRuntime.MountCharacter</c> (372px
/// default: ContentHeight=362f + the 10px NineSlice inset), switches to
/// the REAL Titles tab via <see cref="CharacterStatController"/>'s own
/// click handler (not a manual <c>Visible</c> poke — the CT3 tab-switch
/// closure this file's own <c>CharacterTabs_UseImportedChromeWithout...</c>
/// sibling test already exercises), and draws through a
/// <see cref="RecordingGpuDevice"/>. Unlike <see cref="BindWithEarnedTitles"/>'s
/// harness (which resolves every sprite to texture 0 for the OTHER
/// Titles tests in this file — sufficient for their geometry/wiring
/// assertions), this test resolves real non-zero textures so
/// <c>UiDatElement.OnDraw</c> actually queues quad geometry to inspect.
/// </summary>
[Fact]
public void TitlesPage_Divider_ClipsAwayAtTheCT6Default_AndAppearsWhenTheWindowGrowsTaller()
{
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCharacterInfos(), id => (id, 8, 8), null);
CharacterStatController.Bind(
layout, SampleData.SampleCharacter, spriteResolve: id => (id, 8, 8));
var titlesTab = Assert.IsType<UiText>(
layout.FindElement(CharacterStatController.TabTitlesId));
Assert.NotNull(titlesTab.OnClick);
titlesTab.OnClick!(); // the REAL tab-switch path — flips TitlesPage.Visible
UiElement divider = UiElement.FindDescendant(layout.Root, 0x10000530u)!;
Assert.NotNull(divider);
UiElement siblingDivider = UiElement.FindDescendant(layout.Root, 0x10000534u)!;
Assert.NotNull(siblingDivider);
var screen = new UiRoot { Width = 1600f, Height = 1200f };
RetailWindowHandle handle = RetailWindowFrame.Mount(
screen,
layout.Root,
id => (id, 8, 8),
new RetailWindowFrame.Options
{
WindowName = WindowNames.Character,
Chrome = RetailWindowChrome.NineSlice,
Left = 0f,
Top = 0f,
// CT6's own corrected default: the host's content parent is
// 300x362, not 0x2100002E's raw 300x600 authoring canvas.
ContentHeight = 362f,
MinWidth = 310f,
MaxWidth = 310f,
MinHeight = 372f,
MaxHeight = 1000f,
ResizeX = false,
ResizeY = true,
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
});
Assert.Equal(372f, handle.Height); // the CT6-correct mounted default
var device = new RecordingGpuDevice();
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
renderer.Begin(new Vector2(screen.Width, screen.Height));
var ctx = new UiRenderContext(renderer, new Vector2(screen.Width, screen.Height));
handle.OuterFrame.DrawSelfAndChildren(ctx);
// At the 372px default the divider's computed Y must be negative
// (above the window) — the owner's reported Y≈-178 shape.
Vector2 dividerAtDefault = divider.ScreenPosition;
Assert.True(
dividerAtDefault.Y + divider.Height <= 0f,
"expected the Titles divider to compute a Y above the window at the 372px " +
$"default (owner-reported ≈-178); got {dividerAtDefault.Y}");
// Nothing at all may render meaningfully above the window's own top
// edge (Y=0 itself is the window's own top border/frame, not "above
// the window") — the exact shape of the owner's screenshot finding.
AssertNoQuadCoversY(renderer, -10_000f, -1f);
// Grow the window taller. A real frame draws every tick, which is
// what reflows a bottom-anchored child against its parent's CURRENT
// size (UiElement.ApplyAnchor / LayoutPolicy.Apply run only from
// DrawSelfAndChildren) — two passes, matching the CT6 sibling test's
// own raw-edge-LayoutPolicy "policies settle" pattern above.
handle.OuterFrame.Height = 600f;
renderer.Begin(new Vector2(screen.Width, screen.Height));
handle.OuterFrame.DrawSelfAndChildren(ctx);
renderer.Begin(new Vector2(screen.Width, screen.Height));
handle.OuterFrame.DrawSelfAndChildren(ctx);
Vector2 dividerGrown = divider.ScreenPosition;
Assert.True(
dividerGrown.Y >= 0f && dividerGrown.Y + divider.Height <= 600f,
"expected the Titles divider to land inside the grown window at its authored " +
$"spot; got {dividerGrown.Y}");
// CT-GF1 fix round (S5): AssertQuadCoversY alone only proves SOMETHING drew
// in that Y band -- not that it was specifically THIS divider's own quad (the
// grown Titles page has other rows/backgrounds that could coincidentally
// share the band). Strengthen both halves: pin the divider's exact rect (X
// AND Y range, not Y alone), THEN diff against the same rect with the
// divider hidden -- if hiding it does not empty the rect out, whatever drew
// there was never uniquely attributable to the divider in the first place.
AssertQuadCoversRect(
renderer, dividerGrown.X, dividerGrown.Y,
dividerGrown.X + divider.Width, dividerGrown.Y + divider.Height);
divider.Visible = false;
renderer.Begin(new Vector2(screen.Width, screen.Height));
handle.OuterFrame.DrawSelfAndChildren(ctx);
AssertNoQuadCoversRect(
renderer, dividerGrown.X, dividerGrown.Y,
dividerGrown.X + divider.Width, dividerGrown.Y + divider.Height);
}
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
{
public IGpuFrame? CurrentFrame => null;
}
private static void AssertNoQuadCoversY(TextRenderer renderer, float yLo, float yHi)
{
foreach (var seg in renderer.DebugSpriteSegmentVerts)
{
for (int i = 0; i < seg.Verts.Count / 8; i++)
{
float vy = seg.Verts[i * 8 + 1];
Assert.False(
vy > yLo - 0.01f && vy < yHi + 0.01f,
$"unexpected quad vertex at Y={vy} inside the clipped-away range " +
$"[{yLo},{yHi}] (texture {seg.Texture})");
}
}
}
/// <summary>CT-GF1 fix round (S5): the X-and-Y-range companion to
/// <see cref="AssertQuadCoversY"/> — a passing vertex must land inside BOTH
/// axes' rect, not just the Y band, so an unrelated same-band element (a
/// background, another row) cannot satisfy this vacuously.</summary>
private static void AssertQuadCoversRect(TextRenderer renderer, float xLo, float yLo, float xHi, float yHi)
{
bool found = renderer.DebugSpriteSegmentVerts.Any(seg =>
{
for (int i = 0; i < seg.Verts.Count / 8; i++)
{
float vx = seg.Verts[i * 8];
float vy = seg.Verts[i * 8 + 1];
if (vx >= xLo - 0.5f && vx <= xHi + 0.5f && vy >= yLo - 0.5f && vy <= yHi + 0.5f)
return true;
}
return false;
});
Assert.True(found, $"expected at least one quad vertex inside rect [{xLo},{yLo}]..[{xHi},{yHi}]");
}
/// <summary>CT-GF1 fix round (S5): the negative half of <see cref="AssertQuadCoversRect"/> —
/// used after hiding the divider to prove the earlier positive assertion was
/// attributable to it specifically (a diff, not a coincidence).</summary>
private static void AssertNoQuadCoversRect(TextRenderer renderer, float xLo, float yLo, float xHi, float yHi)
{
foreach (var seg in renderer.DebugSpriteSegmentVerts)
{
for (int i = 0; i < seg.Verts.Count / 8; i++)
{
float vx = seg.Verts[i * 8];
float vy = seg.Verts[i * 8 + 1];
Assert.False(
vx >= xLo - 0.5f && vx <= xHi + 0.5f && vy >= yLo - 0.5f && vy <= yHi + 0.5f,
$"unexpected quad vertex at ({vx},{vy}) inside the divider's own rect " +
$"[{xLo},{yLo}]..[{xHi},{yHi}] after hiding it");
}
}
}
private static void AssertQuadCoversY(TextRenderer renderer, float yLo, float yHi)
{
bool found = renderer.DebugSpriteSegmentVerts.Any(seg =>
{
for (int i = 0; i < seg.Verts.Count / 8; i++)
{
float vy = seg.Verts[i * 8 + 1];
if (vy >= yLo - 0.5f && vy <= yHi + 0.5f) return true;
}
return false;
});
Assert.True(found, $"expected at least one quad in Y range [{yLo},{yHi}]");
}
// ── 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);
}
}