merge: Campaign LA LA8 - retail character screen review-closed

This commit is contained in:
Erik 2026-08-14 21:16:25 +02:00
commit fe63ce186a
20 changed files with 3431 additions and 37 deletions

View file

@ -11,6 +11,7 @@ using AcDream.App.World;
using AcDream.Core.Net;
using AcDream.Core.World;
using AcDream.Runtime;
using AcDream.Runtime.Session;
using AcDream.UI.Abstractions;
namespace AcDream.App.Tests.Composition;
@ -106,6 +107,44 @@ public sealed class InteractionUiRuntimeSourcesTests
Assert.Throws<ObjectDisposedException>(() => source.Bind(first, first));
}
[Fact]
public void CharacterSelectionProjectionBorrowsExactAdapterAndEveryRouteBecomesInert()
{
var source = new DeferredGameRuntimeStateCommands();
var target = new RuntimeTarget(new RuntimeGenerationToken(11));
Assert.Null(source.CharacterSelection);
Assert.Equal(RuntimeCommandStatus.Inactive,
source.CharacterSelectionEnter().Status);
IDisposable binding = source.Bind(target, target);
Assert.Same(target, source.CharacterSelection);
RuntimeCommandResult[] accepted =
[
source.CharacterSelectionHighlight(0x50000001u),
source.CharacterSelectionEnter(),
source.CharacterSelectionRequestDelete(),
source.CharacterSelectionConfirmDelete(),
source.CharacterSelectionRestore(),
source.CharacterSelectionCancel(),
];
Assert.All(accepted, result =>
{
Assert.True(result.Accepted);
Assert.Equal(new RuntimeGenerationToken(11), result.Generation);
});
binding.Dispose();
Assert.Null(source.CharacterSelection);
Assert.Equal(RuntimeCommandStatus.Inactive,
source.CharacterSelectionRestore().Status);
source.Deactivate();
Assert.Null(source.CharacterSelection);
Assert.Equal(RuntimeCommandStatus.Inactive,
source.CharacterSelectionCancel().Status);
}
[Fact]
public void RadarNeverCachesAnUnboundBootstrapSnapshot()
{
@ -216,7 +255,9 @@ public sealed class InteractionUiRuntimeSourcesTests
IGameRuntimeCommands,
IRuntimeInventoryStateCommands,
IRuntimeSpellbookCommands,
IRuntimeCharacterCommands
IRuntimeCharacterCommands,
IRuntimeCharacterSelectionView,
IRuntimeCharacterSelectionCommands
{
public RuntimeTarget(RuntimeGenerationToken generation)
{
@ -234,6 +275,7 @@ public sealed class InteractionUiRuntimeSourcesTests
public IRuntimeCharacterView Character => null!;
public IRuntimeSocialView Social => null!;
public IRuntimeChatView Chat => null!;
public IRuntimeCharacterSelectionView CharacterSelection => this;
public IRuntimeFellowshipView Fellowship => null!;
public IRuntimeAllegianceView Allegiance => null!;
public IRuntimeActionView Actions => null!;
@ -242,6 +284,7 @@ public sealed class InteractionUiRuntimeSourcesTests
null!;
public IRuntimePortalView Portal => null!;
public IRuntimeSessionCommands Session => null!;
IRuntimeCharacterSelectionCommands IGameRuntimeCommands.CharacterSelection => this;
public IRuntimeSelectionCommands Selection => null!;
public IRuntimeCombatCommands Combat => null!;
public IRuntimeMagicCommands Magic => null!;
@ -257,6 +300,67 @@ public sealed class InteractionUiRuntimeSourcesTests
public RuntimeStateCheckpoint CaptureCheckpoint() => default;
RuntimeCharacterSelectionSnapshot IRuntimeCharacterSelectionView.Snapshot =>
new(
Generation,
RuntimeCharacterSelectionLifecycle.AwaitingSelection,
Revision: 1,
AccountName: "account",
SlotCount: 0,
RosterCount: 0,
HighlightedCharacterId: 0u,
HighlightedDisplayIndex: -1,
PendingDeleteCharacterId: 0u,
LastRestoreRequestedCharacterId: 0u,
Operation: RuntimeCharacterSelectionOperation.None,
Error: null,
Buttons: RuntimeCharacterSelectionButtons.None);
public bool TryGetAt(
int displayIndex,
out RuntimeCharacterSelectionEntry character)
{
character = default;
return false;
}
public bool TryGet(
uint characterId,
out RuntimeCharacterSelectionEntry character)
{
character = default;
return false;
}
public void Visit(IRuntimeCharacterSelectionVisitor visitor) { }
public IDisposable Subscribe(IRuntimeCharacterSelectionObserver observer) =>
EmptyDisposable.Instance;
public RuntimeCommandResult Highlight(
RuntimeGenerationToken expectedGeneration,
uint characterId) => Accepted(expectedGeneration, characterId);
public RuntimeCommandResult Enter(
RuntimeGenerationToken expectedGeneration) =>
Accepted(expectedGeneration);
public RuntimeCommandResult RequestDelete(
RuntimeGenerationToken expectedGeneration) =>
Accepted(expectedGeneration);
public RuntimeCommandResult ConfirmDelete(
RuntimeGenerationToken expectedGeneration) =>
Accepted(expectedGeneration);
public RuntimeCommandResult Restore(
RuntimeGenerationToken expectedGeneration) =>
Accepted(expectedGeneration);
public RuntimeCommandResult Cancel(
RuntimeGenerationToken expectedGeneration) =>
Accepted(expectedGeneration);
public RuntimeCommandResult AddShortcut(
RuntimeGenerationToken expectedGeneration,
in RuntimeShortcutCommand command) =>
@ -392,6 +496,12 @@ public sealed class InteractionUiRuntimeSourcesTests
}
}
private sealed class EmptyDisposable : IDisposable
{
public static EmptyDisposable Instance { get; } = new();
public void Dispose() { }
}
private sealed class EmptyRadarSource : ILiveEntityRadarSource
{
public static EmptyRadarSource Instance { get; } = new();

View file

@ -137,12 +137,19 @@ public sealed class SessionPlayerCompositionTests
[Fact]
public void GraphicalCompositionPausesOnlyWhenCharacterSelectorIsAbsent()
{
string root = FindRepoRoot();
string phase = File.ReadAllText(Path.Combine(
FindRepoRoot(),
root,
"src",
"AcDream.App",
"Composition",
"SessionPlayerComposition.cs"));
string retainedUi = File.ReadAllText(Path.Combine(
root,
"src",
"AcDream.App",
"Composition",
"InteractionRetainedUiComposition.cs"));
Assert.Contains(
"AwaitCharacterSelection:",
@ -156,6 +163,18 @@ public sealed class SessionPlayerCompositionTests
"CharacterList.TrySelectFirstAvailable",
phase,
StringComparison.Ordinal);
Assert.Contains(
"CharacterSelection: d.Options.LiveCharacterSelector is null",
retainedUi,
StringComparison.Ordinal);
Assert.Contains(
"() => late.GameRuntime.CharacterSelection",
retainedUi,
StringComparison.Ordinal);
Assert.Contains(
"late.GameRuntime.CharacterSelectionEnter",
retainedUi,
StringComparison.Ordinal);
}
private sealed class RetryBinding(

View file

@ -0,0 +1,181 @@
using System.IO;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Content;
using DatReaderWriter;
using DatReaderWriter.Options;
using StringTable = DatReaderWriter.DBObjs.StringTable;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Installed-retail-DAT acceptance gate for LA8. Opt in with
/// <c>ACDREAM_PROBE_LIVE_MOUNT=1</c>; <c>ACDREAM_DAT_DIR</c> can override the
/// ordinary Documents/Asheron's Call location. Reads the DATs read-only.
/// </summary>
public sealed class CharacterManagementLiveDatTests
{
[InstalledDatFact]
public void EnumTable5_ResolvesAndImportsTheExactRetailScreenAndDialogs()
{
string datDirectory = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents",
"Asheron's Call");
using var dats = new DatCollection(datDirectory, DatAccessType.Read);
const uint expectedLayoutDid = 0x21000004u;
uint layoutDid = RetailDataIdResolver.Resolve(
dats,
CharacterManagementUiController.RootEnum,
5u);
Assert.Equal(expectedLayoutDid, layoutDid);
Console.WriteLine(
"[LA8-DAT] category=5 enum=0x10000005 -> DID=0x21000004; "
+ "selected-root=0x1000039A");
ElementInfo rootInfo = Assert.IsType<ElementInfo>(
LayoutImporter.ImportInfos(
dats,
layoutDid,
CharacterManagementUiController.RootElementId));
Assert.Equal(800f, rootInfo.Width);
Assert.Equal(600f, rootInfo.Height);
Assert.Equal(8, rootInfo.Children.Count);
Assert.Equal(0x06007576u, rootInfo.StateMedia[""].File);
ImportedLayout screen = LayoutImporter.Build(
rootInfo,
_ => (0u, 0, 0),
null,
null,
new DatStringResolver(dats).Resolve);
var list = Assert.IsType<UiTemplateListBox>(screen.FindElement(
CharacterManagementUiController.ListElementId));
UiTemplateListEntry template = Assert.Single(list.Templates);
Assert.Equal(expectedLayoutDid, template.TemplateLayoutId);
Assert.Equal(0x100003A5u, template.TemplateElementId);
AssertButton(screen, CharacterManagementUiController.CreateElementId,
"Create Character");
AssertButton(screen, CharacterManagementUiController.EnterElementId,
"ENTER");
AssertButton(screen, CharacterManagementUiController.DeleteElementId,
"DELETE");
AssertButton(screen, CharacterManagementUiController.RestoreElementId,
"RESTORE");
Assert.DoesNotContain(
Descendants(screen.Root),
static element => element is UiViewport);
ElementInfo rowInfo = Assert.IsType<ElementInfo>(
LayoutImporter.ImportInfos(dats, layoutDid, template.TemplateElementId));
Assert.Equal(1u, rowInfo.Type);
Assert.Equal(160f, rowInfo.Width);
Assert.Equal(16f, rowInfo.Height);
Assert.Equal(0x40000009u, rowInfo.FontDid);
Assert.Equal(
[
UiButtonStateMachine.Normal,
UiButtonStateMachine.NormalRollover,
UiButtonStateMachine.NormalPressed,
UiButtonStateMachine.Highlight,
UiButtonStateMachine.HighlightRollover,
uint.MaxValue,
],
rowInfo.States.Keys.Order().ToArray());
uint dialogDid = RetailDataIdResolver.Resolve(dats, 2u, 5u);
Assert.Equal(0x2100003Cu, dialogDid);
ImportedLayout message = BuildSelected(dats, dialogDid, 0x24u);
Assert.IsType<UiDialogRoot>(message.Root);
Assert.IsType<UiText>(message.FindElement(0x3Eu));
Assert.IsType<UiButton>(message.FindElement(0x26u));
ImportedLayout delete = BuildSelected(dats, dialogDid, 0x2Cu);
Assert.IsType<UiDialogRoot>(delete.Root);
Assert.IsType<UiField>(delete.FindElement(0x2Cu));
Assert.IsType<UiButton>(delete.FindElement(0x2Eu));
Assert.IsType<UiButton>(delete.FindElement(0x2Fu));
var strings = new DatStringResolver(dats);
const uint table = 0x23000002u;
Assert.Equal("DELETE", Resolve(strings, table,
"ID_CharacterManagement_DeleteCharacterResponse"));
Assert.Equal("Please Wait", Resolve(strings, table,
"ID_CharacterManagement_PleaseWait"));
Assert.Equal("Entering World", Resolve(strings, table,
"ID_Character_EnteringWorld"));
string confirmation = Assert.IsType<string>(strings.ResolveTemplate(
table,
"ID_CharacterManagement_DeleteCharacterConfirmation",
new Dictionary<uint, string>
{
[DatStringResolver.PlayerVariable] = "Test Character",
}));
Assert.Contains("Test Character", confirmation);
Assert.Contains("'DELETE'", confirmation);
StringTable stringTable = Assert.IsType<StringTable>(dats.Get<StringTable>(table));
var deleteEntry = stringTable.Strings[
DatStringResolver.ComputeHash(
"ID_CharacterManagement_DeleteCharacterConfirmation")];
Assert.Equal([DatStringResolver.PlayerVariable], deleteEntry.Variables);
}
private static ImportedLayout BuildSelected(
IDatReaderWriter dats,
uint layoutDid,
uint rootId)
{
ElementInfo info = Assert.IsType<ElementInfo>(
LayoutImporter.ImportInfos(dats, layoutDid, rootId));
return LayoutImporter.Build(
info,
_ => (0u, 0, 0),
null,
null,
new DatStringResolver(dats).Resolve);
}
private static string Resolve(
DatStringResolver strings,
uint table,
string key) => Assert.IsType<string>(strings.Resolve(
table,
DatStringResolver.ComputeHash(key)));
private static void AssertButton(
ImportedLayout layout,
uint elementId,
string label) => Assert.Equal(
label,
Assert.IsType<UiButton>(layout.FindElement(elementId)).Label);
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;
}
}
internal sealed class InstalledDatFactAttribute : FactAttribute
{
public InstalledDatFactAttribute()
{
if (Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIVE_MOUNT") != "1")
{
Skip = "Set ACDREAM_PROBE_LIVE_MOUNT=1 to run the installed-DAT LA8 gate.";
return;
}
string datDirectory = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents",
"Asheron's Call");
if (!File.Exists(Path.Combine(datDirectory, "client_portal.dat")))
Skip = $"Installed client_portal.dat is required at '{datDirectory}'.";
}
}

View file

@ -0,0 +1,995 @@
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
{
[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);
}
[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());
}
[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");
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(ButtonInfo(
CharacterManagementUiController.CreateElementId));
root.Children.Add(ButtonInfo(
CharacterManagementUiController.EnterElementId));
root.Children.Add(ButtonInfo(
CharacterManagementUiController.DeleteElementId));
root.Children.Add(ButtonInfo(
CharacterManagementUiController.RestoreElementId));
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);
}
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 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 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 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,
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() { }
}
}

View file

@ -31,6 +31,8 @@ public class DatWidgetFactoryTests
[Theory]
[InlineData(0x13u)] // ConfirmationDialog (catalog root 0x15)
[InlineData(0x15u)] // ConfirmationTextInputDialog (catalog root 0x2C)
[InlineData(0x17u)] // MessageDialog (catalog root 0x24)
[InlineData(0x19u)] // WaitDialog (catalog root 0x31 — OP8 #396's live
// crash: unmapped type built a plain UiDatElement and
// RetailWaitDialogView's ctor threw out of OnClick)

View file

@ -378,6 +378,274 @@ public sealed class RetailDialogFactoryTests
Assert.Equal("text", data.GetString(RetailDialogProperty.Message));
}
[Fact]
public void MessageDialog_UsesAuthoredOkButtonAndReturnsThroughFactoryCallback()
{
var root = new UiRoot { Width = 800f, Height = 600f };
var layouts = new List<(RetailDialogType Type, ImportedLayout Layout)>();
using var factory = new RetailDialogFactory(root, type =>
{
ImportedLayout layout = BuildDialogLayout(type);
layouts.Add((type, layout));
return layout;
});
bool completed = false;
factory.MakeMessage("Character selection failed.", _ => completed = true);
(RetailDialogType type, ImportedLayout layout) = Assert.Single(layouts);
Assert.Equal(RetailDialogType.Message, type);
Assert.Equal("Character selection failed.", Message(layout));
Button(layout, RetailMessageDialogView.OkButtonId).OnClick!();
Assert.True(completed);
Assert.False(factory.IsOpen);
Assert.Null(root.Modal);
}
[Fact]
public void ConfirmationTextInput_AcceptsTypedResultAndRejectsWithEmptyResult()
{
var root = new UiRoot { Width = 800f, Height = 600f };
var layouts = new List<(RetailDialogType Type, ImportedLayout Layout)>();
using var factory = new RetailDialogFactory(root, type =>
{
ImportedLayout layout = BuildDialogLayout(type);
layouts.Add((type, layout));
return layout;
});
var results = new List<string>();
factory.MakeConfirmationTextInput(
"Type DELETE.",
data => results.Add(
data.GetString(RetailDialogProperty.TextInputResult) ?? "<null>"));
ImportedLayout accepted = layouts[^1].Layout;
var field = Assert.IsType<UiField>(
accepted.FindElement(RetailConfirmationTextInputDialogView.InputElementId));
Assert.Same(field, root.KeyboardFocus);
field.SetText("delete");
Button(accepted, RetailConfirmationTextInputDialogView.AcceptButtonId).OnClick!();
factory.MakeConfirmationTextInput(
"Type DELETE.",
data => results.Add(
data.GetString(RetailDialogProperty.TextInputResult) ?? "<null>"));
ImportedLayout rejected = layouts[^1].Layout;
UiDialogRoot rejectedRoot = Assert.IsType<UiDialogRoot>(rejected.Root);
Assert.NotNull(rejectedRoot.Cancel);
rejectedRoot.Cancel!();
Assert.Equal(["delete", ""], results);
Assert.False(factory.IsOpen);
Assert.Null(root.KeyboardFocus);
}
[Theory]
[InlineData(RetailDialogType.Wait, 0)]
[InlineData(RetailDialogType.Message, 1)]
[InlineData(RetailDialogType.ConfirmationTextInput, 2)]
public void CatalogFailure_DoesNotPoisonActiveQueue_AndTickRecovers(
RetailDialogType type,
int failureKind)
{
var root = new UiRoot { Width = 800f, Height = 600f };
bool available = false;
int attempts = 0;
using var factory = new RetailDialogFactory(root, requested =>
{
Assert.Equal(type, requested);
attempts++;
if (!available)
{
return failureKind switch
{
0 => throw new InvalidOperationException("catalog unavailable"),
1 => null,
_ => new ImportedLayout(
new UiDialogRoot(),
new Dictionary<uint, UiElement>()),
};
}
return BuildDialogLayout(type);
});
RetailDialogData data = type switch
{
RetailDialogType.Wait => RetailDialogData.Wait("Please Wait"),
RetailDialogType.Message => RetailDialogData.Message("Error"),
_ => RetailDialogData.ConfirmationTextInput("Type DELETE"),
};
uint context = 0u;
Exception? creationError = Record.Exception(
() => context = factory.MakeDialog(data));
Assert.Null(creationError);
Assert.NotEqual(0u, context);
Assert.Equal(0, factory.ActiveCount);
Assert.Equal(0, factory.PendingCount);
Assert.Equal(1, factory.RetryCount);
Assert.Null(root.Modal);
Assert.Empty(root.Children);
available = true;
factory.Tick();
Assert.Equal(2, attempts);
Assert.Equal(1, factory.ActiveCount);
Assert.Equal(0, factory.PendingCount);
Assert.Equal(0, factory.RetryCount);
Assert.NotNull(root.Modal);
Assert.True(factory.CloseDialog(context));
Assert.False(factory.IsOpen);
}
[Fact]
public void PendingCatalogFailure_MovesOutOfQueue_ThenRecoversBeforeLaterWork()
{
var root = new UiRoot { Width = 800f, Height = 600f };
bool messageAvailable = false;
var layouts = new List<(RetailDialogType Type, ImportedLayout Layout)>();
using var factory = new RetailDialogFactory(root, type =>
{
if (type == RetailDialogType.Message && !messageAvailable)
return null;
ImportedLayout layout = BuildDialogLayout(type);
layouts.Add((type, layout));
return layout;
});
uint active = factory.MakeWait("active");
uint failed = factory.MakeMessage("recover me");
uint later = factory.MakeWait("later");
Assert.Equal(2, factory.PendingCount);
Assert.True(factory.CloseDialog(active));
Assert.Equal(0, factory.ActiveCount);
Assert.Equal(1, factory.PendingCount);
Assert.Equal(1, factory.RetryCount);
Assert.Null(root.Modal);
messageAvailable = true;
factory.Tick();
Assert.Equal(1, factory.ActiveCount);
Assert.Equal(1, factory.PendingCount);
Assert.Equal(0, factory.RetryCount);
Assert.Equal(
"recover me",
MessageFromAnyDialog(layouts.Last(static entry =>
entry.Type == RetailDialogType.Message).Layout.Root));
Assert.True(factory.CloseDialog(failed));
Assert.Equal("later", MessageFromAnyDialog(root.Modal!));
Assert.True(factory.CloseDialog(later));
}
[Fact]
public void PriorityRequest_PreemptsOrdinaryRetryAndPreservesOrdinaryFifo()
{
var root = new UiRoot { Width = 800f, Height = 600f };
bool waitsAvailable = false;
using var factory = new RetailDialogFactory(root, type =>
type == RetailDialogType.Wait && !waitsAvailable
? null
: BuildDialogLayout(type));
uint failed = factory.MakeWait("failed ordinary");
uint later = factory.MakeWait("later ordinary");
uint priority = factory.MakeDialog(
Priority(RetailDialogData.Message("priority")));
Assert.Equal(1, factory.ActiveCount);
Assert.Equal(1, factory.RetryCount);
Assert.Equal(1, factory.PendingCount);
Assert.Equal("priority", MessageFromAnyDialog(root.Modal!));
Assert.True(factory.CloseDialog(priority));
Assert.Equal(0, factory.ActiveCount);
Assert.Equal(1, factory.RetryCount);
Assert.Equal(1, factory.PendingCount);
waitsAvailable = true;
factory.Tick();
Assert.Equal("failed ordinary", MessageFromAnyDialog(root.Modal!));
Assert.True(factory.CloseDialog(failed));
Assert.Equal("later ordinary", MessageFromAnyDialog(root.Modal!));
Assert.True(factory.CloseDialog(later));
}
[Fact]
public void FailedPriority_RetriesAheadOfRestoredActiveAndQueuedDialog()
{
var root = new UiRoot { Width = 800f, Height = 600f };
bool priorityAvailable = false;
using var factory = new RetailDialogFactory(root, type =>
type == RetailDialogType.Message && !priorityAvailable
? null
: BuildDialogLayout(type));
uint active = factory.MakeWait("active");
uint queued = factory.MakeWait("queued");
uint priority = factory.MakeDialog(
Priority(RetailDialogData.Message("priority")));
Assert.Equal(1, factory.ActiveCount);
Assert.Equal(1, factory.RetryCount);
Assert.Equal(1, factory.PendingCount);
Assert.Equal("active", MessageFromAnyDialog(root.Modal!));
priorityAvailable = true;
factory.Tick();
Assert.Equal(1, factory.ActiveCount);
Assert.Equal(0, factory.RetryCount);
Assert.Equal(2, factory.PendingCount);
Assert.Equal("priority", MessageFromAnyDialog(root.Modal!));
Assert.True(factory.CloseDialog(priority));
Assert.Equal("active", MessageFromAnyDialog(root.Modal!));
Assert.Equal(1, factory.PendingCount);
Assert.True(factory.CloseDialog(active));
Assert.Equal("queued", MessageFromAnyDialog(root.Modal!));
Assert.True(factory.CloseDialog(queued));
}
[Fact]
public void MultipleRetries_NewestPriorityFirstThenOlderPriorityThenOrdinaryFifo()
{
var root = new UiRoot { Width = 800f, Height = 600f };
bool available = false;
using var factory = new RetailDialogFactory(root, type =>
!available ? null : BuildDialogLayout(type));
uint ordinary = factory.MakeWait("ordinary");
uint later = factory.MakeWait("later");
uint olderPriority = factory.MakeDialog(
Priority(RetailDialogData.Message("older priority")));
uint newerPriority = factory.MakeDialog(Priority(
RetailDialogData.ConfirmationTextInput("newer priority")));
Assert.Equal(0, factory.ActiveCount);
Assert.Equal(3, factory.RetryCount);
Assert.Equal(1, factory.PendingCount);
available = true;
factory.Tick();
Assert.Equal(1, factory.ActiveCount);
Assert.Equal(2, factory.RetryCount);
Assert.Equal("newer priority", MessageFromAnyDialog(root.Modal!));
Assert.True(factory.CloseDialog(newerPriority));
Assert.Equal("older priority", MessageFromAnyDialog(root.Modal!));
Assert.True(factory.CloseDialog(olderPriority));
Assert.Equal("ordinary", MessageFromAnyDialog(root.Modal!));
Assert.True(factory.CloseDialog(ordinary));
Assert.Equal("later", MessageFromAnyDialog(root.Modal!));
Assert.True(factory.CloseDialog(later));
}
private static RetailDialogFactory CreateFactory(
UiRoot root,
List<ImportedLayout> layouts)
@ -401,4 +669,105 @@ public sealed class RetailDialogFactoryTests
private static string Message(ImportedLayout layout)
=> string.Join(" ", Assert.IsType<UiText>(layout.FindElement(
RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text));
private static string MessageFromAnyDialog(UiElement root)
=> string.Join(
" ",
Assert.IsType<UiText>(UiElement.FindDescendant(
root,
RetailConfirmationDialogView.MessageElementId))
.LinesProvider()
.Select(static line => line.Text));
private static RetailDialogData Priority(RetailDialogData data) =>
data.Set(RetailDialogProperty.Priority, true)
.Set(RetailDialogProperty.QueueKey,
RetailDialogFactory.DefaultQueueKey);
internal static ImportedLayout BuildDialogLayout(RetailDialogType type)
{
uint rootId = RetailDialogFactory.RootElementId(type);
uint rootType = type switch
{
RetailDialogType.Message => 0x17u,
RetailDialogType.ConfirmationTextInput => 0x15u,
RetailDialogType.Wait => 0x19u,
_ => 0x13u,
};
var root = new ElementInfo
{
Id = rootId,
Type = rootType,
Width = 800f,
Height = 600f,
};
var popup = new ElementInfo
{
Id = 0x3Du,
Type = 3u,
Width = 400f,
Height = type == RetailDialogType.ConfirmationTextInput ? 125f : 95f,
};
popup.Children.Add(new ElementInfo
{
Id = 0x3Eu,
Type = 12u,
X = 15f,
Y = 15f,
Width = 370f,
Height = 18f,
});
if (type == RetailDialogType.Message)
{
popup.Children.Add(new ElementInfo
{
Id = RetailMessageDialogView.OkButtonId,
Type = 1u,
X = 160f,
Y = 48f,
Width = 80f,
Height = 32f,
});
}
else if (type == RetailDialogType.ConfirmationTextInput)
{
var field = new ElementInfo
{
Id = RetailConfirmationTextInputDialogView.InputElementId,
Type = 12u,
X = 4f,
Y = 43f,
Width = 152f,
Height = 16f,
};
var direct = new UiStateInfo { Id = UiStateInfo.DirectStateId };
direct.Properties.Values[0x16u] = new UiPropertyValue
{
Kind = UiPropertyKind.Bool,
BoolValue = true,
};
field.States.Add(UiStateInfo.DirectStateId, direct);
popup.Children.Add(field);
popup.Children.Add(new ElementInfo
{
Id = RetailConfirmationTextInputDialogView.AcceptButtonId,
Type = 1u,
X = 80f,
Y = 78f,
Width = 80f,
Height = 32f,
});
popup.Children.Add(new ElementInfo
{
Id = RetailConfirmationTextInputDialogView.RejectButtonId,
Type = 1u,
X = 240f,
Y = 78f,
Width = 80f,
Height = 32f,
});
}
root.Children.Add(popup);
return LayoutImporter.Build(root, _ => (0u, 0, 0), null);
}
}

View file

@ -30,6 +30,29 @@ public class UiButtonTests
Assert.Equal((17, 9), clicked);
}
[Fact]
public void DoubleClick_IsOptInAndDisabledButtonsSwallowWithoutInvoking()
{
int activations = 0;
var button = new UiButton(
new ElementInfo { Type = 1, Width = 46, Height = 18 },
NoTex);
var doubleClick = new UiEvent(
0,
button,
UiEventType.DoubleClick);
Assert.False(button.OnEvent(doubleClick));
button.OnDoubleClick = () => activations++;
Assert.True(button.OnEvent(doubleClick));
Assert.Equal(1, activations);
button.Enabled = false;
Assert.True(button.OnEvent(doubleClick));
Assert.Equal(1, activations);
}
[Fact]
public void PointerDownAndUp_InvokeDistinctTransitionHandlers()
{