acdream/tests/AcDream.App.Tests/UI/GameplayConfirmationControllerTests.cs
Erik 7ed79eaf10 feat(ui): FA5 -- allegiance page fully live: CF-1 subscription, blocks, roster, swear/break/kick
Campaign FA slice FA5. The Allegiance page (0x10000291) goes from FA3's
empty-state shell to fully live, wired against FA1's parser and FA2's
RuntimeAllegianceState/IRuntimeAllegianceCommands (both already shipped
the full command surface, including SetUpdateSubscription).

CF-1 (the corrected data subscription): 0x001F AllegianceUpdateRequest,
not 0x027B, is the panel's data source (0x027B/0x027C are text-only chat
per FA2 MF-2). Wired at retail's three arming points -- Bind's PostInit
attempt (almost always a pre-world no-op), the post-world EnteredWorld
seam (RedeclareAfterWorldEntry, UNCONDITIONAL -- does not check the
current latch, matching retail's own PlayerDescReceived arm and avoiding
the exact MF-3-REOPEN bug class FA4 hit for 0x00A6), and the visible
branch (SetPageVisible, edge-triggered, folded into
SocialPanelController's existing window-shown+active-tab conjunction
alongside Fellowship's 0x00A6).

Monarch/patron/self blocks: per-relationship empty-state gate (fix-round
SF-7) replacing FA3's coarse HasProfile-only gate -- the monarch block
hides when there is no monarch OR the monarch is the viewer; the patron
block hides when there is no patron OR the patron is the monarch (in
which case the monarch block's 0x10000490 sub-block reveals and its
label swaps to PatronSlashMonarchLabel). Field sources decompiled fresh
from gmAllegianceUI::UpdatePlayerData/UpdateMonarchData/UpdatePatronData:
0x10000251 is the ALLEGIANCE's own name (not the viewer's), follower
counts are TotalVassals/TotalMembers-1 directly off the wire, and the
"experience passed up" text (0x10000492, doubled -- scoped FindDescendant
under each of its two parents) is the viewer's own CpTithed under the
monarch/patron blocks and each vassal's own CpTithed in their row.

Vassal roster: flat list built via UiTemplateListBox.FlushPreservingScroll
in the FA4 roster-diff pattern (guid-set diff, in-place update on an
unchanged set), rendering in the bindings' own already-reversed order.

Swear/break/kick: each opens a local confirmation dialog
(RetailDialogFactory via ShowConfirmation) before sending, mirroring
retail's MakeSwearConfirmationDialog family -- Swear targets the WORLD
selection (via the same ClientObjectTable name resolver
ToolbarRuntimeBindings.ResolveName already uses), Break targets the
current patron, Kick targets the panel-local selected vassal row (no
world-selection sync for Allegiance, unlike Fellowship). The
server-driven "accept incoming swear" (ConfirmationType 1) needed no new
code -- GameplayConfirmationController already handles every type
generically; a new test verifies it explicitly.

Runtime/composition plumbing: DeferredGameRuntimeStateCommands gains
Allegiance{Swear,Break,Kick,SetUpdateSubscription}; SocialRuntimeBindings
gains the Allegiance view/command projections; SocialPanelController.
Callbacks.AllegianceSnapshot widens to a full
SocialAllegiancePageController.Bindings record, mirroring FA4's
Fellowship widening.

