Fixes the CT7 gate finding: on the Titles tab, the authored divider 0x10000530 escapes the Character window above its top edge at the CT6-correct 372px mounted default (computed Y ~ -178, matching the owner's screenshot). Retail clips child rendering to the intersected ancestor clip-rect chain -- UIRegion::DrawHere @0x0069FA30 takes the element's screen Box2D plus a SmartArray<Box2D> of inherited clip rects, intersects them (the min/max clamp loop @0x0069FAA7..0x0069FB82), and draws EraseSelf/DrawChildren/DrawSelf with the intersected rect only when non-empty (the var_24 gate @0x0069FB8E). Our UiElement draw walk rendered children unclipped by default, so any authored element relying on clipping -- this divider, and the chat input row at small window sizes (the owner's earlier "text input sticks out on resize" report) -- became a visible artifact. Mechanism (element-level, reusing the existing clip-rect-stack infrastructure in UiRenderContext.PushClip/PopClip): - UiElement.ClipsChildren now defaults to TRUE for every element (was an opt-in used only by UiScrollablePanel/UiItemList). Each element's children draw AND hit-test clipped to the intersection of its own rect with the inherited ancestor clip; an element positioned outside its parent's box silently disappears, matching retail's non-empty-intersection gate. HitTest's existing early bounds check already implemented this shape for ClipsChildren=true elements -- flipping the default aligns hit-testing with the new draw-clip default in one property, per the plan's own point 4. - UiElement.ExpandsClipForPopup (default false) is the one opt-out: retail spawns a menu popup as a SEPARATE top-level region (UIElement_Menu::MakePopup), clipped only by the screen; acdream draws UiMenu's popup inline from the owning button in a second traversal (OnDrawOverlay, pre-existing -- its own doc comment already says "regardless of this element's position in the tree"). DrawOverlays now resets the accumulated clip to unbounded (UiRenderContext.PushClipUnbounded, sharing the existing clip stack) for exactly the OnDrawOverlay call of an opted-in element. UiMenu overrides ExpandsClipForPopup=>true, paired with ClipsChildren=>false so its own out-of-bounds OnHitTest union (the popup occupies ly<0 or ly>=Height depending on open direction) stays reachable through the same early-bounds gate that now defaults on for every other element. Opt-out audit (grep for OnDrawOverlay overrides + negative/overflow OnDraw coordinates across src/AcDream.App/UI): UiMenu's popup is the ONLY OnDrawOverlay override client-wide, so it is the only element needing ExpandsClipForPopup. RetailTooltipPresenter's popup and UiRoot's drag ghost both already escape structurally -- the tooltip mounts as an ordinary UiRoot CHILD (sibling of every window, clipped only by the canvas), and the drag ghost is drawn directly by UiRoot outside the tree entirely -- neither needed a code change, both are covered by new tests proving the invariant. UiResizeGrip and UiNineSlicePanel's frame/bevel draw entirely within their own [0,Width]x[0,Height] (grip flush at the window's own edges; the window's own Width/Height already represents the OUTER frame including its 5px bevel, so its ClipsChildren push already covers the frame's own content children correctly -- no negative insets found). UiScrollbar draws entirely within its own bounds (confirmed by reading OnDraw). Hit-testing: aligned with the new default via the single ClipsChildren flip (see above); UiMenu's own opt-out override keeps its popup hit-test union working, verified by the full UiMenuTests suite staying green. Divergence register: AD-113 filed for the ExpandsClipForPopup adaptation (inline popup drawing vs retail's separate top-level region). Fixed two pre-existing test-harness gaps the new default surfaced (both real bugs in the harnesses, not workarounds around the fix): - ChatLayoutConformanceTests' bottom-right-grip grow test read a STALE (pre-shrink) grip screen position because it drove two resize gestures back-to-back with no intervening Draw pass -- the only place UiElement.ApplyAnchor/LayoutPolicy.Apply run. A real frame draws every tick, so production never hits this; the test now inserts a real DrawSelfAndChildren pass between the two gestures, matching a real frame boundary. - VendorUiControllerTests' hand-built Items/Buying/Selling page containers were left at their bare 0x0 UiElement default (the harness never runs a real DAT-driven layout pass) -- harmless before ancestor clipping existed, but now hides every child of an unsized page. Sized them to the window's own content root, matching production's shape (a tab page fills the window body). Tests (all confirmed as genuine regression pins by temporarily reverting the relevant default/override and observing the exact predicted failure, then reverting back): - CharacterTitlesControllerTests.TitlesPage_Divider_ClipsAwayAtThe CT6Default_AndAppearsWhenTheWindowGrowsTaller: the literal gate repro against the real character_2100002E.json fixture through RetailWindowFrame.Mount at the CT6 372px default -- the divider renders nothing (computed Y ~ -173, matching the owner's ~-178); growing the window to 600px renders it at its authored spot. - ChatLayoutConformanceTests.ResizingTheWindowSmall_NoInputRowQuad RendersOutsideTheWindowRect: no input-row quad escapes the chat window rect at three small sizes (300x100 sanity control, 120x40/80x30 genuine pre-fix overflow -- verified failing without the fix at Y=38/55 past the window edge). - UiAncestorClipTests (new file): the core mechanism against plain synthetic elements (culled-outside / clipped-at-the-edge / hit-test parity), UiMenu's popup escaping a tiny owning window (and staying clipped while closed), and the tooltip's structural immunity (mounts as a UiRoot sibling, unaffected by a tiny ancestor window). Verification: full solution build green; hermetic suite green (--filter "Lane!=InstalledDat&Lane!=PreparedPackage&Lane!=Live& Lane!=Manual&Lane!=Timing&Lane!=Windows&Lane!=Linux& Lane!=SystemFont&Purpose!=Diagnostic&Status!=KnownFailure", 14,000+ tests across every project); InstalledDat lane green (ACDREAM_RUN_INSTALLED_DAT_TESTS=1, Status!=KnownFailure, 205+34+3+172 tests). CharacterTitlesControllerTests' existing suite and the full UiMenuTests/UiScrollbarTests suites are unaffected. src/AcDream.App/UI/UiRoot.cs carries an unrelated, pre-existing uncommitted owner probe (ACDREAM_PROBE_UI_HOVER) -- untouched by this change and deliberately left out of this commit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
774 lines
34 KiB
C#
774 lines
34 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}");
|
|
AssertQuadCoversY(renderer, dividerGrown.Y, 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})");
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|