Harden retail character selection recovery
This commit is contained in:
parent
6cfab727f1
commit
aeac874dab
8 changed files with 736 additions and 81 deletions
|
|
@ -15,12 +15,9 @@ namespace AcDream.App.Tests.UI.Layout;
|
|||
/// </summary>
|
||||
public sealed class CharacterManagementLiveDatTests
|
||||
{
|
||||
[Fact]
|
||||
[InstalledDatFact]
|
||||
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),
|
||||
|
|
@ -162,3 +159,23 @@ public sealed class CharacterManagementLiveDatTests
|
|||
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}'.";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,9 @@ public sealed class CharacterManagementUiControllerTests
|
|||
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);
|
||||
|
|
@ -65,6 +68,126 @@ public sealed class CharacterManagementUiControllerTests
|
|||
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 DeleteConfirmation_IsCaseInsensitive_ThenWaitsThroughAckUntilFreshRoster()
|
||||
{
|
||||
|
|
@ -218,6 +341,78 @@ public sealed class CharacterManagementUiControllerTests
|
|||
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()
|
||||
{
|
||||
|
|
@ -475,6 +670,12 @@ public sealed class CharacterManagementUiControllerTests
|
|||
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)
|
||||
{
|
||||
|
|
@ -578,7 +779,13 @@ public sealed class CharacterManagementUiControllerTests
|
|||
|
||||
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,
|
||||
|
|
@ -590,6 +797,7 @@ public sealed class CharacterManagementUiControllerTests
|
|||
false,
|
||||
true),
|
||||
});
|
||||
AfterRestoreProjection?.Invoke();
|
||||
return Result(RuntimeCommandStatus.Accepted, id);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -440,6 +440,108 @@ public sealed class RetailDialogFactoryTests
|
|||
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));
|
||||
}
|
||||
|
||||
private static RetailDialogFactory CreateFactory(
|
||||
UiRoot root,
|
||||
List<ImportedLayout> layouts)
|
||||
|
|
@ -464,6 +566,15 @@ public sealed class RetailDialogFactoryTests
|
|||
=> 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));
|
||||
|
||||
internal static ImportedLayout BuildDialogLayout(RetailDialogType type)
|
||||
{
|
||||
uint rootId = RetailDialogFactory.RootElementId(type);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue