fix(ui): OP3 review fixes — byte-verified Magic chat lines, Gameplay/OptionPage shape, mid-air tri-state, shared geometry

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>
This commit is contained in:
Erik 2026-08-11 03:05:27 +02:00
parent 64898f1301
commit 386076af0f
11 changed files with 505 additions and 72 deletions

View file

@ -69,6 +69,29 @@ public class OP2ReworkBlastRadiusConformanceTests
Assert.True(host.ClickThrough);
}
/// <summary>
/// Blast review SHOULD-FIX 3 / NOTE 2 (2026-08-11 fix round): AD-73's
/// dormancy contract is that a Type-8 host with an authored tab table
/// performs NO switching until a controller calls
/// <see cref="UiTabPanel.ActivateTabBehavior"/> — but nothing had pinned
/// <see cref="UiTabPanel.ActivePageChanged"/> ITSELF against a dormant
/// host (as opposed to <c>BehaviorActive</c>/<c>ActivePageElementId</c>,
/// which the test above already covers). <c>SwitchTo</c> is public and
/// its own doc invites a future direct call, so this guards the CURRENT
/// dormant-by-default contract, not <c>SwitchTo</c> itself.
/// </summary>
[Fact]
public void Vendor_TabHost_DormantHost_NeverRaisesActivePageChanged()
{
var layout = FixtureLoader.LoadVendor();
var host = Assert.IsType<UiTabPanel>(layout.FindElement(0x100000B8u));
int raised = 0;
host.ActivePageChanged += (_, _) => raised++;
Assert.False(host.BehaviorActive);
Assert.Equal(0, raised);
}
// ── Type 8 — panel roots with their own controller-owned switching ──────
/// <summary>

View file

@ -4,16 +4,28 @@ namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Pure logic tests for <see cref="OptionPage"/>/<see cref="BoolOptionRow"/> —
/// no DAT, no widgets, no runtime. Campaign OP slice OP3 ships the model
/// against an EMPTY page (the Gameplay tab has no options at all — research
/// doc <c>2026-08-10-options-panel-structure.md</c> §6); this file also
/// exercises 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.
/// 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 (the Gameplay tab's own shape) ───────────────────────────
// ── Empty page (a generic zero-row PlayerOptionPage-shaped page — NOT
// modeling Gameplay, which is not an OptionPage at all) ─────────────
[Fact]
public void EmptyPage_ChangedIsAlwaysFalse()
@ -45,11 +57,15 @@ public sealed class OptionPageModelTests
}
[Fact]
public void EmptyPage_Apply_StillInvokesAfterApply()
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.
// 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++;
@ -60,7 +76,7 @@ public sealed class OptionPageModelTests
}
[Fact]
public void EmptyPage_OnShown_InvokesAfterApply()
public void EmptyPlayerOptionPageShapedPage_WithAfterApplyWired_OnShown_StillFlushes()
{
var page = new OptionPage();
int flushCount = 0;
@ -71,6 +87,19 @@ public sealed class OptionPageModelTests
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()
{
@ -231,4 +260,116 @@ public sealed class OptionPageModelTests
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);
}
}

View file

