fix #FA4-mechanism-MUST-FIX-3,SF-4: re-arm 0x00A6 on reconnect; unsubscribe ActivePageChanged on Dispose

MUST-FIX 3 -- SocialFellowshipPageController.SetPageVisible is edge-triggered
on a bool that survives a generation reset unchanged while the panel stays
open, so a reconnect never re-sends 0x00A6 and fellow vitals freeze for the
rest of the new session. SocialPanelController.ResetSessionDeclaration
clears the fellowship page's latch (SocialFellowshipPageController.
ResetPageVisibleLatch, this commit's counterpart) and re-evaluates the
existing "window shown AND Fellowship active" conjunction, wired into
RetailUiRuntime.ResetSessionTransientUi -- a seam that already runs on
every generation reset. A still-open Fellowship page re-declares; a closed
or other-tab page correctly stays silent.

SF-4 -- SocialPanelController's constructor subscribed an anonymous lambda
to UiTabPanel.ActivePageChanged with no way to remove it; Dispose only set
a flag. A tab switch after Dispose still reached
UpdateFellowshipPageVisibility and issued a Runtime command, since Tick's
own _disposed guard doesn't cover this event path. Stored the handler as a
field and unsubscribe it in Dispose.

Also adds the panel-level D4 conjunction test mechanism SF-5 flagged as
missing (the only prior D4 test exercised the PAGE controller's own
edge-trigger directly, never SocialPanelController's "window shown AND
Fellowship active" logic or its ActivePageChanged subscription).

Per docs/research/2026-08-12-fa4-review-mechanism.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-12 07:33:32 +02:00
parent 5499f0581f
commit df00030697
3 changed files with 166 additions and 2 deletions

View file

@ -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) ─────
/// <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) — a generation reset (reconnect) while
/// the Fellowship page is still open must re-declare <c>0x00A6</c> on
/// the fresh session, not silently stay latched from the old one.
/// </summary>
[Fact]
public void ResetSessionDeclaration_ReArms0x00A6_ForAStillOpenFellowshipPage()
{
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();
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);
}
/// <summary>MUST-FIX 3's counterpart: a reconnect while the panel is
/// closed (or on a different tab) must NOT spuriously declare
/// <c>0x00A6</c> true.</summary>
[Fact]
public void ResetSessionDeclaration_StaysSilent_WhenFellowshipPageIsNotActuallyOpen()
{
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(); // 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<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"));
}
}