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:
parent
5499f0581f
commit
df00030697
3 changed files with 166 additions and 2 deletions
|
|
@ -105,6 +105,16 @@ public sealed class SocialPanelController : IRetainedPanelController
|
|||
private readonly SocialAllegiancePageController? _allegiance;
|
||||
private readonly SocialFriendsPageController? _friends;
|
||||
private readonly SocialSquelchPageController? _squelch;
|
||||
|
||||
/// <summary>Fix-round SF-4: stored so <see cref="Dispose"/> can
|
||||
/// unsubscribe it — the constructor previously subscribed an anonymous
|
||||
/// lambda with no way to remove it, so a tab switch reaching
|
||||
/// <see cref="UiTabPanel.ActivePageChanged"/> AFTER <see cref="Dispose"/>
|
||||
/// still dispatched into <see cref="UpdateFellowshipPageVisibility"/> and
|
||||
/// a Runtime command (<c>Tick</c> itself already guards on
|
||||
/// <see cref="_disposed"/>; this event does not go through <c>Tick</c>).</summary>
|
||||
private readonly Action<uint, uint> _onActivePageChanged;
|
||||
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -267,6 +279,28 @@ public sealed class SocialPanelController : IRetainedPanelController
|
|||
private void UpdateFellowshipPageVisibility() =>
|
||||
_fellowship?.SetPageVisible(_visible && IsShowingFellowship);
|
||||
|
||||
/// <summary>
|
||||
/// MUST-FIX 3 (FA4 fix round, 2026-08-12): called from
|
||||
/// <see cref="RetailUiRuntime.ResetSessionTransientUi"/> — a seam that
|
||||
/// already runs on every generation reset — to re-arm the Fellowship
|
||||
/// page's <c>0x00A6</c> edge-trigger latch. <see cref="SocialPanelController"/>
|
||||
/// is process-lifetime and the latch
|
||||
/// (<see cref="SocialFellowshipPageController.SetPageVisible"/>) is
|
||||
/// edge-triggered on a bool that survives a reconnect unchanged, so
|
||||
/// without this a still-open Fellowship page never re-declares
|
||||
/// <c>0x00A6</c> on the fresh session and every fellow's vitals freeze
|
||||
/// for its remaining lifetime. Clears the latch, then re-evaluates the
|
||||
/// SAME D4 conjunction <see cref="OnShown"/>/<see cref="OnHidden"/>/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).
|
||||
/// </summary>
|
||||
public void ResetSessionDeclaration()
|
||||
{
|
||||
_fellowship?.ResetPageVisibleLatch();
|
||||
UpdateFellowshipPageVisibility();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-frame poll: the Fellowship/Allegiance empty-state gates (no
|
||||
/// data-driven <see cref="UiElement.Visible"/> 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -729,11 +729,24 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MUST-FIX 3 (FA4 fix round): also re-arms the fellowship page's
|
||||
/// <c>0x00A6</c> panel-open declaration latch
|
||||
/// (<see cref="Layout.SocialPanelController.ResetSessionDeclaration"/>).
|
||||
/// <see cref="SocialPanelController"/> is process-lifetime, but its
|
||||
/// edge-trigger latch is not — without this, a reconnect while the
|
||||
/// Fellowship page is left open never re-sends <c>0x00A6</c> on the new
|
||||
/// session, and every fellow's vitals freeze for the rest of the
|
||||
/// session (lane B §4.5: ACE streams <c>0x02C0</c> only to fellows with
|
||||
/// <c>FellowshipPanelOpen</c> set, and that flag is set only by
|
||||
/// <c>0x00A6</c>).
|
||||
/// </summary>
|
||||
public void ResetSessionTransientUi()
|
||||
{
|
||||
ResetSessionDialogs();
|
||||
AppraisalController?.ResetSession();
|
||||
Host.HideWindow(WindowNames.Examination);
|
||||
SocialPanelController?.ResetSessionDeclaration();
|
||||
}
|
||||
|
||||
public void UpdateCursor(IEnumerable<IMouse> mice)
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue