acdream/tests/AcDream.App.Tests/UI/Layout/OptionPageModelTests.cs
Erik ac0304dcf0 fix(ui,runtime): OP4 re-review residuals R1-R4 (coordinator pass) — OP4 CLOSED
R1: the timestamp prefix moves from ChatLog.Append (which stamped the
stored BODY, rendering 'Alice says, "13:05:09 hi"') to ChatVM's display
composition — FormatTimestampPrefix(entry.Received) prepends the COMPOSED
line, matching retail's separate-leading-string model (fprintf("%ls%ls",
ts, text) @0x00563e5b; AddTextToScroll receives composed lines). The
prefix renders entry.Received in LOCAL time (retail strftime), invariant
literal colons. The ten defect-pinning test cases across
ChatLogTests/RuntimeCommunicationStateTests are rewritten to pin the
corrected contract (stored bodies stay clean; the composed line carries
the stamp outside the quotes — ChatVMTests).

R2: open option-bearing panels converge on every PlayerDescription seed:
OptionPage.ReloadFromLive (per-row live re-read + gating re-eval, NO
AfterApply flush — the seed just cleared the dirty module),
OptionsPanelController.OnServerOptionsSeeded (active page),
CombatUiController.OnServerOptionsSeeded (SyncControls), wired through
RuntimeSettingsController.ServerOptionsSeeded from the same factory hook
LockUI already uses. Retail cannot reach this state (its panels close
across login); the adaptation exists because retained panels survive the
session boundary — documented at the seam.

R3: tests drive the refresh widget push (model AND checkbox converge) and
ReloadFromLive's no-flush contract. R4: AP-196 addendum names the
headless AutoRepeatAttack false->true effective-default flip and the
characterOptions escape hatch.

Full Release suite: 13,083 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 06:36:14 +02:00

434 lines
14 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
}
}