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); /// 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) { calls ??= new List(); 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? calls = null, RuntimeFellowshipSnapshot fellowship = default, RuntimeAllegianceSnapshot allegiance = default, FriendsState? friends = null, SquelchState? squelch = null) { calls ??= new List(); return new SocialPanelController.Callbacks( Toggle: () => calls.Add("toggle"), Fellowship: MakeFellowshipBindings(calls, fellowship), AllegianceSnapshot: () => 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 (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( 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); } [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 { 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) — a generation reset (reconnect) while /// the Fellowship page is still open must re-declare 0x00A6 on /// the fresh session, not silently stay latched from the old one. /// [Fact] public void ResetSessionDeclaration_ReArms0x00A6_ForAStillOpenFellowshipPage() { 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(); controller.ShowFellowship(); Assert.Contains("fellowship-set-panel-open:True", calls); calls.Clear(); // Simulate a reconnect: generation reset while the panel stays open // on the Fellowship tab (nothing else in this controller changes). controller.ResetSessionDeclaration(); 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 spuriously declare /// 0x00A6 true. [Fact] public void ResetSessionDeclaration_StaysSilent_WhenFellowshipPageIsNotActuallyOpen() { 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(); // default tab: Allegiance controller.OnShown(); calls.Clear(); controller.ResetSessionDeclaration(); 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")); } }