Finding 1 (Exit button dead): retail's gmCharacterManagementUI Exit
button (element 0x100003A4, offset 7 from the listbox base in
ListenToElementMessage@0x004ed5a0) opens MakeConfirmExitDialog
(0x004ed250), whose exact ID_CharacterManagement_ConfirmExit text
(table 0x23000002) and m_confirmExitDialogContext re-entry guard are
now ported. On confirm (matching RecvNotice_CloseDialog@0x004ed760
case 1's ConfirmationResult check) the client exits through the
EXISTING graceful window-close seam (CharacterSelectionRuntimeBindings
.RequestExit -> d.Window.Close, the same delegate
GameplayInputCommandController's Escape fallback already uses) so
disconnected/exited status events still fire via GameWindow.OnClosing
-> CompleteShutdown. Retail's real post-confirm destination is
QueueUIMode(0x10000009) -> gmEpilogueUI, an epilogue screen this round
does not port — recorded as AD-99. Credits (element 0x100003A3,
QueueUIMode(0x10000005) -> gmCreditsUI) stays visibly ghosted like
Create, same treatment, out of scope this round.
Finding 2 (row names center-aligned, retail is left): the character
row template (LayoutDesc 0x21000004, element 0x100003A5, live-DAT
confirmed HJustify=Left with three stateful Type-3 highlight-art
children and no Type-12 caption child) authors its OWN justify
directly, with no separate text child to lift a label from.
DatWidgetFactory.BuildButton's Left-justify branch required
!ReferenceEquals(labelInfo, info) — true only when a label was LIFTED
from a distinct child — so a button's own direct HJustify=Left was
silently dropped to UiButton's Center default. Widened the branch to
also honor the direct case, preserving the existing lifted-child
LabelOffsetX behavior and leaving genuinely-centered buttons
(CREATE/ENTER/DELETE/RESTORE) untouched.
Finding 3 (World box empty): parsed ACE's GameMessageServerName
(opcode 0xF7E1, ACE.Server/Network/GameMessages/Messages/
GameMessageServerName.cs; retail CM_Login::DispatchUI_WorldInfo
@0x006ad860 -> ClientUISystem::Handle_Login__WorldInfo@0x005641a0 ->
ECM_Login::SendNotice_WorldName@0x00692b10, notice 0x186a2, consumed
by gmCharacterManagementUI::UpdateWorldName@0x004ec120 /
RecvNotice_WorldName@0x004ec360 onto element 0x1000039B) as
src/AcDream.Core.Net/Messages/ServerName.cs, cross-checked against
holtburger's ServerNameData. WorldSession.ServerNameReceived fires
alongside CharacterListReceived (ACE sends both in one
SendConnectResponse batch); RuntimeCharacterSelectionState.
ApplyWorldName is the new J-owner field (ungated by lifecycle, since
either message can arrive first); CharacterManagementUiController
binds it onto the WorldTextElementId UiText. Per the LA1 status
vocabulary, the characterList STATUS event's worldName field is
intentionally NOT added this round (kept bounded to the client-side
fix) — a follow-up if the launcher UI wants it.
Also corrects AD-44, discovered stale while filing AD-99: its opening
claim ("acdream has no retained character-management screen") was
false as of this session — LA7/LA8 shipped the screen in earlier
commits without updating this row.
Tests: exit-confirm open/cancel/confirm/re-entry-guard flow;
DatWidgetFactory own-HJustify-Left/Center regression tests plus the
live-DAT pinned row-justify assertion; ServerName parse round-trip
(byte-exact vs ACE's AceWireWriter fixture, truncation/wrong-opcode
cases); WorldSession dispatch test (roster+world in one wire batch);
RuntimeCharacterSelectionState.ApplyWorldName tests (order-independent
of ApplyRoster, unchanged-value no-op, Reset clears); controller test
binding the World text element to the live snapshot. Extended the
shared RetailDialogFactoryTests.BuildDialogLayout test fixture with a
Confirmation-type branch (Accept/Reject buttons) since this is its
first RetailDialogType.Confirmation consumer.
Suites: full solution Release build green; AcDream.App.Tests 5100/6
skips, AcDream.Core.Net.Tests 965/0, AcDream.Runtime.Tests 1665/0, all
Release, 0 failures; live-DAT probes (ACDREAM_PROBE_LIVE_MOUNT=1)
green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
799 lines
30 KiB
C#
799 lines
30 KiB
C#
using AcDream.App.UI;
|
|
using AcDream.App.UI.Layout;
|
|
|
|
namespace AcDream.App.Tests.UI.Layout;
|
|
|
|
public sealed class RetailDialogFactoryTests
|
|
{
|
|
[Theory]
|
|
[InlineData(RetailDialogType.Confirmation, 0x15u)]
|
|
[InlineData(RetailDialogType.Wait, 0x31u)]
|
|
[InlineData(RetailDialogType.Message, 0x24u)]
|
|
[InlineData(RetailDialogType.TextInput, 0x28u)]
|
|
[InlineData(RetailDialogType.ConfirmationTextInput, 0x2Cu)]
|
|
[InlineData(RetailDialogType.Menu, 0x1Bu)]
|
|
[InlineData(RetailDialogType.ConfirmationMenu, 0x1Fu)]
|
|
public void DialogTypesMapToRetailCatalogRoots(
|
|
RetailDialogType type,
|
|
uint expectedRoot)
|
|
=> Assert.Equal(expectedRoot, RetailDialogFactory.RootElementId(type));
|
|
|
|
[Fact]
|
|
public void FixtureBuildsProductionConfirmationWidgets()
|
|
{
|
|
var layout = FixtureLoader.LoadConfirmationDialog();
|
|
|
|
Assert.IsType<UiDialogRoot>(layout.Root);
|
|
Assert.IsType<UiText>(layout.FindElement(RetailConfirmationDialogView.MessageElementId));
|
|
Assert.IsType<UiButton>(layout.FindElement(RetailConfirmationDialogView.AcceptButtonId));
|
|
Assert.IsType<UiButton>(layout.FindElement(RetailConfirmationDialogView.RejectButtonId));
|
|
}
|
|
|
|
[Fact]
|
|
public void ConfirmationCreatesFreshCenteredRootAndReturnsPropertyResult()
|
|
{
|
|
var root = new UiRoot { Width = 1024f, Height = 768f };
|
|
var layouts = new List<ImportedLayout>();
|
|
var factory = CreateFactory(root, layouts);
|
|
bool? result = null;
|
|
|
|
factory.MakeConfirmation(
|
|
"Do you really want to kill your character?",
|
|
data => result = data.GetBoolean(RetailDialogProperty.ConfirmationResult));
|
|
|
|
ImportedLayout layout = Assert.Single(layouts);
|
|
Assert.Same(layout.Root, root.Modal);
|
|
var popup = Assert.IsAssignableFrom<UiElement>(
|
|
layout.FindElement(RetailConfirmationDialogView.PopupElementId));
|
|
Assert.Equal(MathF.Round((1024f - popup.Width) * 0.5f), popup.Left);
|
|
Assert.Equal(MathF.Round((768f - popup.Height) * 0.5f), popup.Top);
|
|
|
|
Accept(layout);
|
|
|
|
Assert.True(result);
|
|
Assert.Null(root.Modal);
|
|
Assert.False(factory.IsOpen);
|
|
}
|
|
|
|
[Fact]
|
|
public void SameQueuePresentsFifoUsingFreshLiveRoots()
|
|
{
|
|
var root = new UiRoot { Width = 800f, Height = 600f };
|
|
var layouts = new List<ImportedLayout>();
|
|
var factory = CreateFactory(root, layouts);
|
|
var results = new List<string>();
|
|
|
|
factory.MakeConfirmation("first", data =>
|
|
results.Add($"first:{data.GetBoolean(RetailDialogProperty.ConfirmationResult)}"));
|
|
factory.MakeConfirmation("second", data =>
|
|
results.Add($"second:{data.GetBoolean(RetailDialogProperty.ConfirmationResult)}"));
|
|
|
|
Assert.Equal(1, factory.PendingCount);
|
|
Reject(layouts[0]);
|
|
Assert.Equal(["first:False"], results);
|
|
Assert.Equal(2, layouts.Count);
|
|
Assert.NotSame(layouts[0].Root, layouts[1].Root);
|
|
Assert.Same(layouts[1].Root, root.Modal);
|
|
Assert.Equal("second", Message(layouts[1]));
|
|
|
|
Accept(layouts[1]);
|
|
Assert.Equal(["first:False", "second:True"], results);
|
|
Assert.False(factory.IsOpen);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Blast review SHOULD-FIX 3 / NOTE 3 (2026-08-11 fix round): the Options
|
|
/// panel's Exit-to-Character-Selection confirmation goes through
|
|
/// <c>RetailUiRuntime.ShowConfirmation</c> -> <c>MakeConfirmation(message,
|
|
/// callback)</c> with NO explicit <c>queueKey</c> argument. Nothing pinned
|
|
/// that the omitted-key overload actually shares
|
|
/// <see cref="RetailDialogFactory.DefaultQueueKey"/> with every other
|
|
/// confirmation in the codebase, rather than silently defaulting to a
|
|
/// DIFFERENT key that would let two confirmations sit active
|
|
/// simultaneously instead of queuing.
|
|
/// </summary>
|
|
[Fact]
|
|
public void MakeConfirmation_OmittedQueueKey_SharesDefaultQueueKey()
|
|
{
|
|
var root = new UiRoot { Width = 800f, Height = 600f };
|
|
var layouts = new List<ImportedLayout>();
|
|
var factory = CreateFactory(root, layouts);
|
|
|
|
factory.MakeConfirmation("uses the omitted-queueKey overload");
|
|
factory.MakeConfirmation("explicit DefaultQueueKey", queueKey: RetailDialogFactory.DefaultQueueKey);
|
|
|
|
// If the omitted-queueKey call had used a DIFFERENT key, both would
|
|
// be ACTIVE simultaneously (distinct queue groups run independently
|
|
// — QueueGroupsAndNonQueuedDialogsCanBeActiveTogether below) instead
|
|
// of the second queuing FIFO behind the first.
|
|
Assert.Equal(1, factory.ActiveCount);
|
|
Assert.Equal(1, factory.PendingCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void QueueGroupsAndNonQueuedDialogsCanBeActiveTogether()
|
|
{
|
|
var root = new UiRoot { Width = 800f, Height = 600f };
|
|
var layouts = new List<ImportedLayout>();
|
|
var factory = CreateFactory(root, layouts);
|
|
|
|
uint first = factory.MakeConfirmation("queue two", queueKey: 2u);
|
|
uint second = factory.MakeConfirmation("queue three", queueKey: 3u);
|
|
uint third = factory.MakeConfirmation("nonqueued", queueKey: 1u);
|
|
|
|
Assert.Equal(3, factory.ActiveCount);
|
|
Assert.Equal(0, factory.PendingCount);
|
|
Assert.Same(layouts[2].Root, root.Modal);
|
|
|
|
Assert.True(factory.CloseDialog(third));
|
|
Assert.Same(layouts[1].Root, root.Modal);
|
|
Assert.True(factory.CloseDialog(second));
|
|
Assert.Same(layouts[0].Root, root.Modal);
|
|
Assert.True(factory.CloseDialog(first));
|
|
Assert.Null(root.Modal);
|
|
}
|
|
|
|
[Fact]
|
|
public void PriorityDialogSuspendsCurrentAndRestoresItBeforeOlderPendingWork()
|
|
{
|
|
var root = new UiRoot { Width = 800f, Height = 600f };
|
|
var layouts = new List<ImportedLayout>();
|
|
var factory = CreateFactory(root, layouts);
|
|
|
|
factory.MakeConfirmation("current");
|
|
factory.MakeConfirmation("ordinary pending");
|
|
factory.MakeConfirmation("priority", priority: true);
|
|
|
|
Assert.Equal(2, factory.PendingCount);
|
|
Assert.Equal("priority", Message(layouts[1]));
|
|
Assert.DoesNotContain(layouts[0].Root, root.Children);
|
|
|
|
Reject(layouts[1]);
|
|
|
|
Assert.Equal(3, layouts.Count);
|
|
Assert.Equal("current", Message(layouts[2]));
|
|
Assert.Equal(1, factory.PendingCount);
|
|
Assert.NotSame(layouts[0].Root, layouts[2].Root);
|
|
}
|
|
|
|
[Fact]
|
|
public void CallbackPrecedesCloseNoticeAndCustomLabelsAreApplied()
|
|
{
|
|
var root = new UiRoot { Width = 800f, Height = 600f };
|
|
var layouts = new List<ImportedLayout>();
|
|
var factory = CreateFactory(root, layouts);
|
|
var order = new List<string>();
|
|
factory.DialogClosed += (_, _) => order.Add("notice");
|
|
RetailDialogData data = RetailDialogData.Confirmation("Proceed?")
|
|
.Set(RetailDialogProperty.AcceptLabel, "Yes")
|
|
.Set(RetailDialogProperty.RejectLabel, "No");
|
|
|
|
factory.MakeDialog(data, _ => order.Add("callback"));
|
|
|
|
Assert.Equal("Yes", Button(layouts[0], RetailConfirmationDialogView.AcceptButtonId).Label);
|
|
Assert.Equal("No", Button(layouts[0], RetailConfirmationDialogView.RejectButtonId).Label);
|
|
Accept(layouts[0]);
|
|
Assert.Equal(["callback", "notice"], order);
|
|
}
|
|
|
|
[Fact]
|
|
public void PendingContextCanBeClosedWithoutDisturbingActiveDialog()
|
|
{
|
|
var root = new UiRoot { Width = 800f, Height = 600f };
|
|
var layouts = new List<ImportedLayout>();
|
|
var factory = CreateFactory(root, layouts);
|
|
var completed = new List<uint>();
|
|
|
|
uint active = factory.MakeConfirmation("active");
|
|
uint pending = 0u;
|
|
pending = factory.MakeConfirmation("pending", _ => completed.Add(pending));
|
|
|
|
Assert.True(factory.CloseDialog(pending));
|
|
Assert.Equal([pending], completed);
|
|
Assert.Equal(0, factory.PendingCount);
|
|
Assert.Same(layouts[0].Root, root.Modal);
|
|
Assert.True(factory.CloseDialog(active));
|
|
}
|
|
|
|
[Fact]
|
|
public void ResetCompletesActiveAndPendingContexts()
|
|
{
|
|
var root = new UiRoot { Width = 800f, Height = 600f };
|
|
var layouts = new List<ImportedLayout>();
|
|
var factory = CreateFactory(root, layouts);
|
|
var completed = new List<string>();
|
|
var notices = new List<uint>();
|
|
factory.DialogClosed += (context, _) => notices.Add(context);
|
|
|
|
uint active = factory.MakeConfirmation("active", _ => completed.Add("active"));
|
|
uint pending = factory.MakeConfirmation("pending", _ => completed.Add("pending"));
|
|
factory.Reset();
|
|
|
|
Assert.Equal(["active", "pending"], completed);
|
|
Assert.Equal([active, pending], notices);
|
|
Assert.False(factory.IsOpen);
|
|
Assert.Equal(0, factory.PendingCount);
|
|
Assert.Null(root.Modal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Reset_CompletesEveryDialogAndKeepsContextSequenceMonotonic()
|
|
{
|
|
var root = new UiRoot { Width = 800f, Height = 600f };
|
|
var layouts = new List<ImportedLayout>();
|
|
var factory = CreateFactory(root, layouts);
|
|
int callbacks = 0;
|
|
int notices = 0;
|
|
factory.DialogClosed += (_, _) => notices++;
|
|
|
|
uint first = factory.MakeConfirmation("active", _ => callbacks++);
|
|
factory.MakeConfirmation("pending", _ => callbacks++);
|
|
factory.Reset();
|
|
|
|
Assert.Equal(1u, first);
|
|
Assert.Equal(2, callbacks);
|
|
Assert.Equal(2, notices);
|
|
Assert.False(factory.IsOpen);
|
|
Assert.Equal(0, factory.PendingCount);
|
|
Assert.Null(root.Modal);
|
|
Assert.Empty(root.Children);
|
|
Assert.Equal(3u, factory.MakeConfirmation("new session"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Reset_AttemptsEveryDialogWhenOneCallbackThrows()
|
|
{
|
|
var root = new UiRoot { Width = 800f, Height = 600f };
|
|
var layouts = new List<ImportedLayout>();
|
|
var factory = CreateFactory(root, layouts);
|
|
int completed = 0;
|
|
|
|
factory.MakeConfirmation("active", _ => throw new InvalidOperationException("boom"));
|
|
factory.MakeConfirmation("pending", _ => completed++);
|
|
|
|
AggregateException error = Assert.Throws<AggregateException>(factory.Reset);
|
|
|
|
Assert.Contains("boom", error.ToString());
|
|
Assert.Equal(1, completed);
|
|
Assert.False(factory.IsOpen);
|
|
Assert.Equal(0, factory.PendingCount);
|
|
Assert.Null(root.Modal);
|
|
Assert.Empty(root.Children);
|
|
factory.Reset();
|
|
}
|
|
|
|
[Fact]
|
|
public void Reset_DrainsDialogCreatedReentrantlyByCompletionCallback()
|
|
{
|
|
var root = new UiRoot { Width = 800f, Height = 600f };
|
|
var layouts = new List<ImportedLayout>();
|
|
var factory = CreateFactory(root, layouts);
|
|
int completed = 0;
|
|
factory.MakeConfirmation("first", _ =>
|
|
{
|
|
completed++;
|
|
factory.MakeConfirmation("reentrant", _ => completed++);
|
|
});
|
|
|
|
factory.Reset();
|
|
|
|
Assert.Equal(2, completed);
|
|
Assert.False(factory.IsOpen);
|
|
Assert.Equal(0, factory.PendingCount);
|
|
Assert.Null(root.Modal);
|
|
Assert.Empty(root.Children);
|
|
}
|
|
|
|
[Fact]
|
|
public void AcceptingChainedReentrantDialogDoesNotThrowAndDrainsQueueAfterChainCompletes()
|
|
{
|
|
// CH4 re-review should-fix 1: a callback that synchronously reopens a new
|
|
// dialog under the SAME queue key (retail's two-stage house-abandon
|
|
// confirmation) used to crash OpenNextDialog with a duplicate-key
|
|
// ArgumentException whenever a third confirmation was already queued
|
|
// behind the chain. See RetailDialogFactory.OpenNextDialog.
|
|
var root = new UiRoot { Width = 800f, Height = 600f };
|
|
var layouts = new List<ImportedLayout>();
|
|
var factory = CreateFactory(root, layouts);
|
|
var completed = new List<string>();
|
|
|
|
factory.MakeConfirmation("stage1", _ =>
|
|
{
|
|
completed.Add("stage1");
|
|
factory.MakeConfirmation("stage2", _ => completed.Add("stage2"));
|
|
});
|
|
factory.MakeConfirmation("queued behind chain", _ => completed.Add("queued"));
|
|
|
|
Assert.Equal(1, factory.PendingCount);
|
|
|
|
Exception? thrown = Record.Exception(() => Accept(layouts[0]));
|
|
|
|
Assert.Null(thrown);
|
|
Assert.Equal(["stage1"], completed);
|
|
Assert.Equal(2, layouts.Count);
|
|
Assert.Equal("stage2", Message(layouts[1]));
|
|
Assert.Same(layouts[1].Root, root.Modal);
|
|
Assert.Equal(1, factory.PendingCount);
|
|
|
|
Accept(layouts[1]);
|
|
|
|
Assert.Equal(["stage1", "stage2"], completed);
|
|
Assert.Equal(3, layouts.Count);
|
|
Assert.Equal("queued behind chain", Message(layouts[2]));
|
|
Assert.Same(layouts[2].Root, root.Modal);
|
|
Assert.Equal(0, factory.PendingCount);
|
|
|
|
Accept(layouts[2]);
|
|
|
|
Assert.Equal(["stage1", "stage2", "queued"], completed);
|
|
Assert.False(factory.IsOpen);
|
|
}
|
|
|
|
/// <summary>
|
|
/// OP8 re-gate (2026-08-14): the type-2 wait dialog — retail's MapWarn
|
|
/// capture-instruction shape (<c>OpenMapWarnDialog @ 0x00488A00</c>):
|
|
/// text-only, no buttons wire a result, closed programmatically by the
|
|
/// opener via <see cref="RetailDialogFactory.CloseDialog"/>.
|
|
/// </summary>
|
|
[Fact]
|
|
public void MakeWait_CreatesTextOnlyModal_ClosedByTheOpener()
|
|
{
|
|
var root = new UiRoot { Width = 1024f, Height = 768f };
|
|
var layouts = new List<ImportedLayout>();
|
|
// The shipped catalog authors the SAME popup/message child ids
|
|
// (0x3D/0x3E) under the wait root 0x31 as under the confirmation root
|
|
// (live-DAT probed 2026-08-14); the committed fixture only carries the
|
|
// confirmation subtree, which therefore stands in structurally here.
|
|
var factory = new RetailDialogFactory(root, type =>
|
|
{
|
|
Assert.Equal(RetailDialogType.Wait, type);
|
|
ImportedLayout layout = FixtureLoader.LoadConfirmationDialog();
|
|
layouts.Add(layout);
|
|
return layout;
|
|
});
|
|
|
|
uint context = factory.MakeWait(
|
|
"The next key you press will be mapped.", queueKey: 0x10000001u);
|
|
|
|
Assert.NotEqual(0u, context);
|
|
ImportedLayout layout = Assert.Single(layouts);
|
|
Assert.Same(layout.Root, root.Modal);
|
|
Assert.Equal("The next key you press will be mapped.", Message(layout));
|
|
|
|
Assert.True(factory.CloseDialog(context));
|
|
Assert.Null(root.Modal);
|
|
Assert.False(factory.IsOpen);
|
|
}
|
|
|
|
[Fact]
|
|
public void WaitData_CarriesRetailsMapWarnPropertyShape()
|
|
{
|
|
RetailDialogData data = RetailDialogData.Wait("text");
|
|
|
|
// OpenMapWarnDialog @ 0x00488A00: 0x8E=2 (Wait), 0xAC=true, 0xC5=text.
|
|
Assert.Equal(
|
|
(uint)RetailDialogType.Wait,
|
|
data.GetUInt32(RetailDialogProperty.Type));
|
|
Assert.True(data.GetBoolean(RetailDialogProperty.ElementAttribute40));
|
|
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)
|
|
=> new(root, type =>
|
|
{
|
|
Assert.Equal(RetailDialogType.Confirmation, type);
|
|
ImportedLayout layout = FixtureLoader.LoadConfirmationDialog();
|
|
layouts.Add(layout);
|
|
return layout;
|
|
});
|
|
|
|
private static UiButton Button(ImportedLayout layout, uint id)
|
|
=> Assert.IsType<UiButton>(layout.FindElement(id));
|
|
|
|
private static void Accept(ImportedLayout layout)
|
|
=> Button(layout, RetailConfirmationDialogView.AcceptButtonId).OnClick!();
|
|
|
|
private static void Reject(ImportedLayout layout)
|
|
=> Button(layout, RetailConfirmationDialogView.RejectButtonId).OnClick!();
|
|
|
|
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.Confirmation)
|
|
{
|
|
// Campaign LA gate round 2 finding 1: CharacterManagementUiController's
|
|
// exit-confirm dialog is the first BuildDialogLayout consumer that
|
|
// exercises RetailDialogType.Confirmation through this synthetic
|
|
// builder (other Confirmation coverage in THIS file uses the real
|
|
// FixtureLoader.LoadConfirmationDialog() fixture instead).
|
|
popup.Children.Add(new ElementInfo
|
|
{
|
|
Id = RetailConfirmationDialogView.AcceptButtonId,
|
|
Type = 1u,
|
|
X = 80f,
|
|
Y = 48f,
|
|
Width = 80f,
|
|
Height = 32f,
|
|
});
|
|
popup.Children.Add(new ElementInfo
|
|
{
|
|
Id = RetailConfirmationDialogView.RejectButtonId,
|
|
Type = 1u,
|
|
X = 240f,
|
|
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);
|
|
}
|
|
}
|