Harden retail character selection recovery

This commit is contained in:
Erik 2026-08-14 20:59:10 +02:00
parent 6cfab727f1
commit aeac874dab
8 changed files with 736 additions and 81 deletions

View file

@ -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}'.";
}
}

View file

@ -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);
}

View file

@ -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);