Implement retail character management screen
This commit is contained in:
parent
5535d0adac
commit
6cfab727f1
19 changed files with 2435 additions and 6 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,164 @@
|
|||
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
|
||||
{
|
||||
[Fact]
|
||||
public void EnumTable5_ResolvesAndImportsTheExactRetailScreenAndDialogs()
|
||||
{
|
||||
if (Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIVE_MOUNT") != "1")
|
||||
return;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,716 @@
|
|||
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.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 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 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 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 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()
|
||||
{
|
||||
uint id = View.Snapshot.HighlightedCharacterId;
|
||||
Update(snapshot => snapshot with
|
||||
{
|
||||
LastRestoreRequestedCharacterId = id,
|
||||
Operation = RuntimeCharacterSelectionOperation.RestoreRequested,
|
||||
Buttons = new RuntimeCharacterSelectionButtons(
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true),
|
||||
});
|
||||
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() { }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -378,6 +378,68 @@ 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);
|
||||
}
|
||||
|
||||
private static RetailDialogFactory CreateFactory(
|
||||
UiRoot root,
|
||||
List<ImportedLayout> layouts)
|
||||
|
|
@ -401,4 +463,91 @@ 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));
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue