Consolidated fix round for the two OP3 dual-lens reviews
(docs/research/2026-08-11-op3-review-{mechanism,blast}.md), both
APPROVE-WITH-FIXES.
MUST-FIX:
- The six "Use Mouse Turning Settings" chat lines were typed
RetailLogTextType.ClientLocal (0x1A); retail types them 0x07 (Magic).
BYTE-VERIFIED against the PDB-paired binary at all six
gmConfigUI::SetMouseTurningDefaults call sites (0x0049E972/E9E2/EA52/
EAA4/EAF6/EB48): every site pushes `6a 07` (type=7) immediately before
the text-pointer push and the AddTextToScroll call. Added a dedicated
OptionsRuntimeBindings.DisplayMouseTurningMacroLine seam routed at
Magic (scrolling chat transcript, light blue, timestamped) instead of
the 4-slot SpewBox ClientLocal uses; the mid-air refusal and UA/RA
keep ClientLocal (both independently confirmed correct).
- Filed AD-77: the client-wide floating-only gmPanelUI host divergence
(retail also exposes a docked 0x21000017 host) the plan §5 delegated
to this review, scoped to every main panel, not just Options.
SHOULD-FIX:
- gmGameplayOptionsUI is not an OptionPage in retail (acclient.h:55857,
UIElement_Field). OptionsPanelController now constructs the Gameplay
slot's OptionPage with AfterApply deliberately null, so entering/
leaving that tab never publishes SaveCharacterOptionsRuntimeCmd.
Corrected OptionPageModel's doc comment and rewrote the two tests
that pinned the wrong (Gameplay-flushes) shape.
- Added the OptionPage.OnOptionChanged seam (PlayerOptionPage::
OnOptionChanged @0x004F27D0) — fires as the last step of Apply/
Reset/Defaults, plus once per live LED edit via a new
IOptionRow.AttachPageNotify hook (BoolOptionRow wires it into
SetCurrentValue only, matching retail's Apply(1)-only
HandleDialogAndNotices path). OP4-6 will bind Apply/Reset enable
state to this.
- Exit to Character Selection's mid-air refusal is now tri-state
(Func<bool?> IsGrounded): retail's UseTime only reaches the airborne
test inside `else if (smartbox->player)`, so outside player mode (or
with no live controller) the button is a SILENT no-op, not a
refusal. Fixed the inverted comment at both call sites.
- Options panel geometry now matches its nine gmPanelUI siblings
sharing RetailPanelUiController's one main-panel rectangle
(ResizeX=false, bottom-edge-only resize, no invented Min/MaxWidth/
Height) instead of being the only all-four-edge/horizontal-resize
outlier whose width silently reverted whenever a sibling was shown.
- Added the three missing test pins: Options/Character mutual
exclusion through a REAL RetailPanelUiController registration,
RetailDialogFactory.MakeConfirmation's omitted-queueKey overload
sharing DefaultQueueKey, and UiTabPanel.ActivePageChanged never
firing on a dormant (non-activated) host.
- TS-74's What/Where now names the five store-only CameraTurning
preferences explicitly instead of only mentioning them in Risk.
- Test script gains the toolbar-button ghosted->enabled+highlight
check, UseMouseTurning-survives-relogin and the five prefs-survive-
relaunch steps, a UA/RA legibility eye-item, and the corrected
bottom-edge-only geometry description for step 5.
One-liners fixed in files already touched: symmetric close-button
resolve-failure logging in OptionsPanelController.Bind (blast NOTE 8).
Full Release suite: 12,947 passed / 4 skipped / 0 failed (baseline
12,935/4/0 post-OP7 — 12 net new tests; the two OptionPageModelTests
"wrong-shape" tests were renamed/rewritten in place, not removed).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
355 lines
14 KiB
C#
355 lines
14 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);
|
|
}
|
|
|
|
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));
|
|
}
|