feat(ui): Campaign OP slice OP3 — Options panel shell, open paths, Gameplay tab

Mounts retail's Options panel (LayoutDesc 0x2100002B resolved through host
0x2100006E slot 0x1000018D, gmPanelUI key 10) via the same catalog-import
pattern CharacterController already validates, registered through
RetailPanelUiController so it shares retail's "one active gmPanelUI child"
mutual exclusion with every other sibling panel for free. F11 and the
toolbar's options button (0x1000019B, already authoring panel id 10) both
now open it; the close button fires the same ToggleOptionsPanel action.

OptionPageModel (OptionPage/BoolOptionRow) ports retail's exact
Apply/Reset/Defaults/visibility semantics from
UIOption_Checkbox/PlayerOptionPage — LED clicks apply live immediately,
Apply commits every row unconditionally + flushes the batched blob, Reset
reverts only Changed rows, Defaults restores without committing, and
tab-switch/window-hide revert uncommitted edits. Wired for all four tabs;
this slice registers real rows on none of them (Gameplay authentically has
none — a pure button list). UiTabPanel gains an ActivePageChanged event so
the page model can hook every tab transition, including the initial
default-tab activation.

The seven Gameplay-tab buttons: Exit Game reuses the existing graceful
window-close path; Exit to Character Selection gets retail's confirmation
dialog and byte-verified mid-air refusal but still behaves as Exit Game
(AD-74 — no pre-world character-select flow exists); Configure Keyboard
and In-Game Help Files are inert this slice (AD-76 for Help — the
plugin retail depends on doesn't exist); Urgent Assistance/Report Abuse
short-circuit to their own byte-verified failure text through the
interface-text seam instead of ShellExecute against a dead URL (AD-75);
Use Mouse Turning Settings runs the pure MouseTurningSettingsMacro port,
persisting five new CameraTurningSettings preferences and sending
PlayerOption.UseMouseTurning — TS-74 records that acdream has no
persistent mouse-turning camera mode for the bit to drive yet.

Full Release suite: 12,918 passed / 4 skipped / 0 failed (baseline
12,871/4/0 — only new tests added).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-11 02:14:40 +02:00
parent 5242de9f15
commit 9d26ecc623
27 changed files with 25696 additions and 8 deletions

View file

@ -15,7 +15,10 @@ public sealed class GameplayInputCommandControllerTests
[InlineData(InputAction.AcdreamToggleFlyMode, "fly-or-chase")]
[InlineData(InputAction.AcdreamTogglePlayerMode, "player-mode")]
[InlineData(InputAction.ToggleChatEntry, "chat")]
[InlineData(InputAction.ToggleOptionsPanel, "settings")]
// Campaign OP slice OP3 (D1): F11 now opens the retail Options panel
// through IRetainedGameplayWindowCommands, not the retired
// IDevToolsGameplayCommands.ToggleSettingsPanel() no-op.
[InlineData(InputAction.ToggleOptionsPanel, "options")]
[InlineData(InputAction.CombatToggleCombat, "combat")]
[InlineData(InputAction.ToggleFloatingChatWindow1, "chat-window-1")]
[InlineData(InputAction.ToggleFloatingChatWindow2, "chat-window-2")]
@ -126,6 +129,8 @@ public sealed class GameplayInputCommandControllerTests
public void ToggleFloatingChatWindow(int windowId) =>
calls.Add($"chat-window-{windowId}");
public void ToggleOptionsPanel() => calls.Add("options");
}
private sealed class FakeDevTools(List<string> calls)

View file

@ -1292,6 +1292,9 @@ public sealed class RuntimeSettingsControllerTests
public CharacterSettings DefaultCharacterValue { get; set; } =
CharacterSettings.Default;
public CameraTurningSettings CameraTurningValue { get; set; } =
CameraTurningSettings.Default;
public Dictionary<string, CharacterSettings> Characters { get; } =
new(StringComparer.OrdinalIgnoreCase);
@ -1412,6 +1415,14 @@ public sealed class RuntimeSettingsControllerTests
DefaultCharacterValue = character;
}
public CameraTurningSettings LoadCameraTurning() => CameraTurningValue;
public void SaveCameraTurning(CameraTurningSettings cameraTurning)
{
_events.Add("save-camera-turning");
CameraTurningValue = cameraTurning;
}
public void ClearEvents() => _events.Clear();
}

View file

@ -248,6 +248,21 @@ public static class FixtureLoader
public static ElementInfo LoadOptionsConfigInfos()
=> LoadInfos("options_config_21000029.json");
/// <summary>The Options panel resolved through its retail host — LayoutDesc
/// <c>0x2100006E</c> (<c>gmFloatyPanelUI</c>) at slot <c>0x1000018D</c> (stack
/// key 10, <see cref="AcDream.App.UI.RetailPanelCatalog.Options"/>) — the
/// SAME catalog-import shape <c>CharacterController</c>'s own
/// <c>character_info_2100006E_10000183.json</c> fixture uses. This is what
/// <see cref="OptionsPanelController.Bind"/> actually mounts (Campaign OP
/// slice OP3), distinct from <see cref="LoadOptionsPanel"/> above (the
/// standalone <c>0x2100002B</c> import, which OP2 pinned for widget-mapping
/// conformance).</summary>
public static ImportedLayout LoadOptionsPanelHost()
=> LayoutImporter.Build(LoadOptionsPanelHostInfos(), _ => (0u, 0, 0), null);
public static ElementInfo LoadOptionsPanelHostInfos()
=> LoadInfos("options_panel_2100006E_1000018D.json");
// ── Shared loader ────────────────────────────────────────────────────────
private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName)

View file

@ -0,0 +1,113 @@
using AcDream.App.UI.Layout;
using AcDream.Core.Chat;
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Pure logic tests for <see cref="MouseTurningSettingsMacro"/> — the port of
/// <c>gmConfigUI::SetMouseTurningDefaults @0x0049E8F0</c> (research doc
/// <c>2026-08-10-keyboard-config-and-gameplay-tab.md</c> §4.4). No DAT, no
/// runtime — pure input/output over <see cref="CameraTurningSettings"/>.
/// </summary>
public sealed class MouseTurningSettingsMacroTests
{
[Fact]
public void AllSixValuesAtOrdinaryDefaults_ChangesEveryOne()
{
// Retail's ordinary Config-tab defaults (0.45/40.0/0.55/on/off) all
// differ from the macro's targets (0.95/50.0/0.7/off/on) — a fresh
// character clicking this button changes all six.
MouseTurningSettingsMacro.Result result = MouseTurningSettingsMacro.Compute(
CameraTurningSettings.Default,
useMouseTurningCurrent: false);
CameraTurningSettings target = CameraTurningSettings.MouseTurningTarget;
Assert.Equal(target, result.Updated);
Assert.True(result.UseMouseTurningChanged);
Assert.Equal(6, result.ChatLines.Count);
}
[Fact]
public void AllSixValuesAlreadyAtTarget_ChangesNone()
{
MouseTurningSettingsMacro.Result result = MouseTurningSettingsMacro.Compute(
CameraTurningSettings.MouseTurningTarget,
useMouseTurningCurrent: true);
Assert.Equal(CameraTurningSettings.MouseTurningTarget, result.Updated);
Assert.False(result.UseMouseTurningChanged);
Assert.Empty(result.ChatLines);
}
[Fact]
public void OnlyStiffnessDiffers_ChangesOnlyStiffness_WithItsOwnChatLine()
{
CameraTurningSettings current = CameraTurningSettings.MouseTurningTarget with
{
Stiffness = 0.45f,
};
MouseTurningSettingsMacro.Result result =
MouseTurningSettingsMacro.Compute(current, useMouseTurningCurrent: true);
Assert.Equal(0.95f, result.Updated.Stiffness);
Assert.Equal(CameraTurningSettings.MouseTurningTarget.AdjustmentSpeed, result.Updated.AdjustmentSpeed);
Assert.False(result.UseMouseTurningChanged);
Assert.Equal(
[OptionsPanelText.CameraStiffnessChanged(0.45f, 0.95f)],
result.ChatLines);
}
[Fact]
public void OnlyAlignToSlopeDiffers_EmitsTheByteVerifiedLiteralLine()
{
CameraTurningSettings current = CameraTurningSettings.MouseTurningTarget with
{
AlignToSlope = true,
};
MouseTurningSettingsMacro.Result result =
MouseTurningSettingsMacro.Compute(current, useMouseTurningCurrent: true);
Assert.False(result.Updated.AlignToSlope);
Assert.Equal([OptionsPanelText.AlignToSlopeChanged], result.ChatLines);
}
[Fact]
public void OnlyUseMouseTurningDiffers_ReportsChangedWithoutTouchingCameraSettings()
{
MouseTurningSettingsMacro.Result result = MouseTurningSettingsMacro.Compute(
CameraTurningSettings.MouseTurningTarget,
useMouseTurningCurrent: false);
Assert.Equal(CameraTurningSettings.MouseTurningTarget, result.Updated);
Assert.True(result.UseMouseTurningChanged);
Assert.Equal([OptionsPanelText.TurnToFaceCameraChanged], result.ChatLines);
}
[Fact]
public void ChatLines_AreEmittedInRetailOrder()
{
// Retail's own call order: Stiffness, AdjustmentSpeed, Sensitivity,
// AlignToSlope, InvertMouseLookYAxis, UseMouseTurning
// (0x0049E924..0x0049EB57).
MouseTurningSettingsMacro.Result result = MouseTurningSettingsMacro.Compute(
CameraTurningSettings.Default,
useMouseTurningCurrent: false);
CameraTurningSettings d = CameraTurningSettings.Default;
CameraTurningSettings t = CameraTurningSettings.MouseTurningTarget;
Assert.Equal(
new[]
{
OptionsPanelText.CameraStiffnessChanged(d.Stiffness, t.Stiffness),
OptionsPanelText.CameraAdjustmentChanged(d.AdjustmentSpeed, t.AdjustmentSpeed),
OptionsPanelText.MouseSensitivityChanged(d.MouseLookSensitivity, t.MouseLookSensitivity),
OptionsPanelText.AlignToSlopeChanged,
OptionsPanelText.InvertMouseLookAxesChanged,
OptionsPanelText.TurnToFaceCameraChanged,
},
result.ChatLines);
}
}

