using AcDream.App.UI; using AcDream.App.UI.Layout; using AcDream.Core.Chat; namespace AcDream.App.Tests.UI.Layout; /// /// Controller-level tests for against /// the committed options_panel_2100006E_1000018D.json fixture /// (Campaign OP slice OP3) — the SAME LayoutImporter.ImportInfos(dats, /// 0x2100006Eu, 0x1000018Du) catalog import /// performs against real /// DATs. No DAT access, no live runtime — dat-free per the established /// FixtureLoader pattern. /// public sealed class OptionsPanelControllerTests { private static UiButton Click(ImportedLayout layout, uint elementId) { var button = Assert.IsType(layout.FindElement(elementId)); button.OnEvent(new UiEvent(0, button, UiEventType.Click)); return button; } private static OptionsPanelController.Callbacks MakeCallbacks( List calls, Action? 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(layout.Root); } [Fact] public void Bind_Succeeds_AndExposesFourEmptyPages() { ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost(); var calls = new List(); 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); } /// Every under a subtree, in /// build (document) order. private static List CollectFooterBackings(UiElement root) { var found = new List(); Walk(root, found); return found; static void Walk(UiElement node, List acc) { if (node is UiSolidSpriteFill fill) acc.Add(fill); foreach (UiElement child in node.Children) Walk(child, acc); } } /// /// #381 regression (2026-08-11, gate 4): before this fix, the /// Character/Chat/Config pages drew their Apply/Reset/Defaults buttons /// directly over the ListBox content with nothing behind them — a live- /// DAT probe found retail authors no backing element either (page root /// has exactly 5 children, zero direct-state media), so this is a /// genuine acdream synthesis (register row AP-205), not a missing /// import. Pins that exactly one opaque field is synthesized per page, /// sized/positioned from the buttons' OWN resolved rects, and z-ordered /// strictly behind everything else on that page (so it can never /// occlude the buttons it backs). /// [Fact] public void Bind_SynthesizesOneOpaqueFooterBacking_PerPageWithApplyResetDefaults() { ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost(); var calls = new List(); OptionsPanelController? controller = OptionsPanelController.Bind( layout, MakeCallbacks(calls), resolveSprite: _ => (1u, 8, 8)); Assert.NotNull(controller); // Page slot ids — the SAME literals ConfigOptionsPageController's // own PageSlotElementId/ChatOptionsPageController's own // PageSlotElementId cite, and this file's own TabSwitch tests // already use for Character (0x10000211). foreach (uint pageSlotId in new[] { 0x10000211u, 0x1000050Cu, 0x10000213u }) { UiElement pageRoot = UiElement.FindDescendant(controller!.TabPanel, pageSlotId)!; Assert.NotNull(pageRoot); List backings = CollectFooterBackings(pageRoot); UiSolidSpriteFill backing = Assert.Single(backings); Assert.Equal(RetailChromeSprites.CenterFill, backing.SpriteId); Assert.NotNull(backing.SpriteResolve); Assert.True(backing.ClickThrough); Assert.Equal(pageRoot.Width, backing.Width); // Drawn BEHIND every sibling already on the page (buttons, // ListBox, scrollbar) — lowest ZOrder wins the painter's-algorithm // back-to-front sort (UiElement.cs's own convention). Assert.All(pageRoot.Children.Where(c => !ReferenceEquals(c, backing)), sibling => Assert.True(backing.ZOrder < sibling.ZOrder)); } } [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(); 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(); var flushes = new List(); 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(); 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(); 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(); int flushCount = 0; OptionsPanelController controller = OptionsPanelController.Bind( layout, MakeCallbacks(calls) with { AfterApply = () => flushCount++ })!; controller.GameplayPage.OnShown(); controller.GameplayPage.OnHidden(); Assert.Equal(0, flushCount); } // ── MUST-FIX 2 (OP4 review-fix round, 2026-08-11): Apply/Reset gating ────── private static (UiButton Apply, UiButton Reset, UiButton Defaults) GetCharacterPageButtons( OptionsPanelController controller) { UiElement pageRoot = UiElement.FindDescendant(controller.Root, 0x10000211u)!; // Character page slot var apply = Assert.IsType(UiElement.FindDescendant(pageRoot, 0x100001FCu)); var reset = Assert.IsType(UiElement.FindDescendant(pageRoot, 0x100001FDu)); var defaults = Assert.IsType(UiElement.FindDescendant(pageRoot, 0x100001FEu)); return (apply, reset, defaults); } [Fact] public void CharacterPage_ApplyAndReset_StartDisabled_OnFreshBind() { // Retail's PostInit calls InitOptions() then OnOptionChanged(0), so // the pair starts Ghosted before any row has ever been touched. ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost(); var calls = new List(); OptionsPanelController controller = OptionsPanelController.Bind(layout, MakeCallbacks(calls))!; (UiButton apply, UiButton reset, _) = GetCharacterPageButtons(controller); Assert.False(apply.Enabled); Assert.False(reset.Enabled); } [Fact] public void CharacterPage_OneLedClick_EnablesApplyAndReset() { ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost(); var calls = new List(); OptionsPanelController controller = OptionsPanelController.Bind(layout, MakeCallbacks(calls))!; var row = new BoolOptionRow(initial: false, defaultValue: false); controller.CharacterPage.Register(row); (UiButton apply, UiButton reset, _) = GetCharacterPageButtons(controller); row.SetCurrentValue(true); Assert.True(apply.Enabled); Assert.True(reset.Enabled); } [Fact] public void CharacterPage_Apply_DisablesApplyAndResetAgain() { ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost(); var calls = new List(); OptionsPanelController controller = OptionsPanelController.Bind(layout, MakeCallbacks(calls))!; var row = new BoolOptionRow(initial: false, defaultValue: false); controller.CharacterPage.Register(row); (UiButton apply, UiButton reset, _) = GetCharacterPageButtons(controller); row.SetCurrentValue(true); controller.CharacterPage.Apply(); Assert.False(apply.Enabled); Assert.False(reset.Enabled); } [Fact] public void CharacterPage_Defaults_LeavesApplyAndResetEnabled_WhenSomethingActuallyChanged() { ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost(); var calls = new List(); OptionsPanelController controller = OptionsPanelController.Bind(layout, MakeCallbacks(calls))!; var row = new BoolOptionRow(initial: false, defaultValue: true); controller.CharacterPage.Register(row); (UiButton apply, UiButton reset, _) = GetCharacterPageButtons(controller); controller.CharacterPage.Defaults(); Assert.True(row.Current); Assert.True(controller.CharacterPage.Changed); Assert.True(apply.Enabled); Assert.True(reset.Enabled); } [Fact] public void CharacterPage_Defaults_IsNeverGated() { // Retail's Defaults override never fetches its own child id at all // — it must never grey itself out, before OR after a real change. ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost(); var calls = new List(); OptionsPanelController controller = OptionsPanelController.Bind(layout, MakeCallbacks(calls))!; var row = new BoolOptionRow(initial: false, defaultValue: false); controller.CharacterPage.Register(row); (_, _, UiButton defaults) = GetCharacterPageButtons(controller); Assert.True(defaults.Enabled); row.SetCurrentValue(true); Assert.True(defaults.Enabled); controller.CharacterPage.Apply(); Assert.True(defaults.Enabled); controller.CharacterPage.Defaults(); Assert.True(defaults.Enabled); } // ── Close button ───────────────────────────────────────────────────────── [Fact] public void CloseButton_FiresToggleCallback() { ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost(); var calls = new List(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(layout.FindElement(id)); } }