Tests: SocialPanelControllerTests.cs gains 10 tests covering the SF-7
gate (4), roster population, swear/break/kick wiring (3), and the CF-1
subscription arming points (2); GameplayConfirmationControllerTests.cs
gains the type-1 verification test.
Also extends SocialPanelLiveMountProbeTests.cs (production-mount
assertions: scoped 0x10000492 resolution, the vassal row template, the
checkbox, confirmation-dialog string resolution, and a full production
Bind() pass) -- not yet run against live DATs in this worktree (no
Documents/Asheron's Call present here).

Release build green; full solution suite 13,296 passed / 4 skipped / 0
failed (13,300 total), up from FA4's 13,285/4/0 baseline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 08:40:07 +02:00

163 lines
7.4 KiB
C#

using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.App.Tests.UI.Layout;
using AcDream.Core.Net.Messages;
namespace AcDream.App.Tests.UI;
public sealed class GameplayConfirmationControllerTests
{
[Fact]
public void SkillRequestAppendsContinueAndCloseNoticeSendsServerTuple()
{
var root = new UiRoot { Width = 800f, Height = 600f };
ImportedLayout? shown = null;
var factory = new RetailDialogFactory(root, _ =>
shown = FixtureLoader.LoadConfirmationDialog());
var responses = new List<(uint Type, uint Context, bool Accepted)>();
using var controller = new GameplayConfirmationController(
factory,
(type, context, accepted) => responses.Add((type, context, accepted)));
Assert.True(controller.HandleRequest(
new GameEvents.CharacterConfirmationRequest(2u, 42u, "Raise this skill?")));
Assert.Equal(
"Raise this skill? Continue?",
string.Join(" ", Assert.IsType<UiText>(shown!.FindElement(
RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text)));
Assert.IsType<UiButton>(shown.FindElement(
RetailConfirmationDialogView.AcceptButtonId)).OnClick!();
Assert.Equal([(2u, 42u, true)], responses);
Assert.Equal(0u, controller.ActiveDialogContext);
}
/// <summary>
/// MUST-FIX 2 (FA4 fix round, 2026-08-12): a fellowship invite
/// (<c>ConfirmationType.Fellowship</c>, type 4) reaches this generic
/// controller and opens a dialog exactly like any other type — no
/// client-side interceptor exists anymore (D6's correction: retail's
/// client reads neither option bit on the invite path;
/// <c>RetailUiRuntime.HandleConfirmationRequest</c> now routes every
/// type, including 4, straight here). Type 4 is NOT in the
/// 2/3/5/6 " Continue?"-suffix set, so the message renders verbatim —
/// matching <c>Handle_Character__ConfirmationRequest @0x005640A0</c>'s
/// case-4 arm, which is a single call with no text transformation.
/// </summary>
[Fact]
public void FellowshipInviteRequest_Type4_OpensDialog_MessageVerbatim_AndSendsAcceptOnClose()
{
var root = new UiRoot { Width = 800f, Height = 600f };
ImportedLayout? shown = null;
var factory = new RetailDialogFactory(root, _ =>
shown = FixtureLoader.LoadConfirmationDialog());
var responses = new List<(uint Type, uint Context, bool Accepted)>();
using var controller = new GameplayConfirmationController(
factory,
(type, context, accepted) => responses.Add((type, context, accepted)));
Assert.True(controller.HandleRequest(
new GameEvents.CharacterConfirmationRequest(4u, 7u, "Alice invites you to join their fellowship.")));
Assert.Equal(
"Alice invites you to join their fellowship.",
string.Join(" ", Assert.IsType<UiText>(shown!.FindElement(
RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text)));
Assert.IsType<UiButton>(shown.FindElement(
RetailConfirmationDialogView.AcceptButtonId)).OnClick!();
Assert.Equal([(4u, 7u, true)], responses);
Assert.Equal(0u, controller.ActiveDialogContext);
}
/// <summary>
/// Campaign FA slice FA5, item 3: verifies the allegiance-swear
/// confirmation (<c>ConfirmationType.AllegianceSwear</c>, type 1 —
/// <c>RecvNotice_SwearAllegianceRequest</c> ->
/// <c>Handle_Character__ConfirmationRequest @0x005640A0</c>'s case 1)
/// reaches this SAME generic controller, mirroring the type-4 test
/// above exactly — no allegiance-specific intercept exists (there
/// never was one to remove; D6's fellowship correction did not touch
/// type 1 at all, but FA5's own contract calls for this explicit check
/// since <c>SocialAllegiancePageController</c> is the new panel that
/// makes this path reachable). Type 1 is NOT in the 2/3/5/6 " Continue?"
/// suffix set, so the message renders verbatim — ACE's own type-1
/// message is the target's BARE name (lane C §6.4:
/// <c>Player_Allegiance.cs:91</c>/<c>ConfirmationManager.cs:38</c>), not
/// a full sentence, which this test's message deliberately mirrors
/// rather than inventing retail's unported <c>StringInfo</c>-wrapped
/// sentence (AD-85).
/// </summary>
[Fact]
public void AllegianceSwearRequest_Type1_OpensDialog_MessageVerbatim_AndSendsAcceptOnClose()
{
var root = new UiRoot { Width = 800f, Height = 600f };
ImportedLayout? shown = null;
var factory = new RetailDialogFactory(root, _ =>
shown = FixtureLoader.LoadConfirmationDialog());
var responses = new List<(uint Type, uint Context, bool Accepted)>();
using var controller = new GameplayConfirmationController(
factory,
(type, context, accepted) => responses.Add((type, context, accepted)));
Assert.True(controller.HandleRequest(
new GameEvents.CharacterConfirmationRequest(1u, 13u, "Bob")));
Assert.Equal(
"Bob",
string.Join(" ", Assert.IsType<UiText>(shown!.FindElement(
RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text)));
Assert.IsType<UiButton>(shown.FindElement(
RetailConfirmationDialogView.AcceptButtonId)).OnClick!();
Assert.Equal([(1u, 13u, true)], responses);
Assert.Equal(0u, controller.ActiveDialogContext);
}
[Fact]
public void MatchingConfirmationDoneClosesDialogAndUnmatchedTupleDoesNothing()
{
var root = new UiRoot { Width = 800f, Height = 600f };
var factory = new RetailDialogFactory(root, _ =>
FixtureLoader.LoadConfirmationDialog());
var responses = new List<(uint Type, uint Context, bool Accepted)>();
using var controller = new GameplayConfirmationController(
factory,
(type, context, accepted) => responses.Add((type, context, accepted)));
controller.HandleRequest(new GameEvents.CharacterConfirmationRequest(7u, 99u, "Proceed?"));
Assert.False(controller.HandleDone(
new GameEvents.CharacterConfirmationDone(7u, 100u)));
Assert.True(factory.IsOpen);
Assert.True(controller.HandleDone(
new GameEvents.CharacterConfirmationDone(7u, 99u)));
Assert.False(factory.IsOpen);
Assert.Equal([(7u, 99u, false)], responses);
}
[Fact]
public void FactoryReset_CompletesResponseBeforeSessionTupleIsForgotten()
{
var root = new UiRoot { Width = 800f, Height = 600f };
var factory = new RetailDialogFactory(root, _ =>
FixtureLoader.LoadConfirmationDialog());
var responses = new List<(uint Type, uint Context, bool Accepted)>();
using var controller = new GameplayConfirmationController(
factory,
(type, context, accepted) => responses.Add((type, context, accepted)));
controller.HandleRequest(
new GameEvents.CharacterConfirmationRequest(7u, 99u, "Proceed?"));
factory.Reset();
controller.ResetSession();
Assert.Equal(0u, controller.ActiveDialogContext);
Assert.Equal([(7u, 99u, false)], responses);
Assert.False(factory.IsOpen);
}
}