fix(CT): CT3 fix round — port Refresh's unconditional selection clear, drop unresolvable-id rows
Opus dual-lens review of CT3 (03e073b7) found 1 BLOCKER + 2 SHOULD-FIX + notes.
BLOCKER: CharacterTitlesController never ported Refresh @0x0049abc0's own
SetSelectedItem(nullptr, 1) (@0x0049ac5a) — retail clears the current
title selection UNCONDITIONALLY on every Refresh() call, regardless of
whether the previously-selected id is still earned. OnTableReplaced
(0x0029) and OnDisplayTitleChanged (the display half of 0x002B) are
retail's two Refresh() call sites, so both now clear _selectedTitleId
before rebuilding/re-highlighting. OnTitleAdded (0x002B's add half) is a
DIFFERENT retail method — RecvNotice_AddCharacterTitle @0x0049a990 splices
one row without ever touching m_pSelectedItem — so it deliberately still
preserves selection. Net effect: after the user sets a display title and
ACE echoes 0x002B, the previously-highlighted row now goes dark and the
Set-as-Display button re-ghosts, matching retail; earning a new title
while a row is selected still leaves that selection alone.
SHOULD-FIX: ported AddTitleToList @0x0049A840's early-outs
(@0x0049a873/@0x0049a914) — an id of 0, or an id CharacterTitleResolver
fails to resolve, now produces NO row at all. The "Unknown" fallback
literal belongs only to the display-title text (Refresh @0x0049abc0's
other half), never a row — this was previously ported backwards.
SHOULD-FIX: rows and the display text now use their UiText's own authored
DefaultColor instead of a hardcoded Vector4.One, and each LinesProvider
now returns a cached UiText.Line[] built once per text change instead of
allocating a fresh array literal every draw call (pattern:
CharacterCreationSkillsPage.cs:829).
Notes (all ruled in): corrected two CharacterStatController comments that
falsely claimed the Titles page authors its own copies of the raise
buttons (verified against the fixture — it does not; the hide loop that
comment guarded is a defensive no-op given Visible's draw/click cascade,
kept only for the contentPage-not-found fallback); switched the row sort
from List.Sort to a stable OrderBy/ThenBy (ties broken by title id) so
equal-text rows keep retail's insert-after-equals order; wrapped the
title-resolver delegate in RetailUiRuntime.MountCharacter with the same
DatLock the row-template resolver already takes (DatCollection is
documented not thread-safe); set the list box's authored 24px row height
so wheel/line scroll lands row-aligned; kept the bind-time display-text
refresh with a comment explaining why the pre-notice "Unknown" frame is
unreachable in live play (ACE always sends 0x0029 before this panel can
open).
Tests: inverted TableReplaced_SelectedTitleStillEarned_KeepsSelectionHighlighted
into TableReplaced_ClearsSelection_EvenWhenTheSelectedIdIsStillEarned (cites
@0x0049ac5a), added its DisplayTitleChanged twin, and added
TitleAdded_PreservesSelection (the case most at risk from the blocker fix).
Inverted Rows_UnresolvedTitle_ShowsRetailUnknownLiteral into
Rows_UnresolvedTitle_ProducesNoRow (cites @0x0049a873/@0x0049a914) and added
Rows_TitleIdZero_ProducesNoRow for the other early-out. Extended
ClickingSetDisplay_..._AndMutatesNothingLocally to assert the row set and
selection are untouched by the click. Added
Fixture_PageCaptions_ResolveToNonEmptyText, which rebuilds the committed
character_2100002E.json fixture with a stub string resolver to pin this
class's own claim that the two page captions (0x1000052E/0x10000531) carry
a resolvable authored StringInfo.
Verified pre-existing/unrelated: the full hermetic suite run surfaced 2
failures in AcDream.App.Tests (LiveEntityNetworkBranchRoutingTests IL-shape
assertion, GameWindowRenderLeafCompositionTests IL-shape assertion) that
also fail with these five files stashed back to their pre-fix-round state —
confirmed unrelated to this change.
Build green. CharacterTitlesControllerTests: 24/24 (was 21, +3 net after
one invert-and-split and two new facts). Full hermetic solution suite
(Lane!=InstalledDat/PreparedPackage/Live/Manual/Timing/Windows/Linux/
SystemFont, Purpose!=Diagnostic, Status!=KnownFailure): only the two
pre-existing IL-shape failures above; every other project green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
4ea946257d
commit
4cc9448b0a
5 changed files with 287 additions and 33 deletions
|
|
@ -95,6 +95,37 @@ public sealed class CharacterTitlesControllerTests
|
|||
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()
|
||||
{
|
||||
|
|
@ -206,15 +237,34 @@ public sealed class CharacterTitlesControllerTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void Rows_UnresolvedTitle_ShowsRetailUnknownLiteral()
|
||||
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("Unknown", h.RowText(row));
|
||||
Assert.Equal("Adventurer", h.RowText(row));
|
||||
}
|
||||
|
||||
// ── Selection + highlight ─────────────────────────────────────────────
|
||||
|
|
@ -288,6 +338,7 @@ public sealed class CharacterTitlesControllerTests
|
|||
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!();
|
||||
|
||||
|
|
@ -297,6 +348,12 @@ public sealed class CharacterTitlesControllerTests
|
|||
// 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]
|
||||
|
|
@ -331,8 +388,14 @@ public sealed class CharacterTitlesControllerTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void TableReplaced_SelectedTitleStillEarned_KeepsSelectionHighlighted()
|
||||
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" });
|
||||
|
|
@ -340,13 +403,64 @@ public sealed class CharacterTitlesControllerTests
|
|||
((UiDatElement)lifeMageRow).OnClick!();
|
||||
Assert.True(h.SetDisplayButton.Enabled);
|
||||
|
||||
// A resend of the SAME table (retail's own Refresh() is
|
||||
// unconditional — CT2 review anchor 1) must not silently desync the
|
||||
// ghost state from the still-valid selection.
|
||||
// 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(RowHighlightSprite, h.RowMedia(rebuiltLifeMageRow));
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue