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 { /// /// 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. /// [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(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); } /// /// 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. /// [Fact] public void WorldName_TicksFromSnapshot_IntoTheWorldTextElement() { using var environment = new EnvironmentHarness(); CharacterManagementUiController controller = environment.Controller; var worldText = Assert.IsType(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( 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(); 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(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()); } /// /// 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. /// [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); } /// /// 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 Action /// (), not a /// generation-gated Runtime command. /// [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); } /// /// MakeConfirmExitDialog's own guard (m_confirmExitDialogContext != 0 /// -> return): a second Exit click while the confirmation is /// already open does not open a second dialog. /// [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.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(screen.FindElement( CharacterManagementUiController.EnterElementId)).OnClick); Assert.Null(Assert.IsType(screen.FindElement( CharacterManagementUiController.DeleteElementId)).OnClick); Assert.Null(Assert.IsType(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 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(layout.FindElement(id)); private static UiField Input(ImportedLayout layout) => Assert.IsType(layout.FindElement( RetailConfirmationTextInputDialogView.InputElementId)); private static string Message(ImportedLayout layout) => string.Join( " ", Assert.IsType(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.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(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() .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 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() { } } }