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; /// /// Controller-level tests for against the /// committed social_panel_2100006E_1000018F.json fixture (Campaign FA /// slice FA3) — the SAME LayoutImporter.ImportInfos(dats, 0x2100006Eu, /// 0x1000018Fu) catalog import /// performs against real DATs. No DAT access, no live runtime — dat-free per /// the established pattern. /// public sealed class SocialPanelControllerTests { private static readonly RuntimeCommandResult InactiveResult = new(RuntimeCommandStatus.Inactive, default); private static readonly RuntimeCommandResult AcceptedResult = new(RuntimeCommandStatus.Accepted, default); /// Campaign FA slice FA4: the Fellowship page's full read/write /// seam. Every command records its call (by name) into /// and returns — /// hermetic tests here exercise WIRING (does clicking X call the right /// delegate with the right argument), not live command semantics /// (covered by RuntimeFellowshipStateTests/ /// DirectGameRuntimeCommandAdapterTests). private static SocialFellowshipPageController.Bindings MakeFellowshipBindings( List? calls = null, RuntimeFellowshipSnapshot snapshot = default, IEnumerable? members = null, SelectionState? selection = null, uint localPlayerGuid = 0u, Func? currentCharacterOption = null, Func? templateResolver = null, Func? resolveString = null, Func? panelOpenInWorld = null) { calls ??= new List(); // 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 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)); } /// Campaign FA slice FA5: the Allegiance page's full read/write /// seam, mirroring '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 without wiring the rest. private static SocialAllegiancePageController.Bindings MakeAllegianceBindings( List? calls = null, RuntimeAllegianceSnapshot snapshot = default, RuntimeAllegianceMemberSnapshot? monarch = null, Func? patron = null, Func? member = null, Func>? vassals = null, AcDream.Core.Selection.SelectionState? selection = null, uint localPlayerGuid = 0u, Func? currentCharacterOption = null, Func? templateResolver = null, Func? resolveString = null, Func? resolveWorldObjectName = null, Func, uint>? showConfirmation = null) { calls ??= new List(); 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? calls = null, RuntimeFellowshipSnapshot fellowship = default, RuntimeAllegianceSnapshot allegiance = default, FriendsState? friends = null, SquelchState? squelch = null, Func? panelOpenInWorld = null, SocialAllegiancePageController.Bindings? allegianceBindings = null) { calls ??= new List(); 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); } /// 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 /// has something realistic /// to resolve and grows /// realistically per row (blast MF-1's long-roster scroll tests), /// without needing the templates' own (uncommitted, separate /// LayoutDesc) fixtures. 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(layout.Root); } /// /// Pins the authored 0x2E tab table exactly as read from the live /// DATs (FA3 live-mount probe) — CORRECTING the coordinator addendum's /// x-order guess (see 's own class /// doc). Four entries; Allegiance is the sole default. /// [Fact] public void TabTable_MatchesLiveDatPairing_AllegianceIsDefault() { ImportedLayout layout = FixtureLoader.LoadSocialPanelHost(); var tabs = Assert.IsType(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(); SocialPanelController? controller = SocialPanelController.Bind(layout, MakeCallbacks(calls)); Assert.NotNull(controller); var close = Assert.IsType(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( UiElement.FindDescendant(monarchField, 0x10000257u)); var patronName = Assert.IsType( UiElement.FindDescendant(patronField, 0x1000025Cu)); Assert.Equal(" ", Assert.Single(monarchName.LinesProvider()).Text); Assert.Equal(" ", Assert.Single(patronName.LinesProvider()).Text); } /// /// Fix-round SF-7: gmAllegianceUI::UpdateMonarchData @0x00491B40 /// hides the monarch block PER-RELATIONSHIP, not on the coarse FA3 /// HasProfile flag alone — a real monarch who is NOT the viewer /// shows the block, with the name and _total_members - 1 follower /// count rendered. /// [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(UiElement.FindDescendant(monarchField, 0x10000257u)); Assert.Equal("Queen Alice", Assert.Single(monarchName.LinesProvider()).Text); var monarchFollowers = Assert.IsType(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); } /// SF-7's own additional test — the monarch IS the viewer: /// retail hides the block even though a monarch record exists. [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); } /// Patron analogue of the monarch test — a real patron who is /// NOT the monarch shows the block with the patron's name. [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(UiElement.FindDescendant(patronField, 0x1000025Cu)); Assert.Equal("Sir Bob", Assert.Single(patronName.LinesProvider()).Text); } /// /// SF-7's headline case: patron IS the monarch — the patron block hides /// AND the monarch block's 0x10000490 sub-block (the XP-passed-up /// line) reveals, matching UpdateMonarchData's /// PatronSlashMonarchLabel branch. /// [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); } /// The vassal list populates one row per vassal, in the /// bindings' own (already-reversed) order — the controller must not /// re-sort. [Fact] public void Allegiance_VassalList_PopulatesOneRowPerVassal() { const uint selfGuid = 100u; var vassals = new List { 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( UiElement.FindDescendant(allegiancePage, 0x10000260u)); Assert.Equal(2, listBox.ViewportForTest!.Children.Count); } /// FA5 mechanism-review SF-1: the offline cue is the authored /// 0x100004AA marker ALONE — visible for an offline vassal, hidden /// for an online one — and the vassal NAME always renders in the normal /// white regardless of online state (retail's /// UpdateVassalsData writes the name with no colour change; the /// grey-name cue was invented and is removed). [Fact] public void Allegiance_OfflineCue_IsTheMarkerOnly_NameStaysWhite() { const uint selfGuid = 100u; var vassals = new List { 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( UiElement.FindDescendant(page, 0x10000260u)); var rows = listBox.ViewportForTest!.Children; Assert.Equal(2, rows.Count); foreach (UiElement row in rows) { var name = Assert.IsType(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); } } /// Swear latches the WORLD selection at click time and, on /// accept, sends the swear command for that exact guid. [Fact] public void Allegiance_Swear_ShowsConfirmation_AndSendsOnAccept() { const uint selfGuid = 100u; const uint targetGuid = 500u; var calls = new List(); Action? 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(UiElement.FindDescendant(allegiancePage, 0x10000263u)); swearButton.OnClick!(); Assert.Contains("confirm:Target Player", calls); Assert.NotNull(capturedCallback); capturedCallback!(true); Assert.Contains($"allegiance-swear:{targetGuid:X8}", calls); } /// Break targets the CURRENT patron (latched at click time), /// not the world/panel selection. [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(); Action? 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(UiElement.FindDescendant(allegiancePage, 0x10000264u)); breakButton.OnClick!(); Assert.NotNull(capturedCallback); capturedCallback!(true); Assert.Contains($"allegiance-break:{patronGuid:X8}", calls); } /// 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). [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(); Action? 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( UiElement.FindDescendant(allegiancePage, 0x10000260u)); var kickButton = Assert.IsType(UiElement.FindDescendant(allegiancePage, 0x10000265u)); Assert.False(kickButton.Enabled); // nothing selected yet UiElement row = Assert.Single(listBox.ViewportForTest!.Children); var rowName = Assert.IsType(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 ───────────────────────────── /// CF-1's visible-branch arm, mirroring /// FellowshipPageVisible_Declares0x00A6_OnlyWhenWindowShownANDFellowshipActive. /// Allegiance is the DEFAULT tab, so the conjunction is already true the /// instant the window is shown (no tab switch needed). [Fact] public void AllegiancePageVisible_DeclaresSubscription_OnWindowShown_AndClearsOnHidden() { var calls = new List(); 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); } /// /// The exact bug class MF-3-REOPEN caught for Fellowship's 0x00A6, /// re-verified here for Allegiance's 0x001F: a reconnect must /// re-subscribe from the POST-world seam, UNCONDITIONALLY — even while /// the panel is closed, because retail's own RecvNotice_PlayerDescReceived /// arm does not check panel visibility either (§1.2). /// [Fact] public void Reconnect_ReDeclaresSubscription_AfterWorldEntry_EvenWhilePanelClosed() { var calls = new List(); 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 { new(1u, "Alice", true, false, System.Array.Empty(), System.Array.Empty()), new(2u, "Bob", false, false, System.Array.Empty(), System.Array.Empty()), })); ImportedLayout layout = FixtureLoader.LoadSocialPanelHost(); SocialPanelController? controller = SocialPanelController.Bind( layout, MakeCallbacks(friends: friends)); Assert.NotNull(controller); var listBox = Assert.IsType( 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( UiElement.FindDescendant(friendsPage, 0x10000517u)); Assert.Equal(0, listBox.ViewportForTest?.Children.Count ?? 0); friends.Apply(new FriendsUpdate( FriendsUpdateType.Add, new List { new(1u, "Alice", true, false, System.Array.Empty(), System.Array.Empty()), })); controller.Tick(); Assert.Single(listBox.ViewportForTest!.Children); } [Fact] public void Squelch_PopulatesCharacterAndAccountRows() { var squelch = new SquelchState(); squelch.Replace(new SquelchDatabase( new Dictionary(System.StringComparer.OrdinalIgnoreCase) { ["BadAccount"] = 1u }, new Dictionary { [7u] = new SquelchInfo("Grief", false, new HashSet()), }, new SquelchInfo(string.Empty, false, new HashSet()))); 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( UiElement.FindDescendant(squelchPage, 0x1000053Eu)); Assert.Equal(2, listBox.ViewportForTest!.Children.Count); } // ── D1: Friends/Squelch action buttons are honest INERT ──────────────── /// /// 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 — /// 0x1000052C builds through /// as a too, so it slots into the same loop. /// [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( UiElement.FindDescendant(controller!.TabPanel, buttonId)); Assert.Null(button.OnClick); } foreach (uint buttonId in new[] { 0x10000547u, 0x1000054Bu, 0x1000054Cu }) { var button = Assert.IsType( 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( UiElement.FindDescendant(friendsPage, 0x10000517u)); var scrollbar = Assert.IsType( 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( UiElement.FindDescendant(squelchPage, 0x1000053Eu)); var scrollbar = Assert.IsType( UiElement.FindDescendant(squelchPage, 0x10000543u)); Assert.Same(listBox.Scroll, scrollbar.Model); } /// /// 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. /// [Fact] public void Friends_LongRoster_IsReachableViaScrollbar() { var friends = new FriendsState(); var entries = new List(); for (uint i = 0; i < 40; i++) entries.Add(new(i, $"Friend{i}", true, false, System.Array.Empty(), System.Array.Empty())); 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( UiElement.FindDescendant(friendsPage, 0x10000517u)); var scrollbar = Assert.IsType( 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); } /// Squelch counterpart of /// (270x430 authored extent). [Fact] public void Squelch_LongRoster_IsReachableViaScrollbar() { var characters = new Dictionary(); for (uint i = 0; i < 40; i++) characters[i] = new SquelchInfo($"Grief{i}", false, new HashSet()); var squelch = new SquelchState(); squelch.Replace(new SquelchDatabase( new Dictionary(System.StringComparer.OrdinalIgnoreCase), characters, new SquelchInfo(string.Empty, false, new HashSet()))); 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( UiElement.FindDescendant(squelchPage, 0x1000053Eu)); var scrollbar = Assert.IsType( 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) ──────────────────────────────── /// /// Blast SF-2: the panel's Friends/Squelch rebuild must not run while /// the window is closed. starts /// with no visibility notification (matches production: the window /// mounts Visible = false and nothing calls OnShown until /// F3/F4), so a revision bump with no OnShown call must NOT /// rebuild the list; the SAME bump picked up on the next Tick() /// after OnShown() proves the rebuild is deferred, not lost. /// [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( UiElement.FindDescendant(friendsPage, 0x10000517u)); friends.Apply(new FriendsUpdate( FriendsUpdateType.Add, new List { new(1u, "Alice", true, false, System.Array.Empty(), System.Array.Empty()) })); // 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) ───── /// /// Fix-round mechanism SF-5 — the only pre-existing D4 test /// (SocialFellowshipPageControllerTests.SetPageVisible_SendsPanelOpen_OnlyOnATransition) /// exercises the PAGE controller's edge-trigger directly; nothing /// exercised the actual conjunction this slice is named for — /// SocialPanelController's own "window shown AND Fellowship /// active" logic, its ActivePageChanged subscription, or /// /. /// [Fact] public void FellowshipPageVisible_Declares0x00A6_OnlyWhenWindowShownANDFellowshipActive() { var calls = new List(); 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); } /// /// MUST-FIX 3 (FA4 fix round) + its re-review REOPEN re-fix: a reconnect /// while the Fellowship page stays open must re-declare 0x00A6 on /// the fresh session — but ONLY AFTER world entry. The generation reset /// () runs /// BEFORE the new session is in world, where SetPanelOpen is /// dropped (Inactive); it must only clear the latch. The re-declaration /// is , wired /// to the post-world EnteredWorld 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. /// [Fact] public void Reconnect_ReDeclares0x00A6_AfterWorldEntry_NotDuringPreWorldReset() { var calls = new List(); 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); } /// MUST-FIX 3's counterpart: a reconnect while the panel is /// closed (or on a different tab) must NOT declare 0x00A6 true, /// even after world entry. [Fact] public void Reconnect_StaysSilent_WhenFellowshipPageIsNotActuallyOpen() { var calls = new List(); 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(); 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")); } }