acdream/tests/AcDream.App.Tests/UI/Layout/SocialPanelControllerTests.cs
Erik 72ceddce2e fix: social panel completion batch (user gate 2026-08-13, "fix all")
One user-ordered batch across the FA social panel + world selection.
Every root cause was probe-proven before the fix (new
ProbeSocialClickRouting in SocialPanelLiveMountProbeTests - production
window mount + real UiRoot hit-tests + a synthetic click):

1. STUCK CHECKBOXES (fellowship x4, allegiance x1, "always checked /
   can't change any options"): the authored checkboxes carry DAT
   ToggleBehavior, so UiButton SELF-FLIPS Selected at MouseUp - the old
   handlers read the flipped value and wrote the ORIGINAL back, snapping
   every click to where it started (the probe recorded (id, oldValue)).
   Fix: SuppressSelfToggle (the CH6a/b mirror discipline) + derive the
   next value from the STORE; the per-tick seeding mirrors it back.
2. UNCLICKABLE ROSTER ROWS ("only get the move window cursor"): the row
   name text is display-text ClickThrough=true, which the hit-test walk
   skips regardless of HandlesClick - the wired OnClick was unreachable.
   Fix: UiText.OnClick assignment now clears ClickThrough (central,
   documented); the stats text gains the same select handler so most of
   the row's width selects the fellow.
3. TRUNCATED EMPTY-STATE ("You do not belong... To create MISSING"):
   the authored string resolves COMPLETE (three sentences) but embedded
   '\n's rendered as one clipped line. DatWidgetFactory now splits
   authored strings into one Line per newline, with the provider still
   re-reading DefaultColor live (the state-color contract - caught by
   BuildText_AuthoredLineTracksStateFontColor).
4. FELLOW NAMES WHITE (user-directed): the AD-82 invented leader-gold +
   selection-blue tints are deleted; names always white (register row
   narrowed).
5. ALLEGIANCE HEADER LABELS: bare "0"/"0" -> "Followers: N" / "Rank: [N]"
   (user-specified format; the full retail StringInfo composition stays
   AD-85's gap), monarch block matching.
6. FRIENDS/SQUELCH LIVE (AD-79 mostly retired): Add friend (name box ->
   0x0018, retail clears the box - Request_AddFriend @0x0048D240),
   Remove (row-click selection -> 0x0017), Appear Offline (CharacterOption
   0x27 via the immediate 0x0005 auto-save, ACE pushes FriendStatusChanged
   to your friend-of list), Squelch Character/Account add-by-name
   (0x0058 guid0/type AllChannels + 0x0059) and Remove for the selected
   row. The wire beneath (builders, WorldSession sends, Runtime commands,
   parsers) existed end-to-end since J4.1/FA1 - this is panel wiring only
   (docs/research/2026-08-13-social-wire-completion.md, committed here).
   Send Tell stays inert (not in the order; AD-79's remainder).
7. WORLD SELF-SELECTION ("clicking my own char should select myself"):
   retail has NO self-exclusion (CPhysicsPart::Draw @0x0050D823 arms
   every physobj; RecvNotice_SmartBoxObjectFound @0x004E5BAE selects
   unconditionally) - the includeSelf gate was an unregistered
   divergence, now removed on both the left-click and right-click paths.

Element roles were probe-measured, never guessed (Add 0x10000514 /
Remove 0x10000515 / Send Tell 0x10000516 / Appear Offline 0x1000052C /
name field 0x1000051B; Squelch: field 0x10000540, Remove 0x10000547,
Squelch Character 0x1000054B, Squelch Account 0x1000054C).

Register: AD-79 mostly retired, AD-82 narrowed. Known remainder, filed
not hidden: the fellowship page's authored 600px content vs the 362px
viewport leaves Dismiss/Assign-Leader below the fold until the window is
resized taller (probe-measured; candidate follow-up).

Tests: Checkbox_Click fact rewritten to the mirror contract (both
directions), monarch-followers label updated, includeSelf expectation
updated, probe extended (click routing, synthetic click, action-widget
role dump). App suite 4,976/3 skips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 19:30:51 +02:00

1066 lines
50 KiB
C#

using System.Numerics;
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);
private static readonly RuntimeCommandResult AcceptedResult =
new(RuntimeCommandStatus.Accepted, 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,
Func<bool>? panelOpenInWorld = null)
{
calls ??= new List<string>();
// SetPanelOpen (0x00A6) is world-gated in production; only its result
// status feeds the MUST-FIX 3 latch. Default in-world (Accepted) so
// the D4-conjunction tests model production; a reconnect test flips it.
Func<bool> inWorld = panelOpenInWorld ?? (static () => true);
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 inWorld() ? AcceptedResult : InactiveResult; },
Selection: selection ?? new SelectionState(),
LocalPlayerGuid: () => localPlayerGuid,
CurrentCharacterOption: currentCharacterOption ?? (_ => false),
SetCharacterOption: (id, value) => calls.Add($"fellowship-set-option:{id}:{value}"),
ResolveString: resolveString ?? ((_, _) => null));
}
/// <summary>Campaign FA slice FA5: the Allegiance page's full read/write
/// seam, mirroring <see cref="MakeFellowshipBindings"/>'s recorded-call
/// shape. Every accessor defaults to "empty" (no monarch/patron/member/
/// vassals) so a test that only cares about ONE relationship can pass a
/// bare <paramref name="snapshot"/> without wiring the rest.</summary>
private static SocialAllegiancePageController.Bindings MakeAllegianceBindings(
List<string>? calls = null,
RuntimeAllegianceSnapshot snapshot = default,
RuntimeAllegianceMemberSnapshot? monarch = null,
Func<uint, RuntimeAllegianceMemberSnapshot?>? patron = null,
Func<uint, RuntimeAllegianceMemberSnapshot?>? member = null,
Func<uint, IEnumerable<RuntimeAllegianceMemberSnapshot>>? vassals = null,
AcDream.Core.Selection.SelectionState? selection = null,
uint localPlayerGuid = 0u,
Func<CharacterOptionId, bool>? currentCharacterOption = null,
Func<uint, uint, UiElement?>? templateResolver = null,
Func<uint, uint, string?>? resolveString = null,
Func<uint, string?>? resolveWorldObjectName = null,
Func<string, Action<bool>, uint>? showConfirmation = null)
{
calls ??= new List<string>();
return new SocialAllegiancePageController.Bindings(
Snapshot: () => snapshot,
Monarch: () => monarch,
Patron: patron ?? (_ => null),
Member: member ?? (_ => null),
Vassals: vassals ?? (_ => []),
Swear: guid => { calls.Add($"allegiance-swear:{guid:X8}"); return InactiveResult; },
Break: guid => { calls.Add($"allegiance-break:{guid:X8}"); return InactiveResult; },
Kick: guid => { calls.Add($"allegiance-kick:{guid:X8}"); return InactiveResult; },
// Defaults Accepted (in-world) like MakeFellowshipBindings' own
// SetPanelOpen default — CF-1's edge-trigger latch only advances
// on Accepted, so a test exercising OnShown->OnHidden needs the
// send to actually "take" for the second transition to register
// as a real change.
SetUpdateSubscription: on => { calls.Add($"allegiance-set-subscription:{on}"); return AcceptedResult; },
Selection: selection ?? new AcDream.Core.Selection.SelectionState(),
LocalPlayerGuid: () => localPlayerGuid,
CurrentCharacterOption: currentCharacterOption ?? (_ => false),
SetCharacterOption: (id, value) => calls.Add($"allegiance-set-option:{id}:{value}"),
TemplateResolver: templateResolver ?? FakeRowTemplateResolver,
ResolveString: resolveString ?? ((_, _) => null),
ResolveWorldObjectName: resolveWorldObjectName ?? (_ => null),
ShowConfirmation: showConfirmation ?? ((_, _) => 0u));
}
private static SocialPanelController.Callbacks MakeCallbacks(
List<string>? calls = null,
RuntimeFellowshipSnapshot fellowship = default,
RuntimeAllegianceSnapshot allegiance = default,
FriendsState? friends = null,
SquelchState? squelch = null,
Func<bool>? panelOpenInWorld = null,
SocialAllegiancePageController.Bindings? allegianceBindings = null)
{
calls ??= new List<string>();
return new SocialPanelController.Callbacks(
Toggle: () => calls.Add("toggle"),
Fellowship: MakeFellowshipBindings(calls, fellowship, panelOpenInWorld: panelOpenInWorld),
Allegiance: allegianceBindings ?? MakeAllegianceBindings(calls, 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 + per-relationship gate (FA5, fix-round SF-7) ─
[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);
}
/// <summary>
/// Fix-round SF-7: <c>gmAllegianceUI::UpdateMonarchData @0x00491B40</c>
/// hides the monarch block PER-RELATIONSHIP, not on the coarse FA3
/// <c>HasProfile</c> flag alone — a real monarch who is NOT the viewer
/// shows the block, with the name and <c>_total_members - 1</c> follower
/// count rendered.
/// </summary>
[Fact]
public void Allegiance_HasMonarch_NotSelf_ShowsMonarchBlock_AndRendersData()
{
const uint selfGuid = 100u;
const uint monarchGuid = 200u;
var monarch = new RuntimeAllegianceMemberSnapshot(
monarchGuid, 0u, true, "Queen Alice", 0, 0, 0, 0, 0u, 0u, 0, 0, false);
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
SocialAllegiancePageController.Bindings bindings = MakeAllegianceBindings(
snapshot: new RuntimeAllegianceSnapshot { HasProfile = true, TotalMembers = 3u },
monarch: monarch,
localPlayerGuid: selfGuid);
SocialPanelController? controller = SocialPanelController.Bind(
layout, MakeCallbacks(allegianceBindings: bindings));
Assert.NotNull(controller);
UiElement monarchField = UiElement.FindDescendant(controller!.TabPanel, 0x10000255u)!;
Assert.True(monarchField.Visible);
var monarchName = Assert.IsType<UiText>(UiElement.FindDescendant(monarchField, 0x10000257u));
Assert.Equal("Queen Alice", Assert.Single(monarchName.LinesProvider()).Text);
var monarchFollowers = Assert.IsType<UiText>(UiElement.FindDescendant(monarchField, 0x10000258u));
// 2026-08-13 gate: the bare count gained its label (user-directed
// format; the full retail StringInfo composition stays AD-85's gap).
Assert.Equal("Followers: 2", Assert.Single(monarchFollowers.LinesProvider()).Text);
}
/// <summary>SF-7's own additional test — the monarch IS the viewer:
/// retail hides the block even though a monarch record exists.</summary>
[Fact]
public void Allegiance_IsMonarch_HidesMonarchBlock()
{
const uint selfGuid = 100u;
var monarch = new RuntimeAllegianceMemberSnapshot(
selfGuid, 0u, true, "Me", 0, 0, 0, 0, 0u, 0u, 0, 0, false);
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
SocialAllegiancePageController.Bindings bindings = MakeAllegianceBindings(
snapshot: new RuntimeAllegianceSnapshot { HasProfile = true },
monarch: monarch,
localPlayerGuid: selfGuid);
SocialPanelController? controller = SocialPanelController.Bind(
layout, MakeCallbacks(allegianceBindings: bindings));
Assert.NotNull(controller);
UiElement monarchField = UiElement.FindDescendant(controller!.TabPanel, 0x10000255u)!;
Assert.False(monarchField.Visible);
}
/// <summary>Patron analogue of the monarch test — a real patron who is
/// NOT the monarch shows the block with the patron's name.</summary>
[Fact]
public void Allegiance_HasPatron_NotMonarch_ShowsPatronBlock_AndRendersName()
{
const uint selfGuid = 100u;
const uint patronGuid = 300u;
var patron = new RuntimeAllegianceMemberSnapshot(
patronGuid, 0u, true, "Sir Bob", 0, 0, 0, 0, 0u, 0u, 0, 0, false);
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
SocialAllegiancePageController.Bindings bindings = MakeAllegianceBindings(
snapshot: new RuntimeAllegianceSnapshot { HasProfile = true },
patron: guid => guid == selfGuid ? patron : null,
localPlayerGuid: selfGuid);
SocialPanelController? controller = SocialPanelController.Bind(
layout, MakeCallbacks(allegianceBindings: bindings));
Assert.NotNull(controller);
UiElement patronField = UiElement.FindDescendant(controller!.TabPanel, 0x1000025Au)!;
Assert.True(patronField.Visible);
var patronName = Assert.IsType<UiText>(UiElement.FindDescendant(patronField, 0x1000025Cu));
Assert.Equal("Sir Bob", Assert.Single(patronName.LinesProvider()).Text);
}
/// <summary>
/// SF-7's headline case: patron IS the monarch — the patron block hides
/// AND the monarch block's <c>0x10000490</c> sub-block (the XP-passed-up
/// line) reveals, matching <c>UpdateMonarchData</c>'s
/// <c>PatronSlashMonarchLabel</c> branch.
/// </summary>
[Fact]
public void Allegiance_PatronIsMonarch_HidesPatronBlock_RevealsMonarchSubBlock()
{
const uint selfGuid = 100u;
const uint monarchGuid = 200u;
var monarch = new RuntimeAllegianceMemberSnapshot(
monarchGuid, 0u, true, "Queen Alice", 0, 0, 0, 0, 0u, 0u, 0, 0, false);
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
SocialAllegiancePageController.Bindings bindings = MakeAllegianceBindings(
snapshot: new RuntimeAllegianceSnapshot { HasProfile = true, TotalMembers = 2u },
monarch: monarch,
patron: guid => guid == selfGuid ? monarch : null,
localPlayerGuid: selfGuid);
SocialPanelController? controller = SocialPanelController.Bind(
layout, MakeCallbacks(allegianceBindings: bindings));
Assert.NotNull(controller);
UiElement monarchField = UiElement.FindDescendant(controller!.TabPanel, 0x10000255u)!;
UiElement patronField = UiElement.FindDescendant(controller.TabPanel, 0x1000025Au)!;
Assert.True(monarchField.Visible);
Assert.False(patronField.Visible);
UiElement subBlock = UiElement.FindDescendant(monarchField, 0x10000490u)!;
Assert.True(subBlock.Visible);
}
/// <summary>The vassal list populates one row per vassal, in the
/// bindings' own (already-reversed) order — the controller must not
/// re-sort.</summary>
[Fact]
public void Allegiance_VassalList_PopulatesOneRowPerVassal()
{
const uint selfGuid = 100u;
var vassals = new List<RuntimeAllegianceMemberSnapshot>
{
new(401u, selfGuid, true, "Vassal One", 0, 0, 0, 0, 0u, 10u, 0, 0, true),
new(402u, selfGuid, false, "Vassal Two", 0, 0, 0, 0, 0u, 20u, 0, 0, true),
};
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
SocialAllegiancePageController.Bindings bindings = MakeAllegianceBindings(
snapshot: new RuntimeAllegianceSnapshot { HasProfile = true, Revision = 1 },
vassals: guid => guid == selfGuid ? vassals : [],
localPlayerGuid: selfGuid);
SocialPanelController? controller = SocialPanelController.Bind(
layout, MakeCallbacks(allegianceBindings: bindings));
Assert.NotNull(controller);
UiElement allegiancePage = UiElement.FindDescendant(controller!.TabPanel, 0x10000291u)!;
var listBox = Assert.IsType<UiTemplateListBox>(
UiElement.FindDescendant(allegiancePage, 0x10000260u));
Assert.Equal(2, listBox.ViewportForTest!.Children.Count);
}
/// <summary>FA5 mechanism-review SF-1: the offline cue is the authored
/// <c>0x100004AA</c> marker ALONE — visible for an offline vassal, hidden
/// for an online one — and the vassal NAME always renders in the normal
/// white <see cref="Vector4"/> regardless of online state (retail's
/// <c>UpdateVassalsData</c> writes the name with no colour change; the
/// grey-name cue was invented and is removed).</summary>
[Fact]
public void Allegiance_OfflineCue_IsTheMarkerOnly_NameStaysWhite()
{
const uint selfGuid = 100u;
var vassals = new List<RuntimeAllegianceMemberSnapshot>
{
new(401u, selfGuid, true, "Online Vassal", 0, 0, 0, 0, 0u, 0u, 0, 0, true),
new(402u, selfGuid, false, "Offline Vassal", 0, 0, 0, 0, 0u, 0u, 0, 0, true),
};
static UiElement? TaggedRowResolver(uint layoutId, uint elementId)
{
var row = new UiPanel();
var name = new UiText { DatElementId = 0x10000268u };
var marker = new UiPanel { DatElementId = 0x100004AAu };
row.AddChild(name);
row.AddChild(marker);
return row;
}
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
SocialAllegiancePageController.Bindings bindings = MakeAllegianceBindings(
snapshot: new RuntimeAllegianceSnapshot { HasProfile = true, Revision = 1 },
vassals: guid => guid == selfGuid ? vassals : [],
localPlayerGuid: selfGuid,
templateResolver: TaggedRowResolver);
SocialPanelController? controller = SocialPanelController.Bind(
layout, MakeCallbacks(allegianceBindings: bindings));
Assert.NotNull(controller);
UiElement page = UiElement.FindDescendant(controller!.TabPanel, 0x10000291u)!;
var listBox = Assert.IsType<UiTemplateListBox>(
UiElement.FindDescendant(page, 0x10000260u));
var rows = listBox.ViewportForTest!.Children;
Assert.Equal(2, rows.Count);
foreach (UiElement row in rows)
{
var name = Assert.IsType<UiText>(UiElement.FindDescendant(row, 0x10000268u));
UiElement marker = UiElement.FindDescendant(row, 0x100004AAu)!;
bool online = name.LinesProvider()[0].Text == "Online Vassal";
// The marker IS the offline cue: shown iff offline.
Assert.Equal(!online, marker.Visible);
// The name is always white — no invented offline grey.
Assert.Equal(Vector4.One, name.LinesProvider()[0].Color);
}
}
/// <summary>Swear latches the WORLD selection at click time and, on
/// accept, sends the swear command for that exact guid.</summary>
[Fact]
public void Allegiance_Swear_ShowsConfirmation_AndSendsOnAccept()
{
const uint selfGuid = 100u;
const uint targetGuid = 500u;
var calls = new List<string>();
Action<bool>? capturedCallback = null;
var selection = new AcDream.Core.Selection.SelectionState();
selection.Select(targetGuid, AcDream.Core.Selection.SelectionChangeSource.World);
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
SocialAllegiancePageController.Bindings bindings = MakeAllegianceBindings(
calls,
snapshot: new RuntimeAllegianceSnapshot { HasProfile = true },
selection: selection,
localPlayerGuid: selfGuid,
resolveWorldObjectName: guid => guid == targetGuid ? "Target Player" : null,
showConfirmation: (message, completed) =>
{
calls.Add($"confirm:{message}");
capturedCallback = completed;
return 1u;
});
SocialPanelController? controller = SocialPanelController.Bind(
layout, MakeCallbacks(calls, allegianceBindings: bindings));
Assert.NotNull(controller);
UiElement allegiancePage = UiElement.FindDescendant(controller!.TabPanel, 0x10000291u)!;
var swearButton = Assert.IsType<UiButton>(UiElement.FindDescendant(allegiancePage, 0x10000263u));
swearButton.OnClick!();
Assert.Contains("confirm:Target Player", calls);
Assert.NotNull(capturedCallback);
capturedCallback!(true);
Assert.Contains($"allegiance-swear:{targetGuid:X8}", calls);
}
/// <summary>Break targets the CURRENT patron (latched at click time),
/// not the world/panel selection.</summary>
[Fact]
public void Allegiance_Break_TargetsCurrentPatron()
{
const uint selfGuid = 100u;
const uint patronGuid = 300u;
var patron = new RuntimeAllegianceMemberSnapshot(
patronGuid, 0u, true, "Sir Bob", 0, 0, 0, 0, 0u, 0u, 0, 0, false);
var calls = new List<string>();
Action<bool>? capturedCallback = null;
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
SocialAllegiancePageController.Bindings bindings = MakeAllegianceBindings(
calls,
snapshot: new RuntimeAllegianceSnapshot { HasProfile = true },
patron: guid => guid == selfGuid ? patron : null,
localPlayerGuid: selfGuid,
showConfirmation: (message, completed) =>
{
calls.Add($"confirm:{message}");
capturedCallback = completed;
return 1u;
});
SocialPanelController? controller = SocialPanelController.Bind(
layout, MakeCallbacks(calls, allegianceBindings: bindings));
Assert.NotNull(controller);
UiElement allegiancePage = UiElement.FindDescendant(controller!.TabPanel, 0x10000291u)!;
var breakButton = Assert.IsType<UiButton>(UiElement.FindDescendant(allegiancePage, 0x10000264u));
breakButton.OnClick!();
Assert.NotNull(capturedCallback);
capturedCallback!(true);
Assert.Contains($"allegiance-break:{patronGuid:X8}", calls);
}
/// <summary>Kick targets the panel-local SELECTED VASSAL ROW, not the
/// world selection (lane A §6.2 — Allegiance's list-selection message
/// has no SetSelectedObject call, unlike Fellowship's).</summary>
[Fact]
public void Allegiance_Kick_TargetsSelectedVassalRow()
{
const uint selfGuid = 100u;
const uint vassalGuid = 401u;
var vassal = new RuntimeAllegianceMemberSnapshot(
vassalGuid, selfGuid, true, "Vassal One", 0, 0, 0, 0, 0u, 0u, 0, 0, true);
var calls = new List<string>();
Action<bool>? capturedCallback = null;
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
// The shared fixture-page FakeRowTemplateResolver (untagged, sized
// for Friends/Squelch's two-deep text nesting) doesn't tag its
// built row with the vassal row's own element ids — a real click
// target needs 0x10000268 resolvable, so this test supplies its own
// tagged resolver (mirrors SocialFellowshipPageControllerTests'
// hand-built row shape).
UiElement? TaggedVassalRowResolver(uint layoutId, uint elementId)
{
var row = new UiPanel();
var name = new UiText();
name.DatElementId = 0x10000268u;
row.AddChild(name);
return row;
}
SocialAllegiancePageController.Bindings bindings = MakeAllegianceBindings(
calls,
snapshot: new RuntimeAllegianceSnapshot { HasProfile = true, Revision = 1 },
vassals: guid => guid == selfGuid ? [vassal] : [],
member: guid => guid == vassalGuid ? vassal : null,
localPlayerGuid: selfGuid,
templateResolver: TaggedVassalRowResolver,
showConfirmation: (message, completed) =>
{
calls.Add($"confirm:{message}");
capturedCallback = completed;
return 1u;
});
SocialPanelController? controller = SocialPanelController.Bind(
layout, MakeCallbacks(calls, allegianceBindings: bindings));
Assert.NotNull(controller);
UiElement allegiancePage = UiElement.FindDescendant(controller!.TabPanel, 0x10000291u)!;
var listBox = Assert.IsType<UiTemplateListBox>(
UiElement.FindDescendant(allegiancePage, 0x10000260u));
var kickButton = Assert.IsType<UiButton>(UiElement.FindDescendant(allegiancePage, 0x10000265u));
Assert.False(kickButton.Enabled); // nothing selected yet
UiElement row = Assert.Single(listBox.ViewportForTest!.Children);
var rowName = Assert.IsType<UiText>(UiElement.FindDescendant(row, 0x10000268u));
rowName.OnClick!();
controller!.Tick();
Assert.True(kickButton.Enabled);
kickButton.OnClick!();
Assert.NotNull(capturedCallback);
capturedCallback!(true);
Assert.Contains($"allegiance-kick:{vassalGuid:X8}", calls);
}
// ── CF-1: 0x001F subscription arming points ─────────────────────────────
/// <summary>CF-1's visible-branch arm, mirroring
/// <c>FellowshipPageVisible_Declares0x00A6_OnlyWhenWindowShownANDFellowshipActive</c>.
/// Allegiance is the DEFAULT tab, so the conjunction is already true the
/// instant the window is shown (no tab switch needed).</summary>
[Fact]
public void AllegiancePageVisible_DeclaresSubscription_OnWindowShown_AndClearsOnHidden()
{
var calls = new List<string>();
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
SocialPanelController? controller = SocialPanelController.Bind(
layout, MakeCallbacks(calls));
Assert.NotNull(controller);
controller!.ActivateTabs();
calls.Clear();
Assert.DoesNotContain(calls, c => c.StartsWith("allegiance-set-subscription"));
controller.OnShown();
Assert.Contains("allegiance-set-subscription:True", calls);
calls.Clear();
controller.OnHidden();
Assert.Contains("allegiance-set-subscription:False", calls);
}
/// <summary>
/// The exact bug class MF-3-REOPEN caught for Fellowship's <c>0x00A6</c>,
/// re-verified here for Allegiance's <c>0x001F</c>: a reconnect must
/// re-subscribe from the POST-world seam, UNCONDITIONALLY — even while
/// the panel is closed, because retail's own <c>RecvNotice_PlayerDescReceived</c>
/// arm does not check panel visibility either (§1.2).
/// </summary>
[Fact]
public void Reconnect_ReDeclaresSubscription_AfterWorldEntry_EvenWhilePanelClosed()
{
var calls = new List<string>();
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
SocialPanelController? controller = SocialPanelController.Bind(
layout, MakeCallbacks(calls));
Assert.NotNull(controller);
controller!.ActivateTabs(); // Allegiance is the default tab, window still closed
calls.Clear();
controller.ResetSessionDeclaration();
Assert.DoesNotContain(calls, c => c.StartsWith("allegiance-set-subscription"));
controller.RedeclareAfterWorldEntry();
Assert.Contains("allegiance-set-subscription:True", calls);
}
// ── 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);
}
// ── D4 conjunction + reconnect re-arm (mechanism SF-5, MUST-FIX 3) ─────
/// <summary>
/// Fix-round mechanism SF-5 — the only pre-existing D4 test
/// (<c>SocialFellowshipPageControllerTests.SetPageVisible_SendsPanelOpen_OnlyOnATransition</c>)
/// exercises the PAGE controller's edge-trigger directly; nothing
/// exercised the actual conjunction this slice is named for —
/// <c>SocialPanelController</c>'s own "window shown AND Fellowship
/// active" logic, its <c>ActivePageChanged</c> subscription, or
/// <see cref="SocialPanelController.OnShown"/>/<see cref="SocialPanelController.OnHidden"/>.
/// </summary>
[Fact]
public void FellowshipPageVisible_Declares0x00A6_OnlyWhenWindowShownANDFellowshipActive()
{
var calls = new List<string>();
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
SocialPanelController? controller = SocialPanelController.Bind(
layout,
MakeCallbacks(calls, fellowship: new RuntimeFellowshipSnapshot { IsInFellowship = true }));
Assert.NotNull(controller);
controller!.ActivateTabs(); // authored default tab is Allegiance, not Fellowship
calls.Clear();
// Window not shown yet, and not on the Fellowship tab -> no send.
Assert.DoesNotContain(calls, c => c.StartsWith("fellowship-set-panel-open"));
// Window shown, but STILL on Allegiance -> conjunction false, still no send.
controller.OnShown();
Assert.DoesNotContain(calls, c => c.StartsWith("fellowship-set-panel-open"));
// Switch to Fellowship while shown -> conjunction true -> declares.
controller.ShowFellowship();
Assert.Contains("fellowship-set-panel-open:True", calls);
calls.Clear();
controller.OnHidden(); // window closes while on Fellowship -> declares false
Assert.Contains("fellowship-set-panel-open:False", calls);
}
/// <summary>
/// MUST-FIX 3 (FA4 fix round) + its re-review REOPEN re-fix: a reconnect
/// while the Fellowship page stays open must re-declare <c>0x00A6</c> on
/// the fresh session — but ONLY AFTER world entry. The generation reset
/// (<see cref="SocialPanelController.ResetSessionDeclaration"/>) runs
/// BEFORE the new session is in world, where <c>SetPanelOpen</c> is
/// dropped (Inactive); it must only clear the latch. The re-declaration
/// is <see cref="SocialPanelController.RedeclareAfterWorldEntry"/>, wired
/// to the post-world <c>EnteredWorld</c> seam. The original fix declared
/// from the pre-world reset — the fake recorded a send the real
/// world-gated command would have dropped, so the roster stayed frozen.
/// </summary>
[Fact]
public void Reconnect_ReDeclares0x00A6_AfterWorldEntry_NotDuringPreWorldReset()
{
var calls = new List<string>();
bool inWorld = true;
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
SocialPanelController? controller = SocialPanelController.Bind(
layout,
MakeCallbacks(
calls,
fellowship: new RuntimeFellowshipSnapshot { IsInFellowship = true },
panelOpenInWorld: () => inWorld));
Assert.NotNull(controller);
controller!.ActivateTabs();
controller.OnShown();
controller.ShowFellowship();
Assert.Contains("fellowship-set-panel-open:True", calls); // declared in world
calls.Clear();
// Reconnect: the generation reset runs BEFORE the new session is in
// world. The pre-world reset must leave NO published 0x00A6 (the
// REOPEN bug latched a dropped send here and never retried).
inWorld = false;
controller.ResetSessionDeclaration();
Assert.DoesNotContain(calls, c => c.StartsWith("fellowship-set-panel-open"));
// World entry: the post-world seam re-declares, now Accepted.
inWorld = true;
controller.RedeclareAfterWorldEntry();
Assert.Contains("fellowship-set-panel-open:True", calls);
}
/// <summary>MUST-FIX 3's counterpart: a reconnect while the panel is
/// closed (or on a different tab) must NOT declare <c>0x00A6</c> true,
/// even after world entry.</summary>
[Fact]
public void Reconnect_StaysSilent_WhenFellowshipPageIsNotActuallyOpen()
{
var calls = new List<string>();
bool inWorld = true;
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
SocialPanelController? controller = SocialPanelController.Bind(
layout,
MakeCallbacks(
calls,
fellowship: new RuntimeFellowshipSnapshot { IsInFellowship = true },
panelOpenInWorld: () => inWorld));
Assert.NotNull(controller);
controller!.ActivateTabs(); // default tab: Allegiance (Fellowship not active)
controller.OnShown();
calls.Clear();
inWorld = false;
controller.ResetSessionDeclaration();
inWorld = true;
controller.RedeclareAfterWorldEntry();
Assert.DoesNotContain(calls, c => c.StartsWith("fellowship-set-panel-open"));
}
// ── SF-4: Dispose unsubscribes ActivePageChanged ────────────────────────
[Fact]
public void Dispose_UnsubscribesActivePageChanged_TabSwitchAfterDisposeSendsNoCommand()
{
var calls = new List<string>();
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
SocialPanelController? controller = SocialPanelController.Bind(
layout,
MakeCallbacks(calls, fellowship: new RuntimeFellowshipSnapshot { IsInFellowship = true }));
Assert.NotNull(controller);
controller!.ActivateTabs();
controller.OnShown();
calls.Clear();
controller.Dispose();
// A tab switch after Dispose must not reach UpdateFellowshipPageVisibility
// (and therefore must not issue a Runtime command) -- the whole
// point of unsubscribing in Dispose rather than relying on Tick's
// own _disposed guard, since this path never goes through Tick.
controller.ShowFellowship();
Assert.DoesNotContain(calls, c => c.StartsWith("fellowship-set-panel-open"));
}
}