acdream/tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs
Erik ec50455a63 feat(CT6): character window Y-resize clamped at retail's authored host minimum + shrink-and-scroll list contract
CT6 (Campaign CT slice 6): the resize clamp source is the SHARED
gmPanelUI host (0x100005FE in LayoutDesc 0x2100006E), not 0x2100002E's
own root and not the Character/Skills slot 0x1000018E either — live
probe confirmed the host authors MinWidth=MaxWidth=310 (fixed — no
horizontal Resizebar authored), MinHeight=372, MaxHeight=1000, and that
the bottom Resizebar (0x10000660) and top Dragbar (0x1000065C) are
direct children of the host, not the content parent. Decomp chain:
UIElement_Resizebar::StartMouseResizing @0x0046B7E0 calls
UIElement::StartResizing(this->GetParent(), ...), stashing drag state on
that parent; UIElement::MouseResizeElement @0x00461130 then reads
GetAttribute_Int(this, 0x3C..0x3F) off that same element every
mouse-move.

RetailUiRuntime.MountCharacter now imports the host element and passes
it as RetailWindowFrame.Options.DatConstraintSource, matching the
existing MountSideVitals pattern.

CharacterStatController.RebuildActiveList now wraps BOTH the Attributes
and Skills tabs' rows in the same UiScrollablePanel viewport (previously
only Skills got one; Attributes rows had no clipping/scrolling and the
shared scrollbar was force-hidden — owner report item 2). The shared
scrollbar is now always bound + visible; UiScrollbar's own
IsPresentationVisible/IsModelDisabled already draw the correct
full-track "disabled" thumb when content fits. This surfaced and fixed
a real #372/#412-class anchor-baseline bug: the viewport's
Left|Top|Bottom anchor was capturing its baseline margins lazily on its
own first ApplyAnchor call, which happens AFTER the ListBox has already
grown from its raw DAT height to its mounted height, permanently
capping the viewport short on every later resize. Fixed with an eager
CaptureCurrentAnchorBaseline() call, mirroring UiTemplateListBox
.Viewport's own lazy getter.

CharacterTitlesController.Bind gained the same defensive
Anchors = Left|Top|Bottom fallback for the Titles ListBox that
CharacterStatController already had (a no-op on the real DAT — both the
Titles page and its ListBox already carry a real authored LayoutPolicy
that stretches correctly).

Standardization audit: UiElement.MinWidth/MinHeight/MaxWidth/MaxHeight,
set once at RetailWindowFrame.Mount, are the ONLY clamp fields — read
identically by interactive drag, RetailWindowManager.ResizeTo, and
RetailWindowLayoutPersistence's restore clamp. No gaps found; no
register row (every number is a live-probed authored DAT value or a
structural correctness fix, nothing inferred).

Tests: CharacterStatControllerTests
.CharacterWindow_ResizesYWithinAuthoredHostClamp_AndReflowsListAndScrollbar,
CharacterTitlesControllerTests
.TitlesList_ReflowsWithWindowResize_AndScrollbarOverflowFlips,
RetailWindowFrameTests
.NineSlice_ChatShapedConstraints_ClampProgrammaticResizeAtAuthoredBounds
(shared-mechanism regression pin), CharacterPanelLiveDatTests
.PanelHost_AuthorsFixedWidthAndBottomOnlyResizeContract (InstalledDat
pin). Existing attribute-row tests updated from list.Children to
Descendants(list) for the new nested-viewport shape (the pattern skill
rows already needed).

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

623 lines
26 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 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.
// (The Titles page container 0x10000539 carries its own authored
// LayoutPolicy that already stretches with the mounted content's
// height — verified live during this test's development — so only
// the ListBox's OWN Anchors, set above by CharacterTitlesController,
// were the missing piece.)
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);
}
}
// ── 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);
}
}