acdream/tests/AcDream.App.Tests/UI/Layout/RetailPanelUiControllerTests.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

224 lines
8.7 KiB
C#

using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
public sealed class RetailPanelUiControllerTests
{
[Fact]
public void ShowingPanel_HidesCurrent_AndClosingLeavesHostEmpty()
{
var visible = new Dictionary<string, bool>(StringComparer.Ordinal)
{
["inventory"] = false,
["helpful"] = false,
};
var controller = Create(visible);
controller.Register(7u, "inventory");
controller.Register(4u, "helpful");
Assert.True(controller.SetPanelVisibility(7u, true));
Assert.True(visible["inventory"]);
Assert.Equal(7u, controller.ActivePanelId);
Assert.True(controller.SetPanelVisibility(4u, true));
Assert.False(visible["inventory"]);
Assert.True(visible["helpful"]);
Assert.Equal(4u, controller.ActivePanelId);
Assert.False(controller.TogglePanel(4u));
Assert.False(visible["helpful"]);
Assert.Null(controller.ActivePanelId);
}
[Fact]
public void RestorePreviousProperty_ReopensOnlyNonRestoringPreviousPanel()
{
var visible = new Dictionary<string, bool>(StringComparer.Ordinal)
{
["ordinary"] = false,
["transient"] = false,
};
var controller = Create(visible);
controller.Register(1u, "ordinary", restorePrevious: false);
controller.Register(2u, "transient", restorePrevious: true);
controller.SetPanelVisibility(1u, true);
controller.SetPanelVisibility(2u, true);
Assert.False(visible["ordinary"]);
Assert.True(visible["transient"]);
controller.SetPanelVisibility(2u, false);
Assert.True(visible["ordinary"]);
Assert.False(visible["transient"]);
Assert.Equal(1u, controller.ActivePanelId);
}
[Fact]
public void ObservedPersistenceShow_UsesSameExclusiveLifecycle()
{
var visible = new Dictionary<string, bool>(StringComparer.Ordinal)
{
["first"] = false,
["second"] = false,
};
var controller = Create(visible);
controller.Register(1u, "first");
controller.Register(2u, "second");
controller.SetPanelVisibility(1u, true);
visible["second"] = true;
controller.ObserveWindowVisibility("second", visible: true);
Assert.False(visible["first"]);
Assert.True(visible["second"]);
Assert.Equal(2u, controller.ActivePanelId);
}
[Fact]
public void MainPanels_ShareOneCanonicalPlacementAcrossMoveCloseAndSwitch()
{
var root = new UiRoot { Width = 1280f, Height = 720f };
RetailWindowHandle inventory = Mount(root, WindowNames.Inventory, 40f, 60f, 360f);
RetailWindowHandle character = Mount(root, WindowNames.Character, 540f, 18f, 500f);
RetailWindowHandle spellbook = Mount(root, WindowNames.Spellbook, 18f, 18f, 420f);
using var controller = Create(root);
controller.RegisterMainPanel(7u, WindowNames.Inventory, inventory);
controller.RegisterMainPanel(11u, WindowNames.Character, character);
controller.RegisterMainPanel(13u, WindowNames.Spellbook, spellbook);
int characterMoves = 0;
int spellbookMoves = 0;
character.Moved += _ => characterMoves++;
spellbook.Moved += _ => spellbookMoves++;
controller.SetPanelVisibility(7u, visible: true);
inventory.MoveTo(312f, 147f);
inventory.ResizeTo(310f, 540f);
Assert.Equal((312f, 147f), (character.Left, character.Top));
Assert.Equal((312f, 147f), (spellbook.Left, spellbook.Top));
Assert.Equal((310f, 540f), (character.Width, character.Height));
Assert.Equal((310f, 540f), (spellbook.Width, spellbook.Height));
Assert.True(characterMoves > 0);
Assert.True(spellbookMoves > 0);
controller.SetPanelVisibility(11u, visible: true);
Assert.False(inventory.IsVisible);
Assert.True(character.IsVisible);
Assert.Equal((312f, 147f), (character.Left, character.Top));
Assert.Equal((310f, 540f), (character.Width, character.Height));
controller.SetPanelVisibility(11u, visible: false);
controller.SetPanelVisibility(13u, visible: true);
Assert.False(character.IsVisible);
Assert.True(spellbook.IsVisible);
Assert.Equal((312f, 147f), (spellbook.Left, spellbook.Top));
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()
{
var root = new UiRoot { Width = 1280f, Height = 720f };
RetailWindowHandle inventory = Mount(root, WindowNames.Inventory, 40f, 60f);
RetailWindowHandle character = Mount(root, WindowNames.Character, 540f, 18f);
RetailWindowHandle effect = Mount(root, WindowNames.PositiveEffects, 900f, 30f);
using var controller = Create(root);
controller.RegisterMainPanel(7u, WindowNames.Inventory, inventory);
controller.RegisterMainPanel(11u, WindowNames.Character, character);
controller.RegisterMainPanel(
4u,
WindowNames.PositiveEffects,
effect,
restorePrevious: true);
controller.SetPanelVisibility(7u, visible: true);
inventory.MoveTo(280f, 120f);
inventory.ResizeTo(310f, 600f);
controller.SetPanelVisibility(4u, visible: true);
Assert.Equal((280f, 120f), (effect.Left, effect.Top));
Assert.Equal((310f, 600f), (effect.Width, effect.Height));
Assert.False(inventory.IsVisible);
Assert.True(effect.IsVisible);
controller.SetPanelVisibility(4u, visible: false);
Assert.True(inventory.IsVisible);
Assert.Equal((280f, 120f), (inventory.Left, inventory.Top));
Assert.Equal((310f, 600f), (inventory.Width, inventory.Height));
controller.SetPanelVisibility(11u, visible: true);
Assert.Equal((280f, 120f), (character.Left, character.Top));
}
private static RetailPanelUiController Create(Dictionary<string, bool> visible)
=> new(
name => visible[name],
name =>
{
visible[name] = true;
return true;
},
name =>
{
visible[name] = false;
return true;
});
private static RetailPanelUiController Create(UiRoot root)
=> new(root.IsWindowVisible, root.ShowWindow, root.HideWindow);
private static RetailWindowHandle Mount(
UiRoot root,
string name,
float left,
float top,
float contentHeight = 500f)
{
var content = new UiPanel { Width = 300f, Height = contentHeight };
return RetailWindowFrame.Mount(
root,
content,
_ => (0u, 0, 0),
new RetailWindowFrame.Options
{
WindowName = name,
Left = left,
Top = top,
Visible = false,
ConstrainDragToParent = true,
});
}
}