fix(app,headless): Campaign CC slice CC4 re-review round — R1 arbiter + R2-R4
CC4 re-review returned NOT CLOSED: R1 (MEDIUM, blocking) is a new residual the F1 fix itself introduced, plus three LOW riders (R2, R3, R4). R1 — nulling UiRoot.FixedCanvasSize on chargen Close() stripped it from character-management, which stays active underneath and only sets the canvas on its own activation edge. Root cause (reviewer-named): two controllers writing one host-global with no owner. Fixed with the root-cause shape (reviewer's option (c)): UiRoot.DeclareFixedCanvas(owner, size)/RevokeFixedCanvas(owner), an owner-scoped arbiter — every declarer must agree on the canvas size (a mismatch throws instead of silently last-writer-wins), and the canvas nulls only once EVERY declarer has revoked. Both CharacterCreationUiController and CharacterManagementUi- Controller now declare/revoke instead of writing FixedCanvasSize directly; grepped for stragglers, none remain in production code (the raw setter stays public only for UiRootFixedCanvasTests' isolated scale-math coverage). New test (reviewer-specified): CharacterScreensFixedCanvasArbiterTests — two controllers sharing one UiRoot, proving the canvas stays set through chargen's Exit-confirm Close while char-management is still active, nulling only once char-management also deactivates, plus the original F1 defect's own covering case (both revoke together at world entry). R3 — HeadlessSessionHostTests.ContentLease_InstallsRealChargenOptions_ SelectHeritageIsAccepted proves F6's install actually opens the gate: a content lease carrying a real hand-built DatCharGen heritage (not ChargenOptions.Empty) is installed, and TrySelectHeritage for it succeeds. R2 — filed docs/ISSUES.md #402 for the pre-existing Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate full-suite flake (unrelated to Campaign CC). R4 — fixed "unchached" -> "uncached" typo in InteractionRetainedUiComposition.cs. Runtime 1713/0, App 5127/13 skips (+2), Headless 166/0 (+1), full solution Release build green. Live-DAT probes 7/7 under ACDREAM_PROBE_LIVE_MOUNT=1. The known #402 flake did not fire across 3 consecutive full-suite runs this session. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
ec854db045
commit
8add0667a5
9 changed files with 780 additions and 29 deletions
|
|
@ -0,0 +1,430 @@
|
|||
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);
|
||||
}
|
||||
|
||||
// ── Fixture: one shared UiRoot, both controllers ────────────────────
|
||||
|
||||
private sealed class TwoControllerHarness : IDisposable
|
||||
{
|
||||
public TwoControllerHarness()
|
||||
{
|
||||
Host = new UiRoot { Width = 800f, Height = 600f };
|
||||
Management = new ManagementHarness(Host);
|
||||
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)
|
||||
{
|
||||
ImportedLayout screen = BuildManagementScreen();
|
||||
Runtime = new ManagementFakeRuntime();
|
||||
_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 ManagementFakeRuntime Runtime { get; }
|
||||
public CharacterManagementUiController Controller { get; }
|
||||
|
||||
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()
|
||||
{
|
||||
_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));
|
||||
Bindings = new CharacterSelectionRuntimeBindings(
|
||||
View: () => _view,
|
||||
Highlight: _ => Result(),
|
||||
Enter: Result,
|
||||
RequestDelete: Result,
|
||||
ConfirmDelete: Result,
|
||||
Restore: Result,
|
||||
Cancel: Result,
|
||||
RequestExit: () => { });
|
||||
}
|
||||
|
||||
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?")));
|
||||
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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue