acdream/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs
Erik ef96c55489 fix(ui,net): Campaign LA gate round 2 — char-select exit confirmation, authored row justify, world name
Finding 1 (Exit button dead): retail's gmCharacterManagementUI Exit
button (element 0x100003A4, offset 7 from the listbox base in
ListenToElementMessage@0x004ed5a0) opens MakeConfirmExitDialog
(0x004ed250), whose exact ID_CharacterManagement_ConfirmExit text
(table 0x23000002) and m_confirmExitDialogContext re-entry guard are
now ported. On confirm (matching RecvNotice_CloseDialog@0x004ed760
case 1's ConfirmationResult check) the client exits through the
EXISTING graceful window-close seam (CharacterSelectionRuntimeBindings
.RequestExit -> d.Window.Close, the same delegate
GameplayInputCommandController's Escape fallback already uses) so
disconnected/exited status events still fire via GameWindow.OnClosing
-> CompleteShutdown. Retail's real post-confirm destination is
QueueUIMode(0x10000009) -> gmEpilogueUI, an epilogue screen this round
does not port — recorded as AD-99. Credits (element 0x100003A3,
QueueUIMode(0x10000005) -> gmCreditsUI) stays visibly ghosted like
Create, same treatment, out of scope this round.

Finding 2 (row names center-aligned, retail is left): the character
row template (LayoutDesc 0x21000004, element 0x100003A5, live-DAT
confirmed HJustify=Left with three stateful Type-3 highlight-art
children and no Type-12 caption child) authors its OWN justify
directly, with no separate text child to lift a label from.
DatWidgetFactory.BuildButton's Left-justify branch required
!ReferenceEquals(labelInfo, info) — true only when a label was LIFTED
from a distinct child — so a button's own direct HJustify=Left was
silently dropped to UiButton's Center default. Widened the branch to
also honor the direct case, preserving the existing lifted-child
LabelOffsetX behavior and leaving genuinely-centered buttons
(CREATE/ENTER/DELETE/RESTORE) untouched.

Finding 3 (World box empty): parsed ACE's GameMessageServerName
(opcode 0xF7E1, ACE.Server/Network/GameMessages/Messages/
GameMessageServerName.cs; retail CM_Login::DispatchUI_WorldInfo
@0x006ad860 -> ClientUISystem::Handle_Login__WorldInfo@0x005641a0 ->
ECM_Login::SendNotice_WorldName@0x00692b10, notice 0x186a2, consumed
by gmCharacterManagementUI::UpdateWorldName@0x004ec120 /
RecvNotice_WorldName@0x004ec360 onto element 0x1000039B) as
src/AcDream.Core.Net/Messages/ServerName.cs, cross-checked against
holtburger's ServerNameData. WorldSession.ServerNameReceived fires
alongside CharacterListReceived (ACE sends both in one
SendConnectResponse batch); RuntimeCharacterSelectionState.
ApplyWorldName is the new J-owner field (ungated by lifecycle, since
either message can arrive first); CharacterManagementUiController
binds it onto the WorldTextElementId UiText. Per the LA1 status
vocabulary, the characterList STATUS event's worldName field is
intentionally NOT added this round (kept bounded to the client-side
fix) — a follow-up if the launcher UI wants it.

Also corrects AD-44, discovered stale while filing AD-99: its opening
claim ("acdream has no retained character-management screen") was
false as of this session — LA7/LA8 shipped the screen in earlier
commits without updating this row.

Tests: exit-confirm open/cancel/confirm/re-entry-guard flow;
DatWidgetFactory own-HJustify-Left/Center regression tests plus the
live-DAT pinned row-justify assertion; ServerName parse round-trip
(byte-exact vs ACE's AceWireWriter fixture, truncation/wrong-opcode
cases); WorldSession dispatch test (roster+world in one wire batch);
RuntimeCharacterSelectionState.ApplyWorldName tests (order-independent
of ApplyRoster, unchanged-value no-op, Reset clears); controller test
binding the World text element to the live snapshot. Extended the
shared RetailDialogFactoryTests.BuildDialogLayout test fixture with a
Confirmation-type branch (Accept/Reject buttons) since this is its
first RetailDialogType.Confirmation consumer.

Suites: full solution Release build green; AcDream.App.Tests 5100/6
skips, AcDream.Core.Net.Tests 965/0, AcDream.Runtime.Tests 1665/0, all
Release, 0 failures; live-DAT probes (ACDREAM_PROBE_LIVE_MOUNT=1)
green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 11:50:45 +02:00

1149 lines
43 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Numerics;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Runtime;
using AcDream.Runtime.Session;
namespace AcDream.App.Tests.UI.Layout;
public sealed class CharacterManagementUiControllerTests
{
/// <summary>
/// Campaign LA gate round 2 (register AD-98): the root KEEPS its authored
/// 800×600 extent (retail never resizes it — zero edge anchors), and while
/// the screen is active the HOST carries the fixed canvas so the whole tree
/// — widgets, glyphs, and the painted background whose art contains the
/// World/Characters captions — stretches together. Resizing the root while
/// stretching only the art is exactly the misalignment the 2026-08-15 user
/// gate caught. Dispose must release the canvas so in-world UI returns to
/// native pixels.
/// </summary>
[Fact]
public void ActiveScreen_KeepsAuthoredRootExtent_AndSetsHostFixedCanvas()
{
var environment = new EnvironmentHarness();
try
{
UiElement root = environment.Controller.Root;
Assert.Equal(800f, root.Width);
Assert.Equal(600f, root.Height);
Assert.Equal(
new Vector2(root.Width, root.Height),
environment.Host.FixedCanvasSize);
}
finally
{
environment.Dispose();
}
Assert.Null(environment.Host.FixedCanvasSize);
}
[Fact]
public void AuthoredChildContract_PreservesRuntimeOrderGreyTailHighlightAndButtonMatrix()
{
using var environment = new EnvironmentHarness();
CharacterManagementUiController controller = environment.Controller;
Assert.True(controller.Root.Visible);
Assert.Equal(
CharacterManagementUiController.RootElementId,
controller.Root.DatElementId);
Assert.IsType<UiTemplateListBox>(environment.Screen.FindElement(
CharacterManagementUiController.ListElementId));
UiButton create = environment.Button(
CharacterManagementUiController.CreateElementId);
UiButton enter = environment.Button(
CharacterManagementUiController.EnterElementId);
UiButton delete = environment.Button(
CharacterManagementUiController.DeleteElementId);
UiButton restore = environment.Button(
CharacterManagementUiController.RestoreElementId);
Assert.True(create.Visible);
Assert.False(create.Enabled);
Assert.Null(create.OnClick);
Assert.True(enter.Enabled);
Assert.True(delete.Visible);
Assert.True(delete.Enabled);
Assert.False(restore.Visible);
Assert.False(restore.Enabled);
// Runtime owns wcscmp sorting and the stable grey-to-tail partition.
// "Aaron (pending)" would sort first if App incorrectly re-sorted it;
// the controller must preserve this exact borrowed display order.
Assert.Equal(
["Alpha", "Zulu", "Aaron (pending)"],
controller.Rows.Select(static row => row.Label!).ToArray());
Assert.All(controller.Rows, static row => Assert.Equal(64f, row.Height));
Assert.Equal([0f, 64f, 128f],
controller.Rows.Select(static row => row.Top).ToArray());
Assert.True(controller.Rows[0].Selected);
Assert.False(controller.Rows[1].Selected);
Assert.Equal(Vector4.One, controller.Rows[0].LabelColor);
Assert.Equal(new Vector4(1f, 0f, 0f, 1f), controller.Rows[2].LabelColor);
Assert.DoesNotContain(
Descendants(controller.Root),
static element => element is UiViewport);
controller.Rows[2].OnClick!();
Assert.Equal(1, environment.Runtime.HighlightCalls);
Assert.Equal(0x50000003u,
environment.Runtime.View.Snapshot.HighlightedCharacterId);
Assert.True(controller.Rows[2].Selected);
Assert.False(enter.Enabled);
Assert.False(delete.Visible);
Assert.False(delete.Enabled);
Assert.True(restore.Visible);
Assert.True(restore.Enabled);
}
/// <summary>
/// Campaign LA gate round 2 finding 3: retail's UpdateWorldName@0x004ec120
/// / RecvNotice_WorldName@0x004ec360 both push Client::GetWorldName()
/// onto element 0x1000039B. The controller binds Runtime's borrowed
/// snapshot field to that same element.
/// </summary>
[Fact]
public void WorldName_TicksFromSnapshot_IntoTheWorldTextElement()
{
using var environment = new EnvironmentHarness();
CharacterManagementUiController controller = environment.Controller;
var worldText = Assert.IsType<UiText>(environment.Screen.FindElement(
CharacterManagementUiController.WorldTextElementId));
Assert.Equal(
"sawato",
string.Join(" ", worldText.LinesProvider().Select(static line => line.Text)));
environment.Runtime.SetWorldName("Frostfell");
controller.Tick();
Assert.Equal(
"Frostfell",
string.Join(" ", worldText.LinesProvider().Select(static line => line.Text)));
}
[Fact]
public void RowHeight_UsesAllowedSlotsAndClampsAtOneTenthForLargeRosters()
{
using var environment = new EnvironmentHarness();
CharacterManagementUiController controller = environment.Controller;
Assert.Equal(64, CharacterManagementUiController.ComputeRowHeight(
320f,
rosterCount: 3,
allowedSlotCount: 5));
Assert.Equal(63, CharacterManagementUiController.ComputeRowHeight(
319f,
rosterCount: 5,
allowedSlotCount: 5));
Assert.Equal(31, CharacterManagementUiController.ComputeRowHeight(
319f,
rosterCount: 11,
allowedSlotCount: 5));
RuntimeCharacterSelectionEntry[] large = Enumerable.Range(0, 12)
.Select(index => new RuntimeCharacterSelectionEntry(
index,
(uint)(0x50000100 + index),
$"Character {index:D2}",
0u))
.ToArray();
environment.Runtime.ReplaceRoster(
large,
highlightedCharacterId: large[0].CharacterId);
controller.Tick();
Assert.Equal(12, controller.Rows.Count);
Assert.All(controller.Rows, static row => Assert.Equal(32f, row.Height));
Assert.Equal(
Enumerable.Range(0, 12).Select(static index => index * 32f),
controller.Rows.Select(static row => row.Top));
UiTemplateListBox list = Assert.IsType<UiTemplateListBox>(
environment.Screen.FindElement(
CharacterManagementUiController.ListElementId));
Assert.Equal(384, list.ContentHeight);
Assert.Equal(32, list.LineHeight);
}
[Fact]
public void MountCoordinator_RetriesCatalogRootAndStrings_ThenMountsOnce()
{
var host = new UiRoot();
var runtime = new FakeRuntime();
using var dialogs = new RetailDialogFactory(
host,
RetailDialogFactoryTests.BuildDialogLayout);
bool catalogAvailable = false;
bool rootAvailable = false;
bool stringsAvailable = false;
int dialogAttempts = 0;
int resourceAttempts = 0;
using var coordinator = new CharacterManagementUiMountCoordinator(
host,
runtime.Bindings,
() =>
{
dialogAttempts++;
return catalogAvailable ? dialogs : null;
},
() =>
{
resourceAttempts++;
if (!rootAvailable || !stringsAvailable)
return null;
return new CharacterManagementUiMountResources(
0x21000004u,
BuildScreen(),
static (layoutId, elementId) =>
layoutId == 0x21000004u
&& elementId == 0x100003A5u
? BuildRow()
: null,
TestStrings());
});
coordinator.Tick();
Assert.Null(coordinator.Controller);
Assert.Equal(1, dialogAttempts);
Assert.Equal(0, resourceAttempts);
catalogAvailable = true;
coordinator.Tick();
Assert.Null(coordinator.Controller);
Assert.Equal(2, dialogAttempts);
Assert.Equal(1, resourceAttempts);
rootAvailable = true;
coordinator.Tick();
Assert.Null(coordinator.Controller);
Assert.Equal(3, dialogAttempts);
Assert.Equal(2, resourceAttempts);
stringsAvailable = true;
coordinator.Tick();
CharacterManagementUiController controller = Assert.IsType<
CharacterManagementUiController>(coordinator.Controller);
Assert.Single(host.Children);
Assert.Same(controller.Root, host.Children[0]);
Assert.Equal(4, dialogAttempts);
Assert.Equal(3, resourceAttempts);
coordinator.Tick();
Assert.Same(controller, coordinator.Controller);
Assert.Single(host.Children);
Assert.Equal(4, dialogAttempts);
Assert.Equal(3, resourceAttempts);
coordinator.Dispose();
Assert.Empty(host.Children);
coordinator.Tick();
Assert.Null(coordinator.Controller);
Assert.Equal(4, dialogAttempts);
Assert.Equal(3, resourceAttempts);
}
[Fact]
public void MountCoordinator_PostAttachFailuresDisposeBeforeRetryAndRecovery()
{
var host = new UiRoot { Width = 800f, Height = 600f };
var runtime = new FakeRuntime();
using var dialogs = new RetailDialogFactory(
host,
RetailDialogFactoryTests.BuildDialogLayout);
var screens = new List<ImportedLayout>();
int failingAttempts = 2;
using var coordinator = new CharacterManagementUiMountCoordinator(
host,
runtime.Bindings,
() => dialogs,
() =>
{
ImportedLayout screen = BuildScreen();
screens.Add(screen);
bool throwAfterAttach = failingAttempts-- > 0;
return new CharacterManagementUiMountResources(
0x21000004u,
screen,
(_, _) => throwAfterAttach
? throw new InvalidOperationException(
"template failed after root attach")
: BuildRow(),
TestStrings());
});
coordinator.Tick();
Assert.Null(coordinator.Controller);
Assert.Empty(host.Children);
Assert.Single(screens);
AssertDetachedAndUnbound(screens[0]);
coordinator.Tick();
Assert.Null(coordinator.Controller);
Assert.Empty(host.Children);
Assert.Equal(2, screens.Count);
Assert.All(screens, AssertDetachedAndUnbound);
coordinator.Tick();
CharacterManagementUiController mounted = Assert.IsType<
CharacterManagementUiController>(coordinator.Controller);
Assert.Equal(3, screens.Count);
Assert.Single(host.Children);
Assert.Same(mounted.Root, host.Children[0]);
Assert.NotNull(Assert.IsType<UiButton>(screens[2].FindElement(
CharacterManagementUiController.EnterElementId)).OnClick);
coordinator.Tick();
Assert.Equal(3, screens.Count);
Assert.Single(host.Children);
coordinator.Dispose();
Assert.Empty(host.Children);
Assert.Null(coordinator.Controller);
Assert.All(screens, AssertDetachedAndUnbound);
}
[Fact]
public void DeleteConfirmation_IsCaseInsensitive_ThenWaitsThroughAckUntilFreshRoster()
{
using var environment = new EnvironmentHarness();
CharacterManagementUiController controller = environment.Controller;
UiButton delete = environment.Button(
CharacterManagementUiController.DeleteElementId);
// A wrong typed response closes the modal and cancels Runtime's exact
// pending-delete owner without sending the wire request.
delete.OnClick!();
ImportedLayout wrong = environment.LastDialog(
RetailDialogType.ConfirmationTextInput);
Assert.Contains("Alpha", Message(wrong));
Assert.Contains("Type DELETE", Message(wrong));
Input(wrong).SetText("not delete");
DialogButton(
wrong,
RetailConfirmationTextInputDialogView.AcceptButtonId).OnClick!();
Assert.Equal(1, environment.Runtime.CancelCalls);
Assert.Equal(0, environment.Runtime.ConfirmDeleteCalls);
Assert.Equal(0u, controller.DeleteDialogContext);
Assert.False(environment.Dialogs.IsOpen);
// Retail compares the localized response case-insensitively.
delete.OnClick!();
ImportedLayout accepted = environment.LastDialog(
RetailDialogType.ConfirmationTextInput);
Input(accepted).SetText("delete");
DialogButton(
accepted,
RetailConfirmationTextInputDialogView.AcceptButtonId).OnClick!();
Assert.Equal(1, environment.Runtime.ConfirmDeleteCalls);
Assert.Equal(
RuntimeCharacterSelectionOperation.DeleteRequested,
environment.Runtime.View.Snapshot.Operation);
uint waitContext = controller.OperationWaitContext;
Assert.NotEqual(0u, waitContext);
Assert.Equal(
RetailDialogType.Wait,
environment.DialogLayouts[^1].Type);
Assert.Equal("Please Wait", Message(environment.DialogLayouts[^1].Layout));
// Opcode-only ack does not close the wait. Neither does silence.
environment.Runtime.SetOperation(
RuntimeCharacterSelectionOperation.DeleteAcknowledged);
controller.Tick();
controller.Tick();
Assert.Equal(waitContext, controller.OperationWaitContext);
// Retail closes via the fresh CharacterList rebuild that follows ack.
environment.Runtime.ReplaceRoster(
[
new RuntimeCharacterSelectionEntry(1, 0x50000002u, "Zulu", 0u),
new RuntimeCharacterSelectionEntry(2, 0x50000003u, "Aaron (pending)", 1u),
],
highlightedCharacterId: 0x50000002u);
controller.Tick();
Assert.Equal(0u, controller.OperationWaitContext);
Assert.False(environment.Dialogs.IsOpen);
Assert.Equal(
["Zulu", "Aaron (pending)"],
controller.Rows.Select(static row => row.Label!).ToArray());
}
/// <summary>
/// Campaign LA gate round 2 finding 1: Exit -> MakeConfirmExitDialog
/// (0x004ed250, retail's confirm-only dialog type 1) -> Cancel/Reject
/// leaves the screen exactly as it was — no exit request reaches
/// Runtime's window-close binding.
/// </summary>
[Fact]
public void ExitButton_OpenThenCancel_KeepsScreenActive_NoExitRequested()
{
using var environment = new EnvironmentHarness();
CharacterManagementUiController controller = environment.Controller;
UiButton exit = environment.Button(
CharacterManagementUiController.ExitElementId);
exit.OnClick!();
Assert.NotEqual(0u, controller.ConfirmExitDialogContext);
ImportedLayout dialog = environment.LastDialog(RetailDialogType.Confirmation);
Assert.Equal(
"Are you sure you want to leave?",
Message(dialog));
DialogButton(dialog, RetailConfirmationDialogView.RejectButtonId).OnClick!();
Assert.Equal(0, environment.Runtime.RequestExitCalls);
Assert.Equal(0u, controller.ConfirmExitDialogContext);
Assert.False(environment.Dialogs.IsOpen);
Assert.True(controller.Root.Visible);
}
/// <summary>
/// Confirm reaches the SAME graceful-shutdown seam window-close uses —
/// asserted here via the bindings fake, since the controller/Runtime
/// boundary is a plain host <c>Action</c>
/// (<see cref="CharacterSelectionRuntimeBindings.RequestExit"/>), not a
/// generation-gated Runtime command.
/// </summary>
[Fact]
public void ExitButton_OpenThenConfirm_ReachesGracefulShutdownSeam()
{
using var environment = new EnvironmentHarness();
CharacterManagementUiController controller = environment.Controller;
UiButton exit = environment.Button(
CharacterManagementUiController.ExitElementId);
exit.OnClick!();
ImportedLayout dialog = environment.LastDialog(RetailDialogType.Confirmation);
DialogButton(dialog, RetailConfirmationDialogView.AcceptButtonId).OnClick!();
Assert.Equal(1, environment.Runtime.RequestExitCalls);
Assert.Equal(0u, controller.ConfirmExitDialogContext);
Assert.False(environment.Dialogs.IsOpen);
}
/// <summary>
/// MakeConfirmExitDialog's own guard (<c>m_confirmExitDialogContext != 0
/// -&gt; return</c>): a second Exit click while the confirmation is
/// already open does not open a second dialog.
/// </summary>
[Fact]
public void ExitButton_SecondClickWhileOpen_IsNoOp()
{
using var environment = new EnvironmentHarness();
UiButton exit = environment.Button(
CharacterManagementUiController.ExitElementId);
exit.OnClick!();
Assert.Equal(1, environment.DialogLayouts.Count(
entry => entry.Type == RetailDialogType.Confirmation));
exit.OnClick!();
Assert.Equal(1, environment.DialogLayouts.Count(
entry => entry.Type == RetailDialogType.Confirmation));
}
[Fact]
public void AuthoredRowDoubleActivation_EntersTheHighlightedCharacter()
{
using var environment = new EnvironmentHarness();
CharacterManagementUiController controller = environment.Controller;
UiButton row = controller.Rows[1];
Assert.True(row.OnEvent(new UiEvent(
row.EventId,
row,
UiEventType.Click)));
Assert.True(row.OnEvent(new UiEvent(
row.EventId,
row,
UiEventType.DoubleClick)));
Assert.Equal(1, environment.Runtime.HighlightCalls);
Assert.Equal(0x50000002u,
environment.Runtime.View.Snapshot.HighlightedCharacterId);
Assert.Equal(1, environment.Runtime.EnterCalls);
Assert.Equal(
RuntimeCharacterSelectionLifecycle.EnteringWorld,
environment.Runtime.View.Snapshot.Lifecycle);
Assert.NotEqual(0u, controller.EnterWaitContext);
}
[Fact]
public void RestoreSilenceExpires_EnterTransitions_AndErrorUsesMessageDialog()
{
using var environment = new EnvironmentHarness();
CharacterManagementUiController controller = environment.Controller;
controller.Rows[2].OnClick!();
environment.Button(CharacterManagementUiController.RestoreElementId).OnClick!();
uint restoreWait = controller.OperationWaitContext;
Assert.NotEqual(0u, restoreWait);
Assert.Equal(
RuntimeCharacterSelectionOperation.RestoreRequested,
environment.Runtime.View.Snapshot.Operation);
// ACE may send no restore response. Runtime's correlation expiry is
// represented by Operation=None; the presentation never owns a timer.
controller.Tick();
Assert.Equal(restoreWait, controller.OperationWaitContext);
environment.Runtime.SetOperation(RuntimeCharacterSelectionOperation.None);
controller.Tick();
Assert.Equal(0u, controller.OperationWaitContext);
Assert.False(environment.Dialogs.IsOpen);
controller.Rows[0].OnClick!();
environment.Button(CharacterManagementUiController.EnterElementId).OnClick!();
Assert.Equal(1, environment.Runtime.EnterCalls);
Assert.Equal(
RuntimeCharacterSelectionLifecycle.EnteringWorld,
environment.Runtime.View.Snapshot.Lifecycle);
Assert.NotEqual(0u, controller.EnterWaitContext);
Assert.Equal("Entering World", Message(
environment.LastDialog(RetailDialogType.Wait)));
environment.Runtime.SetError("That character is unavailable.");
controller.Tick();
Assert.Equal(0u, controller.EnterWaitContext);
Assert.NotEqual(0u, controller.ErrorDialogContext);
ImportedLayout error = environment.LastDialog(RetailDialogType.Message);
Assert.Equal("That character is unavailable.", Message(error));
DialogButton(error, RetailMessageDialogView.OkButtonId).OnClick!();
Assert.Equal(1, environment.Runtime.CancelCalls);
Assert.Null(environment.Runtime.View.Snapshot.Error);
Assert.False(environment.Dialogs.IsOpen);
// CharacterError.NumErrors is ignored by Runtime without a revision;
// an unchanged, error-free projection must not manufacture a dialog.
int createdBeforeSentinel = environment.DialogLayouts.Count;
controller.Tick();
Assert.Equal(createdBeforeSentinel, environment.DialogLayouts.Count);
environment.Button(CharacterManagementUiController.EnterElementId).OnClick!();
Assert.NotEqual(0u, controller.EnterWaitContext);
environment.Runtime.SetLifecycle(
RuntimeCharacterSelectionLifecycle.InWorld);
controller.Tick();
Assert.False(controller.Root.Visible);
Assert.Equal(0u, controller.EnterWaitContext);
Assert.False(environment.Dialogs.IsOpen);
}
[Fact]
public void Restore_OpensWaitBeforeCommand_AndKeepsOneModalAcrossReentrantTick()
{
using var environment = new EnvironmentHarness();
CharacterManagementUiController controller = environment.Controller;
controller.Rows[2].OnClick!();
uint observedContext = 0u;
environment.Runtime.BeforeRestore = () =>
{
observedContext = controller.OperationWaitContext;
Assert.NotEqual(0u, observedContext);
Assert.True(environment.Dialogs.IsOpen);
Assert.Same(
environment.LastDialog(RetailDialogType.Wait).Root,
environment.Host.Modal);
// A synchronous callback can pump the presentation before the
// command has returned. The in-flight edge must retain the one
// wait context instead of closing/reopening it.
controller.Tick();
Assert.Equal(observedContext, controller.OperationWaitContext);
};
environment.Runtime.AfterRestoreProjection = () =>
{
controller.Tick();
Assert.Equal(observedContext, controller.OperationWaitContext);
};
environment.Button(
CharacterManagementUiController.RestoreElementId).OnClick!();
Assert.Equal(1, environment.Runtime.RestoreCalls);
Assert.Equal(observedContext, controller.OperationWaitContext);
Assert.Equal(
1,
environment.DialogLayouts.Count(static entry =>
entry.Type == RetailDialogType.Wait));
Assert.Same(
environment.LastDialog(RetailDialogType.Wait).Root,
environment.Host.Modal);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void Restore_ImmediateRejectionOrFailure_ClosesPreopenedWait(
bool throwFailure)
{
using var environment = new EnvironmentHarness();
CharacterManagementUiController controller = environment.Controller;
controller.Rows[2].OnClick!();
environment.Runtime.RestoreStatus = RuntimeCommandStatus.Rejected;
environment.Runtime.ThrowOnRestore = throwFailure;
environment.Runtime.BeforeRestore = () =>
{
Assert.NotEqual(0u, controller.OperationWaitContext);
Assert.NotNull(environment.Host.Modal);
};
Exception? error = Record.Exception(() => environment.Button(
CharacterManagementUiController.RestoreElementId).OnClick!());
Assert.Null(error);
Assert.Equal(1, environment.Runtime.RestoreCalls);
Assert.Equal(0u, controller.OperationWaitContext);
Assert.False(environment.Dialogs.IsOpen);
Assert.Null(environment.Host.Modal);
Assert.Equal(
RuntimeCharacterSelectionOperation.None,
environment.Runtime.View.Snapshot.Operation);
}
[Fact]
public void MissingOrDisposedBorrowedView_ClosesDialogsFlushesRowsAndDisposesSafely()
{
using var environment = new EnvironmentHarness();
CharacterManagementUiController controller = environment.Controller;
environment.Button(CharacterManagementUiController.DeleteElementId).OnClick!();
Assert.NotEqual(0u, controller.DeleteDialogContext);
environment.Runtime.ProvideView = false;
controller.Tick();
Assert.False(controller.Root.Visible);
Assert.Empty(controller.Rows);
Assert.False(environment.Dialogs.IsOpen);
Assert.Equal(0, environment.Runtime.CancelCalls);
controller.Dispose();
Assert.Null(controller.Root.Parent);
Assert.Null(environment.Button(
CharacterManagementUiController.EnterElementId).OnClick);
controller.Tick();
}
[Fact]
public void SessionReset_ClosesOwnedContextsWithoutReentrantCancel()
{
using var environment = new EnvironmentHarness();
CharacterManagementUiController controller = environment.Controller;
environment.Button(CharacterManagementUiController.DeleteElementId).OnClick!();
Assert.NotEqual(0u, controller.DeleteDialogContext);
controller.ResetSession();
Assert.False(controller.Root.Visible);
Assert.Empty(controller.Rows);
Assert.False(environment.Dialogs.IsOpen);
Assert.Equal(0, environment.Runtime.CancelCalls);
}
[Fact]
public void PreviewViewport_IsRejectedBeforeTheAuthoredScreenIsMounted()
{
var host = new UiRoot { Width = 800f, Height = 600f };
ImportedLayout screen = BuildScreen(includePreview: true);
using var dialogs = new RetailDialogFactory(
host,
RetailDialogFactoryTests.BuildDialogLayout);
var runtime = new FakeRuntime();
CharacterManagementUiController? controller =
CharacterManagementUiController.Bind(
host,
screen,
static (_, _) => BuildRow(),
dialogs,
runtime.Bindings,
TestStrings());
Assert.Null(controller);
Assert.Empty(host.Children);
}
[Fact]
public void TransientTemplateMiss_DoesNotConsumeTheRuntimeRevision()
{
var host = new UiRoot { Width = 800f, Height = 600f };
ImportedLayout screen = BuildScreen();
using var dialogs = new RetailDialogFactory(
host,
RetailDialogFactoryTests.BuildDialogLayout);
var runtime = new FakeRuntime();
int resolveCalls = 0;
using CharacterManagementUiController controller =
Assert.IsType<CharacterManagementUiController>(
CharacterManagementUiController.Bind(
host,
screen,
(_, _) => ++resolveCalls == 1 ? null : BuildRow(),
dialogs,
runtime.Bindings,
TestStrings()));
Assert.Empty(controller.Rows);
controller.Tick();
Assert.Equal(3, controller.Rows.Count);
Assert.True(resolveCalls >= 4);
}
private static CharacterManagementUiController.DialogStrings TestStrings() =>
new(
name => $"WARNING! {name}\nType DELETE in the box below.",
"DELETE",
"Please Wait",
"Entering World",
"Are you sure you want to leave?");
private static void AssertDetachedAndUnbound(ImportedLayout screen)
{
Assert.Null(screen.Root.Parent);
Assert.Null(Assert.IsType<UiButton>(screen.FindElement(
CharacterManagementUiController.EnterElementId)).OnClick);
Assert.Null(Assert.IsType<UiButton>(screen.FindElement(
CharacterManagementUiController.DeleteElementId)).OnClick);
Assert.Null(Assert.IsType<UiButton>(screen.FindElement(
CharacterManagementUiController.RestoreElementId)).OnClick);
}
private static ImportedLayout BuildScreen(bool includePreview = false)
{
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,
X = 21f,
Y = 44f,
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));
if (includePreview)
{
root.Children.Add(new ElementInfo
{
Id = 0xDEADBEEFu,
Type = 0xDu,
Width = 100f,
Height = 100f,
});
}
return LayoutImporter.Build(root, _ => (0u, 0, 0), null);
}
private static ElementInfo ButtonInfo(uint id) => new()
{
Id = id,
Type = 1u,
Width = 100f,
Height = 30f,
};
private static UiElement BuildRow() => LayoutImporter.Build(
new ElementInfo
{
Id = 0x100003A5u,
Type = 1u,
Width = 160f,
Height = 16f,
},
_ => (0u, 0, 0),
null).Root;
private static IEnumerable<UiElement> Descendants(UiElement root)
{
yield return root;
foreach (UiElement child in root.Children)
foreach (UiElement descendant in Descendants(child))
yield return descendant;
}
private static UiButton DialogButton(ImportedLayout layout, uint id) =>
Assert.IsType<UiButton>(layout.FindElement(id));
private static UiField Input(ImportedLayout layout) =>
Assert.IsType<UiField>(layout.FindElement(
RetailConfirmationTextInputDialogView.InputElementId));
private static string Message(ImportedLayout layout) => string.Join(
" ",
Assert.IsType<UiText>(layout.FindElement(0x3Eu))
.LinesProvider()
.Select(static line => line.Text));
private sealed class EnvironmentHarness : IDisposable
{
public EnvironmentHarness()
{
Host = new UiRoot { Width = 800f, Height = 600f };
Screen = BuildScreen();
Runtime = new FakeRuntime();
Dialogs = new RetailDialogFactory(Host, type =>
{
ImportedLayout layout =
RetailDialogFactoryTests.BuildDialogLayout(type);
DialogLayouts.Add((type, layout));
return layout;
});
Controller = Assert.IsType<CharacterManagementUiController>(
CharacterManagementUiController.Bind(
Host,
Screen,
static (_, _) => BuildRow(),
Dialogs,
Runtime.Bindings,
TestStrings()));
}
public UiRoot Host { get; }
public ImportedLayout Screen { get; }
public FakeRuntime Runtime { get; }
public RetailDialogFactory Dialogs { get; }
public List<(RetailDialogType Type, ImportedLayout Layout)> DialogLayouts { get; } = [];
public CharacterManagementUiController Controller { get; }
public UiButton Button(uint id) =>
Assert.IsType<UiButton>(Screen.FindElement(id));
public ImportedLayout LastDialog(RetailDialogType type) =>
DialogLayouts.Last(entry => entry.Type == type).Layout;
public void Dispose()
{
Controller.Dispose();
Dialogs.Dispose();
}
}
private sealed class FakeRuntime
{
private static readonly RuntimeGenerationToken Generation = new(7u);
public FakeRuntime()
{
View.Entries =
[
new RuntimeCharacterSelectionEntry(0, 0x50000001u, "Alpha", 0u),
new RuntimeCharacterSelectionEntry(1, 0x50000002u, "Zulu", 0u),
new RuntimeCharacterSelectionEntry(2, 0x50000003u, "Aaron (pending)", 1u),
];
View.Snapshot = Snapshot(
RuntimeCharacterSelectionLifecycle.AwaitingSelection,
revision: 1,
highlightedCharacterId: 0x50000001u,
buttons: ButtonsFor(0x50000001u));
Bindings = new CharacterSelectionRuntimeBindings(
() => ProvideView ? View : null,
Highlight,
Enter,
RequestDelete,
ConfirmDelete,
Restore,
Cancel,
RequestExit);
}
public FakeView View { get; } = new();
public CharacterSelectionRuntimeBindings Bindings { get; }
public bool ProvideView { get; set; } = true;
public int HighlightCalls { get; private set; }
public int EnterCalls { get; private set; }
public int ConfirmDeleteCalls { get; private set; }
public int CancelCalls { get; private set; }
public int RestoreCalls { get; private set; }
public int RequestExitCalls { get; private set; }
public RuntimeCommandStatus RestoreStatus { get; set; } =
RuntimeCommandStatus.Accepted;
public bool ThrowOnRestore { get; set; }
public Action? BeforeRestore { get; set; }
public Action? AfterRestoreProjection { get; set; }
public void SetOperation(RuntimeCharacterSelectionOperation operation)
{
RuntimeCharacterSelectionButtons buttons = operation is
RuntimeCharacterSelectionOperation.DeleteRequested
or RuntimeCharacterSelectionOperation.DeleteAcknowledged
? RuntimeCharacterSelectionButtons.None
: ButtonsFor(View.Snapshot.HighlightedCharacterId);
Update(snapshot => snapshot with
{
Operation = operation,
Buttons = buttons,
});
}
public void ReplaceRoster(
RuntimeCharacterSelectionEntry[] entries,
uint highlightedCharacterId)
{
View.Entries = entries;
Update(snapshot => snapshot with
{
RosterCount = entries.Length,
HighlightedCharacterId = highlightedCharacterId,
HighlightedDisplayIndex = Array.FindIndex(
entries,
entry => entry.CharacterId == highlightedCharacterId),
PendingDeleteCharacterId = 0u,
Operation = RuntimeCharacterSelectionOperation.None,
Buttons = ButtonsFor(highlightedCharacterId),
});
}
public void SetLifecycle(RuntimeCharacterSelectionLifecycle lifecycle) =>
Update(snapshot => snapshot with { Lifecycle = lifecycle });
public void SetWorldName(string worldName) =>
Update(snapshot => snapshot with { WorldName = worldName });
public void SetError(string message) => Update(snapshot => snapshot with
{
Lifecycle = RuntimeCharacterSelectionLifecycle.AwaitingSelection,
Error = new RuntimeCharacterSelectionError(
1u,
AcDream.Core.Net.Messages.CharacterError.Code.Logon,
message),
PendingDeleteCharacterId = 0u,
Operation = RuntimeCharacterSelectionOperation.None,
});
private RuntimeCommandResult Highlight(uint characterId)
{
HighlightCalls++;
int index = Array.FindIndex(
View.Entries,
entry => entry.CharacterId == characterId);
if (index < 0)
return Result(RuntimeCommandStatus.Rejected);
Update(snapshot => snapshot with
{
HighlightedCharacterId = characterId,
HighlightedDisplayIndex = index,
Buttons = ButtonsFor(characterId),
});
return Result(RuntimeCommandStatus.Accepted, characterId);
}
private RuntimeCommandResult Enter()
{
EnterCalls++;
Update(snapshot => snapshot with
{
Lifecycle = RuntimeCharacterSelectionLifecycle.EnteringWorld,
Error = null,
});
return Result(
RuntimeCommandStatus.Accepted,
View.Snapshot.HighlightedCharacterId);
}
private RuntimeCommandResult RequestDelete()
{
uint id = View.Snapshot.HighlightedCharacterId;
Update(snapshot => snapshot with
{
PendingDeleteCharacterId = id,
Error = null,
});
return Result(RuntimeCommandStatus.Accepted, id);
}
private RuntimeCommandResult ConfirmDelete()
{
ConfirmDeleteCalls++;
uint id = View.Snapshot.HighlightedCharacterId;
Update(snapshot => snapshot with
{
PendingDeleteCharacterId = 0u,
Operation = RuntimeCharacterSelectionOperation.DeleteRequested,
Buttons = RuntimeCharacterSelectionButtons.None,
});
return Result(RuntimeCommandStatus.Accepted, id);
}
private RuntimeCommandResult Restore()
{
RestoreCalls++;
uint id = View.Snapshot.HighlightedCharacterId;
BeforeRestore?.Invoke();
if (ThrowOnRestore)
throw new InvalidOperationException("restore transport failed");
if (RestoreStatus != RuntimeCommandStatus.Accepted)
return Result(RestoreStatus, id);
Update(snapshot => snapshot with
{
LastRestoreRequestedCharacterId = id,
Operation = RuntimeCharacterSelectionOperation.RestoreRequested,
Buttons = new RuntimeCharacterSelectionButtons(
false,
false,
false,
false,
true),
});
AfterRestoreProjection?.Invoke();
return Result(RuntimeCommandStatus.Accepted, id);
}
private RuntimeCommandResult Cancel()
{
CancelCalls++;
Update(snapshot => snapshot with
{
PendingDeleteCharacterId = 0u,
Error = null,
Buttons = ButtonsFor(snapshot.HighlightedCharacterId),
});
return Result(RuntimeCommandStatus.Accepted);
}
private void RequestExit() => RequestExitCalls++;
private RuntimeCharacterSelectionButtons ButtonsFor(uint characterId)
{
RuntimeCharacterSelectionEntry? selected = View.Entries
.Cast<RuntimeCharacterSelectionEntry?>()
.FirstOrDefault(entry => entry?.CharacterId == characterId);
if (selected is null)
return RuntimeCharacterSelectionButtons.None;
if (selected.Value.IsPendingDelete)
{
return new RuntimeCharacterSelectionButtons(
false,
false,
true,
false,
true);
}
return new RuntimeCharacterSelectionButtons(
true,
true,
false,
true,
false);
}
private void Update(
Func<RuntimeCharacterSelectionSnapshot,
RuntimeCharacterSelectionSnapshot> update)
{
RuntimeCharacterSelectionSnapshot current = View.Snapshot;
RuntimeCharacterSelectionSnapshot next = update(current);
View.Snapshot = next with { Revision = current.Revision + 1 };
}
private RuntimeCharacterSelectionSnapshot Snapshot(
RuntimeCharacterSelectionLifecycle lifecycle,
long revision,
uint highlightedCharacterId,
RuntimeCharacterSelectionButtons buttons) => new(
Generation,
lifecycle,
revision,
"account",
SlotCount: 5,
RosterCount: View.Entries.Length,
WorldName: "sawato",
highlightedCharacterId,
HighlightedDisplayIndex: Array.FindIndex(
View.Entries,
entry => entry.CharacterId == highlightedCharacterId),
PendingDeleteCharacterId: 0u,
LastRestoreRequestedCharacterId: 0u,
Operation: RuntimeCharacterSelectionOperation.None,
Error: null,
buttons);
private static RuntimeCommandResult Result(
RuntimeCommandStatus status,
uint objectId = 0u) => new(status, Generation, objectId);
}
private sealed class FakeView : 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) =>
NoopDisposable.Instance;
}
private sealed class NoopDisposable : IDisposable
{
public static NoopDisposable Instance { get; } = new();
public void Dispose() { }
}
}