diff --git a/src/AcDream.App/UI/Layout/SocialPanelController.cs b/src/AcDream.App/UI/Layout/SocialPanelController.cs index 59538a3e..65692704 100644 --- a/src/AcDream.App/UI/Layout/SocialPanelController.cs +++ b/src/AcDream.App/UI/Layout/SocialPanelController.cs @@ -105,6 +105,16 @@ public sealed class SocialPanelController : IRetainedPanelController private readonly SocialAllegiancePageController? _allegiance; private readonly SocialFriendsPageController? _friends; private readonly SocialSquelchPageController? _squelch; + + /// Fix-round SF-4: stored so can + /// unsubscribe it — the constructor previously subscribed an anonymous + /// lambda with no way to remove it, so a tab switch reaching + /// AFTER + /// still dispatched into and + /// a Runtime command (Tick itself already guards on + /// ; this event does not go through Tick). + private readonly Action _onActivePageChanged; + private bool _disposed; /// Root element of the imported panel (the tab host itself — @@ -136,8 +146,10 @@ public sealed class SocialPanelController : IRetainedPanelController // four-page mount that's the conjunction of both. Subscribed here // (not in Bind) so it observes every tab switch, including the // very first one ActivateTabBehavior's default-entry activation - // fires. - _tabPanel.ActivePageChanged += (_, _) => UpdateFellowshipPageVisibility(); + // fires. Fix-round SF-4: stored as a field (not an inline lambda) + // so Dispose can unsubscribe it. + _onActivePageChanged = (_, _) => UpdateFellowshipPageVisibility(); + _tabPanel.ActivePageChanged += _onActivePageChanged; } /// @@ -267,6 +279,28 @@ public sealed class SocialPanelController : IRetainedPanelController private void UpdateFellowshipPageVisibility() => _fellowship?.SetPageVisible(_visible && IsShowingFellowship); + /// + /// MUST-FIX 3 (FA4 fix round, 2026-08-12): called from + /// — a seam that + /// already runs on every generation reset — to re-arm the Fellowship + /// page's 0x00A6 edge-trigger latch. + /// is process-lifetime and the latch + /// () is + /// edge-triggered on a bool that survives a reconnect unchanged, so + /// without this a still-open Fellowship page never re-declares + /// 0x00A6 on the fresh session and every fellow's vitals freeze + /// for its remaining lifetime. Clears the latch, then re-evaluates the + /// SAME D4 conjunction //tab + /// switches use — a still-open Fellowship page re-declares; a closed or + /// other-tab page correctly stays silent (the conjunction is false, so + /// the now-cleared latch is simply left at its default). + /// + public void ResetSessionDeclaration() + { + _fellowship?.ResetPageVisibleLatch(); + UpdateFellowshipPageVisibility(); + } + /// /// Per-frame poll: the Fellowship/Allegiance empty-state gates (no /// data-driven provider exists) and the @@ -306,5 +340,9 @@ public sealed class SocialPanelController : IRetainedPanelController { if (_disposed) return; _disposed = true; + // Fix-round SF-4: without this, a tab switch after Dispose still + // reaches UpdateFellowshipPageVisibility -> a Runtime command — + // Tick's own _disposed guard doesn't cover this event handler. + _tabPanel.ActivePageChanged -= _onActivePageChanged; } } diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index cc85f4c7..e82a3c01 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -729,11 +729,24 @@ public sealed class RetailUiRuntime : IDisposable } } + /// + /// MUST-FIX 3 (FA4 fix round): also re-arms the fellowship page's + /// 0x00A6 panel-open declaration latch + /// (). + /// is process-lifetime, but its + /// edge-trigger latch is not — without this, a reconnect while the + /// Fellowship page is left open never re-sends 0x00A6 on the new + /// session, and every fellow's vitals freeze for the rest of the + /// session (lane B §4.5: ACE streams 0x02C0 only to fellows with + /// FellowshipPanelOpen set, and that flag is set only by + /// 0x00A6). + /// public void ResetSessionTransientUi() { ResetSessionDialogs(); AppraisalController?.ResetSession(); Host.HideWindow(WindowNames.Examination); + SocialPanelController?.ResetSessionDeclaration(); } public void UpdateCursor(IEnumerable mice) diff --git a/tests/AcDream.App.Tests/UI/Layout/SocialPanelControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SocialPanelControllerTests.cs index ff0db6a8..3640f260 100644 --- a/tests/AcDream.App.Tests/UI/Layout/SocialPanelControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/SocialPanelControllerTests.cs @@ -497,4 +497,117 @@ public sealed class SocialPanelControllerTests 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")); + } }