acdream/tests/AcDream.App.Tests/UI/Layout/SocialPanelControllerTests.cs
Erik 5bdd0528f1 feat(ui): FA4 -- fellowship page fully live
Roster: SocialFellowshipPageController now builds one row per fellow
from the authored template (0x21000030/0x10000281, live-DAT verified),
diffing the member GUID set on each revision-gated Tick -- an unchanged
set updates every row's bound widgets in place (no ListBox mutation, so
scroll position is untouched by construction); only a real join/leave/
disband triggers a rebuild, via UiTemplateListBox.FlushPreservingScroll
(FA3 carry-forward 1, both the widget-level fix and the controller-level
diff). Health/stamina/mana meters bind Fill+Label; the leader's name
tints gold (lane A's row template has no dedicated leader marker, so
this is a flagged adaptation, not a ported mechanism). Row-click
selection (SelectFellow) drives Dismiss/Leader targeting and the world
selection (SelectionChangeSource.Social).

D4: SocialPanelController now tracks "is the social window shown AND is
Fellowship the active tab" via UiTabPanel.ActivePageChanged +
OnShown/OnHidden, and calls SetPageVisible on every transition, which
sends 0x00A6 (idempotent, no-op while disconnected) -- the prerequisite
ACE gates its 0x02C0 vitals stream on.

Create flow: the inline name field (0x1000026F, an authored Editable
UiField -- live-DAT verified) gates the Create button's enabled state
exactly like retail (empty name = disabled = the whole refusal
mechanism, no separate error text); FellowshipShareXP's live value is
read at click time.

Actions + confirmations: Recruit/Dismiss/Quit/Disband/AssignLeader/
SetOpen all route through DeferredGameRuntimeStateCommands (new
Fellowship* methods) rather than a raw WorldSession send, so Quit
correctly picks up RuntimeFellowshipState's leader hand-off rule.
Button enable states port gmFellowshipUI::UpdateButtons verbatim. The
Open/Close button's caption swaps between the two DAT-resolved strings
cached once at Bind (never per-tick -- DatCollection is not safe to
touch unprotected from the render loop). RetailUiRuntime intercepts a
type-4 confirmation request before it reaches the generic
GameplayConfirmationController: IgnoreFellowshipRequests auto-declines,
FellowshipAutoAcceptRequests auto-accepts, neither set falls through to
the existing dialog machinery unchanged (D6).

D5 display: the per-fellow stats line uses retail's byte-decoded
even-split percentage table verbatim (1.0/.../.3111111/.28, default
0.0); the proportional branch omits the percentage rather than
inventing a formula (no acdream ExperienceToRaiseLevel table exists
yet). Both StringInfo variable substitution (row/stats/vitals text) and
ACCharGenData::FormatName (create-flow name canonicalization) are
unported prerequisites, so row text renders as plain numeric composites
-- register rows AD-80/AD-81 (docs commit).

D7: un-dims IgnoreFellowshipRequests/FellowshipAutoAcceptRequests
(consumed by the D6 auto-decline/accept) and FellowshipShareXP/
FellowshipShareLoot (consumed by Create + the page's own second
checkbox surface) on the Character tab -- 4 of 35 store-only rows
promoted to Live (31 remain dimmed).

Carry-forwards from the FA3 re-review, folded into this slice's
contract:
- UiTemplateListBox.FlushPreservingScroll -- preserves scroll offset
  across a rebuild instead of resetting to 0 (Flush's existing
  contract, unchanged, for Friends/Squelch).
- RowTemplateResolver -- the FA3 caching row-template resolver
  extracted from a MountSocialPanel local function into its own
  hermetically-testable class; now shared by Friends/Squelch/
  Fellowship's row families.
- Friends/Squelch scrollbars now resolve via the built
  UiTemplateListBox.ScrollbarElementId (DAT property 0x72) instead of
  a hardcoded literal, matching ConfigOptionsPageController's own OP6
  precedent.
- The Fellowship roster path never advances its revision latch on a
  partial resolver failure until the NEXT real membership change --
  never a per-frame retry loop.

Live-DAT verified (ACDREAM_PROBE_LIVE_MOUNT=1, extended
SocialPanelLiveMountProbeTests): the name field builds as UiField, all
11 buttons/checkboxes resolve, the row template's 5 checked fields
resolve to the right widget types, every checkbox label/tooltip and the
Open/Close captions resolve to real retail strings ("Open"/"Close"),
and a full production-path Bind() against live DATs produces zero
"not found" warnings.

