Opus dual-lens review of CT6 (ec50455a) found 1 blocker, 4 should-fix, 5
notes. All applied:
BLOCKER B1 — the shared gmPanelUI host (0x100005FE) IS retail's own
outer window frame, not a content element: its authored 310/372/310/1000
already include the 5px bevel on every side. RetailWindowFrame.Mount was
adding the NineSlice wrapper's OWN 10px chrome inset on top of that
already-chrome-inclusive source, clamping MinWidth to 320 while the
window's actual mounted outer width stayed 310 — silently below its own
minimum until RetailWindowManager.ResizeTo forcibly widened it despite
ResizeX=false. Fixed with a new
RetailWindowFrame.Options.DatConstraintSourceIsOuterFrame opt-out
(chrome inset = 0 for constraint resolution only, value stays
DAT-sourced); MountCharacter sets it true. Mounted clamp is now exactly
the host's four raw values: width fixed 310, height 372..1000. Added a
mount-time invariant (throws if the mounted outer extent falls outside
its own just-computed clamp) that would have caught this at the first
test run.
S4 (campaign-lead ruling) — the window must MOUNT at retail's authored
default, outer 372 (content 362, matching the host's own content parent
0x10000180), not 0x2100002E's own 300x600 content-authoring canvas
(which produced a stale 610px default pre-fix: 600 + 10 chrome inset).
372 is exactly the host's own authored MinHeight — retail opens at its
resize floor and can only be dragged taller. MountCharacter now sets
ContentHeight=362f explicitly. At this default the 9 attribute/vital
rows (180px) overflow the 160px list immediately — retail-correct, not
a regression.
S2 — 0x1000023E and 0x10000533 both author property 0x79
(HideWhenDisabled) TRUE (fixture-verified: BoolValue=true on both). A
fitting list HIDES the scrollbar entirely; it does not draw a full-track
"disabled" thumb. The code was already correct; four wrong descriptions
(plan ledger, CharacterStatController comment, CT7 script, test comment)
are corrected, plus a new IsPresentationVisible assertion pair in the
resize test.
S3 — CharacterTitlesController's `if (listBox.LayoutPolicy is null)`
Anchors fallback was unreachable on both the real DAT and the fixture
(0x10000532/0x10000539 both author HasOriginalParentSize=true, so
LayoutPolicy is always assigned). Deleted; added an InstalledDatFact pin
guarding the deletion against DAT drift.
N4 — renamed NineSlice_ChatShapedConstraints_... to
NineSlice_ContentShapedConstraints_InsetArithmeticClampsProgrammaticResize
(it tested inset arithmetic on a content-shaped source, not chat's real
contract) and added a true chat-contract pin mounting Chrome=Imported
with chat's real 300/100/2000/2000 constraints, asserting no inset
applies.
N5 — corrected the "nothing inferred, no register row" sentences in the
ground-truth doc and plan ledger: they were false pre-fix (the mounted
clamp WAS an inferred double-counted composition); true now that B1
removes the composition.
CT7 script §4 rewritten with exact clamps (no "≈"), the corrected
default-overflow scrollbar behavior, and an absolute starting-height
statement.
Verified: full hermetic solution suite green (15,441 tests, Release,
Lane exclusions per the release gate), InstalledDat lane green across
the whole solution (414 tests, ACDREAM_RUN_INSTALLED_DAT_TESTS=1,
Status!=KnownFailure) including two new pins
(TitlesListAndPage_AuthorHasOriginalParentSize,
Imported_ChatContract_ClampsAtAuthoredBoundsWithNoChromeInset).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
627 lines
26 KiB
C#
627 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.
|
|
// (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);
|
|
}
|
|
}
|
|
|
|
// ── 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);
|
|
}
|
|
}
|