View file

@ -0,0 +1,234 @@
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. 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.
/// </summary>
public sealed class OptionPageModelTests
{
// ── Empty page (the Gameplay tab's own shape) ───────────────────────────
[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 EmptyPage_Apply_StillInvokesAfterApply()
{
// 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.
var page = new OptionPage();
int flushCount = 0;
page.AfterApply = () => flushCount++;
page.Apply();
Assert.Equal(1, flushCount);
}
[Fact]
public void EmptyPage_OnShown_InvokesAfterApply()
{
var page = new OptionPage();
int flushCount = 0;
page.AfterApply = () => flushCount++;
page.OnShown();
Assert.Equal(1, flushCount);
}
[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);
}
}

View file

@ -0,0 +1,269 @@
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_AndAppliesItsPage()
{
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
// OnShown() fired for the initial default tab -> Apply() -> AfterApply.
Assert.Equal(1, gameplayFlushCount);
}
[Fact]
public void TabSwitch_RevertsLeavingPage_AndAppliesEnteringPage()
{
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
controller.TabPanel.SwitchTo(0x10000211u); // Character page slot
Assert.Equal(0x10000211u, controller.TabPanel.ActivePageElementId);
// Both OnHidden (Gameplay, Reset — no flush) and OnShown (Character, Apply — flush).
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()
{
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
var calls = new List<string>();
int flushCount = 0;
OptionsPanelController controller = OptionsPanelController.Bind(
layout, MakeCallbacks(calls) with { AfterApply = () => flushCount++ })!;
controller.ActivateTabs();
flushCount = 0;
controller.OnShown();
Assert.Equal(1, 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));
}
}

View file

@ -575,4 +575,48 @@ public class OptionsPanelLayoutConformanceTests
Assert.Equal(before, tabControl.ActivePageElementId);
Assert.True(layout.FindElement(before)!.Visible);
}
// ── ActivePageChanged (Campaign OP slice OP3 — the page-model hook) ──────
[Fact]
public void ActivePageChanged_FiresOnInitialActivation_WithOldEqualsZero()
{
var layout = FixtureLoader.LoadOptionsPanel();
var tabControl = Assert.IsType<UiTabPanel>(layout.FindElement(0x10000208u));
var transitions = new List<(uint Old, uint New)>();
tabControl.ActivePageChanged += (oldId, newId) => transitions.Add((oldId, newId));
tabControl.ActivateTabBehavior();
Assert.Equal([(0u, 0x10000212u)], transitions); // Gameplay default, no prior page.
}
[Fact]
public void ActivePageChanged_FiresOnEverySwitch_WithBothOldAndNew()
{
var layout = FixtureLoader.LoadOptionsPanel();
var tabControl = Assert.IsType<UiTabPanel>(layout.FindElement(0x10000208u));
tabControl.ActivateTabBehavior();
var transitions = new List<(uint Old, uint New)>();
tabControl.ActivePageChanged += (oldId, newId) => transitions.Add((oldId, newId));
tabControl.SwitchTo(0x1000050Cu); // Chat page slot
Assert.Equal([(0x10000212u, 0x1000050Cu)], transitions);
}
[Fact]
public void ActivePageChanged_DoesNotFireForANoOpSwitch()
{
var layout = FixtureLoader.LoadOptionsPanel();
var tabControl = Assert.IsType<UiTabPanel>(layout.FindElement(0x10000208u));
tabControl.ActivateTabBehavior();
uint before = tabControl.ActivePageElementId;
var transitions = new List<(uint Old, uint New)>();
tabControl.ActivePageChanged += (oldId, newId) => transitions.Add((oldId, newId));
tabControl.SwitchTo(before);
Assert.Empty(transitions);
}
}