App tests: +30 (7 UiTemplateListBox/RowTemplateResolver unit tests, 23
SocialFellowshipPageControllerTests covering roster diff/rebuild,
button enable rules, checkbox wiring, create-flow gating, D4
idempotency, and D5 formatting) plus 2 CharacterOptionsPageController
counts updated for the D7 un-dim (35->31 dimmed, 15->19 live).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 04:40:53 +02:00

500 lines
23 KiB
C#

using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Net.Messages;
using AcDream.Core.Selection;
using AcDream.Core.Social;
using AcDream.Runtime;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Controller-level tests for <see cref="SocialPanelController"/> against the
/// committed <c>social_panel_2100006E_1000018F.json</c> fixture (Campaign FA
/// slice FA3) — the SAME <c>LayoutImporter.ImportInfos(dats, 0x2100006Eu,
/// 0x1000018Fu)</c> catalog import <see cref="RetailUiRuntime.MountSocialPanel"/>
/// performs against real DATs. No DAT access, no live runtime — dat-free per
/// the established <see cref="FixtureLoader"/> pattern.
/// </summary>
public sealed class SocialPanelControllerTests
{
private static readonly RuntimeCommandResult InactiveResult =
new(RuntimeCommandStatus.Inactive, default);
/// <summary>Campaign FA slice FA4: the Fellowship page's full read/write
/// seam. Every command records its call (by name) into
/// <paramref name="calls"/> and returns <see cref="InactiveResult"/> —
/// hermetic tests here exercise WIRING (does clicking X call the right
/// delegate with the right argument), not live command semantics
/// (covered by <c>RuntimeFellowshipStateTests</c>/
/// <c>DirectGameRuntimeCommandAdapterTests</c>).</summary>
private static SocialFellowshipPageController.Bindings MakeFellowshipBindings(
List<string>? calls = null,
RuntimeFellowshipSnapshot snapshot = default,
IEnumerable<RuntimeFellowMemberSnapshot>? members = null,
SelectionState? selection = null,
uint localPlayerGuid = 0u,
Func<CharacterOptionId, bool>? currentCharacterOption = null,
Func<uint, uint, UiElement?>? templateResolver = null,
Func<uint, uint, string?>? resolveString = null)
{
calls ??= new List<string>();
return new SocialFellowshipPageController.Bindings(
Snapshot: () => snapshot,
Members: () => members ?? [],
TemplateResolver: templateResolver ?? FakeRowTemplateResolver,
Create: (name, shareXp) => { calls.Add($"fellowship-create:{name}:{shareXp}"); return InactiveResult; },
Recruit: guid => { calls.Add($"fellowship-recruit:{guid:X8}"); return InactiveResult; },
Dismiss: guid => { calls.Add($"fellowship-dismiss:{guid:X8}"); return InactiveResult; },
Quit: disband => { calls.Add($"fellowship-quit:{disband}"); return InactiveResult; },
AssignLeader: guid => { calls.Add($"fellowship-assign-leader:{guid:X8}"); return InactiveResult; },
SetOpen: isOpen => { calls.Add($"fellowship-set-open:{isOpen}"); return InactiveResult; },
SetPanelOpen: panelOpen => { calls.Add($"fellowship-set-panel-open:{panelOpen}"); return InactiveResult; },
Selection: selection ?? new SelectionState(),
LocalPlayerGuid: () => localPlayerGuid,
CurrentCharacterOption: currentCharacterOption ?? (_ => false),
SetCharacterOption: (id, value) => calls.Add($"fellowship-set-option:{id}:{value}"),
ResolveString: resolveString ?? ((_, _) => null));
}
private static SocialPanelController.Callbacks MakeCallbacks(
List<string>? calls = null,
RuntimeFellowshipSnapshot fellowship = default,
RuntimeAllegianceSnapshot allegiance = default,
FriendsState? friends = null,
SquelchState? squelch = null)
{
calls ??= new List<string>();
return new SocialPanelController.Callbacks(
Toggle: () => calls.Add("toggle"),
Fellowship: MakeFellowshipBindings(calls, fellowship),
AllegianceSnapshot: () => allegiance,
Friends: friends ?? new FriendsState(),
Squelch: squelch ?? new SquelchState(),
TemplateResolver: FakeRowTemplateResolver);
}
/// <summary>Mirrors the real Friends/Squelch row templates' own two-deep
/// nested-text shape AND their own authored extent (270x24, mechanism
/// SF-9's live dump) — FA3 live-mount probe — so
/// <see cref="SocialPanelRowText.FindDeepest"/> has something realistic
/// to resolve and <see cref="UiTemplateListBox.ContentHeight"/> grows
/// realistically per row (blast MF-1's long-roster scroll tests),
/// without needing the templates' own (uncommitted, separate
/// LayoutDesc) fixtures.</summary>
private static UiElement? FakeRowTemplateResolver(uint layoutId, uint elementId)
{
var outer = new UiText { Width = 270f, Height = 24f };
var inner = new UiText();
outer.AddChild(inner);
return outer;
}
// ── Mount conformance ────────────────────────────────────────────────────
[Fact]
public void Bind_RootBuildsAsUiTabPanel()
{
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
Assert.IsType<UiTabPanel>(layout.Root);
}
/// <summary>
/// Pins the authored <c>0x2E</c> tab table exactly as read from the live
/// DATs (FA3 live-mount probe) — CORRECTING the coordinator addendum's
/// x-order guess (see <see cref="SocialPanelController"/>'s own class
/// doc). Four entries; Allegiance is the sole default.
/// </summary>
[Fact]
public void TabTable_MatchesLiveDatPairing_AllegianceIsDefault()
{
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
var tabs = Assert.IsType<UiTabPanel>(layout.Root);
Assert.Equal(4, tabs.Tabs.Count);
Assert.Contains(tabs.Tabs, e =>
e.ButtonElementId == 0x1000028Cu && e.PageElementId == 0x10000291u && e.IsDefault);
Assert.Contains(tabs.Tabs, e =>
e.ButtonElementId == 0x1000028Eu && e.PageElementId == 0x10000292u && !e.IsDefault);
Assert.Contains(tabs.Tabs, e =>
e.ButtonElementId == 0x10000512u && e.PageElementId == 0x10000513u && !e.IsDefault);
Assert.Contains(tabs.Tabs, e =>
e.ButtonElementId == 0x1000053Bu && e.PageElementId == 0x1000054Au && !e.IsDefault);
Assert.Single(tabs.Tabs, e => e.IsDefault);
}
[Fact]
public void Bind_Succeeds_AndActivateTabs_SelectsTheAuthoredDefault()
{
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
SocialPanelController? controller = SocialPanelController.Bind(layout, MakeCallbacks());
Assert.NotNull(controller);
controller!.ActivateTabs();
Assert.Empty(controller.TabPanel.UnresolvedEntries);
Assert.True(controller.IsShowingAllegiance);
Assert.False(controller.IsShowingFellowship);
}
[Fact]
public void ShowFellowship_SwitchesTheActiveTab()
{
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
SocialPanelController? controller = SocialPanelController.Bind(layout, MakeCallbacks());
Assert.NotNull(controller);
controller!.ActivateTabs();
controller.ShowFellowship();
Assert.True(controller.IsShowingFellowship);
Assert.False(controller.IsShowingAllegiance);
}
[Fact]
public void CloseButton_InvokesToggleCallback()
{
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
var calls = new List<string>();
SocialPanelController? controller = SocialPanelController.Bind(layout, MakeCallbacks(calls));
Assert.NotNull(controller);
var close = Assert.IsType<UiButton>(layout.FindElement(0x10000290u));
close.OnEvent(new UiEvent(0, close, UiEventType.Click));
Assert.Contains("toggle", calls);
}
// ── Fellowship empty state (item 4) ─────────────────────────────────────
[Fact]
public void Fellowship_NoFellowship_ShowsNotInFellowshipFrame()
{
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
SocialPanelController? controller = SocialPanelController.Bind(
layout,
MakeCallbacks(fellowship: new RuntimeFellowshipSnapshot { IsInFellowship = false }));
Assert.NotNull(controller);
UiElement notIn = UiElement.FindDescendant(controller!.TabPanel, 0x1000026Bu)!;
UiElement inFellowship = UiElement.FindDescendant(controller.TabPanel, 0x10000275u)!;
Assert.True(notIn.Visible);
Assert.False(inFellowship.Visible);
}
[Fact]
public void Fellowship_HasFellowship_ShowsInFellowshipFrame()
{
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
SocialPanelController? controller = SocialPanelController.Bind(
layout,
MakeCallbacks(fellowship: new RuntimeFellowshipSnapshot { IsInFellowship = true }));
Assert.NotNull(controller);
UiElement notIn = UiElement.FindDescendant(controller!.TabPanel, 0x1000026Bu)!;
UiElement inFellowship = UiElement.FindDescendant(controller.TabPanel, 0x10000275u)!;
Assert.False(notIn.Visible);
Assert.True(inFellowship.Visible);
}
// ── Allegiance empty state (item 4) ─────────────────────────────────────
[Fact]
public void Allegiance_NoProfile_HidesBlocksAndBlanksNames()
{
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
SocialPanelController? controller = SocialPanelController.Bind(
layout,
MakeCallbacks(allegiance: new RuntimeAllegianceSnapshot { HasProfile = false }));
Assert.NotNull(controller);
UiElement monarchField = UiElement.FindDescendant(controller!.TabPanel, 0x10000255u)!;
UiElement patronField = UiElement.FindDescendant(controller.TabPanel, 0x1000025Au)!;
Assert.False(monarchField.Visible);
Assert.False(patronField.Visible);
var monarchName = Assert.IsType<UiText>(
UiElement.FindDescendant(monarchField, 0x10000257u));
var patronName = Assert.IsType<UiText>(
UiElement.FindDescendant(patronField, 0x1000025Cu));
Assert.Equal(" ", Assert.Single(monarchName.LinesProvider()).Text);
Assert.Equal(" ", Assert.Single(patronName.LinesProvider()).Text);
}
[Fact]
public void Allegiance_HasProfile_ShowsBothBlocks()
{
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
SocialPanelController? controller = SocialPanelController.Bind(
layout,
MakeCallbacks(allegiance: new RuntimeAllegianceSnapshot { HasProfile = true }));
Assert.NotNull(controller);
UiElement monarchField = UiElement.FindDescendant(controller!.TabPanel, 0x10000255u)!;
UiElement patronField = UiElement.FindDescendant(controller.TabPanel, 0x1000025Au)!;
Assert.True(monarchField.Visible);
Assert.True(patronField.Visible);
}
// ── Friends/Squelch read-only lists (item 5) ────────────────────────────
[Fact]
public void Friends_PopulatesOneRowPerEntry()
{
var friends = new FriendsState();
friends.Apply(new FriendsUpdate(
FriendsUpdateType.Full,
new List<FriendEntry>
{
new(1u, "Alice", true, false, System.Array.Empty<uint>(), System.Array.Empty<uint>()),
new(2u, "Bob", false, false, System.Array.Empty<uint>(), System.Array.Empty<uint>()),
}));
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
SocialPanelController? controller = SocialPanelController.Bind(
layout, MakeCallbacks(friends: friends));
Assert.NotNull(controller);
var listBox = Assert.IsType<UiTemplateListBox>(
UiElement.FindDescendant(controller!.TabPanel, 0x10000513u) is { } friendsPage
? UiElement.FindDescendant(friendsPage, 0x10000517u)
: null);
Assert.Equal(2, listBox.ViewportForTest!.Children.Count);
}
[Fact]
public void Friends_ReactsToRevisionChange_OnTick()
{
var friends = new FriendsState();
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
SocialPanelController? controller = SocialPanelController.Bind(
layout, MakeCallbacks(friends: friends));
Assert.NotNull(controller);
// Fix-round blast SF-2: Friends/Squelch rebuild is gated on the
// panel's own visibility — simulate the window being shown, the
// same IRetainedPanelController.OnShown() the window manager calls
// in production.
controller!.OnShown();
UiElement friendsPage = UiElement.FindDescendant(controller.TabPanel, 0x10000513u)!;
var listBox = Assert.IsType<UiTemplateListBox>(
UiElement.FindDescendant(friendsPage, 0x10000517u));
Assert.Equal(0, listBox.ViewportForTest?.Children.Count ?? 0);
friends.Apply(new FriendsUpdate(
FriendsUpdateType.Add,
new List<FriendEntry>
{
new(1u, "Alice", true, false, System.Array.Empty<uint>(), System.Array.Empty<uint>()),
}));
controller.Tick();
Assert.Single(listBox.ViewportForTest!.Children);
}
[Fact]
public void Squelch_PopulatesCharacterAndAccountRows()
{
var squelch = new SquelchState();
squelch.Replace(new SquelchDatabase(
new Dictionary<string, uint>(System.StringComparer.OrdinalIgnoreCase) { ["BadAccount"] = 1u },
new Dictionary<uint, SquelchInfo>
{
[7u] = new SquelchInfo("Grief", false, new HashSet<uint>()),
},
new SquelchInfo(string.Empty, false, new HashSet<uint>())));
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
SocialPanelController? controller = SocialPanelController.Bind(
layout, MakeCallbacks(squelch: squelch));
Assert.NotNull(controller);
UiElement squelchPage = UiElement.FindDescendant(controller!.TabPanel, 0x1000054Au)!;
var listBox = Assert.IsType<UiTemplateListBox>(
UiElement.FindDescendant(squelchPage, 0x1000053Eu));
Assert.Equal(2, listBox.ViewportForTest!.Children.Count);
}
// ── D1: Friends/Squelch action buttons are honest INERT ────────────────
/// <summary>
/// Pins the INERT contract register row AD-79 enumerates. Fix-round
/// mechanism SF-5: AD-79 names SEVEN controls (three Friends buttons,
/// the Friends "Appear Offline"-shaped checkbox, three Squelch
/// buttons) but this test previously only covered the six buttons —
/// <c>0x1000052C</c> builds through <see cref="DatWidgetFactory.BuildCheckbox"/>
/// as a <see cref="UiButton"/> too, so it slots into the same loop.
/// </summary>
[Fact]
public void FriendsAndSquelchActionButtons_AreClickable_ButHaveNoHandler()
{
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
SocialPanelController? controller = SocialPanelController.Bind(layout, MakeCallbacks());
Assert.NotNull(controller);
foreach (uint buttonId in new[] { 0x10000514u, 0x10000515u, 0x10000516u, 0x1000052Cu })
{
var button = Assert.IsType<UiButton>(
UiElement.FindDescendant(controller!.TabPanel, buttonId));
Assert.Null(button.OnClick);
}
foreach (uint buttonId in new[] { 0x10000547u, 0x1000054Bu, 0x1000054Cu })
{
var button = Assert.IsType<UiButton>(
UiElement.FindDescendant(controller!.TabPanel, buttonId));
Assert.Null(button.OnClick);
}
}
// ── Scrollbar wiring (blast MF-1) ───────────────────────────────────────
[Fact]
public void Friends_ScrollbarModel_IsWiredToListBoxScroll()
{
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
SocialPanelController? controller = SocialPanelController.Bind(layout, MakeCallbacks());
Assert.NotNull(controller);
UiElement friendsPage = UiElement.FindDescendant(controller!.TabPanel, 0x10000513u)!;
var listBox = Assert.IsType<UiTemplateListBox>(
UiElement.FindDescendant(friendsPage, 0x10000517u));
var scrollbar = Assert.IsType<UiScrollbar>(
UiElement.FindDescendant(friendsPage, 0x10000518u));
Assert.Same(listBox.Scroll, scrollbar.Model);
}
[Fact]
public void Squelch_ScrollbarModel_IsWiredToListBoxScroll()
{
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
SocialPanelController? controller = SocialPanelController.Bind(layout, MakeCallbacks());
Assert.NotNull(controller);
UiElement squelchPage = UiElement.FindDescendant(controller!.TabPanel, 0x1000054Au)!;
var listBox = Assert.IsType<UiTemplateListBox>(
UiElement.FindDescendant(squelchPage, 0x1000053Eu));
var scrollbar = Assert.IsType<UiScrollbar>(
UiElement.FindDescendant(squelchPage, 0x10000543u));
Assert.Same(listBox.Scroll, scrollbar.Model);
}
/// <summary>
/// Blast MF-1: proves a roster taller than the panel is actually
/// REACHABLE through the scrollbar, not merely wired-but-unused. 40 rows
/// at the real Friends row template's own height (270x24, mechanism
/// SF-9's live dump) comfortably exceed the ListBox's authored extent
/// (270x400) — this roster genuinely needs scrolling.
/// </summary>
[Fact]
public void Friends_LongRoster_IsReachableViaScrollbar()
{
var friends = new FriendsState();
var entries = new List<FriendEntry>();
for (uint i = 0; i < 40; i++)
entries.Add(new(i, $"Friend{i}", true, false, System.Array.Empty<uint>(), System.Array.Empty<uint>()));
friends.Apply(new FriendsUpdate(FriendsUpdateType.Full, entries));
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
SocialPanelController? controller = SocialPanelController.Bind(
layout, MakeCallbacks(friends: friends));
Assert.NotNull(controller);
UiElement friendsPage = UiElement.FindDescendant(controller!.TabPanel, 0x10000513u)!;
var listBox = Assert.IsType<UiTemplateListBox>(
UiElement.FindDescendant(friendsPage, 0x10000517u));
var scrollbar = Assert.IsType<UiScrollbar>(
UiElement.FindDescendant(friendsPage, 0x10000518u));
Assert.True(
listBox.ContentHeight > (int)listBox.Height,
$"40 rows (contentHeight={listBox.ContentHeight}) should exceed the box's own height ({listBox.Height})");
// The scrollbar's Model IS the ListBox's own Scroll — moving it
// moves the SAME state that positions the rows
// (UiScrollablePanel.LayoutScrollableChildren, driven by the real
// render loop; simulated directly here since this is a dat-free
// controller test with no Draw pass).
scrollbar.Model!.SetExtents(listBox.ContentHeight, (int)listBox.Height);
Assert.True(scrollbar.Model.HasOverflow);
int before = scrollbar.Model.ScrollY;
scrollbar.Model.ScrollByLines(4);
Assert.True(scrollbar.Model.ScrollY > before);
}
/// <summary>Squelch counterpart of <see cref="Friends_LongRoster_IsReachableViaScrollbar"/>
/// (270x430 authored extent).</summary>
[Fact]
public void Squelch_LongRoster_IsReachableViaScrollbar()
{
var characters = new Dictionary<uint, SquelchInfo>();
for (uint i = 0; i < 40; i++)
characters[i] = new SquelchInfo($"Grief{i}", false, new HashSet<uint>());
var squelch = new SquelchState();
squelch.Replace(new SquelchDatabase(
new Dictionary<string, uint>(System.StringComparer.OrdinalIgnoreCase),
characters,
new SquelchInfo(string.Empty, false, new HashSet<uint>())));
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
SocialPanelController? controller = SocialPanelController.Bind(
layout, MakeCallbacks(squelch: squelch));
Assert.NotNull(controller);
UiElement squelchPage = UiElement.FindDescendant(controller!.TabPanel, 0x1000054Au)!;
var listBox = Assert.IsType<UiTemplateListBox>(
UiElement.FindDescendant(squelchPage, 0x1000053Eu));
var scrollbar = Assert.IsType<UiScrollbar>(
UiElement.FindDescendant(squelchPage, 0x10000543u));
Assert.True(
listBox.ContentHeight > (int)listBox.Height,
$"40 rows (contentHeight={listBox.ContentHeight}) should exceed the box's own height ({listBox.Height})");
scrollbar.Model!.SetExtents(listBox.ContentHeight, (int)listBox.Height);
Assert.True(scrollbar.Model.HasOverflow);
int before = scrollbar.Model.ScrollY;
scrollbar.Model.ScrollByLines(4);
Assert.True(scrollbar.Model.ScrollY > before);
}
// ── Rebuild discipline (blast SF-2/SF-3) ────────────────────────────────
/// <summary>
/// Blast SF-2: the panel's Friends/Squelch rebuild must not run while
/// the window is closed. <see cref="SocialPanelController"/> starts
/// with no visibility notification (matches production: the window
/// mounts <c>Visible = false</c> and nothing calls <c>OnShown</c> until
/// F3/F4), so a revision bump with no <c>OnShown</c> call must NOT
/// rebuild the list; the SAME bump picked up on the next <c>Tick()</c>
/// after <c>OnShown()</c> proves the rebuild is deferred, not lost.
/// </summary>
[Fact]
public void Friends_RevisionBumpWhileHidden_DoesNotRebuild_ButRebuildsOnShow()
{
var friends = new FriendsState();
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
SocialPanelController? controller = SocialPanelController.Bind(
layout, MakeCallbacks(friends: friends));
Assert.NotNull(controller);
UiElement friendsPage = UiElement.FindDescendant(controller!.TabPanel, 0x10000513u)!;
var listBox = Assert.IsType<UiTemplateListBox>(
UiElement.FindDescendant(friendsPage, 0x10000517u));
friends.Apply(new FriendsUpdate(
FriendsUpdateType.Add,
new List<FriendEntry> { new(1u, "Alice", true, false, System.Array.Empty<uint>(), System.Array.Empty<uint>()) }));
// Panel never shown — Tick() must not rebuild.
controller!.Tick();
Assert.Equal(0, listBox.ViewportForTest?.Children.Count ?? 0);
// Showing the panel and ticking again picks up the accumulated bump.
controller.OnShown();
controller.Tick();
Assert.Single(listBox.ViewportForTest!.Children);
}
}