using AcDream.App.UI.Layout; namespace AcDream.App.Tests.UI.Layout; /// /// Pure logic tests for / — /// no DAT, no widgets, no runtime. Exercises a SYNTHETIC empty /// PlayerOptionPage-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. /// /// /// NEITHER shape models the Gameplay tab. gmGameplayOptionsUI /// (acclient.h:55857) derives from UIElement_Field, not /// OptionPage/PlayerOptionPage at all — research doc /// 2026-08-10-options-panel-structure.md §6, mechanism review S1 /// (2026-08-11 fix round). pins /// the Gameplay-specific consequence (its instance /// is constructed with deliberately left /// null, so it never flushes). /// /// public sealed class OptionPageModelTests { // ── Empty page (a generic zero-row PlayerOptionPage-shaped page — NOT // modeling Gameplay, which is not an OptionPage at all) ───────────── [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 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. 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++; page.Apply(); Assert.Equal(1, flushCount); } [Fact] public void EmptyPlayerOptionPageShapedPage_WithAfterApplyWired_OnShown_StillFlushes() { var page = new OptionPage(); int flushCount = 0; page.AfterApply = () => flushCount++; page.OnShown(); 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() { 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(); 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(); 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(); 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); } // ── 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(); 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); } // ── OP4 re-review R3: SaveCurrentValue's live re-read pushes the WIDGET // too (the refresh delegate), so a re-seed converges both the row's // model state AND the visible checkbox. ────────────────────────────── [Fact] public void SaveCurrentValue_ReReadsLiveSource_AndPushesTheWidgetRefresh() { bool live = false; bool widgetChecked = true; // deliberately out of sync with live var row = new BoolOptionRow( initial: true, defaultValue: false, read: () => live, refresh: value => widgetChecked = value); live = false; row.SaveCurrentValue(); // Model AND widget both converge on the live source. Assert.False(row.Current); Assert.False(row.Changed); Assert.False(widgetChecked); live = true; row.SaveCurrentValue(); Assert.True(row.Current); Assert.True(widgetChecked); } // ── OP4 re-review R2: ReloadFromLive = per-row live re-read + gating // re-eval, WITHOUT Apply's AfterApply flush (a seed just cleared the // dirty module — a flush publication here would be spurious). ──────── [Fact] public void ReloadFromLive_ReReadsRows_WithoutFiringAfterApply() { bool live = false; bool widgetChecked = false; int flushCount = 0; int gatingCount = 0; var page = new OptionPage { AfterApply = () => flushCount++ }; var row = new BoolOptionRow( initial: false, defaultValue: false, read: () => live, refresh: value => widgetChecked = value); page.Register(row); page.OnOptionChanged = () => gatingCount++; live = true; page.ReloadFromLive(); Assert.True(row.Current); Assert.True(widgetChecked); Assert.False(row.Changed); // (current, saved) both re-read — no phantom dirt Assert.Equal(0, flushCount); // NO AfterApply publication on a seed Assert.Equal(1, gatingCount); // Apply/Reset ghosting re-evaluated } }