acdream/tests/AcDream.App.Tests/UI/Layout/CharacterScreensFixedCanvasArbiterTests.cs
Erik 2176ba768e fix(chargen): Campaign CC CC7 review fix round — F1-F9 — REVIEW-CLOSED
Both dual-lens reviewers of `9cf6c522`+`ddcbf1fb` returned PASS-with-items.
This round closes all nine findings:

F1 files AP-229 for the screen-layering divergence (retail destroys/
reconstructs the current UI framework via UIFlow::UseNewMode; acdream
keeps both CharacterManagementUiController and CharacterCreationUiController
mounted for the whole lifetime and reveals/occludes) plus its narrow
residual risk (the shared RetailDialogFactory can hand UiRoot.Modal to a
dialog opened by the still-ticking, occluded management screen on an
inbound CharacterError) and what already matches retail (selection/
world-name persistence, click-through isolation, one coherent Modal
stack).

F2 rewrites the connected-gate script's roster-full step with the exact
`@modifylong max_chars_per_account` recipe and the pending-delete-counts
note. F3 adds AP-221's console-diagnostic lines to the known-gaps
paragraph. F4 adds an empty-name/AP-227 step. F9 notes that a uniform
Random pick over 13 heritages can repeat.

F5 adds an App-layer source-text pin
(GameWindowLiveSessionOwnershipTests.LiveSessionRuntimeFactoryBinds
CharacterCreatedAndCreationFailedToTheStatusWriter) for the delegate
wiring the reviewer proved was deletable without breaking any test — no
practical seam exists to construct LiveSessionRuntimeFactory without a
GameWindow, so this follows the file's own established source-text-pin
pattern; the payload shape is already pinned separately at
SessionStatusWriterTests.

F6 corrects the CC7 ledger's checksum-assertion wording (it is a
round-trip purity check, not an independent golden — the golden is
CharacterCreateTests.ComputeChecksum_ExactRetailAccumulationSet) and
cross-references it from the test's own doc comment.

F7 corrects the CC7 ledger's fixture-ordering claim (it had chargen
constructing first, backwards from RetailUiRuntime.Tick's real
management-then-chargen order) and reorders CharacterScreensFixedCanvas
ArbiterTests to match production, adding ClickThrough/ZOrder assertions
that pin the occlusion the reviewer previously verified only by hand.

F8 records a known flake (RuntimeCollisionReportingStateTests.
WarmedSteadyContactRefreshDoesNotAllocate, allocation-assertion load
sensitivity, pre-existing) seen under full-solution parallel load on
both reviewer runs.

Campaign status: all seven slices (CC1-CC7) are REVIEW-CLOSED; the
campaign is CODE-COMPLETE pending the user's own connected gate.

Runtime 1735/0 (unchanged), App 5257/3 skips (+1: the new F5 pin).
Full Release build: 0 warnings, 0 errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 03:13:02 +02:00

506 lines
22 KiB
C#