@ -60,8 +60,14 @@ public sealed class OptionsPanelControllerTests
}
[Fact]
public void ActivateTabs_SelectsGameplayAsDefault_AndAppliesItsPage()
public void ActivateTabs_SelectsGameplayAsDefault_ButNeverFlushesIt()
{
// Mechanism review S1 (2026-08-11 fix round): gmGameplayOptionsUI is
// NOT an OptionPage in retail (acclient.h:55857) — its own OptionPage
// instance is constructed with AfterApply deliberately left null
// (OptionsPanelController's constructor), regardless of what the
// controller's OWN AfterApply callback is, so the initial default-
// tab activation (OnShown -> Apply) never publishes a flush.
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
var calls = new List<string>();
int gameplayFlushCount = 0;
@ -73,12 +79,11 @@ public sealed class OptionsPanelControllerTests
Assert.True(controller.TabPanel.BehaviorActive);
Assert.Equal(0x10000212u, controller.TabPanel.ActivePageElementId); // Gameplay slot
// OnShown() fired for the initial default tab -> Apply() -> AfterApply.
Assert.Equal(1, gameplayFlushCount);
Assert.Equal(0, gameplayFlushCount);
}
[Fact]
public void TabSwitch_RevertsLeavingPage_AndAppliesEnteringPage()
public void TabSwitch_RevertsLeavingGameplayPage_AndAppliesEnteringCharacterPage()
{
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
var calls = new List<string>();
@ -86,12 +91,13 @@ public sealed class OptionsPanelControllerTests
OptionsPanelController controller = OptionsPanelController.Bind(
layout, MakeCallbacks(calls) with { AfterApply = () => flushes.Add("flush") })!;
controller.ActivateTabs();
flushes.Clear(); // drop the initial-activation flush
flushes.Clear(); // drop the initial-activation flush (Gameplay never flushes anyway)
controller.TabPanel.SwitchTo(0x10000211u); // Character page slot
Assert.Equal(0x10000211u, controller.TabPanel.ActivePageElementId);
// Both OnHidden (Gameplay, Reset — no flush) and OnShown (Character, Apply — flush).
// OnHidden (Gameplay, Reset — no flush regardless) and OnShown
// (Character, a REAL AfterApply-wired page — Apply flushes).
Assert.Equal(["flush"], flushes);
}
@ -116,12 +122,17 @@ public sealed class OptionsPanelControllerTests
[Fact]
public void WholeWindowShow_AppliesCurrentlyActivePage()
{
// Switches off the Gameplay default onto Character FIRST — Gameplay
// never flushes (S1), so testing "OnShown applies the currently
// active page" needs a REAL AfterApply-wired page active, exactly
// like a returning user re-opening the window on a non-default tab.
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
var calls = new List<string>();
int flushCount = 0;
OptionsPanelController controller = OptionsPanelController.Bind(
layout, MakeCallbacks(calls) with { AfterApply = () => flushCount++ })!;
controller.ActivateTabs();
controller.TabPanel.SwitchTo(0x10000211u); // Character page slot
flushCount = 0;
controller.OnShown();
@ -129,6 +140,24 @@ public sealed class OptionsPanelControllerTests
Assert.Equal(1, flushCount);
}
[Fact]
public void GameplayPage_OnShownAndOnHidden_NeverFlush_EvenWhenControllerAfterApplyIsWired()
{
// Direct pin of S1's fix: cycling Gameplay's own OnShown/OnHidden
// (the page-model hooks OnActivePageChanged drives) never publishes
// a flush, independent of TabSwitch/ActivateTabs framing.
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
var calls = new List<string>();
int flushCount = 0;
OptionsPanelController controller = OptionsPanelController.Bind(
layout, MakeCallbacks(calls) with { AfterApply = () => flushCount++ })!;
controller.GameplayPage.OnShown();
controller.GameplayPage.OnHidden();
Assert.Equal(0, flushCount);
}
// ── Close button ─────────────────────────────────────────────────────────
[Fact]

View file

@ -81,6 +81,35 @@ public sealed class RetailDialogFactoryTests
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()
{

View file

@ -117,6 +117,37 @@ public sealed class RetailPanelUiControllerTests
Assert.Equal((310f, 540f), (spellbook.Width, spellbook.Height));
}
/// <summary>
/// Blast review SHOULD-FIX 3 (2026-08-11 fix round): OP3's headline claim
/// is that registering the Options panel through
/// <see cref="RetailPanelUiController.RegisterMainPanel"/> gives it
/// retail's "one active gmPanelUI child" mutual exclusion against every
/// OTHER already-shipped sibling "for free" — nothing pinned that against
/// a REAL sibling (as opposed to the synthetic ids the other tests in
/// this file use). Uses the actual <see cref="RetailPanelCatalog.Options"/>
/// (10) and <see cref="RetailPanelCatalog.Character"/> (11) ids.
/// </summary>
[Fact]
public void Options_AndCharacter_ShareExclusiveMainPanelLifecycle()
{
var root = new UiRoot { Width = 1280f, Height = 720f };
RetailWindowHandle character = Mount(root, WindowNames.Character, 540f, 18f);
RetailWindowHandle options = Mount(root, WindowNames.Options, 150f, 80f);
using var controller = Create(root);
controller.RegisterMainPanel(RetailPanelCatalog.Character, WindowNames.Character, character);
controller.RegisterMainPanel(RetailPanelCatalog.Options, WindowNames.Options, options);
controller.SetPanelVisibility(RetailPanelCatalog.Character, visible: true);
Assert.True(character.IsVisible);
Assert.False(options.IsVisible);
Assert.Equal(RetailPanelCatalog.Character, controller.ActivePanelId);
controller.SetPanelVisibility(RetailPanelCatalog.Options, visible: true);
Assert.False(character.IsVisible);
Assert.True(options.IsVisible);
Assert.Equal(RetailPanelCatalog.Options, controller.ActivePanelId);
}
[Fact]
public void IndicatorDetailPanel_SharesPlacementAndRestoresPreviousPanel()
{