fix(ui): fellowship checkbox count, row-text + ListBox Flush doc corrections, promote probe findings to assertions

Mechanism SHOULD-FIX 4: SocialFellowshipPageController's class doc said
0x1000026B holds "all three visible option checkboxes" — the fixture and
the decomp both authors FOUR (Ignore Fellowship Requests / Auto-Accept
Requests / Share XP / Share Loot, 0x10000270-0x10000273). Corrected.

Mechanism SHOULD-FIX 9: SocialPanelRowText.FindDeepest's doc promised
"the deepest UiText descendant" but the implementation returns the LAST
match in pre-order traversal order, which only equals the deepest when
the subtree is a single chain. Both real row templates ARE single
chains today, so behavior is unaffected — the doc now describes what the
code actually does instead of a stronger guarantee it doesn't implement.

Blast SHOULD-FIX 5: UiTemplateListBox.Flush()'s doc only mentioned the
ContentHeight reset; UiScrollablePanel.ClearContent() also resets scroll
position to 0, which the sibling UiItemList.Flush() (same method name,
different semantics) does NOT do. Documented explicitly, including the
UX cost this creates for a scrolled-in Friends/Squelch roster once its
scrollbar is wired (this fix round's blast MF-1) — flagged for whoever
revisits Friends/Squelch scrolling next rather than silently fixed as an
unasked behavior change.

Mechanism SHOULD-FIX 3: SocialPanelLiveMountProbeTests printed two
headline findings (the 0x10000492-authored-twice count, page exclusivity
after ActivateTabBehavior) without ever asserting them — a future
importer regression collapsing/dropping an instance, or breaking
exclusivity, could not fail this test. Both are now real assertions
(Assert.Equal(2, passupCount); exactly one page Visible and it is
Allegiance).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-12 03:44:05 +02:00
parent 35c40a9b56
commit ae77270939
4 changed files with 56 additions and 6 deletions

View file

@ -16,7 +16,9 @@ namespace AcDream.App.UI.Layout;
/// <c>m_pFellowship == null</c>, and vice-versa. Both are AUTHORED sibling /// <c>m_pFellowship == null</c>, and vice-versa. Both are AUTHORED sibling
/// containers of the fellowship page (<c>0x10000292</c>) — confirmed by the /// containers of the fellowship page (<c>0x10000292</c>) — confirmed by the
/// FA3 live-mount probe: <c>0x1000026B</c> holds the name-entry box, Create /// FA3 live-mount probe: <c>0x1000026B</c> holds the name-entry box, Create
/// button, and all three visible option checkboxes; <c>0x10000275</c> holds /// button, and all FOUR visible option checkboxes (Ignore Fellowship
/// Requests / Auto-Accept Requests / Share XP / Share Loot —
/// <c>0x10000270</c><c>0x10000273</c>, fix-round mechanism SF-4); <c>0x10000275</c> holds
/// the roster list and all six member-management buttons. A single /// the roster list and all six member-management buttons. A single
/// <see cref="UiElement.Visible"/> toggle on each container is therefore the /// <see cref="UiElement.Visible"/> toggle on each container is therefore the
/// WHOLE empty-state swap; no per-child hiding is needed (closes lane-A /// WHOLE empty-state swap; no per-child hiding is needed (closes lane-A

View file

@ -16,9 +16,20 @@ namespace AcDream.App.UI.Layout;
/// </summary> /// </summary>
internal static class SocialPanelRowText internal static class SocialPanelRowText
{ {
/// <summary>Returns the deepest <see cref="UiText"/> descendant of /// <summary>
/// <paramref name="row"/> (pre-order, last match wins), or null if the /// Returns the LAST <see cref="UiText"/> found by a pre-order walk of
/// built row contains no text element at all.</summary> /// <paramref name="row"/> (row itself, then each child's own subtree in
/// order), or null if the built row contains no text element at all.
/// <b>Fix-round mechanism SF-9:</b> this equals the truly DEEPEST match
/// only when the subtree is a single chain — both real row templates
/// (Friends <c>0x10000519</c>, Squelch <c>0x10000541</c>) ARE single
/// chains (row root -&gt; one Type-0xC text child -&gt; one more Type-0xC
/// text grandchild, per the FA3 live-mount probe), so this method's
/// actual "last match in traversal order" behavior and the class doc's
/// "deepest" framing agree for both templates in production today. A
/// row template with a BRANCHING subtree (multiple text leaves at
/// different depths) would expose the difference.
/// </summary>
public static UiText? FindDeepest(UiElement row) public static UiText? FindDeepest(UiElement row)
{ {
UiText? found = row as UiText; UiText? found = row as UiText;

View file

@ -239,7 +239,10 @@ public sealed class UiTemplateListBox : UiDatElement
/// <summary> /// <summary>
/// Campaign FA slice FA3: removes every row previously added via /// Campaign FA slice FA3: removes every row previously added via
/// <see cref="AddItemFromTemplateList"/>/<see cref="AddPrebuiltRow"/>, resetting /// <see cref="AddItemFromTemplateList"/>/<see cref="AddPrebuiltRow"/>, resetting
/// <see cref="ContentHeight"/> to 0 — the "Gap found" prerequisite lane A/D /// <see cref="ContentHeight"/> to 0 AND the scroll position to 0
/// (<see cref="UiScrollablePanel.ClearContent"/> calls
/// <c>Scroll.SetScrollY(0)</c> — fix-round blast SF-5: the ORIGINAL doc here
/// only mentioned the height reset) — the "Gap found" prerequisite lane A/D
/// flagged (docs/research/2026-08-11-fa-panel-structure.md §6.6: "no Flush, no /// flagged (docs/research/2026-08-11-fa-panel-structure.md §6.6: "no Flush, no
/// selection model, no per-row instance-id"). Needed for a poll-and-rebuild /// selection model, no per-row instance-id"). Needed for a poll-and-rebuild
/// binding (Friends/Squelch read-only lists) where the row COUNT can shrink /// binding (Friends/Squelch read-only lists) where the row COUNT can shrink
@ -247,6 +250,20 @@ public sealed class UiTemplateListBox : UiDatElement
/// grows the stack. A no-op, never allocating the viewport, when the box is /// grows the stack. A no-op, never allocating the viewport, when the box is
/// still dormant (no row has ever been added) — mirrors every other dormancy /// still dormant (no row has ever been added) — mirrors every other dormancy
/// guard on this class (see class doc). /// guard on this class (see class doc).
///
/// <para>
/// <b>Diverges from the sibling <see cref="UiItemList.Flush"/> it shares a
/// name with</b> (fix-round blast SF-5): that method does NOT touch scroll
/// position. For a poll-and-rebuild Friends/Squelch list, this means a user
/// scrolled partway into a long roster is yanked back to the top on every
/// server-side change (a friend's online status flips, etc.) — a real,
/// user-visible UX cost once <see cref="ScrollbarElementId"/> is actually
/// wired (it now is — see <see cref="AcDream.App.UI.Layout.SocialFriendsPageController"/>).
/// Not fixed here: doing so would require this class to preserve/re-clamp
/// the offset across a rebuild whose row COUNT can shrink, which is a
/// bigger behavior change than a fix-round doc correction should make
/// unasked; flagged for whoever revisits Friends/Squelch scrolling next.
/// </para>
/// </summary> /// </summary>
public void Flush() => _viewport?.ClearContent(); public void Flush() => _viewport?.ClearContent();
} }

View file

@ -67,6 +67,11 @@ public sealed class SocialPanelLiveMountProbeTests
tabs.ActivateTabBehavior(); tabs.ActivateTabBehavior();
Assert.Empty(tabs.UnresolvedEntries); Assert.Empty(tabs.UnresolvedEntries);
// Fix-round mechanism SF-3: page exclusivity was activated but never
// asserted — the #372 class ("the panel mounts but the pages are
// wrong/blank") deserves a real assertion, not just a hope.
bool sawVisiblePage = false;
string? visiblePageName = null;
foreach ((uint pageId, string name) in new[] foreach ((uint pageId, string name) in new[]
{ {
(0x10000513u, "Friends"), (0x10000513u, "Friends"),
@ -77,9 +82,20 @@ public sealed class SocialPanelLiveMountProbeTests
{ {
UiElement? page = UiElement.FindDescendant(tabs, pageId); UiElement? page = UiElement.FindDescendant(tabs, pageId);
Console.WriteLine( Console.WriteLine(
$"[socialprobe] page {name} 0x{pageId:X8} -> {(page is null ? "MISSING" : page.GetType().Name)}"); $"[socialprobe] page {name} 0x{pageId:X8} -> {(page is null ? "MISSING" : page.GetType().Name)} "
+ $"Visible={page?.Visible}");
Assert.NotNull(page); Assert.NotNull(page);
if (page!.Visible)
{
Assert.False(
sawVisiblePage,
$"page exclusivity violated: both '{visiblePageName}' and '{name}' report Visible=true after ActivateTabBehavior()");
sawVisiblePage = true;
visiblePageName = name;
}
} }
Assert.True(sawVisiblePage, "no page reports Visible=true after ActivateTabBehavior()");
Assert.Equal("Allegiance", visiblePageName);
// Fellowship empty/full frame pair. // Fellowship empty/full frame pair.
foreach ((uint id, string name) in new[] foreach ((uint id, string name) in new[]
@ -116,6 +132,10 @@ public sealed class SocialPanelLiveMountProbeTests
Assert.NotNull(allegiancePage); Assert.NotNull(allegiancePage);
int passupCount = CountDescendants(allegiancePage!, 0x10000492u); int passupCount = CountDescendants(allegiancePage!, 0x10000492u);
Console.WriteLine($"[socialprobe] 0x10000492 occurrences under allegiance page = {passupCount}"); Console.WriteLine($"[socialprobe] 0x10000492 occurrences under allegiance page = {passupCount}");
// Fix-round mechanism SF-3: this was printed but never asserted — a
// future importer change that collapses or drops one instance must
// fail this test, not just the log.
Assert.Equal(2, passupCount);
// Tab button captions — non-empty (the #375 resolver class: a missing // Tab button captions — non-empty (the #375 resolver class: a missing
// string resolver renders blank captions even though the layout mounts). // string resolver renders blank captions even though the layout mounts).