acdream/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs
Erik 30fa6ee507 fix #394 #395 #396: OP8 re-gate round — caption font, retail key names, capture dialog
Three findings from the user's first Configure Keyboard look (OP8 gate,
2026-08-14), each root-caused against the named retail decomp:

- #394 row-caption font: the synthesized action-label UiText never set
  DatFont and fell to the debug bitmap font. The authored row template
  (0x21000009/0x1000002F, retail UIOption_ActionKeyMap) carries FontDid
  0x4000000A (18px serif) — Bind now takes resolveTemplateFont and applies
  the template's own authored font, resolved once per template pair.

- #395 key captions: raw enum spellings ("Shift+ShiftLeft") replaced by the
  port of CInputManager_WIN32::GetNameFromKey @0x00687F40 /
  GetNameFromKey_Internal @0x00687800 (RetailKeyNames): DAT string-table
  override by DIK-name hash (key enum 4 -> 0x2300000A, meta enum 5 ->
  0x2300000B, delimiter enum 3 -> 0x23000007 — GetDIDByEnum category 4,
  live-probed), else the OS keyboard layout's own key name ("SKIFT") via
  PlatformKeyNameProvider (Win32 GetKeyNameTextW — register row AD-96 for
  the DirectInput-vs-GetKeyNameText adaptation), else the DIK-suffix
  spelling. Bare modifier-key bindings show only the key name.

- #396 capture feedback: clicking a mapping button now opens retail's
  instruction dialog (InitiateBinding @0x004899D0 -> OpenMapWarnDialog
  @0x00488A00): a type-2 WAIT dialog on retail's MapWarn queue key
  0x10000001 with ID_ActionKeyMap_MapInstructions (0x23000004, ACTION
  variable interpolated), closed on key hit or ESC through the capture
  callback; capture is not armed if the dialog cannot open, matching
  retail. New RetailWaitDialogView (wait root 0x31 — same authored
  popup/message pair 0x3D/0x3E as the confirmation root, live-DAT probed)
  behind a shared IRetailDialogView presenter seam.

Probe evidence (env-gated, kept):
KeyboardConfigLiveMountProbeTests.ProbeKeyboardFontsAndKeyNameStrings.
Register: AD-96 filed. Gate script OP8 section updated (step 4 rewritten;
the "pressed/active state is enough" contract is retired).

Full Release solution suite green (13,424 passed / 4 skips).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 13:45:55 +02:00

404 lines
16 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> -&gt; <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));
}
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));
}