using System.Numerics;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.CharGen;
using AcDream.Runtime;
using AcDream.Runtime.Session;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Campaign CC slice CC4 review-fix round R1 (2026-08-15):
/// <see cref="CharacterCreationUiController"/> and
/// <see cref="CharacterManagementUiController"/> can be SIMULTANEOUSLY
/// active against the SAME <see cref="UiRoot"/> — character-creation opens
/// on top of character-management, which keeps ticking underneath it. Both
/// controllers share <see cref="UiRoot.FixedCanvasSize"/>, and before this
/// fix each wrote it directly — a raw write from either screen was a last-
/// writer-wins race with no owner: the F1 fix's own chargen
/// <c>Close()</c> nulled the canvas out from under a STILL-ACTIVE
/// character-management screen underneath it (the exact AD-98 gate-round-2
/// defect resurfacing one layer up). These tests drive
/// <see cref="UiRoot.DeclareFixedCanvas"/>/<see cref="UiRoot.RevokeFixedCanvas"/>
/// across BOTH controllers on one shared host, per the reviewer's required
/// sequence.
/// </summary>
public sealed class CharacterScreensFixedCanvasArbiterTests
{
private static readonly Vector2 AuthoredCanvas = new(800f, 600f);
[Fact]
public void CanvasStaysSetWhileEitherScreenIsActive_AndNullsOnlyWhenBothRevoke()
{
using var environment = new TwoControllerHarness();
// char-mgmt alone: declared on its own activation edge (Tick's
// `if (!_active)` arm).
Assert.Equal(AuthoredCanvas, environment.Host.FixedCanvasSize);
// chargen opens ON TOP of the still-active char-mgmt screen.
environment.Chargen.Controller.Open();
Assert.Equal(AuthoredCanvas, environment.Host.FixedCanvasSize);
// chargen Exit-confirms and closes -- char-mgmt is STILL ACTIVE, so
// the canvas must stay set. This is R1's regression: the pre-fix
// Close() nulled UiRoot.FixedCanvasSize unconditionally here,
// stripping it from char-management underneath.
environment.Chargen.Button(CharacterCreationUiController.ExitElementId)
.OnClick!();
environment.Chargen.ConfirmActiveDialog(confirmed: true);
Assert.Equal(AuthoredCanvas, environment.Host.FixedCanvasSize);
Assert.False(environment.Chargen.Controller.Root.Visible);
// char-mgmt deactivates (e.g. entering the world) -- now NEITHER
// screen declares, so the canvas nulls.
environment.Management.Runtime.SetLifecycle(
RuntimeCharacterSelectionLifecycle.InWorld);
environment.Management.Controller.Tick();
Assert.Null(environment.Host.FixedCanvasSize);
}
/// <summary>The original F1 defect's own covering case: both screens
/// revoke together (world entry while chargen was ALSO still open)
/// still nulls the canvas -- not just "one revokes while the other
/// holds," the scenario above.</summary>
[Fact]
public void CanvasNulls_WhenBothScreensRevokeAtWorldEntry()
{
using var environment = new TwoControllerHarness();
environment.Chargen.Controller.Open();
Assert.Equal(AuthoredCanvas, environment.Host.FixedCanvasSize);
environment.Management.Runtime.SetLifecycle(
RuntimeCharacterSelectionLifecycle.InWorld);
environment.Management.Controller.Tick();
environment.Chargen.Runtime.ProvideView = false;
environment.Chargen.Controller.Tick();
Assert.Null(environment.Host.FixedCanvasSize);
}
/// <summary>
/// Campaign CC slice CC7 item 1: the real Create-button wire — retail's
/// <c>gmCharacterManagementUI::ListenToElementMessage @ 0x004ed5a0</c>
/// case 3 -&gt; <c>QueueUIMode(0x1000000b)</c> — and the return path on
/// chargen Exit (<c>DoExit @ 0x004e8650</c> -&gt;
/// <c>QueueUIMode(0x1000000a)</c>). Character-management is never
/// hidden by chargen opening on top of it (see the canvas-arbiter test
/// above), so "return to character management" needs no separate
/// Runtime action beyond chargen's own <c>Close()</c> — this proves
/// that architecture claim end to end rather than by inspection alone.
/// </summary>
[Fact]
public void CreateButtonClick_OpensChargen_AndExitConfirmReturnsToManagement()
{
using var environment = new TwoControllerHarness();
// Character-management is active and visible before Create is ever
// clicked (AttachAndTick already ran in its own harness ctor).
Assert.True(environment.Management.Controller.Root.Visible);
Assert.False(environment.Chargen.Controller.Root.Visible);
UiButton create = environment.Management.Button(
CharacterManagementUiController.CreateElementId);
Assert.True(create.Enabled);
create.OnClick!();
environment.Chargen.Controller.Tick();
Assert.True(environment.Chargen.Controller.Root.Visible);
// Character-management stays active/visible underneath -- chargen
// opening on top never deactivates or hides it.
Assert.True(environment.Management.Controller.Root.Visible);
// F7 (2026-08-16): pin the occlusion the reviewer verified only by
// hand. Chargen's root is click-opaque over the full authored
// canvas, so no input reaches management underneath it...
Assert.False(environment.Chargen.Controller.Root.ClickThrough);
// ...and BringToFront (called every tick chargen is open, see
// CharacterCreationUiController.Tick) keeps chargen's ZOrder
// strictly above management's, so it paints on top too.
Assert.True(
environment.Chargen.Controller.Root.ZOrder
> environment.Management.Controller.Root.ZOrder);
environment.Chargen.Button(CharacterCreationUiController.ExitElementId)
.OnClick!();
environment.Chargen.ConfirmActiveDialog(confirmed: true);
Assert.False(environment.Chargen.Controller.Root.Visible);
// No separate "return" action was needed -- management was never
// hidden, so it is simply what remains visible.
Assert.True(environment.Management.Controller.Root.Visible);
}
// ── Fixture: one shared UiRoot, both controllers ────────────────────
private sealed class TwoControllerHarness : IDisposable
{
public TwoControllerHarness()
{
Host = new UiRoot { Width = 800f, Height = 600f };
// Campaign CC slice CC7 review-fix round, F7 (2026-08-16):
// construct in PRODUCTION order — management, then chargen —
// matching RetailUiRuntime.Tick's real sequence
// (`_characterManagementMount?.Tick(); ...
// _characterCreationMount?.Tick(); CharacterCreationController
// ?.Tick();`). An earlier version of this fixture built chargen
// FIRST and claimed that matched production; it did not — it
// was the opposite order. Management still needs a RequestCreate
// callback before Chargen's Controller exists, so this closure
// resolves Chargen lazily per-call, the SAME trick production's
// own RetailUiRuntime.ConfigureCharacterManagement uses
// (`bindings with { RequestCreate = () =>
// CharacterCreationController?.Open() }`, closing over `this`
// rather than capturing a not-yet-built controller).
// Chargen is assigned below, before this closure can ever be
// invoked (Management's own ctor only stores the callback, it
// does not call it) -- the null-forgiving operator documents
// that ordering guarantee for the nullable analyzer.
Management = new ManagementHarness(Host, () => Chargen!.Controller.Open());
Chargen = new ChargenHarness(Host);
}
public UiRoot Host { get; }
public ManagementHarness Management { get; }
public ChargenHarness Chargen { get; }
public void Dispose()
{
Chargen.Dispose();
Management.Dispose();
}
}
private sealed class ManagementHarness : IDisposable
{
private readonly RetailDialogFactory _dialogs;
public ManagementHarness(UiRoot host, Action requestCreate)
{
Screen = BuildManagementScreen();
Runtime = new ManagementFakeRuntime(requestCreate);
_dialogs = new RetailDialogFactory(
host,
type => RetailDialogFactoryTests.BuildDialogLayout(type));
Controller = Assert.IsType<CharacterManagementUiController>(
CharacterManagementUiController.Bind(
host,
Screen,
static (_, _) => BuildRow(),
_dialogs,
Runtime.Bindings,
new CharacterManagementUiController.DialogStrings(
name => $"WARNING! {name}",
"DELETE",
"Please Wait",
"Entering World",
"Are you sure you want to leave?")));
}
public ImportedLayout Screen { get; }
public ManagementFakeRuntime Runtime { get; }
public CharacterManagementUiController Controller { get; }
public UiButton Button(uint id) =>
Assert.IsType<UiButton>(Screen.FindElement(id));
public void Dispose()
{
Controller.Dispose();
_dialogs.Dispose();
}
private static UiElement BuildRow() => LayoutImporter.Build(
new ElementInfo { Id = 0x100003A5u, Type = 1u, Width = 160f, Height = 16f },
_ => (0u, 0, 0),
null).Root;
private static ImportedLayout BuildManagementScreen()
{
var root = new ElementInfo
{
Id = CharacterManagementUiController.RootElementId,
Type = 3u,
Width = 800f,
Height = 600f,
};
var list = new ElementInfo
{
Id = CharacterManagementUiController.ListElementId,
Type = 5u,
X = 42f,
Y = 212f,
Width = 160f,
Height = 320f,
};
list.TemplateList.Add(new UiTemplateListEntry(0x21000004u, 0x100003A5u));
root.Children.Add(list);
root.Children.Add(new ElementInfo
{
Id = CharacterManagementUiController.WorldTextElementId,
Type = 12u,
Width = 193f,
Height = 110f,
});
root.Children.Add(ButtonInfo(CharacterManagementUiController.CreateElementId));
root.Children.Add(ButtonInfo(CharacterManagementUiController.EnterElementId));
root.Children.Add(ButtonInfo(CharacterManagementUiController.DeleteElementId));
root.Children.Add(ButtonInfo(CharacterManagementUiController.RestoreElementId));
root.Children.Add(ButtonInfo(CharacterManagementUiController.CreditsElementId));
root.Children.Add(ButtonInfo(CharacterManagementUiController.ExitElementId));
return LayoutImporter.Build(root, _ => (0u, 0, 0), null);
}
}
private sealed class ManagementFakeRuntime
{
private static readonly RuntimeGenerationToken Generation = new(11u);
private readonly FakeManagementView _view = new();
public ManagementFakeRuntime(Action requestCreate)
{
_view.Entries = [new RuntimeCharacterSelectionEntry(0, 0x50000001u, "Alpha", 0u)];
_view.Snapshot = new RuntimeCharacterSelectionSnapshot(
Generation,
RuntimeCharacterSelectionLifecycle.AwaitingSelection,
Revision: 1,
AccountName: "account",
SlotCount: 5,
RosterCount: _view.Entries.Length,
WorldName: "sawato",
HighlightedCharacterId: 0x50000001u,
HighlightedDisplayIndex: 0,
PendingDeleteCharacterId: 0u,
LastRestoreRequestedCharacterId: 0u,
Operation: RuntimeCharacterSelectionOperation.None,
Error: null,
Buttons: new RuntimeCharacterSelectionButtons(true, true, false, true, false, true));
Bindings = new CharacterSelectionRuntimeBindings(
View: () => _view,
Highlight: _ => Result(),
Enter: Result,
RequestDelete: Result,
ConfirmDelete: Result,
Restore: Result,
Cancel: Result,
RequestExit: () => { },
RequestCreate: requestCreate);
}
public CharacterSelectionRuntimeBindings Bindings { get; }
private static RuntimeCommandResult Result() =>
new(RuntimeCommandStatus.Accepted, Generation);
public void SetLifecycle(RuntimeCharacterSelectionLifecycle lifecycle)
{
RuntimeCharacterSelectionSnapshot current = _view.Snapshot;
_view.Snapshot = current with { Lifecycle = lifecycle, Revision = current.Revision + 1 };
}
private sealed class FakeManagementView : IRuntimeCharacterSelectionView
{
public RuntimeCharacterSelectionEntry[] Entries { get; set; } = [];
public RuntimeCharacterSelectionSnapshot Snapshot { get; set; }
public bool TryGetAt(int displayIndex, out RuntimeCharacterSelectionEntry character)
{
if ((uint)displayIndex >= (uint)Entries.Length)
{
character = default;
return false;
}
character = Entries[displayIndex];
return true;
}
public bool TryGet(uint characterId, out RuntimeCharacterSelectionEntry character)
{
int index = Array.FindIndex(Entries, entry => entry.CharacterId == characterId);
if (index < 0)
{
character = default;
return false;
}
character = Entries[index];
return true;
}
public void Visit(IRuntimeCharacterSelectionVisitor visitor)
{
foreach (RuntimeCharacterSelectionEntry character in Entries)
visitor.Visit(in character);
}
public IDisposable Subscribe(IRuntimeCharacterSelectionObserver observer) =>
NullSubscription.Instance;
private sealed class NullSubscription : IDisposable
{
public static readonly NullSubscription Instance = new();
public void Dispose() { }
}
}
}
private sealed class ChargenHarness : IDisposable
{
private readonly List<ImportedLayout> _dialogLayouts = [];
private readonly RetailDialogFactory _dialogs;
public ChargenHarness(UiRoot host)
{
Screen = BuildChargenScreen();
Runtime = new ChargenFakeRuntime();
_dialogs = new RetailDialogFactory(host, type =>
{
ImportedLayout layout = RetailDialogFactoryTests.BuildDialogLayout(type);
_dialogLayouts.Add(layout);
return layout;
});
Controller = Assert.IsType<CharacterCreationUiController>(
CharacterCreationUiController.CreateDetached(
host,
Screen,
static (_, _) => null,
_dialogs,
Runtime.Bindings,
new CharacterCreationUiController.DialogStrings(
"Are you sure you want to leave?",
"No name", "Unspent credits", "Randomize?", "Name too long")));
Controller.AttachAndTick();
}
public ImportedLayout Screen { get; }
public ChargenFakeRuntime Runtime { get; }
public CharacterCreationUiController Controller { get; }
public UiButton Button(uint id) =>
Assert.IsType<UiButton>(Screen.FindElement(id));
public void ConfirmActiveDialog(bool confirmed)
{
ImportedLayout dialog = _dialogLayouts[^1];
uint buttonId = confirmed
? RetailConfirmationDialogView.AcceptButtonId
: RetailConfirmationDialogView.RejectButtonId;
UiButton button = Assert.IsType<UiButton>(dialog.FindElement(buttonId));
button.OnClick!();
}
public void Dispose()
{
Controller.Dispose();
_dialogs.Dispose();
}
private static ImportedLayout BuildChargenScreen()
{
var root = new ElementInfo
{
Id = CharacterCreationUiController.RootElementId,
Type = 3u,
Width = 800f,
Height = 600f,
};
root.Children.Add(ContainerInfo(CharacterCreationUiController.ProgressBarElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.BackElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.NextElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.FinishElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.HelpElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.ExitElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.RandomElementId));
root.Children.Add(ContainerInfo(CharacterCreationUiController.MasterPageElementId));
root.Children.Add(ContainerInfo(CharacterCreationUiController.HeritagePageElementId));
root.Children.Add(ContainerInfo(CharacterCreationUiController.ProfessionPageElementId));
root.Children.Add(ContainerInfo(CharacterCreationUiController.SkillsPageElementId));
root.Children.Add(ContainerInfo(CharacterCreationUiController.AppearancePageElementId));
root.Children.Add(ContainerInfo(CharacterCreationUiController.TownPageElementId));
root.Children.Add(ContainerInfo(CharacterCreationUiController.SummaryPageElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.HeritageTabElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.ProfessionTabElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.SkillsTabElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.AppearanceTabElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.TownTabElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.SummaryTabElementId));
return LayoutImporter.Build(root, _ => (0u, 0, 0), null);
}
private static ElementInfo ContainerInfo(uint id) =>
new() { Id = id, Type = 3u, Width = 200f, Height = 60f };
}
private sealed class ChargenFakeRuntime
{
private static readonly RuntimeGenerationToken Generation = new(13u);
public ChargenFakeRuntime()
{
View = new FakeChargenView();
Bindings = new CharacterCreationRuntimeBindings(
() => ProvideView ? View : null,
_ => Result(),
_ => Result(),
_ => Result(),
(_, _) => Result(),
(_, _) => Result(),
_ => Result(),
_ => Result(),
_ => Result(),
_ => Result(),
_ => Result(),
RequestExit: () => { },
ResolveText: _ => null,
OpenOnStart: false);
}
public FakeChargenView View { get; }
public CharacterCreationRuntimeBindings Bindings { get; }
public bool ProvideView { get; set; } = true;
private static RuntimeCommandResult Result() =>
new(RuntimeCommandStatus.Accepted, Generation);
public sealed class FakeChargenView : IRuntimeCharacterCreationView
{
public RuntimeCharacterCreationSnapshot Snapshot { get; set; } =
new(
Generation,
IsActive: true,
Revision: 1,
HeritageId: 0u,
GenderKey: 0u,
Appearance: RuntimeCharacterCreationAppearance.Default,
Template: RuntimeCharacterCreationSnapshot.TemplateUnset,
Attributes: default,
AttributeLockMask: 0u,
TotalAttributeCredits: 0u,
RemainingAttributeCredits: 0,
TotalSkillCredits: 0u,
RemainingSkillCredits: 0,
Name: string.Empty,
StartArea: -1,
Slot: 0u,
VerificationPending: false,
LastLocalRefusal: default,
LastRejection: null,
LastCreated: null);
public ChargenOptions Options => ChargenOptions.Empty;
public ChargenSkillAdvancementClass GetSkillLevel(uint skillId) =>
ChargenSkillAdvancementClass.Inactive;
public IDisposable Subscribe(IRuntimeCharacterCreationObserver observer) =>
NullSubscription.Instance;
private sealed class NullSubscription : IDisposable
{
public static readonly NullSubscription Instance = new();
public void Dispose() { }
}
}
}
private static ElementInfo ButtonInfo(uint id) =>
new() { Id = id, Type = 1u, Width = 100f, Height = 30f };
}