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>
375 lines
12 KiB
C#
375 lines
12 KiB
C#
using AcDream.App.UI.Layout;
|
|
|
|
namespace AcDream.App.Tests.UI.Layout;
|
|
|
|
/// <summary>
|
|
/// Pure logic tests for <see cref="OptionPage"/>/<see cref="BoolOptionRow"/> —
|
|
/// no DAT, no widgets, no runtime. Exercises a SYNTHETIC empty
|
|
/// <c>PlayerOptionPage</c>-shaped page (zero registered rows — a shape
|
|
/// Character/Chat/Config's pages briefly hold before OP4-6 register rows into
|
|
/// them) to pin the generic model-level "empty page" property, and a
|
|
/// synthetic 2-option page to prove Apply/Reset/Defaults' per-row semantics
|
|
/// before OP4-6 wire real DAT-backed rows into the same model.
|
|
///
|
|
/// <para>
|
|
/// NEITHER shape models the Gameplay tab. <c>gmGameplayOptionsUI</c>
|
|
/// (<c>acclient.h:55857</c>) derives from <c>UIElement_Field</c>, not
|
|
/// <c>OptionPage</c>/<c>PlayerOptionPage</c> at all — research doc
|
|
/// <c>2026-08-10-options-panel-structure.md</c> §6, mechanism review S1
|
|
/// (2026-08-11 fix round). <see cref="OptionsPanelControllerTests"/> pins
|
|
/// the Gameplay-specific consequence (its <see cref="OptionPage"/> instance
|
|
/// is constructed with <see cref="OptionPage.AfterApply"/> deliberately left
|
|
/// null, so it never flushes).
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class OptionPageModelTests
|
|
{
|
|
// ── Empty page (a generic zero-row PlayerOptionPage-shaped page — NOT
|
|
// modeling Gameplay, which is not an OptionPage at all) ─────────────
|
|
|
|
[Fact]
|
|
public void EmptyPage_ChangedIsAlwaysFalse()
|
|
{
|
|
var page = new OptionPage();
|
|
Assert.False(page.Changed);
|
|
}
|
|
|
|
[Fact]
|
|
public void EmptyPage_ApplyResetDefaults_AreNoOps()
|
|
{
|
|
var page = new OptionPage();
|
|
|
|
page.Apply();
|
|
page.Reset();
|
|
page.Defaults();
|
|
|
|
Assert.Empty(page.Rows);
|
|
Assert.False(page.Changed);
|
|
}
|
|
|
|
[Fact]
|
|
public void EmptyPage_OnShownAndOnHidden_DoNotThrow()
|
|
{
|
|
var page = new OptionPage();
|
|
|
|
page.OnShown();
|
|
page.OnHidden();
|
|
}
|
|
|
|
[Fact]
|
|
public void EmptyPlayerOptionPageShapedPage_WithAfterApplyWired_Apply_StillFlushes()
|
|
{
|
|
// Retail's PlayerOptionPage::SaveCurrentValues flushes the batched
|
|
// module regardless of whether THIS page's own rows changed
|
|
// anything — the module's dirty flag is global, not per-page. This
|
|
// is a property of a page that IS AfterApply-wired (Character/Chat/
|
|
// Config, briefly, before OP4-6 register their rows) — NOT of the
|
|
// Gameplay tab, whose own OptionPage instance is constructed with
|
|
// AfterApply left null (see OptionsPanelControllerTests).
|
|
var page = new OptionPage();
|
|
int flushCount = 0;
|
|
page.AfterApply = () => flushCount++;
|
|
|
|
page.Apply();
|
|
|
|
Assert.Equal(1, flushCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void EmptyPlayerOptionPageShapedPage_WithAfterApplyWired_OnShown_StillFlushes()
|
|
{
|
|
var page = new OptionPage();
|
|
int flushCount = 0;
|
|
page.AfterApply = () => flushCount++;
|
|
|
|
page.OnShown();
|
|
|
|
Assert.Equal(1, flushCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void EmptyPage_WithNoAfterApplyWired_Apply_NeverFlushes()
|
|
{
|
|
// The Gameplay tab's own shape: AfterApply is null by construction
|
|
// (OptionsPanelController), so Apply (fired by the initial default-
|
|
// tab activation and every later tab entry) never publishes a flush.
|
|
var page = new OptionPage { AfterApply = null };
|
|
|
|
Exception? thrown = Record.Exception(page.Apply);
|
|
|
|
Assert.Null(thrown);
|
|
}
|
|
|
|
[Fact]
|
|
public void EmptyPage_ResetAndDefaults_DoNotInvokeAfterApply()
|
|
{
|
|
var page = new OptionPage();
|
|
int flushCount = 0;
|
|
page.AfterApply = () => flushCount++;
|
|
|
|
page.Reset();
|
|
page.Defaults();
|
|
|
|
Assert.Equal(0, flushCount);
|
|
}
|
|
|
|
// ── BoolOptionRow leaf semantics (UIOption_Checkbox port) ───────────────
|
|
|
|
[Fact]
|
|
public void BoolOptionRow_SetCurrentValue_AppliesLiveImmediately_WithoutCommitting()
|
|
{
|
|
var applied = new List<bool>();
|
|
var row = new BoolOptionRow(initial: false, defaultValue: false, apply: v => applied.Add(v));
|
|
|
|
row.SetCurrentValue(true);
|
|
|
|
Assert.True(row.Current);
|
|
Assert.False(row.Saved); // NOT committed — SetCurrentValue never touches Saved.
|
|
Assert.True(row.Changed);
|
|
Assert.Equal([true], applied);
|
|
}
|
|
|
|
[Fact]
|
|
public void BoolOptionRow_SaveCurrentValue_CommitsBaseline()
|
|
{
|
|
var row = new BoolOptionRow(initial: false, defaultValue: false);
|
|
row.SetCurrentValue(true);
|
|
|
|
row.SaveCurrentValue();
|
|
|
|
Assert.True(row.Saved);
|
|
Assert.False(row.Changed);
|
|
}
|
|
|
|
[Fact]
|
|
public void BoolOptionRow_RestoreSavedValue_RevertsAndReapplies()
|
|
{
|
|
var applied = new List<bool>();
|
|
var row = new BoolOptionRow(initial: false, defaultValue: false, apply: v => applied.Add(v));
|
|
row.SetCurrentValue(true);
|
|
applied.Clear();
|
|
|
|
row.RestoreSavedValue();
|
|
|
|
Assert.False(row.Current);
|
|
Assert.False(row.Changed);
|
|
Assert.Equal([false], applied);
|
|
}
|
|
|
|
[Fact]
|
|
public void BoolOptionRow_RestoreDefaultValue_AppliesLiveWithoutCommitting()
|
|
{
|
|
var applied = new List<bool>();
|
|
var row = new BoolOptionRow(initial: false, defaultValue: true, apply: v => applied.Add(v));
|
|
|
|
row.RestoreDefaultValue();
|
|
|
|
Assert.True(row.Current);
|
|
Assert.False(row.Saved); // uncommitted — Changed re-arms Apply/Reset.
|
|
Assert.True(row.Changed);
|
|
Assert.Equal([true], applied);
|
|
}
|
|
|
|
// ── Synthetic 2-option page ──────────────────────────────────────────────
|
|
|
|
private static (OptionPage Page, BoolOptionRow A, BoolOptionRow B) MakeTwoOptionPage()
|
|
{
|
|
var page = new OptionPage();
|
|
var a = new BoolOptionRow(initial: true, defaultValue: true);
|
|
var b = new BoolOptionRow(initial: false, defaultValue: true);
|
|
page.Register(a);
|
|
page.Register(b);
|
|
return (page, a, b);
|
|
}
|
|
|
|
[Fact]
|
|
public void TwoOptionPage_Changed_TrueWhenAnyRowChanged()
|
|
{
|
|
var (page, a, _) = MakeTwoOptionPage();
|
|
Assert.False(page.Changed);
|
|
|
|
a.SetCurrentValue(false);
|
|
|
|
Assert.True(page.Changed);
|
|
}
|
|
|
|
[Fact]
|
|
public void TwoOptionPage_Apply_CommitsEveryRowUnconditionally()
|
|
{
|
|
var (page, a, b) = MakeTwoOptionPage();
|
|
a.SetCurrentValue(false); // a changed
|
|
// b left at its initial (unchanged) value.
|
|
|
|
page.Apply();
|
|
|
|
Assert.False(page.Changed);
|
|
Assert.True(a.Saved == a.Current);
|
|
Assert.True(b.Saved == b.Current);
|
|
}
|
|
|
|
[Fact]
|
|
public void TwoOptionPage_Reset_RevertsOnlyChangedRows()
|
|
{
|
|
var (page, a, b) = MakeTwoOptionPage();
|
|
a.SetCurrentValue(false); // a: true -> false, changed
|
|
// b stays at its initial false (unchanged, Saved == Current == false).
|
|
|
|
page.Reset();
|
|
|
|
Assert.True(a.Current); // reverted to its original saved baseline
|
|
Assert.False(b.Current); // untouched — was never Changed
|
|
Assert.False(page.Changed);
|
|
}
|
|
|
|
[Fact]
|
|
public void TwoOptionPage_Defaults_RestoresEveryRow_WithoutCommitting()
|
|
{
|
|
var (page, a, b) = MakeTwoOptionPage();
|
|
a.SetCurrentValue(false);
|
|
page.Apply(); // commit a's change so Changed starts false
|
|
|
|
page.Defaults();
|
|
|
|
Assert.True(a.Current); // default is true
|
|
Assert.True(b.Current); // default is true (was false)
|
|
// Defaults never commits — b's baseline (Saved) is still its old
|
|
// committed value (false), so Changed re-arms Apply/Reset.
|
|
Assert.True(page.Changed);
|
|
}
|
|
|
|
[Fact]
|
|
public void TwoOptionPage_OnHidden_RevertsUncommittedEdits()
|
|
{
|
|
var (page, a, _) = MakeTwoOptionPage();
|
|
a.SetCurrentValue(false);
|
|
|
|
page.OnHidden();
|
|
|
|
Assert.True(a.Current);
|
|
Assert.False(page.Changed);
|
|
}
|
|
|
|
[Fact]
|
|
public void TwoOptionPage_OnShown_AppliesAndCommits()
|
|
{
|
|
var (page, a, _) = MakeTwoOptionPage();
|
|
a.SetCurrentValue(false);
|
|
|
|
page.OnShown();
|
|
|
|
Assert.False(page.Changed);
|
|
Assert.False(a.Saved != a.Current);
|
|
}
|
|
|
|
// ── OnOptionChanged seam (mechanism review S2, 2026-08-11 fix round) ────
|
|
// PlayerOptionPage::OnOptionChanged @0x004F27D0: the sole Apply/Reset
|
|
// enable-gate, run as the LAST statement of all three verbs
|
|
// (0x004F2C95/0x004F2CE5/0x004F2D4A) plus once per live LED click via
|
|
// UIOption::HandleDialogAndNotices @0x004EFB90.
|
|
|
|
[Fact]
|
|
public void OnOptionChanged_FiresAsLastStepOf_Apply()
|
|
{
|
|
var (page, a, _) = MakeTwoOptionPage();
|
|
a.SetCurrentValue(false);
|
|
var order = new List<string>();
|
|
page.AfterApply = () => order.Add("afterApply");
|
|
page.OnOptionChanged = () => order.Add("onOptionChanged");
|
|
|
|
page.Apply();
|
|
|
|
Assert.Equal(["afterApply", "onOptionChanged"], order);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnOptionChanged_FiresOnReset_EvenWithNoAfterApply()
|
|
{
|
|
var (page, a, _) = MakeTwoOptionPage();
|
|
a.SetCurrentValue(false);
|
|
int notifyCount = 0;
|
|
page.OnOptionChanged = () => notifyCount++;
|
|
|
|
page.Reset();
|
|
|
|
Assert.Equal(1, notifyCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnOptionChanged_FiresOnDefaults()
|
|
{
|
|
var (page, _, _) = MakeTwoOptionPage();
|
|
int notifyCount = 0;
|
|
page.OnOptionChanged = () => notifyCount++;
|
|
|
|
page.Defaults();
|
|
|
|
Assert.Equal(1, notifyCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnOptionChanged_FiresOnEmptyPage_ForEveryVerb()
|
|
{
|
|
// Defaults is NEVER gated by retail's own OnOptionChanged override
|
|
// (it never fetches the Defaults child id at all), but the page-
|
|
// level notify still fires from EVERY verb regardless of row count
|
|
// — the model doesn't special-case "which button retail happens to
|
|
// gate" here, only the seam itself.
|
|
var page = new OptionPage();
|
|
int notifyCount = 0;
|
|
page.OnOptionChanged = () => notifyCount++;
|
|
|
|
page.Apply();
|
|
page.Reset();
|
|
page.Defaults();
|
|
|
|
Assert.Equal(3, notifyCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void BoolOptionRow_SetCurrentValue_NotifiesAttachedPage()
|
|
{
|
|
// Retail's Apply(1)-only HandleDialogAndNotices path: a live LED
|
|
// click reaches the page's OnOptionChanged directly, distinct from
|
|
// (and in addition to) the per-row apply callback.
|
|
var row = new BoolOptionRow(initial: false, defaultValue: false);
|
|
int notifyCount = 0;
|
|
row.AttachPageNotify(() => notifyCount++);
|
|
|
|
row.SetCurrentValue(true);
|
|
|
|
Assert.Equal(1, notifyCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void BoolOptionRow_RestoreSavedValueAndRestoreDefaultValue_DoNotNotifyAttachedPage()
|
|
{
|
|
// Apply(0) paths — retail's Reset/Defaults call these directly and
|
|
// notify the page ONCE themselves at their own verb tail; a per-row
|
|
// notify here would double-fire (or fire once per reverted row
|
|
// instead of once per verb call).
|
|
var row = new BoolOptionRow(initial: false, defaultValue: true);
|
|
int notifyCount = 0;
|
|
row.AttachPageNotify(() => notifyCount++);
|
|
row.SetCurrentValue(true);
|
|
notifyCount = 0; // drop the SetCurrentValue notify above
|
|
|
|
row.RestoreSavedValue();
|
|
row.RestoreDefaultValue();
|
|
|
|
Assert.Equal(0, notifyCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void Register_AttachesPageNotify_SoSubsequentLiveEditsNotifyThePage()
|
|
{
|
|
var page = new OptionPage();
|
|
var row = new BoolOptionRow(initial: false, defaultValue: false);
|
|
int notifyCount = 0;
|
|
page.OnOptionChanged = () => notifyCount++;
|
|
|
|
page.Register(row);
|
|
row.SetCurrentValue(true);
|
|
|
|
Assert.Equal(1, notifyCount);
|
|
}
|
|
}
|