477 lines
15 KiB
C#
477 lines
15 KiB
C#
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. Exercises a SYNTHETIC empty
|
|
/// <c>PlayerOptionPage</c>-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.
|
|
///
|
|
/// <para>
|
|
/// NEITHER shape models the Gameplay tab. <c>gmGameplayOptionsUI</c>
|
|
/// (<c>acclient.h:55857</c>) derives from <c>UIElement_Field</c>, not
|
|
/// <c>OptionPage</c>/<c>PlayerOptionPage</c> at all — research doc
|
|
/// <c>2026-08-10-options-panel-structure.md</c> §6, mechanism review S1
|
|
/// (2026-08-11 fix round). <see cref="OptionsPanelControllerTests"/> pins
|
|
/// the Gameplay-specific consequence (its <see cref="OptionPage"/> instance
|
|
/// is constructed with <see cref="OptionPage.AfterApply"/> deliberately left
|
|
/// null, so it never flushes).
|
|
/// </para>
|
|
/// </summary>
|
|
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<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);
|
|
}
|
|
|
|
// ── 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<string>();
|
|
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
|
|
}
|
|
|
|
[Fact]
|
|
public void RemoveTail_DetachesRowsAndPageNotifyOwnership()
|
|
{
|
|
var page = new OptionPage();
|
|
var retained = new BoolOptionRow(false, false);
|
|
var removed = new BoolOptionRow(false, false);
|
|
int notifications = 0;
|
|
page.OnOptionChanged = () => notifications++;
|
|
page.Register(retained);
|
|
page.Register(removed);
|
|
|
|
page.RemoveTail(1);
|
|
notifications = 0;
|
|
removed.SetCurrentValue(true);
|
|
|
|
Assert.Single(page.Rows);
|
|
Assert.Same(retained, page.Rows[0]);
|
|
Assert.Equal(0, notifications);
|
|
Assert.False(page.Changed);
|
|
}
|
|
|
|
[Fact]
|
|
public void Defaults_IsSafeWhenAnEarlierRowReplacesTheDynamicTail()
|
|
{
|
|
var page = new OptionPage();
|
|
int removedApplyCount = 0;
|
|
var pack = new BoolOptionRow(
|
|
initial: true,
|
|
defaultValue: false,
|
|
apply: _ => page.RemoveTail(1));
|
|
var dynamic = new BoolOptionRow(
|
|
initial: false,
|
|
defaultValue: true,
|
|
apply: _ => removedApplyCount++);
|
|
page.Register(pack);
|
|
page.Register(dynamic);
|
|
|
|
page.Defaults();
|
|
|
|
Assert.Single(page.Rows);
|
|
Assert.Equal(0, removedApplyCount);
|
|
}
|
|
}
|