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);
}
}