acdream/tests/AcDream.App.Tests/UI/Layout/OptionsPanelControllerTests.cs
Erik 386076af0f 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>
2026-08-11 03:05:27 +02:00

298 lines
11 KiB
C#

using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Chat;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Controller-level tests for <see cref="OptionsPanelController"/> against
/// the committed <c>options_panel_2100006E_1000018D.json</c> fixture
/// (Campaign OP slice OP3) — the SAME <c>LayoutImporter.ImportInfos(dats,
/// 0x2100006Eu, 0x1000018Du)</c> catalog import
/// <see cref="RetailUiRuntime.MountOptionsPanel"/> performs against real
/// DATs. No DAT access, no live runtime — dat-free per the established
/// <c>FixtureLoader</c> pattern.
/// </summary>
public sealed class OptionsPanelControllerTests
{
private static UiButton Click(ImportedLayout layout, uint elementId)
{
var button = Assert.IsType<UiButton>(layout.FindElement(elementId));
button.OnEvent(new UiEvent(0, button, UiEventType.Click));
return button;
}
private static OptionsPanelController.Callbacks MakeCallbacks(
List<string> calls,
Action<string>? displaySystemMessage = null)
=> new(
Toggle: () => calls.Add("toggle"),
RequestExitToCharacterSelection: () => calls.Add("exit-to-char-select"),
ExitGame: () => calls.Add("exit-game"),
UseMouseTurningSettings: () => calls.Add("mouse-turning"),
DisplaySystemMessage: displaySystemMessage ?? (text => calls.Add($"message:{text}")));
// ── Mount conformance ────────────────────────────────────────────────────
[Fact]
public void Bind_RootBuildsAsUiTabPanel()
{
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
Assert.IsType<UiTabPanel>(layout.Root);
}
[Fact]
public void Bind_Succeeds_AndExposesFourEmptyPages()
{
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
var calls = new List<string>();
OptionsPanelController? controller =
OptionsPanelController.Bind(layout, MakeCallbacks(calls));
Assert.NotNull(controller);
Assert.Equal(4, controller!.Pages.Count);
Assert.Empty(controller.GameplayPage.Rows);
Assert.Empty(controller.CharacterPage.Rows);
Assert.Empty(controller.ChatPage.Rows);
Assert.Empty(controller.ConfigPage.Rows);
}
[Fact]
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;
OptionsPanelController controller = OptionsPanelController.Bind(
layout,
MakeCallbacks(calls) with { AfterApply = () => gameplayFlushCount++ })!;
controller.ActivateTabs();
Assert.True(controller.TabPanel.BehaviorActive);
Assert.Equal(0x10000212u, controller.TabPanel.ActivePageElementId); // Gameplay slot
Assert.Equal(0, gameplayFlushCount);
}
[Fact]
public void TabSwitch_RevertsLeavingGameplayPage_AndAppliesEnteringCharacterPage()
{
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
var calls = new List<string>();
var flushes = new List<string>();
OptionsPanelController controller = OptionsPanelController.Bind(
layout, MakeCallbacks(calls) with { AfterApply = () => flushes.Add("flush") })!;
controller.ActivateTabs();
flushes.Clear(); // drop the initial-activation flush (Gameplay never flushes anyway)
controller.TabPanel.SwitchTo(0x10000211u); // Character page slot
Assert.Equal(0x10000211u, controller.TabPanel.ActivePageElementId);
// OnHidden (Gameplay, Reset — no flush regardless) and OnShown
// (Character, a REAL AfterApply-wired page — Apply flushes).
Assert.Equal(["flush"], flushes);
}
[Fact]
public void WholeWindowHide_RevertsCurrentlyActivePage()
{
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
var calls = new List<string>();
OptionsPanelController controller =
OptionsPanelController.Bind(layout, MakeCallbacks(calls))!;
controller.ActivateTabs();
var row = new BoolOptionRow(initial: false, defaultValue: false);
controller.GameplayPage.Register(row);
row.SetCurrentValue(true);
Assert.True(controller.GameplayPage.Changed);
controller.OnHidden(); // IRetainedPanelController hook — whole window closing
Assert.False(controller.GameplayPage.Changed);
}
[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();
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]
public void CloseButton_FiresToggleCallback()
{
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
var calls = new List<string>();
OptionsPanelController.Bind(layout, MakeCallbacks(calls));
Click(layout, 0x10000210u);
Assert.Equal(["toggle"], calls);
}
// ── The seven Gameplay-tab buttons ───────────────────────────────────────
[Fact]
public void ExitToCharacterSelectionButton_FiresRequestCallback()
{
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
var calls = new List<string>();
OptionsPanelController.Bind(layout, MakeCallbacks(calls));
Click(layout, 0x10000203u);
Assert.Equal(["exit-to-char-select"], calls);
}
[Fact]
public void ExitGameButton_FiresExitGameCallback()
{
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
var calls = new List<string>();
OptionsPanelController.Bind(layout, MakeCallbacks(calls));
Click(layout, 0x10000617u);
Assert.Equal(["exit-game"], calls);
}
[Fact]
public void UseMouseTurningSettingsButton_FiresMacroCallback()
{
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
var calls = new List<string>();
OptionsPanelController.Bind(layout, MakeCallbacks(calls));
Click(layout, 0x100005CCu);
Assert.Equal(["mouse-turning"], calls);
}
[Fact]
public void UrgentAssistanceButton_DisplaysItsOwnByteVerifiedFailureText()
{
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
var calls = new List<string>();
OptionsPanelController.Bind(layout, MakeCallbacks(calls));
Click(layout, 0x10000206u);
Assert.Equal([$"message:{OptionsPanelText.UrgentAssistanceUnavailable}"], calls);
}
[Fact]
public void ReportAbuseButton_DisplaysItsOwnByteVerifiedFailureText()
{
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
var calls = new List<string>();
OptionsPanelController.Bind(layout, MakeCallbacks(calls));
Click(layout, 0x10000207u);
Assert.Equal([$"message:{OptionsPanelText.ReportAbuseUnavailable}"], calls);
}
[Fact]
public void UrgentAssistanceAndReportAbuse_UseDifferentText()
{
// The two buttons share retail's identical body template but differ
// in ONE clause ("urgent assistance request" vs "abuse report").
Assert.NotEqual(
OptionsPanelText.UrgentAssistanceUnavailable,
OptionsPanelText.ReportAbuseUnavailable);
Assert.Contains(OptionsPanelText.SupportUrl, OptionsPanelText.UrgentAssistanceUnavailable);
Assert.Contains(OptionsPanelText.SupportUrl, OptionsPanelText.ReportAbuseUnavailable);
}
[Fact]
public void ConfigureKeyboardButton_IsInert_ClickDoesNothing()
{
// D4/OP8: authored, clickable, no handler this slice.
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
var calls = new List<string>();
OptionsPanelController.Bind(layout, MakeCallbacks(calls));
Click(layout, 0x10000204u);
Assert.Empty(calls);
}
[Fact]
public void InGameHelpFilesButton_IsInert_ClickDoesNothing()
{
// D5: retail's own KeyStone::OpenHelp fails silently without the
// missing ACHelpPlugin.dll — mirrored as an inert button.
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
var calls = new List<string>();
OptionsPanelController.Bind(layout, MakeCallbacks(calls));
Click(layout, 0x10000205u);
Assert.Empty(calls);
}
[Fact]
public void AllSevenGameplayButtons_ResolveInTheBuiltLayout()
{
// Guards against a future fixture regeneration silently dropping an
// id (a missing button degrades to a logged no-op, not a test
// failure, unless asserted here).
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
uint[] buttonIds =
{
0x10000203u, // Exit to Character Selection
0x10000204u, // Configure Keyboard
0x10000205u, // In-Game Help Files
0x10000206u, // Urgent Assistance
0x10000207u, // Report Abuse
0x100005CCu, // Use Mouse Turning Settings
0x10000617u, // Exit Game
};
foreach (uint id in buttonIds)
Assert.IsType<UiButton>(layout.FindElement(id));
}
}