View file

@ -144,6 +144,8 @@ public sealed class RetailLayoutFixtureGenerator
"examine_component_2100006B_1000032E.json"),
(VendorUiController.LayoutId, VendorUiController.RootId,
"vendor_21000012_100000B7.json"),
(OptionsPanelController.HostLayoutId, OptionsPanelController.SlotElementId,
"options_panel_2100006E_1000018D.json"),
})
{
ElementInfo? panelPart = LayoutImporter.ImportInfos(dats, layoutId, rootId);

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,49 @@
using AcDream.App.UI;
namespace AcDream.App.Tests.UI;
/// <summary>
/// Campaign OP slice OP3: pins the Options panel's entry in
/// <see cref="RetailPanelCatalog"/> — the data the toolbar's ALREADY-GENERIC
/// panel-button mechanism (<c>ToolbarController.PanelButtonIds</c> already
/// includes <c>0x1000019B</c>, byte-verified authored panel id 10 —
/// <c>tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs</c>'s own
/// <c>PanelButtons</c> fixture at <c>(0x1000019Bu, 10u)</c>) reads to route
/// the toolbar Options button and <c>RetailUiRuntime.OnWindowVisibilityChanged</c>
/// to the retail Options window. No new toolbar wiring was needed — only
/// this catalog entry, which the existing generic mechanism (
/// <c>RetailUiRuntime.BindToolbarPanelButtons</c>,
/// <c>RetailPanelUiController</c>) picks up automatically.
/// </summary>
public sealed class RetailPanelCatalogTests
{
[Fact]
public void Options_PanelId_Is10_MatchingTheToolbarButtonsAuthoredProperty()
=> Assert.Equal(10u, RetailPanelCatalog.Options);
[Fact]
public void Options_ResolvesToTheOptionsWindowName()
{
Assert.True(RetailPanelCatalog.TryGetWindowName(RetailPanelCatalog.Options, out string name));
Assert.Equal(WindowNames.Options, name);
}
[Fact]
public void OptionsWindowName_ResolvesBackToThePanelId()
{
Assert.True(RetailPanelCatalog.TryGetPanelId(WindowNames.Options, out uint panelId));
Assert.Equal(RetailPanelCatalog.Options, panelId);
}
[Fact]
public void Options_IsListedInMountedPanels()
=> Assert.Contains(
(RetailPanelCatalog.Options, WindowNames.Options),
RetailPanelCatalog.MountedPanels);
[Fact]
public void Options_IsListedInToolbarPanels()
=> Assert.Contains(
(RetailPanelCatalog.Options, WindowNames.Options),
RetailPanelCatalog.ToolbarPanels);
}

View file

@ -225,4 +225,22 @@ public class KeyBindingsRetailTests
Assert.Contains(b.ForAction(InputAction.ScrollUp),
x => x.Chord == new KeyChord(Key.Up, ModifierMask.Ctrl));
}
/// <summary>
/// Campaign OP slice OP3: F11 opens retail's Options panel
/// (<c>docs/research/named-retail/retail-default.keymap.txt:148</c> —
/// <c>ToggleOptionsPanel [ "" [ 0 DIK_F11 ] ]</c>). This row already
/// existed in <see cref="KeyBindings.RetailDefaults"/> before OP3 (the
/// binding was correct; only the routing it fed — the old, unrendered
/// Settings-panel no-op — needed to change). This test pins the ONLY
/// production table stays authoritative going forward (#358's lesson).
/// </summary>
[Fact]
public void ToggleOptionsPanel_bound_to_F11()
{
var b = KeyBindings.RetailDefaults();
var bound = b.Find(new KeyChord(Key.F11, ModifierMask.None), ActivationType.Press);
Assert.NotNull(bound);
Assert.Equal(InputAction.ToggleOptionsPanel, bound!.Value.Action);
}
}