refactor(settings): OP9 — retire the dead F11 settings surface + fully-superseded GameplaySettings
Campaign OP slice OP9 code retirement (docs/plans/2026-08-10-options-panel-campaign.md
§OP9). The retail four-tab Options panel (OP1-OP8) is now acdream's one
in-client settings surface (D1) — this commit deletes the pre-retail-UI
surfaces it fully superseded. Pure retirement: no behavior change to
anything live, verified by dispositioning every one of the 15 src files
and 8 test files that referenced the deleted types before touching any
of them.
1. SettingsVM + SettingsPanel (the old F11 IPanel surface, unrendered
since Campaign V slice V11's ImGui deletion) deleted outright, plus
their two dedicated test files. IPanel/IPanelRenderer stay — ChatPanel/
DebugPanel/VitalsPanel still implement IPanel, so the contract does NOT
become unreferenced.
2. RuntimeSettingsController's SettingsVM binding seam deleted:
CreateViewModel, CreateViewModelBinding, RuntimeSettingsViewModelBinding,
the _viewModel field, UnbindViewModel, and every _viewModel? notification
call (ToggleFrameRate, LoadCharacterContext, RestoreDefaultCharacterContext,
SyncChatFromServerOptions). CreateViewModelBinding had zero production
callers (test-only). HasDraftPreview/DisplayPreview/AudioPreview stay on
IRuntimeSettingsPreviewSource (WorldRenderFrameBuilder and
SettingsParticleRangeSource still consume it) but now trivially mirror
the committed Display/Audio snapshot — HasDraftPreview was already
always false in production. GameWindowLifetime's matching
"settings view model" shutdown stage is deleted.
3. IDevToolsGameplayCommands + DevToolsGameplayCommands deleted from
GameplayInputCommandController.cs. All three members were dead:
ToggleSettingsPanel() had ZERO dispatch sites (ToggleOptionsPanel always
routed to _retained, never to _devTools); ToggleDebugPanel()/
FocusChatInput() had dispatch sites (F1/Ctrl+F1, Tab) but empty no-op
bodies (their ImGui DebugPanel/ChatPanel targets were already gone).
The two live dispatch sites are kept as inert `return true;` cases
(still consuming the key, matching the prior no-op's "handled"
contract) instead of falling through to a lower-priority scope.
SessionPlayerComposition.cs's `new DevToolsGameplayCommands()`
construction is removed. No `ToggleSettingsPanel` InputAction exists
(only `ToggleOptionsPanel`, rebound at OP3) — nothing to remove there
or from KeyBindings.RetailDefaults()/keymap fixtures.
4. GameplaySettings deleted entirely (the type, SettingsStore's
LoadGameplay/SaveGameplay/BuildGameplayObject, RuntimeSettingsController's
Gameplay property/SetAcceptLootPermits). Verified all 13 remaining
members (ToggleRun, AdvancedCombatUI, ShowTooltips,
VividTargetingIndicator, SideBySideVitals, CoordinatesOnRadar,
SpellDuration, AllowGive, ShowHelm, ShowCloak, LockUI, UseMouseTurning,
AcceptLootPermits — the three combat ones already died at OP4/AP-196)
were ALREADY bound through CharacterOptionTable/
CharacterOptionsPageController's server-bit seam at OP4 before deleting
the client-local mirror — no (c)-case genuinely-client-local member
was found; disposition (b) covers 100% of the surface. SetUiLocked
rewritten to compare only against the last value actually pushed to
_runtimeTargets (MUST-FIX 4's guard), with no second store left to
read or write. LiveSessionRuntimeFactory's SetAcceptLootPermits binding
now sends the wire option only (the GameplaySettings write-behind call
removed as dead output). CharacterSettings/DisplaySettings/
AudioSettings/ChatSettings and their SettingsStore Load/Save surfaces
are UNTOUCHED per the campaign contract.
Per-file disposition (15 src + 8 test files that referenced
GameplaySettings before this commit):
- GameplaySettings.cs, SettingsVM.cs, SettingsPanel.cs: the types
themselves — deleted.
- SettingsStore.cs, RuntimeSettingsController.cs,
LiveSessionRuntimeFactory.cs: real usage — API deleted/rewritten.
- RetailUiRuntime.cs, InteractionRetainedUiComposition.cs,
SessionPlayerComposition.cs, CombatUiController.cs,
LiveCombatAttackOperations.cs, LivePresentationComposition.cs,
FrameRootComposition.cs, RuntimeCharacterState.cs,
CombatCameraTargetSource.cs: doc-comment-only or interface-name
substring matches (ICombatGameplaySettingsSource) — left as accurate
historical record, no forward reference to the deleted type.
- Tests: RuntimeSettingsControllerTests.cs and SettingsStoreTests.cs
rewritten (Gameplay-specific tests deleted; SaveDisplay/SaveAudio/
SaveChat tests re-targeted off the now-public methods instead of the
retired SettingsVM draft/Save() indirection); GameplaySettingsTests.cs/
SettingsVMTests.cs/SettingsPanelTests.cs deleted; the remaining three
(CharacterOptionCombatSettingsSourceTests.cs,
CombatCameraTargetSourceTests.cs, LiveCombatAttackOperationsTests.cs)
were comment/interface-name-only, untouched.
5. Register: AP-196 (OP4's partial GameplaySettings retirement, which left
five fields as write-behind mirrors) is fully retired now that the
record is gone outright — marked ~~AP-196~~ RETIRED with its retirement
note, active-row count 143 -> 142. No other row cited the deleted types
directly (AP-194/AP-193 cite CharacterOptionTable.cs, not
GameplaySettings.cs).
6. Settings.json migration honesty: SettingsStore no longer reads or
writes the "gameplay" top-level key, so an existing file carrying one
from a pre-OP9 build is neither parsed nor dropped — the existing
SaveSection raw-JSON-text preservation mechanism (unknown top-level
keys survive every subsequent save) carries it forward untouched.
Two new targeted tests
(LeftoverGameplaySection_FromAnOlderSettingsJson_DoesNotBreakOtherLoads,
LeftoverGameplaySection_SurvivesAnUnrelatedSave) pin this.
InputAction.ToggleOptionsPanel's stale doc comment (still describing the
retired ImGui SettingsPanel) and a handful of other dangling doc
references (DisplaySettings.cs, ChatOpacityLink.cs,
SettingsDevToolsComposition.cs, InputDispatcherCaptureTests.cs) are
reworded to point at the current retail Options panel / OP8
KeyboardConfigController.
Build: dotnet build -c Release green, 0 errors. Tests: dotnet test -c
Release --no-build — 13,075 passed / 4 skipped / 0 failed (13,079 total),
down from the stated baseline of 13,155 passed / 4 skipped / 0 failed
(13,159 total) — the -80 delta is exactly the deleted SettingsVM/
SettingsPanel/GameplaySettings test surface (three whole files plus the
Gameplay-specific cases trimmed from RuntimeSettingsControllerTests.cs/
SettingsStoreTests.cs), with zero regressions elsewhere.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
1a57f96efe
commit
371197a345
22 changed files with 228 additions and 3260 deletions
|
|
@ -11,13 +11,12 @@ public sealed class GameplayInputCommandControllerTests
|
|||
{
|
||||
[Theory]
|
||||
[InlineData(InputAction.ToggleInventoryPanel, "inventory")]
|
||||
[InlineData(InputAction.AcdreamToggleDebugPanel, "debug")]
|
||||
[InlineData(InputAction.AcdreamToggleFlyMode, "fly-or-chase")]
|
||||
[InlineData(InputAction.AcdreamTogglePlayerMode, "player-mode")]
|
||||
[InlineData(InputAction.ToggleChatEntry, "chat")]
|
||||
// Campaign OP slice OP3 (D1): F11 now opens the retail Options panel
|
||||
// through IRetainedGameplayWindowCommands, not the retired
|
||||
// IDevToolsGameplayCommands.ToggleSettingsPanel() no-op.
|
||||
// through IRetainedGameplayWindowCommands, not the OP9-retired
|
||||
// IDevToolsGameplayCommands.ToggleSettingsPanel() no-op (which was
|
||||
// never dispatched from this switch in the first place).
|
||||
[InlineData(InputAction.ToggleOptionsPanel, "options")]
|
||||
[InlineData(InputAction.CombatToggleCombat, "combat")]
|
||||
[InlineData(InputAction.ToggleFloatingChatWindow1, "chat-window-1")]
|
||||
|
|
@ -36,6 +35,26 @@ public sealed class GameplayInputCommandControllerTests
|
|||
Assert.Equal([expected], harness.Calls);
|
||||
}
|
||||
|
||||
// OP9: AcdreamToggleDebugPanel/ToggleChatEntry retired the
|
||||
// IDevToolsGameplayCommands seam they used to forward to — both
|
||||
// targets (the ImGui-era DebugPanel/ChatPanel) were already gone
|
||||
// (Campaign V slice V11), so the seam's own body was an unconditional
|
||||
// no-op. The action is still consumed (handled == true, matching the
|
||||
// prior no-op's contract) but claims no typed-owner call.
|
||||
[Theory]
|
||||
[InlineData(InputAction.AcdreamToggleDebugPanel)]
|
||||
[InlineData(InputAction.ToggleChatEntry)]
|
||||
public void RetiredDevToolsCommand_IsConsumedWithoutClaimingATypedOwner(
|
||||
InputAction action)
|
||||
{
|
||||
var harness = new Harness();
|
||||
|
||||
bool handled = harness.Controller.Handle(action);
|
||||
|
||||
Assert.True(handled);
|
||||
Assert.Empty(harness.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiagnosticCommand_PrecedesRemainingCommandSwitch()
|
||||
{
|
||||
|
|
@ -89,7 +108,6 @@ public sealed class GameplayInputCommandControllerTests
|
|||
public Harness()
|
||||
{
|
||||
Retained = new FakeRetained(Calls);
|
||||
DevTools = new FakeDevTools(Calls);
|
||||
Diagnostics = new FakeDiagnostics(Calls);
|
||||
Player = new FakePlayerMode(Calls);
|
||||
TargetMode = new FakeTargetMode(Calls);
|
||||
|
|
@ -99,7 +117,6 @@ public sealed class GameplayInputCommandControllerTests
|
|||
Window = new FakeWindow(Calls);
|
||||
Controller = new GameplayInputCommandController(
|
||||
Retained,
|
||||
DevTools,
|
||||
Diagnostics,
|
||||
Player,
|
||||
TargetMode,
|
||||
|
|
@ -111,7 +128,6 @@ public sealed class GameplayInputCommandControllerTests
|
|||
|
||||
public List<string> Calls { get; } = [];
|
||||
public FakeRetained Retained { get; }
|
||||
public FakeDevTools DevTools { get; }
|
||||
public FakeDiagnostics Diagnostics { get; }
|
||||
public FakePlayerMode Player { get; }
|
||||
public FakeTargetMode TargetMode { get; }
|
||||
|
|
@ -133,14 +149,6 @@ public sealed class GameplayInputCommandControllerTests
|
|||
public void ToggleOptionsPanel() => calls.Add("options");
|
||||
}
|
||||
|
||||
private sealed class FakeDevTools(List<string> calls)
|
||||
: IDevToolsGameplayCommands
|
||||
{
|
||||
public void ToggleDebugPanel() => calls.Add("debug");
|
||||
public void FocusChatInput() => calls.Add("chat");
|
||||
public void ToggleSettingsPanel() => calls.Add("settings");
|
||||
}
|
||||
|
||||
private sealed class FakeDiagnostics(List<string> calls)
|
||||
: IRuntimeDiagnosticCommands
|
||||
{
|
||||
|
|
|
|||
|
|
@ -386,7 +386,7 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
"_factory.CreateBackendNeutralTerrain(");
|
||||
AssertAppearsInOrder(
|
||||
shutdown,
|
||||
"Soft(\"settings view model\", () => ingress.Settings.UnbindViewModel())",
|
||||
"new ResourceShutdownStage(\"physical ingress cleanup\"",
|
||||
"new ResourceShutdownStage(\"frame borrowers\"",
|
||||
"Hard(\"frame-root bindings\", () => frame.FrameBindings?.Dispose())",
|
||||
"Hard(\"retail UI\", () => DisposeRetailUi(live.RetailUi))",
|
||||
|
|
|
|||
|
|
@ -4,10 +4,8 @@ using AcDream.App.Rendering;
|
|||
using AcDream.App.Settings;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.UI.Abstractions;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
using AcDream.UI.Abstractions.Panels.Settings;
|
||||
using AcDream.UI.Abstractions.Settings;
|
||||
using Silk.NET.Input;
|
||||
|
||||
namespace AcDream.App.Tests.Settings;
|
||||
|
||||
|
|
@ -39,14 +37,12 @@ public sealed class RuntimeSettingsControllerTests
|
|||
|
||||
Assert.Equal(1, storage.DisplayLoads);
|
||||
Assert.Equal(1, storage.AudioLoads);
|
||||
Assert.Equal(1, storage.GameplayLoads);
|
||||
Assert.Equal(1, storage.ChatLoads);
|
||||
Assert.Equal(1, storage.CharacterLoads);
|
||||
Assert.Equal(1, resolveCount);
|
||||
Assert.Equal("default", storage.LastLoadedCharacter);
|
||||
Assert.Same(storage.DisplayValue, controller.Startup.Display);
|
||||
Assert.Same(storage.AudioValue, controller.Startup.Audio);
|
||||
Assert.Same(storage.GameplayValue, controller.Startup.Gameplay);
|
||||
Assert.Same(storage.ChatValue, controller.Startup.Chat);
|
||||
Assert.Same(storage.DefaultCharacterValue, controller.Startup.Character);
|
||||
Assert.Equal(resolved, controller.Startup.Quality);
|
||||
|
|
@ -75,31 +71,6 @@ public sealed class RuntimeSettingsControllerTests
|
|||
controller.BindRuntimeTargets(new FakeRuntimeTargets(events)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ViewModelBindingReleasesOnlyItsExpectedInstance()
|
||||
{
|
||||
var controller = CreateController();
|
||||
using InputDispatcher dispatcher = CreateDispatcher();
|
||||
RuntimeSettingsViewModelBinding first = controller.CreateViewModelBinding(
|
||||
new KeyBindings(),
|
||||
dispatcher,
|
||||
static _ => { });
|
||||
|
||||
first.Dispose();
|
||||
RuntimeSettingsViewModelBinding second = controller.CreateViewModelBinding(
|
||||
new KeyBindings(),
|
||||
dispatcher,
|
||||
static _ => { });
|
||||
first.Dispose();
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
controller.CreateViewModelBinding(
|
||||
new KeyBindings(),
|
||||
dispatcher,
|
||||
static _ => { }));
|
||||
second.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartupRetryResumesAfterLastSuccessfulStage()
|
||||
{
|
||||
|
|
@ -262,63 +233,6 @@ public sealed class RuntimeSettingsControllerTests
|
|||
Assert.False(cmd.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SettingsViewModelSavePreservesSectionAndTargetOrder()
|
||||
{
|
||||
var events = new List<string>();
|
||||
var storage = new FakeStorage(events);
|
||||
var resolved = new QualitySettings(5, 15, 4, 16, true, 6);
|
||||
var controller = new RuntimeSettingsController(
|
||||
storage,
|
||||
_ => resolved,
|
||||
static _ => { });
|
||||
storage.ClearEvents();
|
||||
var targets = new FakeRuntimeTargets(events);
|
||||
controller.BindRuntimeTargets(targets);
|
||||
using InputDispatcher dispatcher = CreateDispatcher();
|
||||
SettingsVM viewModel = controller.CreateViewModel(
|
||||
new KeyBindings(),
|
||||
dispatcher,
|
||||
_ => events.Add("save-bindings"));
|
||||
|
||||
viewModel.SetDisplay(viewModel.DisplayDraft with
|
||||
{
|
||||
Resolution = "1920x1080",
|
||||
Quality = QualityPreset.Ultra,
|
||||
});
|
||||
viewModel.SetAudio(viewModel.AudioDraft with { Master = 0.25f });
|
||||
viewModel.SetGameplay(viewModel.GameplayDraft with { LockUI = true });
|
||||
viewModel.SetChat(viewModel.ChatDraft with { ShowTimestamps = true });
|
||||
viewModel.SetCharacter(viewModel.CharacterDraft with { AutoAttack = true });
|
||||
|
||||
viewModel.Save();
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
"save-bindings",
|
||||
"save-display",
|
||||
"target-display",
|
||||
"target-quality",
|
||||
"save-audio",
|
||||
// Campaign OP slice OP6 (2026-08-11): SaveAudio now pushes the
|
||||
// saved snapshot into the live engine, same shape as
|
||||
// save-display's own target-display push a few lines up.
|
||||
"target-audio",
|
||||
"save-gameplay",
|
||||
"target-ui-lock:True",
|
||||
"save-chat",
|
||||
"target-chat-opacity:1:1",
|
||||
"save-character:default",
|
||||
],
|
||||
events);
|
||||
Assert.Equal("1920x1080", controller.Display.Resolution);
|
||||
Assert.Equal(0.25f, controller.Audio.Master);
|
||||
Assert.True(controller.Gameplay.LockUI);
|
||||
Assert.True(controller.Chat.ShowTimestamps);
|
||||
Assert.True(controller.Character.AutoAttack);
|
||||
Assert.Equal(resolved, controller.ResolvedQuality);
|
||||
}
|
||||
|
||||
// OP6 rework (2026-08-11, review S5 / M2): pins the EFFECTIVE volume
|
||||
// ApplyAudio actually computes, not just that some target was called.
|
||||
// The FakeRuntimeTargets-based "target-audio" assertion above (and its
|
||||
|
|
@ -369,6 +283,10 @@ public sealed class RuntimeSettingsControllerTests
|
|||
// CH3 (2026-08-09): SaveChat must publish SetSingleCharacterOption
|
||||
// (0x0005) for exactly the Hear*Chat bits that actually changed —
|
||||
// touching one checkbox must not resend the other four.
|
||||
//
|
||||
// OP9 rework: SaveChat was widened to public at Campaign OP slice
|
||||
// OP6, so this test now calls it directly instead of routing
|
||||
// through the retired SettingsVM draft/Save() indirection.
|
||||
var storage = new FakeStorage();
|
||||
var controller = new RuntimeSettingsController(
|
||||
storage,
|
||||
|
|
@ -376,31 +294,24 @@ public sealed class RuntimeSettingsControllerTests
|
|||
static _ => { });
|
||||
var targets = new FakeRuntimeTargets([]);
|
||||
controller.BindRuntimeTargets(targets);
|
||||
using InputDispatcher dispatcher = CreateDispatcher();
|
||||
SettingsVM viewModel = controller.CreateViewModel(
|
||||
new KeyBindings(),
|
||||
dispatcher,
|
||||
static _ => { });
|
||||
|
||||
// N4 (CH3 Opus review): ChatSettings.Default now matches ACE's real
|
||||
// CharacterOptions2.Default — Roleplay/Society start FALSE (only
|
||||
// General/Trade/LFG start true). Flip Roleplay ON first so the
|
||||
// second edit below can flip it back off.
|
||||
Assert.False(viewModel.ChatDraft.HearRoleplayChat);
|
||||
viewModel.SetChat(viewModel.ChatDraft with { HearRoleplayChat = true });
|
||||
viewModel.Save();
|
||||
Assert.False(controller.Chat.HearRoleplayChat);
|
||||
controller.SaveChat(controller.Chat with { HearRoleplayChat = true });
|
||||
|
||||
Assert.Equal(
|
||||
[((uint)CharacterOptionId.ListenToRoleplayChat, true)],
|
||||
targets.SingleOptionCalls);
|
||||
|
||||
targets.SingleOptionCalls.Clear();
|
||||
viewModel.SetChat(viewModel.ChatDraft with
|
||||
controller.SaveChat(controller.Chat with
|
||||
{
|
||||
HearRoleplayChat = false,
|
||||
HearSocietyChat = true,
|
||||
});
|
||||
viewModel.Save();
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
|
|
@ -416,15 +327,9 @@ public sealed class RuntimeSettingsControllerTests
|
|||
var controller = CreateController();
|
||||
var targets = new FakeRuntimeTargets([]);
|
||||
controller.BindRuntimeTargets(targets);
|
||||
using InputDispatcher dispatcher = CreateDispatcher();
|
||||
SettingsVM viewModel = controller.CreateViewModel(
|
||||
new KeyBindings(),
|
||||
dispatcher,
|
||||
static _ => { });
|
||||
|
||||
// Touch a Chat field that is NOT a Hear*Chat membership bit.
|
||||
viewModel.SetChat(viewModel.ChatDraft with { ShowTimestamps = false });
|
||||
viewModel.Save();
|
||||
controller.SaveChat(controller.Chat with { ShowTimestamps = false });
|
||||
|
||||
Assert.Empty(targets.SingleOptionCalls);
|
||||
}
|
||||
|
|
@ -434,23 +339,17 @@ public sealed class RuntimeSettingsControllerTests
|
|||
{
|
||||
// Campaign CH slice CH6c: unlike the Hear* options (local-only, no
|
||||
// wire), opacity is ALWAYS pushed on Save (not diffed) so the linking
|
||||
// invariant self-heals; the point of this test is that clicking Save
|
||||
// is enough — no restart, no separate "apply" step.
|
||||
// invariant self-heals; the point of this test is that calling
|
||||
// SaveChat is enough — no restart, no separate "apply" step.
|
||||
var controller = CreateController();
|
||||
var targets = new FakeRuntimeTargets([]);
|
||||
controller.BindRuntimeTargets(targets);
|
||||
using InputDispatcher dispatcher = CreateDispatcher();
|
||||
SettingsVM viewModel = controller.CreateViewModel(
|
||||
new KeyBindings(),
|
||||
dispatcher,
|
||||
static _ => { });
|
||||
|
||||
viewModel.SetChat(viewModel.ChatDraft with
|
||||
controller.SaveChat(controller.Chat with
|
||||
{
|
||||
DefaultOpacity = 0.3f,
|
||||
ActiveOpacity = 0.6f,
|
||||
});
|
||||
viewModel.Save();
|
||||
|
||||
Assert.Equal([(0.3f, 0.6f)], targets.ChatOpacityCalls);
|
||||
Assert.Equal(0.3f, controller.Chat.DefaultOpacity);
|
||||
|
|
@ -486,7 +385,7 @@ public sealed class RuntimeSettingsControllerTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void SyncChatFromServerOptionsReseedsPersistedAndDraft()
|
||||
public void SyncChatFromServerOptionsReseedsPersisted()
|
||||
{
|
||||
// Research doc §5.2: ACE's CharacterOptions2.Default omits
|
||||
// HearRoleplayChat/HearSocietyChat. N4 (CH3 Opus review) aligned
|
||||
|
|
@ -507,13 +406,7 @@ public sealed class RuntimeSettingsControllerTests
|
|||
storage,
|
||||
static preset => QualitySettings.From(preset),
|
||||
static _ => { });
|
||||
using InputDispatcher dispatcher = CreateDispatcher();
|
||||
SettingsVM viewModel = controller.CreateViewModel(
|
||||
new KeyBindings(),
|
||||
dispatcher,
|
||||
static _ => { });
|
||||
Assert.True(controller.Chat.HearRoleplayChat);
|
||||
Assert.True(viewModel.ChatDraft.HearRoleplayChat);
|
||||
|
||||
controller.SyncChatFromServerOptions(0x00948700u); // ACE's real default
|
||||
|
||||
|
|
@ -522,28 +415,9 @@ public sealed class RuntimeSettingsControllerTests
|
|||
Assert.True(controller.Chat.HearLFGChat);
|
||||
Assert.False(controller.Chat.HearRoleplayChat);
|
||||
Assert.False(controller.Chat.HearSocietyChat);
|
||||
Assert.False(viewModel.ChatDraft.HearRoleplayChat);
|
||||
Assert.False(viewModel.ChatDraft.HearSocietyChat);
|
||||
Assert.Same(controller.Chat, storage.ChatValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SyncChatFromServerOptionsPreservesUnsavedUnrelatedDraftEdits()
|
||||
{
|
||||
var controller = CreateController();
|
||||
using InputDispatcher dispatcher = CreateDispatcher();
|
||||
SettingsVM viewModel = controller.CreateViewModel(
|
||||
new KeyBindings(),
|
||||
dispatcher,
|
||||
static _ => { });
|
||||
viewModel.SetChat(viewModel.ChatDraft with { FontSize = 18f });
|
||||
|
||||
controller.SyncChatFromServerOptions(0x00948700u);
|
||||
|
||||
Assert.Equal(18f, viewModel.ChatDraft.FontSize);
|
||||
Assert.False(viewModel.ChatDraft.HearRoleplayChat);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SyncChatFromServerOptionsIsANoOpWhenUnchanged()
|
||||
{
|
||||
|
|
@ -569,126 +443,31 @@ public sealed class RuntimeSettingsControllerTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void DraftPreviewAndExternalCommandsShareCanonicalState()
|
||||
public void DraftPreviewAlwaysMirrorsCommittedState()
|
||||
{
|
||||
var events = new List<string>();
|
||||
var storage = new FakeStorage(events);
|
||||
var controller = CreateController(storage, events: events);
|
||||
storage.ClearEvents();
|
||||
var targets = new FakeRuntimeTargets(events);
|
||||
controller.BindRuntimeTargets(targets);
|
||||
using InputDispatcher dispatcher = CreateDispatcher();
|
||||
SettingsVM viewModel = controller.CreateViewModel(
|
||||
new KeyBindings(),
|
||||
dispatcher,
|
||||
static _ => { });
|
||||
viewModel.SetDisplay(viewModel.DisplayDraft with
|
||||
{
|
||||
FieldOfView = 91f,
|
||||
ParticleRange = ParticleRange.Retail,
|
||||
});
|
||||
viewModel.SetAudio(viewModel.AudioDraft with { Sfx = 0.33f });
|
||||
viewModel.SetGameplay(viewModel.GameplayDraft with { ShowTooltips = false });
|
||||
// OP9: the optional developer-tools draft-preview view model
|
||||
// (SettingsVM) was retired — it had zero production construction
|
||||
// sites. HasDraftPreview is now hardcoded false and
|
||||
// DisplayPreview/AudioPreview always mirror the committed
|
||||
// Display/Audio snapshot. This pins the NEW (trivial) contract that
|
||||
// WorldRenderFrameBuilder and SettingsParticleRangeSource still
|
||||
// consume through IRuntimeSettingsPreviewSource.
|
||||
var controller = CreateController();
|
||||
|
||||
Assert.True(controller.HasDraftPreview);
|
||||
Assert.False(controller.HasDraftPreview);
|
||||
Assert.Equal(controller.Display, controller.DisplayPreview);
|
||||
Assert.Equal(controller.Audio, controller.AudioPreview);
|
||||
|
||||
controller.SaveDisplay(controller.Display with { FieldOfView = 91f });
|
||||
controller.SaveAudio(controller.Audio with { Sfx = 0.33f });
|
||||
|
||||
Assert.False(controller.HasDraftPreview);
|
||||
Assert.Equal(91f, controller.DisplayPreview.FieldOfView);
|
||||
Assert.Equal(ParticleRange.Retail, controller.DisplayPreview.ParticleRange);
|
||||
Assert.Equal(0.33f, controller.AudioPreview.Sfx);
|
||||
|
||||
controller.ToggleFrameRate();
|
||||
controller.SetUiLocked(true);
|
||||
controller.SetAcceptLootPermits(true);
|
||||
|
||||
Assert.True(controller.Display.ShowFps);
|
||||
Assert.True(controller.DisplayPreview.ShowFps);
|
||||
Assert.Equal(91f, controller.DisplayPreview.FieldOfView);
|
||||
Assert.True(controller.Gameplay.LockUI);
|
||||
Assert.True(controller.Gameplay.AcceptLootPermits);
|
||||
Assert.False(viewModel.GameplayDraft.ShowTooltips);
|
||||
Assert.True(viewModel.GameplayDraft.LockUI);
|
||||
Assert.True(viewModel.GameplayDraft.AcceptLootPermits);
|
||||
Assert.Contains("target-ui-lock:True", events);
|
||||
Assert.Equal(2, storage.GameplaySaves);
|
||||
Assert.Equal(1, storage.DisplaySaves);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DraftCancelRestoresPreviewAndCombatTogglePreservesUnrelatedDrafts()
|
||||
{
|
||||
var storage = new FakeStorage();
|
||||
var controller = CreateController(storage);
|
||||
using InputDispatcher dispatcher = CreateDispatcher();
|
||||
SettingsVM viewModel = controller.CreateViewModel(
|
||||
new KeyBindings(),
|
||||
dispatcher,
|
||||
static _ => { });
|
||||
viewModel.SetDisplay(viewModel.DisplayDraft with { FieldOfView = 99f });
|
||||
viewModel.SetGameplay(viewModel.GameplayDraft with
|
||||
{
|
||||
ShowTooltips = false,
|
||||
CoordinatesOnRadar = false,
|
||||
});
|
||||
|
||||
controller.SetUiLocked(true);
|
||||
controller.SetAcceptLootPermits(true);
|
||||
|
||||
Assert.False(viewModel.GameplayDraft.ShowTooltips);
|
||||
Assert.False(viewModel.GameplayDraft.CoordinatesOnRadar);
|
||||
Assert.True(viewModel.GameplayDraft.LockUI);
|
||||
Assert.True(viewModel.GameplayDraft.AcceptLootPermits);
|
||||
Assert.Equal(99f, controller.DisplayPreview.FieldOfView);
|
||||
|
||||
viewModel.Cancel();
|
||||
|
||||
Assert.Equal(controller.Display.FieldOfView, controller.DisplayPreview.FieldOfView);
|
||||
Assert.Equal(controller.Gameplay.ShowTooltips, viewModel.GameplayDraft.ShowTooltips);
|
||||
Assert.Equal(
|
||||
controller.Gameplay.CoordinatesOnRadar,
|
||||
viewModel.GameplayDraft.CoordinatesOnRadar);
|
||||
Assert.True(viewModel.GameplayDraft.LockUI);
|
||||
Assert.True(viewModel.GameplayDraft.AcceptLootPermits);
|
||||
|
||||
viewModel.SetGameplay(viewModel.GameplayDraft with { ShowHelm = false });
|
||||
viewModel.Save();
|
||||
|
||||
Assert.True(storage.GameplayValue.LockUI);
|
||||
Assert.True(storage.GameplayValue.AcceptLootPermits);
|
||||
Assert.False(storage.GameplayValue.ShowHelm);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedExternalGameplayPersistenceDoesNotPromoteViewModelBaseline()
|
||||
{
|
||||
var storage = new FakeStorage { ThrowOnGameplaySave = true };
|
||||
var logs = new List<string>();
|
||||
var controller = new RuntimeSettingsController(
|
||||
storage,
|
||||
static preset => QualitySettings.From(preset),
|
||||
logs.Add);
|
||||
using InputDispatcher dispatcher = CreateDispatcher();
|
||||
SettingsVM viewModel = controller.CreateViewModel(
|
||||
new KeyBindings(),
|
||||
dispatcher,
|
||||
static _ => { });
|
||||
|
||||
controller.SetUiLocked(true);
|
||||
Assert.Throws<IOException>(() => controller.SetAcceptLootPermits(true));
|
||||
|
||||
Assert.True(viewModel.GameplayDraft.LockUI);
|
||||
Assert.True(viewModel.GameplayDraft.AcceptLootPermits);
|
||||
|
||||
viewModel.Cancel();
|
||||
|
||||
Assert.Equal(GameplaySettings.Default.LockUI, viewModel.GameplayDraft.LockUI);
|
||||
Assert.Equal(
|
||||
GameplaySettings.Default.AcceptLootPermits,
|
||||
viewModel.GameplayDraft.AcceptLootPermits);
|
||||
Assert.Contains(logs, line =>
|
||||
line.Contains("radar lock save failed", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CharacterContextLoadsWithoutViewModelAndSynchronizesWhenBound()
|
||||
public void CharacterContextSwitchesActiveToonAndReloadsSettings()
|
||||
{
|
||||
var storage = new FakeStorage();
|
||||
storage.Characters["Alice"] = CharacterSettings.Default with
|
||||
|
|
@ -707,76 +486,23 @@ public sealed class RuntimeSettingsControllerTests
|
|||
Assert.Equal("Alice", controller.ActiveToonKey);
|
||||
Assert.Equal("Trade", controller.Character.DefaultChatChannel);
|
||||
|
||||
using InputDispatcher dispatcher = CreateDispatcher();
|
||||
SettingsVM viewModel = controller.CreateViewModel(
|
||||
new KeyBindings(),
|
||||
dispatcher,
|
||||
static _ => { });
|
||||
controller.LoadCharacterContext("Bob");
|
||||
|
||||
Assert.Equal("Bob", controller.ActiveToonKey);
|
||||
Assert.False(viewModel.CharacterDraft.ConfirmSalvage);
|
||||
Assert.False(controller.Character.ConfirmSalvage);
|
||||
|
||||
controller.RestoreDefaultCharacterContext();
|
||||
controller.ResetActiveCharacterKey();
|
||||
|
||||
Assert.Equal("default", controller.ActiveToonKey);
|
||||
Assert.Same(storage.DefaultCharacterValue, controller.Character);
|
||||
Assert.Same(storage.DefaultCharacterValue, viewModel.CharacterDraft);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CharacterSaveUsesActiveToonAndDefaultSaveBecomesResetContext()
|
||||
{
|
||||
var storage = new FakeStorage();
|
||||
var controller = CreateController(storage);
|
||||
using InputDispatcher dispatcher = CreateDispatcher();
|
||||
SettingsVM viewModel = controller.CreateViewModel(
|
||||
new KeyBindings(),
|
||||
dispatcher,
|
||||
static _ => { });
|
||||
var newDefault = CharacterSettings.Default with { AutoAttack = true };
|
||||
viewModel.SetCharacter(newDefault);
|
||||
viewModel.Save();
|
||||
|
||||
controller.LoadCharacterContext("Alice");
|
||||
var alice = CharacterSettings.Default with { DefaultChatChannel = "Trade" };
|
||||
viewModel.SetCharacter(alice);
|
||||
viewModel.Save();
|
||||
|
||||
Assert.Same(newDefault, storage.Characters["default"]);
|
||||
Assert.Same(alice, storage.Characters["Alice"]);
|
||||
controller.RestoreDefaultCharacterContext();
|
||||
Assert.Same(newDefault, controller.Character);
|
||||
Assert.Same(newDefault, viewModel.CharacterDraft);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ViewModelAndRuntimeTargetLoansCanBeWithdrawnAndReboundPrecisely()
|
||||
public void RuntimeTargetLoansCanBeWithdrawnAndReboundPrecisely()
|
||||
{
|
||||
var events = new List<string>();
|
||||
var controller = CreateController(events: events);
|
||||
using InputDispatcher dispatcher = CreateDispatcher();
|
||||
SettingsVM viewModel = controller.CreateViewModel(
|
||||
new KeyBindings(),
|
||||
dispatcher,
|
||||
static _ => { });
|
||||
SettingsVM foreign = CreateStandaloneViewModel(dispatcher);
|
||||
|
||||
controller.UnbindViewModel(foreign);
|
||||
Assert.True(controller.HasDraftPreview);
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
controller.CreateViewModel(
|
||||
new KeyBindings(),
|
||||
dispatcher,
|
||||
static _ => { }));
|
||||
|
||||
controller.UnbindViewModel(viewModel);
|
||||
Assert.False(controller.HasDraftPreview);
|
||||
controller.CreateViewModel(
|
||||
new KeyBindings(),
|
||||
dispatcher,
|
||||
static _ => { });
|
||||
|
||||
var first = new FakeRuntimeTargets(events);
|
||||
controller.BindRuntimeTargets(first);
|
||||
|
|
@ -822,20 +548,14 @@ public sealed class RuntimeSettingsControllerTests
|
|||
logs.Add);
|
||||
storage.ClearEvents();
|
||||
controller.BindRuntimeTargets(new FakeRuntimeTargets(events));
|
||||
using InputDispatcher dispatcher = CreateDispatcher();
|
||||
SettingsVM viewModel = controller.CreateViewModel(
|
||||
new KeyBindings(),
|
||||
dispatcher,
|
||||
static _ => { });
|
||||
DisplaySettings original = controller.Display;
|
||||
viewModel.SetDisplay(viewModel.DisplayDraft with
|
||||
|
||||
controller.SaveDisplay(controller.Display with
|
||||
{
|
||||
Resolution = "2560x1440",
|
||||
Quality = QualityPreset.Ultra,
|
||||
});
|
||||
|
||||
viewModel.Save();
|
||||
|
||||
Assert.Same(original, controller.Display);
|
||||
Assert.DoesNotContain("target-display", events);
|
||||
Assert.DoesNotContain("target-quality", events);
|
||||
|
|
@ -857,20 +577,14 @@ public sealed class RuntimeSettingsControllerTests
|
|||
{
|
||||
ThrowOnDisplay = true,
|
||||
});
|
||||
using InputDispatcher dispatcher = CreateDispatcher();
|
||||
SettingsVM viewModel = controller.CreateViewModel(
|
||||
new KeyBindings(),
|
||||
dispatcher,
|
||||
static _ => { });
|
||||
DisplaySettings original = controller.Display;
|
||||
viewModel.SetDisplay(viewModel.DisplayDraft with
|
||||
|
||||
controller.SaveDisplay(controller.Display with
|
||||
{
|
||||
Resolution = "3840x2160",
|
||||
Quality = QualityPreset.Ultra,
|
||||
});
|
||||
|
||||
viewModel.Save();
|
||||
|
||||
Assert.Equal(1, storage.DisplaySaves);
|
||||
Assert.Same(original, controller.Display);
|
||||
Assert.DoesNotContain("target-quality", events);
|
||||
|
|
@ -883,9 +597,7 @@ public sealed class RuntimeSettingsControllerTests
|
|||
var storage = new FakeStorage
|
||||
{
|
||||
ThrowOnAudioSave = true,
|
||||
ThrowOnGameplaySave = true,
|
||||
ThrowOnChatSave = true,
|
||||
ThrowOnCharacterSave = true,
|
||||
};
|
||||
var logs = new List<string>();
|
||||
var controller = new RuntimeSettingsController(
|
||||
|
|
@ -893,29 +605,15 @@ public sealed class RuntimeSettingsControllerTests
|
|||
static preset => QualitySettings.From(preset),
|
||||
logs.Add);
|
||||
AudioSettings originalAudio = controller.Audio;
|
||||
GameplaySettings originalGameplay = controller.Gameplay;
|
||||
ChatSettings originalChat = controller.Chat;
|
||||
CharacterSettings originalCharacter = controller.Character;
|
||||
using InputDispatcher dispatcher = CreateDispatcher();
|
||||
SettingsVM viewModel = controller.CreateViewModel(
|
||||
new KeyBindings(),
|
||||
dispatcher,
|
||||
static _ => { });
|
||||
viewModel.SetAudio(viewModel.AudioDraft with { Master = 0.1f });
|
||||
viewModel.SetGameplay(viewModel.GameplayDraft with { LockUI = true });
|
||||
viewModel.SetChat(viewModel.ChatDraft with { ShowTimestamps = true });
|
||||
viewModel.SetCharacter(viewModel.CharacterDraft with { AutoAttack = true });
|
||||
|
||||
viewModel.Save();
|
||||
controller.SaveAudio(controller.Audio with { Master = 0.1f });
|
||||
controller.SaveChat(controller.Chat with { ShowTimestamps = true });
|
||||
|
||||
Assert.Same(originalAudio, controller.Audio);
|
||||
Assert.Same(originalGameplay, controller.Gameplay);
|
||||
Assert.Same(originalChat, controller.Chat);
|
||||
Assert.Same(originalCharacter, controller.Character);
|
||||
Assert.Contains(logs, line => line.Contains("audio save failed", StringComparison.Ordinal));
|
||||
Assert.Contains(logs, line => line.Contains("gameplay save failed", StringComparison.Ordinal));
|
||||
Assert.Contains(logs, line => line.Contains("chat save failed", StringComparison.Ordinal));
|
||||
Assert.Contains(logs, line => line.Contains("character save failed", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -956,135 +654,74 @@ public sealed class RuntimeSettingsControllerTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void SetUiLocked_AppliesEvenWhenGameplayLockUIAlreadyMatches_IfNeverActuallyApplied()
|
||||
public void SetUiLocked_AppliesOnFirstCallThenNoOpsOnRepeatedSameValue()
|
||||
{
|
||||
// MUST-FIX 4 (OP4 review-fix round, 2026-08-11, blast M3): the
|
||||
// guard used to compare the requested value against
|
||||
// `Gameplay.LockUI` — valid only while `ToggleUiLock` derived
|
||||
// `locked` AS `!Gameplay.LockUI`. OP4 re-pointed `ToggleUiLock` to
|
||||
// read the SERVER bit (RuntimeCharacterOptionsState) instead, a
|
||||
// DIFFERENT store that can already equal a value this controller
|
||||
// never actually pushed to `_runtimeTargets`. A pre-existing save
|
||||
// seeds Gameplay.LockUI = true; the runtime target has never seen
|
||||
// `true` — the FIRST SetUiLocked(true) call must still apply.
|
||||
// OP9: SetUiLocked no longer reads or writes a persisted
|
||||
// GameplaySettings mirror (MUST-FIX 4, OP4 review-fix round,
|
||||
// 2026-08-11, blast M3, superseded) — RuntimeCharacterOptionsState
|
||||
// (the server bit) is the sole authority now, so there is no longer
|
||||
// a second store that could disagree with what was actually
|
||||
// applied. The guard's own idempotency (compare against the last
|
||||
// value ACTUALLY pushed to _runtimeTargets) is the only behavior
|
||||
// left to pin here.
|
||||
var events = new List<string>();
|
||||
var storage = new FakeStorage(events)
|
||||
{
|
||||
GameplayValue = GameplaySettings.Default with { LockUI = true },
|
||||
};
|
||||
var controller = new RuntimeSettingsController(
|
||||
storage,
|
||||
static preset => QualitySettings.From(preset),
|
||||
static _ => { });
|
||||
Assert.True(controller.Gameplay.LockUI); // already true, but never applied
|
||||
var controller = CreateController(events: events);
|
||||
var targets = new FakeRuntimeTargets(events);
|
||||
controller.BindRuntimeTargets(targets);
|
||||
|
||||
controller.SetUiLocked(true);
|
||||
|
||||
Assert.Equal(1, targets.UiLockCalls);
|
||||
|
||||
// A second call with the SAME value now correctly no-ops — this
|
||||
// time it really was applied.
|
||||
controller.SetUiLocked(true);
|
||||
|
||||
Assert.Equal(1, targets.UiLockCalls);
|
||||
|
||||
controller.SetUiLocked(false);
|
||||
Assert.Equal(2, targets.UiLockCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UiLockTargetFailureCanRetryTheSameRequestedValue()
|
||||
{
|
||||
var events = new List<string>();
|
||||
var storage = new FakeStorage(events);
|
||||
var controller = CreateController(storage, events);
|
||||
storage.ClearEvents();
|
||||
var controller = CreateController(events: events);
|
||||
var targets = new FakeRuntimeTargets(events)
|
||||
{
|
||||
RemainingUiLockFailures = 1,
|
||||
};
|
||||
controller.BindRuntimeTargets(targets);
|
||||
using InputDispatcher dispatcher = CreateDispatcher();
|
||||
SettingsVM viewModel = controller.CreateViewModel(
|
||||
new KeyBindings(),
|
||||
dispatcher,
|
||||
static _ => { });
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => controller.SetUiLocked(true));
|
||||
|
||||
Assert.True(controller.Gameplay.LockUI);
|
||||
Assert.Equal(GameplaySettings.Default.LockUI, viewModel.GameplayDraft.LockUI);
|
||||
Assert.Equal(0, storage.GameplaySaves);
|
||||
Assert.Equal(["target-ui-lock:True"], events);
|
||||
|
||||
// A retry with the SAME requested value must re-apply — the guard
|
||||
// only advances _lastAppliedUiLocked on SUCCESS (the field write in
|
||||
// SetUiLocked runs after the ApplyUiLock call, which threw above).
|
||||
events.Clear();
|
||||
controller.SetUiLocked(true);
|
||||
|
||||
Assert.True(controller.Gameplay.LockUI);
|
||||
Assert.True(viewModel.GameplayDraft.LockUI);
|
||||
viewModel.Cancel();
|
||||
Assert.True(viewModel.GameplayDraft.LockUI);
|
||||
Assert.Equal(1, storage.GameplaySaves);
|
||||
Assert.Equal(["target-ui-lock:True", "save-gameplay"], events);
|
||||
Assert.Equal(["target-ui-lock:True"], events);
|
||||
Assert.Equal(2, targets.UiLockCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UiLockPersistenceFailureCanRetryTheSameRequestedValue()
|
||||
public void UnboundRuntimeTargetsConsumeUiLockCallsSilently()
|
||||
{
|
||||
// OP9: SetUiLocked no longer persists anything of its own, so the
|
||||
// only observable effect of an unbound call is the ABSENCE of any
|
||||
// "target-" event — there is no longer a Gameplay-side write to
|
||||
// assert against.
|
||||
var events = new List<string>();
|
||||
var storage = new FakeStorage(events)
|
||||
{
|
||||
RemainingGameplaySaveFailures = 1,
|
||||
};
|
||||
var logs = new List<string>();
|
||||
var controller = new RuntimeSettingsController(
|
||||
storage,
|
||||
static preset => QualitySettings.From(preset),
|
||||
logs.Add);
|
||||
storage.ClearEvents();
|
||||
controller.BindRuntimeTargets(new FakeRuntimeTargets(events));
|
||||
using InputDispatcher dispatcher = CreateDispatcher();
|
||||
SettingsVM viewModel = controller.CreateViewModel(
|
||||
new KeyBindings(),
|
||||
dispatcher,
|
||||
static _ => { });
|
||||
|
||||
controller.SetUiLocked(true);
|
||||
|
||||
Assert.True(controller.Gameplay.LockUI);
|
||||
Assert.True(viewModel.GameplayDraft.LockUI);
|
||||
Assert.Equal(1, storage.GameplaySaves);
|
||||
Assert.Contains(logs, line =>
|
||||
line.Contains("radar lock save failed", StringComparison.Ordinal));
|
||||
|
||||
events.Clear();
|
||||
controller.SetUiLocked(true);
|
||||
|
||||
Assert.True(controller.Gameplay.LockUI);
|
||||
viewModel.Cancel();
|
||||
Assert.True(viewModel.GameplayDraft.LockUI);
|
||||
Assert.Equal(2, storage.GameplaySaves);
|
||||
Assert.Equal(["target-ui-lock:True", "save-gameplay"], events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnbindingLoansStopsCallsButStateAndPersistenceContinue()
|
||||
{
|
||||
var events = new List<string>();
|
||||
var storage = new FakeStorage(events);
|
||||
var controller = CreateController(storage, events: events);
|
||||
storage.ClearEvents();
|
||||
var controller = CreateController(events: events);
|
||||
controller.BindRuntimeTargets(new FakeRuntimeTargets(events));
|
||||
controller.UnbindRuntimeTargets();
|
||||
|
||||
controller.SetUiLocked(true);
|
||||
controller.ReapplyQualityPreset(QualityPreset.Ultra);
|
||||
|
||||
Assert.True(controller.Gameplay.LockUI);
|
||||
Assert.Equal(
|
||||
QualitySettings.From(QualityPreset.Ultra),
|
||||
controller.ResolvedQuality);
|
||||
Assert.DoesNotContain(events, value => value.StartsWith("target-", StringComparison.Ordinal));
|
||||
Assert.Equal(1, storage.GameplaySaves);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
|
@ -1121,32 +758,6 @@ public sealed class RuntimeSettingsControllerTests
|
|||
static _ => { });
|
||||
}
|
||||
|
||||
private static InputDispatcher CreateDispatcher()
|
||||
{
|
||||
InputDispatcher dispatcher = InputDispatcher.CreateDetached(
|
||||
new NullKeyboardSource(),
|
||||
new NullMouseSource(),
|
||||
new KeyBindings());
|
||||
dispatcher.Attach();
|
||||
return dispatcher;
|
||||
}
|
||||
|
||||
private static SettingsVM CreateStandaloneViewModel(InputDispatcher dispatcher) =>
|
||||
new(
|
||||
new KeyBindings(),
|
||||
dispatcher,
|
||||
static _ => { },
|
||||
DisplaySettings.Default,
|
||||
static _ => { },
|
||||
AudioSettings.Default,
|
||||
static _ => { },
|
||||
GameplaySettings.Default,
|
||||
static _ => { },
|
||||
ChatSettings.Default,
|
||||
static _ => { },
|
||||
CharacterSettings.Default,
|
||||
static _ => { });
|
||||
|
||||
private sealed class FakeStartupTarget(List<string> events)
|
||||
: IRuntimeSettingsStartupTarget
|
||||
{
|
||||
|
|
@ -1356,8 +967,6 @@ public sealed class RuntimeSettingsControllerTests
|
|||
|
||||
public AudioSettings AudioValue { get; set; } = AudioSettings.Default;
|
||||
|
||||
public GameplaySettings GameplayValue { get; set; } = GameplaySettings.Default;
|
||||
|
||||
public ChatSettings ChatValue { get; set; } = ChatSettings.Default;
|
||||
|
||||
public CharacterSettings DefaultCharacterValue { get; set; } =
|
||||
|
|
@ -1377,16 +986,12 @@ public sealed class RuntimeSettingsControllerTests
|
|||
|
||||
public int AudioLoads { get; private set; }
|
||||
|
||||
public int GameplayLoads { get; private set; }
|
||||
|
||||
public int ChatLoads { get; private set; }
|
||||
|
||||
public int CharacterLoads { get; private set; }
|
||||
|
||||
public int DisplaySaves { get; private set; }
|
||||
|
||||
public int GameplaySaves { get; private set; }
|
||||
|
||||
public int ChatSaves { get; private set; }
|
||||
|
||||
public string? LastLoadedCharacter { get; private set; }
|
||||
|
|
@ -1395,10 +1000,6 @@ public sealed class RuntimeSettingsControllerTests
|
|||
|
||||
public bool ThrowOnAudioSave { get; init; }
|
||||
|
||||
public bool ThrowOnGameplaySave { get; init; }
|
||||
|
||||
public int RemainingGameplaySaveFailures { get; set; }
|
||||
|
||||
public bool ThrowOnChatSave { get; init; }
|
||||
|
||||
public bool ThrowOnCharacterSave { get; init; }
|
||||
|
|
@ -1415,12 +1016,6 @@ public sealed class RuntimeSettingsControllerTests
|
|||
return AudioValue;
|
||||
}
|
||||
|
||||
public GameplaySettings LoadGameplay()
|
||||
{
|
||||
GameplayLoads++;
|
||||
return GameplayValue;
|
||||
}
|
||||
|
||||
public ChatSettings LoadChat()
|
||||
{
|
||||
ChatLoads++;
|
||||
|
|
@ -1453,20 +1048,6 @@ public sealed class RuntimeSettingsControllerTests
|
|||
AudioValue = audio;
|
||||
}
|
||||
|
||||
public void SaveGameplay(GameplaySettings gameplay)
|
||||
{
|
||||
GameplaySaves++;
|
||||
_events.Add("save-gameplay");
|
||||
if (RemainingGameplaySaveFailures > 0)
|
||||
{
|
||||
RemainingGameplaySaveFailures--;
|
||||
throw new IOException("gameplay persistence failed");
|
||||
}
|
||||
if (ThrowOnGameplaySave)
|
||||
throw new IOException("gameplay persistence failed");
|
||||
GameplayValue = gameplay;
|
||||
}
|
||||
|
||||
public void SaveChat(ChatSettings chat)
|
||||
{
|
||||
ChatSaves++;
|
||||
|
|
@ -1532,27 +1113,4 @@ public sealed class RuntimeSettingsControllerTests
|
|||
return _refreshRate is > 0;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NullKeyboardSource : IKeyboardSource
|
||||
{
|
||||
#pragma warning disable CS0067
|
||||
public event Action<Key, ModifierMask>? KeyDown;
|
||||
public event Action<Key, ModifierMask>? KeyUp;
|
||||
#pragma warning restore CS0067
|
||||
public bool IsHeld(Key key) => false;
|
||||
public ModifierMask CurrentModifiers => ModifierMask.None;
|
||||
}
|
||||
|
||||
private sealed class NullMouseSource : IMouseSource
|
||||
{
|
||||
#pragma warning disable CS0067
|
||||
public event Action<MouseButton, ModifierMask>? MouseDown;
|
||||
public event Action<MouseButton, ModifierMask>? MouseUp;
|
||||
public event Action<float, float>? MouseMove;
|
||||
public event Action<float>? Scroll;
|
||||
#pragma warning restore CS0067
|
||||
public bool IsHeld(MouseButton button) => false;
|
||||
public bool WantCaptureMouse => false;
|
||||
public bool WantCaptureKeyboard => false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ namespace AcDream.UI.Abstractions.Tests.Input;
|
|||
|
||||
/// <summary>
|
||||
/// K.3: <see cref="InputDispatcher.BeginCapture"/> is the modal-rebind
|
||||
/// hook used by <c>SettingsPanel</c>. While capture is active, the next
|
||||
/// hook used by Campaign OP slice OP8's <c>KeyboardConfigController</c>
|
||||
/// (the OP9-retired ImGui-era <c>SettingsPanel</c> used it before that).
|
||||
/// While capture is active, the next
|
||||
/// non-modifier chord is reported via the supplied callback and the
|
||||
/// dispatcher does NOT fire normal action events for that chord. Esc
|
||||
/// cancels capture (callback receives a sentinel <c>default</c> chord).
|
||||
|
|
|
|||
|
|
@ -1,52 +0,0 @@
|
|||
using AcDream.UI.Abstractions.Panels.Settings;
|
||||
|
||||
namespace AcDream.UI.Abstractions.Tests.Panels.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// L.0: <see cref="GameplaySettings"/> default-pin tests + value-equality
|
||||
/// guarantees. Defaults are intentionally NOT bit-exact to retail's
|
||||
/// <c>0x50C4A54A</c> mask — see GameplaySettings remarks for rationale.
|
||||
/// </summary>
|
||||
public sealed class GameplaySettingsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Default_values_are_typical_user_friendly()
|
||||
{
|
||||
// These defaults are reviewed in the L.0 brainstorm — typical-user
|
||||
// starting point, not retail-bitmask. A change to any of these
|
||||
// should be a deliberate decision, not a drive-by.
|
||||
var d = GameplaySettings.Default;
|
||||
Assert.True(d.ToggleRun);
|
||||
Assert.False(d.AdvancedCombatUI);
|
||||
Assert.True(d.ShowTooltips);
|
||||
Assert.True(d.VividTargetingIndicator); // retail default mask includes 0x00008000
|
||||
Assert.False(d.SideBySideVitals);
|
||||
Assert.True(d.CoordinatesOnRadar); // retail default mask includes 0x00400000
|
||||
Assert.True(d.SpellDuration);
|
||||
Assert.True(d.AllowGive);
|
||||
Assert.True(d.ShowHelm);
|
||||
Assert.True(d.ShowCloak);
|
||||
Assert.False(d.LockUI);
|
||||
Assert.False(d.UseMouseTurning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equality_is_value_based()
|
||||
{
|
||||
var a = GameplaySettings.Default;
|
||||
var b = GameplaySettings.Default with { ToggleRun = false };
|
||||
var c = GameplaySettings.Default with { ToggleRun = false };
|
||||
Assert.NotEqual(a, b);
|
||||
Assert.Equal(b, c);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void With_expression_clones_one_field()
|
||||
{
|
||||
var d = GameplaySettings.Default with { LockUI = true };
|
||||
Assert.True(d.LockUI);
|
||||
// Other fields untouched.
|
||||
Assert.Equal(GameplaySettings.Default.ToggleRun, d.ToggleRun);
|
||||
Assert.Equal(GameplaySettings.Default.ShowHelm, d.ShowHelm);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,569 +0,0 @@
|
|||
using System.Linq;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
using AcDream.UI.Abstractions.Panels.Settings;
|
||||
using AcDream.UI.Abstractions.Tests.Input;
|
||||
using Silk.NET.Input;
|
||||
|
||||
namespace AcDream.UI.Abstractions.Tests.Panels.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// K.3: <see cref="SettingsPanel"/> renders the rebind UI on top of
|
||||
/// <see cref="SettingsVM"/>. These tests use <see cref="FakePanelRenderer"/>
|
||||
/// to assert the panel emits the expected widget calls — top action
|
||||
/// buttons, section headers, conflict prompt when one is pending, and
|
||||
/// the "Rebind" button forwarding to the VM.
|
||||
/// </summary>
|
||||
public sealed class SettingsPanelTests
|
||||
{
|
||||
private sealed class NullBus : ICommandBus
|
||||
{
|
||||
public void Publish<T>(T command) where T : notnull { }
|
||||
}
|
||||
|
||||
private static (SettingsPanel panel, SettingsVM vm, FakeKeyboardSource kb, InputDispatcher dispatcher)
|
||||
Build()
|
||||
{
|
||||
var kb = new FakeKeyboardSource();
|
||||
var mouse = new FakeMouseSource();
|
||||
var persisted = new KeyBindings();
|
||||
persisted.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward));
|
||||
persisted.Add(new Binding(new KeyChord(Key.A, ModifierMask.None), InputAction.MovementTurnLeft));
|
||||
var dispatcher = InputDispatcher.CreateDetached(kb, mouse, persisted);
|
||||
dispatcher.Attach();
|
||||
var vm = new SettingsVM(
|
||||
persisted, dispatcher, _ => { },
|
||||
DisplaySettings.Default, _ => { },
|
||||
AudioSettings.Default, _ => { },
|
||||
GameplaySettings.Default, _ => { },
|
||||
ChatSettings.Default, _ => { },
|
||||
CharacterSettings.Default, _ => { });
|
||||
var panel = new SettingsPanel(vm);
|
||||
return (panel, vm, kb, dispatcher);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_emits_Save_Cancel_ResetAll_buttons_at_top()
|
||||
{
|
||||
var (panel, _, _, _) = Build();
|
||||
var r = new FakePanelRenderer();
|
||||
|
||||
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
||||
|
||||
var buttonLabels = r.Calls.Where(c => c.Method == "Button")
|
||||
.Select(c => (string)c.Args[0]!).ToList();
|
||||
Assert.Contains(buttonLabels, l => l == "Save changes");
|
||||
Assert.Contains(buttonLabels, l => l == "Cancel changes");
|
||||
Assert.Contains(buttonLabels, l => l == "Reset all to retail defaults");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_emits_section_headers_for_each_category()
|
||||
{
|
||||
var (panel, _, _, _) = Build();
|
||||
var r = new FakePanelRenderer { CollapsingHeaderNextReturn = false };
|
||||
|
||||
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
||||
|
||||
var headers = r.Calls.Where(c => c.Method == "CollapsingHeader")
|
||||
.Select(c => (string)c.Args[0]!).ToList();
|
||||
Assert.Contains("Movement", headers);
|
||||
Assert.Contains("Postures", headers);
|
||||
Assert.Contains("Camera", headers);
|
||||
Assert.Contains("Combat", headers);
|
||||
Assert.Contains("UI panels", headers);
|
||||
Assert.Contains("Chat", headers);
|
||||
Assert.Contains("Hotbar", headers);
|
||||
Assert.Contains("Emotes", headers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_shows_unbound_for_actions_with_no_draft_bindings()
|
||||
{
|
||||
var (panel, _, _, _) = Build();
|
||||
var r = new FakePanelRenderer { CollapsingHeaderNextReturn = true };
|
||||
|
||||
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
||||
|
||||
// The minimal Build() table doesn't bind MovementBackup → expect "(unbound)"
|
||||
// text somewhere in the call stream.
|
||||
var texts = r.Calls.Where(c => c.Method == "Text")
|
||||
.Select(c => (string)c.Args[0]!).ToList();
|
||||
Assert.Contains(texts, t => t.Contains("(unbound)"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clicking_Rebind_button_calls_BeginRebind_on_VM()
|
||||
{
|
||||
var (panel, vm, _, dispatcher) = Build();
|
||||
// First render — capture the rebind-button labels generated for
|
||||
// bound actions. The panel uses "Rebind##{action}" so each action
|
||||
// has a unique imgui ID.
|
||||
var r1 = new FakePanelRenderer { CollapsingHeaderNextReturn = true };
|
||||
panel.Render(new PanelContext(0.016f, new NullBus()), r1);
|
||||
var rebindLabels = r1.Calls.Where(c => c.Method == "Button"
|
||||
&& ((string)c.Args[0]!).StartsWith("Rebind##"))
|
||||
.Select(c => (string)c.Args[0]!).ToList();
|
||||
Assert.NotEmpty(rebindLabels);
|
||||
|
||||
// Second render — simulate clicking the first Rebind button by
|
||||
// making the renderer return true for every Button call. Since
|
||||
// we click the first Rebind button it will invoke BeginRebind on
|
||||
// some bound action.
|
||||
var r2 = new FakePanelRenderer { CollapsingHeaderNextReturn = true, ButtonNextReturn = true };
|
||||
panel.Render(new PanelContext(0.016f, new NullBus()), r2);
|
||||
|
||||
// Either RebindInProgress is set (some action) OR HasUnsavedChanges
|
||||
// changed (Save/Cancel/Reset clicked instead). Since ButtonNextReturn
|
||||
// returns true for ALL buttons, multiple actions fire on this single
|
||||
// render — the more relevant assertion is that the dispatcher entered
|
||||
// capture mode at SOME point during the render. (ButtonNextReturn is
|
||||
// a single shared return value across all buttons so multiple may
|
||||
// have "clicked"; the panel's logic must still route through the VM.)
|
||||
Assert.True(dispatcher.IsCapturing || vm.PendingConflict is not null
|
||||
|| vm.RebindInProgress is not null
|
||||
|| true /* Save/Cancel/Reset may have intervened first; this test
|
||||
only proves the renderer-button path doesn't NRE */);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_with_PendingConflict_displays_conflict_prompt_buttons()
|
||||
{
|
||||
var (panel, vm, kb, _) = Build();
|
||||
// Force a conflict by binding MovementForward → A (already
|
||||
// MovementTurnLeft).
|
||||
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
|
||||
vm.BeginRebind(InputAction.MovementForward, original);
|
||||
kb.EmitKeyDown(Key.A, ModifierMask.None);
|
||||
Assert.NotNull(vm.PendingConflict);
|
||||
|
||||
var r = new FakePanelRenderer { CollapsingHeaderNextReturn = true };
|
||||
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
||||
|
||||
var buttonLabels = r.Calls.Where(c => c.Method == "Button")
|
||||
.Select(c => (string)c.Args[0]!).ToList();
|
||||
Assert.Contains(buttonLabels, l => l == "Yes — Reassign");
|
||||
Assert.Contains(buttonLabels, l => l == "No — Keep existing");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hidden_panel_short_circuits_when_Begin_returns_false()
|
||||
{
|
||||
var (panel, _, _, _) = Build();
|
||||
var r = new FakePanelRenderer { BeginReturns = false };
|
||||
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
||||
|
||||
// Begin + End balanced even when Begin returned false.
|
||||
Assert.Contains(r.Calls, c => c.Method == "Begin");
|
||||
Assert.Contains(r.Calls, c => c.Method == "End");
|
||||
// Section headers should NOT have been emitted.
|
||||
Assert.DoesNotContain(r.Calls, c => c.Method == "CollapsingHeader");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsVisible_defaults_false()
|
||||
{
|
||||
var (panel, _, _, _) = Build();
|
||||
Assert.False(panel.IsVisible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Id_is_acdream_settings()
|
||||
{
|
||||
var (panel, _, _, _) = Build();
|
||||
Assert.Equal("acdream.settings", panel.Id);
|
||||
}
|
||||
|
||||
// -- Tabbed shell -----------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Render_opens_tab_bar_with_six_tab_items()
|
||||
{
|
||||
var (panel, _, _, _) = Build();
|
||||
var r = new FakePanelRenderer();
|
||||
|
||||
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
||||
|
||||
// BeginTabBar exactly once, EndTabBar exactly once.
|
||||
Assert.Single(r.Calls, c => c.Method == "BeginTabBar");
|
||||
Assert.Single(r.Calls, c => c.Method == "EndTabBar");
|
||||
|
||||
// The six tab labels approved in the design brainstorm.
|
||||
var tabLabels = r.Calls.Where(c => c.Method == "BeginTabItem")
|
||||
.Select(c => (string)c.Args[0]!).ToList();
|
||||
Assert.Equal(
|
||||
new[] { "Keybinds", "Display", "Audio", "Gameplay", "Chat", "Character" },
|
||||
tabLabels);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Keybinds_tab_renders_section_headers_when_active()
|
||||
{
|
||||
var (panel, _, _, _) = Build();
|
||||
// Default ActiveTabLabel = null → FakePanelRenderer treats the
|
||||
// first tab item ("Keybinds") as active.
|
||||
var r = new FakePanelRenderer { CollapsingHeaderNextReturn = false };
|
||||
|
||||
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
||||
|
||||
var headers = r.Calls.Where(c => c.Method == "CollapsingHeader")
|
||||
.Select(c => (string)c.Args[0]!).ToList();
|
||||
Assert.Contains("Movement", headers);
|
||||
Assert.Contains("Hotbar", headers);
|
||||
Assert.Contains("Emotes", headers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inactive_tabs_do_not_render_keybind_section_headers()
|
||||
{
|
||||
var (panel, _, _, _) = Build();
|
||||
// Force "Display" to be the active tab — the Keybinds content
|
||||
// must NOT render.
|
||||
var r = new FakePanelRenderer { ActiveTabLabel = "Display" };
|
||||
|
||||
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
||||
|
||||
var headers = r.Calls.Where(c => c.Method == "CollapsingHeader")
|
||||
.Select(c => (string)c.Args[0]!).ToList();
|
||||
Assert.DoesNotContain("Movement", headers);
|
||||
Assert.DoesNotContain("Hotbar", headers);
|
||||
}
|
||||
|
||||
// -- Character tab content -------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Character_tab_when_active_renders_channel_combo_plus_checkboxes()
|
||||
{
|
||||
var (panel, _, _, _) = Build();
|
||||
var r = new FakePanelRenderer { ActiveTabLabel = "Character" };
|
||||
|
||||
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
||||
|
||||
var combos = r.Calls.Where(c => c.Method == "Combo")
|
||||
.Select(c => (string)c.Args[0]!).ToList();
|
||||
Assert.Contains("Default chat channel", combos);
|
||||
|
||||
var checks = r.Calls.Where(c => c.Method == "Checkbox")
|
||||
.Select(c => (string)c.Args[0]!).ToList();
|
||||
Assert.Contains(checks, l => l.StartsWith("Auto-attack"));
|
||||
Assert.Contains(checks, l => l.StartsWith("Confirm before salvaging"));
|
||||
Assert.Contains(checks, l => l.StartsWith("Show pickup messages"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Character_tab_does_not_render_when_a_different_tab_is_active()
|
||||
{
|
||||
var (panel, _, _, _) = Build();
|
||||
var r = new FakePanelRenderer { ActiveTabLabel = "Display" };
|
||||
|
||||
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
||||
|
||||
var combos = r.Calls.Where(c => c.Method == "Combo")
|
||||
.Select(c => (string)c.Args[0]!).ToList();
|
||||
Assert.DoesNotContain("Default chat channel", combos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Character_tab_channel_combo_uses_AvailableChannels_list()
|
||||
{
|
||||
var (panel, _, _, _) = Build();
|
||||
var r = new FakePanelRenderer { ActiveTabLabel = "Character" };
|
||||
|
||||
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
||||
|
||||
var ch = r.Calls.First(c => c.Method == "Combo" && (string)c.Args[0]! == "Default chat channel");
|
||||
var items = (string[])ch.Args[2]!;
|
||||
Assert.Contains("Local", items);
|
||||
Assert.Contains("Allegiance", items);
|
||||
Assert.Contains("Fellowship", items);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void All_six_tabs_are_now_implemented_no_placeholder_text_remains()
|
||||
{
|
||||
// After the L.0 build order finishes, no tab should render the
|
||||
// "Coming soon" placeholder line. If a future commit re-adds a
|
||||
// placeholder tab without updating this test, it will fail.
|
||||
var (panel, _, _, _) = Build();
|
||||
|
||||
foreach (var tabLabel in new[] { "Keybinds", "Display", "Audio", "Gameplay", "Chat", "Character" })
|
||||
{
|
||||
var r = new FakePanelRenderer { ActiveTabLabel = tabLabel };
|
||||
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
||||
|
||||
var wrapped = r.Calls.Where(c => c.Method == "TextWrapped")
|
||||
.Select(c => (string)c.Args[0]!).ToList();
|
||||
Assert.DoesNotContain(wrapped, t => t.Contains("coming soon"));
|
||||
}
|
||||
}
|
||||
|
||||
// -- Display tab content ---------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Display_tab_when_active_renders_resolution_combo_plus_sliders()
|
||||
{
|
||||
var (panel, _, _, _) = Build();
|
||||
var r = new FakePanelRenderer { ActiveTabLabel = "Display" };
|
||||
|
||||
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
||||
|
||||
var combos = r.Calls.Where(c => c.Method == "Combo").Select(c => (string)c.Args[0]!).ToList();
|
||||
var checks = r.Calls.Where(c => c.Method == "Checkbox").Select(c => (string)c.Args[0]!).ToList();
|
||||
var sliders = r.Calls.Where(c => c.Method == "SliderFloat").Select(c => (string)c.Args[0]!).ToList();
|
||||
|
||||
Assert.Contains("Resolution", combos);
|
||||
Assert.Contains("Fullscreen", checks);
|
||||
Assert.Contains("V-Sync", checks);
|
||||
Assert.Contains("Show FPS", checks);
|
||||
Assert.Contains("Field of View", sliders);
|
||||
Assert.Contains("Gamma", sliders);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Display_tab_does_not_render_when_a_different_tab_is_active()
|
||||
{
|
||||
var (panel, _, _, _) = Build();
|
||||
var r = new FakePanelRenderer { ActiveTabLabel = "Audio" };
|
||||
|
||||
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
||||
|
||||
var combos = r.Calls.Where(c => c.Method == "Combo").Select(c => (string)c.Args[0]!).ToList();
|
||||
Assert.DoesNotContain("Resolution", combos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Display_tab_resolution_combo_uses_AvailableResolutions_list()
|
||||
{
|
||||
var (panel, _, _, _) = Build();
|
||||
var r = new FakePanelRenderer { ActiveTabLabel = "Display" };
|
||||
|
||||
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
||||
|
||||
var resCall = r.Calls.First(c => c.Method == "Combo" && (string)c.Args[0]! == "Resolution");
|
||||
var items = (string[])resCall.Args[2]!;
|
||||
Assert.Contains("1920x1080", items);
|
||||
Assert.Contains("3840x2160", items);
|
||||
}
|
||||
|
||||
// -- Audio tab content -----------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Audio_tab_when_active_renders_implemented_volume_sliders()
|
||||
{
|
||||
// The rule is "no slider that does nothing", and Campaign A changed
|
||||
// what qualifies. Ambient is now surfaced because slice A5 gave it a
|
||||
// region ambient system to drive. Music is gone entirely — retail has
|
||||
// no music system, so slice A6 deleted the field rather than leaving a
|
||||
// knob that could never do anything.
|
||||
var (panel, _, _, _) = Build();
|
||||
var r = new FakePanelRenderer { ActiveTabLabel = "Audio" };
|
||||
|
||||
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
||||
|
||||
var sliders = r.Calls.Where(c => c.Method == "SliderFloat")
|
||||
.Select(c => (string)c.Args[0]!).ToList();
|
||||
Assert.Contains("Master", sliders);
|
||||
Assert.Contains("SFX", sliders);
|
||||
Assert.Contains("Ambient", sliders);
|
||||
Assert.DoesNotContain("Music", sliders);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Audio_tab_does_not_render_when_a_different_tab_is_active()
|
||||
{
|
||||
var (panel, _, _, _) = Build();
|
||||
var r = new FakePanelRenderer { ActiveTabLabel = "Display" };
|
||||
|
||||
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
||||
|
||||
var sliders = r.Calls.Where(c => c.Method == "SliderFloat")
|
||||
.Select(c => (string)c.Args[0]!).ToList();
|
||||
Assert.DoesNotContain("Master", sliders);
|
||||
Assert.DoesNotContain("Music", sliders);
|
||||
}
|
||||
|
||||
// -- Gameplay tab content --------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Gameplay_tab_when_active_renders_expected_checkboxes()
|
||||
{
|
||||
var (panel, _, _, _) = Build();
|
||||
var r = new FakePanelRenderer { ActiveTabLabel = "Gameplay" };
|
||||
|
||||
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
||||
|
||||
var checks = r.Calls.Where(c => c.Method == "Checkbox")
|
||||
.Select(c => (string)c.Args[0]!).ToList();
|
||||
// Spot check the major retail-named toggles. Don't assert exact
|
||||
// count — adding new toggles shouldn't break this test.
|
||||
//
|
||||
// OP4 review-fix round (2026-08-11, MUST-FIX 3 / blast M2):
|
||||
// "Auto-target on attack" / "Auto-repeat attacks" were retired
|
||||
// from this panel — those two options (plus "Keep combat target
|
||||
// in view") now read/write the canonical server bit through
|
||||
// CombatUiController, not the client-local GameplaySettings
|
||||
// record this dat-free panel edits.
|
||||
Assert.DoesNotContain("Auto-target on attack", checks);
|
||||
Assert.DoesNotContain("Auto-repeat attacks", checks);
|
||||
Assert.Contains("Run mode is toggle (vs hold)", checks);
|
||||
Assert.Contains("Show item tooltips", checks);
|
||||
Assert.Contains("Show helm on character", checks);
|
||||
Assert.Contains("Show cloak on character", checks);
|
||||
Assert.Contains("Lock UI (disable panel drag/resize)", checks);
|
||||
Assert.Contains("Use mouse turning", checks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gameplay_tab_does_not_render_when_a_different_tab_is_active()
|
||||
{
|
||||
var (panel, _, _, _) = Build();
|
||||
var r = new FakePanelRenderer { ActiveTabLabel = "Display" };
|
||||
|
||||
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
||||
|
||||
var checks = r.Calls.Where(c => c.Method == "Checkbox")
|
||||
.Select(c => (string)c.Args[0]!).ToList();
|
||||
Assert.DoesNotContain("Run mode is toggle (vs hold)", checks);
|
||||
Assert.DoesNotContain("Lock UI (disable panel drag/resize)", checks);
|
||||
}
|
||||
|
||||
// -- Chat tab content ------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Chat_tab_when_active_renders_channel_filter_checkboxes_and_font_slider()
|
||||
{
|
||||
var (panel, _, _, _) = Build();
|
||||
var r = new FakePanelRenderer { ActiveTabLabel = "Chat" };
|
||||
|
||||
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
||||
|
||||
var checks = r.Calls.Where(c => c.Method == "Checkbox")
|
||||
.Select(c => (string)c.Args[0]!).ToList();
|
||||
Assert.Contains("General", checks);
|
||||
Assert.Contains("Trade", checks);
|
||||
Assert.Contains("LFG (looking for group)", checks);
|
||||
Assert.Contains("Roleplay", checks);
|
||||
Assert.Contains("Society (CD / EW / RB)", checks);
|
||||
Assert.Contains("Show timestamps", checks);
|
||||
Assert.Contains("Filter profanity", checks);
|
||||
Assert.Contains("Appear offline (hide from /who)", checks);
|
||||
|
||||
var sliders = r.Calls.Where(c => c.Method == "SliderFloat")
|
||||
.Select(c => (string)c.Args[0]!).ToList();
|
||||
Assert.Contains("Font size (pt)", sliders);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Chat_tab_does_not_render_when_a_different_tab_is_active()
|
||||
{
|
||||
var (panel, _, _, _) = Build();
|
||||
var r = new FakePanelRenderer { ActiveTabLabel = "Display" };
|
||||
|
||||
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
||||
|
||||
var checks = r.Calls.Where(c => c.Method == "Checkbox")
|
||||
.Select(c => (string)c.Args[0]!).ToList();
|
||||
// The tab labels "General", "Trade" etc only appear inside the
|
||||
// Chat tab. Confirm none of them rendered.
|
||||
Assert.DoesNotContain("General", checks);
|
||||
Assert.DoesNotContain("Trade", checks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Audio_sliders_are_clamped_to_zero_one_range()
|
||||
{
|
||||
var (panel, _, _, _) = Build();
|
||||
var r = new FakePanelRenderer { ActiveTabLabel = "Audio" };
|
||||
|
||||
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
||||
|
||||
var masterCall = r.Calls.First(c => c.Method == "SliderFloat" && (string)c.Args[0]! == "Master");
|
||||
Assert.Equal(0f, (float)masterCall.Args[2]!);
|
||||
Assert.Equal(1f, (float)masterCall.Args[3]!);
|
||||
}
|
||||
|
||||
// -- Campaign CH slice CH6c: chat tab opacity sliders -----------------
|
||||
|
||||
[Fact]
|
||||
public void Chat_tab_when_active_renders_two_linked_opacity_sliders()
|
||||
{
|
||||
var (panel, vm, _, _) = Build();
|
||||
var r = new FakePanelRenderer { ActiveTabLabel = "Chat" };
|
||||
|
||||
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
||||
|
||||
var sliders = r.Calls.Where(c => c.Method == "SliderFloat")
|
||||
.Select(c => (string)c.Args[0]!).ToList();
|
||||
Assert.Contains("Background opacity (unfocused)", sliders);
|
||||
Assert.Contains("Active opacity (typing / focused)", sliders);
|
||||
|
||||
var bgCall = r.Calls.First(
|
||||
c => c.Method == "SliderFloat" && (string)c.Args[0]! == "Background opacity (unfocused)");
|
||||
Assert.Equal(vm.ChatDraft.DefaultOpacity, (float)bgCall.Args[1]!);
|
||||
Assert.Equal(0f, (float)bgCall.Args[2]!);
|
||||
Assert.Equal(1f, (float)bgCall.Args[3]!);
|
||||
|
||||
var activeCall = r.Calls.First(
|
||||
c => c.Method == "SliderFloat" && (string)c.Args[0]! == "Active opacity (typing / focused)");
|
||||
Assert.Equal(vm.ChatDraft.ActiveOpacity, (float)activeCall.Args[1]!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Chat_tab_opacity_sliders_do_not_render_when_a_different_tab_is_active()
|
||||
{
|
||||
var (panel, _, _, _) = Build();
|
||||
var r = new FakePanelRenderer { ActiveTabLabel = "Display" };
|
||||
|
||||
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
||||
|
||||
var sliders = r.Calls.Where(c => c.Method == "SliderFloat")
|
||||
.Select(c => (string)c.Args[0]!).ToList();
|
||||
Assert.DoesNotContain("Background opacity (unfocused)", sliders);
|
||||
Assert.DoesNotContain("Active opacity (typing / focused)", sliders);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Chat_tab_dragging_active_opacity_below_background_drags_background_down_in_draft()
|
||||
{
|
||||
// FakePanelRenderer applies ONE injected value to every SliderFloat call
|
||||
// in the same Render pass and each branch's `_vm.SetChat` starts from the
|
||||
// ORIGINAL pre-render draft — so with SliderFloatNextReturn set, the
|
||||
// LAST-rendered opacity slider ("Active", rendered after "Background")
|
||||
// determines the final draft. That exercises ChatOpacityLink.SetActive's
|
||||
// drag-background-down path end-to-end through the real panel code,
|
||||
// starting from ChatSettings.Default (DefaultOpacity=1.0, ActiveOpacity=1.0
|
||||
// as of the CH6c review fix).
|
||||
var (panel, vm, _, _) = Build();
|
||||
var r = new FakePanelRenderer
|
||||
{
|
||||
ActiveTabLabel = "Chat",
|
||||
SliderFloatNextReturn = true,
|
||||
SliderFloatNextValue = 0.2f,
|
||||
};
|
||||
|
||||
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
||||
|
||||
Assert.Equal(0.2f, vm.ChatDraft.DefaultOpacity);
|
||||
Assert.Equal(0.2f, vm.ChatDraft.ActiveOpacity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_Cancel_buttons_render_outside_the_tab_bar()
|
||||
{
|
||||
// The global Save / Cancel / Reset-all row must come BEFORE
|
||||
// BeginTabBar so it stays visible on every tab. Any change that
|
||||
// accidentally moves the buttons inside a tab item should fail
|
||||
// here.
|
||||
var (panel, _, _, _) = Build();
|
||||
var r = new FakePanelRenderer();
|
||||
|
||||
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
||||
|
||||
int saveIdx = r.Calls.FindIndex(c => c.Method == "Button"
|
||||
&& (string)c.Args[0]! == "Save changes");
|
||||
int tabBarIdx = r.Calls.FindIndex(c => c.Method == "BeginTabBar");
|
||||
Assert.True(saveIdx >= 0);
|
||||
Assert.True(tabBarIdx >= 0);
|
||||
Assert.True(saveIdx < tabBarIdx,
|
||||
$"Save button (index {saveIdx}) must render before BeginTabBar (index {tabBarIdx}).");
|
||||
}
|
||||
}
|
||||
|
|
@ -206,64 +206,58 @@ public sealed class SettingsStoreTests : System.IDisposable
|
|||
Assert.True(store.LoadDisplay().ShowFps);
|
||||
}
|
||||
|
||||
// -- Gameplay section round-trip --------------------------------------
|
||||
// -- Gameplay section: OP9 migration honesty ---------------------------
|
||||
//
|
||||
// Campaign OP slice OP9 (docs/plans/2026-08-10-options-panel-campaign.md
|
||||
// §OP9) retired the Gameplay tab's client-local GameplaySettings record
|
||||
// and Load/SaveGameplay outright — all 13 fields were already re-pointed
|
||||
// to the server-bit seam (RuntimeCharacterOptionsState) at OP4. An
|
||||
// EXISTING settings.json written by a pre-OP9 build can still carry a
|
||||
// "gameplay" top-level block; these two tests pin that SettingsStore
|
||||
// tolerates it (no Load* method reads it, so it neither throws nor is
|
||||
// silently dropped) rather than requiring a migration step.
|
||||
|
||||
[Fact]
|
||||
public void LoadGameplay_returns_defaults_when_file_is_missing()
|
||||
{
|
||||
var store = new SettingsStore(_tempPath);
|
||||
Assert.Equal(GameplaySettings.Default, store.LoadGameplay());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveGameplay_then_LoadGameplay_round_trips_all_fields()
|
||||
{
|
||||
var store = new SettingsStore(_tempPath);
|
||||
var original = GameplaySettings.Default with
|
||||
{
|
||||
AdvancedCombatUI = true,
|
||||
ShowHelm = false,
|
||||
LockUI = true,
|
||||
UseMouseTurning = true,
|
||||
AcceptLootPermits = false,
|
||||
};
|
||||
|
||||
store.SaveGameplay(original);
|
||||
var loaded = store.LoadGameplay();
|
||||
|
||||
Assert.Equal(original, loaded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadGameplay_falls_back_per_field_when_keys_missing()
|
||||
public void LeftoverGameplaySection_FromAnOlderSettingsJson_DoesNotBreakOtherLoads()
|
||||
{
|
||||
File.WriteAllText(_tempPath, """
|
||||
{
|
||||
"version": 1,
|
||||
"gameplay": { "lockUI": true }
|
||||
"version": 2,
|
||||
"display": { "resolution": "1366x768" },
|
||||
"gameplay": { "lockUI": true, "toggleRun": false }
|
||||
}
|
||||
""");
|
||||
var store = new SettingsStore(_tempPath);
|
||||
|
||||
var loaded = store.LoadGameplay();
|
||||
|
||||
Assert.True(loaded.LockUI);
|
||||
Assert.Equal(GameplaySettings.Default.ToggleRun, loaded.ToggleRun);
|
||||
Assert.Equal(GameplaySettings.Default.ShowHelm, loaded.ShowHelm);
|
||||
Assert.Equal("1366x768", store.LoadDisplay().Resolution);
|
||||
Assert.Equal(AudioSettings.Default, store.LoadAudio());
|
||||
Assert.Equal(ChatSettings.Default, store.LoadChat());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void All_three_sections_coexist_in_one_settings_json()
|
||||
public void LeftoverGameplaySection_SurvivesAnUnrelatedSave()
|
||||
{
|
||||
// The generic SaveSection round-trip preservation (raw-JSON-text
|
||||
// carry-forward of unknown top-level keys, exercised elsewhere by
|
||||
// SaveDisplay_preserves_unknown_top_level_keys) is exactly what
|
||||
// keeps an orphaned "gameplay" block alive forever once nothing
|
||||
// reads or writes it — this pins that mechanism for the specific
|
||||
// section OP9 just retired.
|
||||
File.WriteAllText(_tempPath, """
|
||||
{
|
||||
"version": 2,
|
||||
"gameplay": { "lockUI": true, "showHelm": false }
|
||||
}
|
||||
""");
|
||||
var store = new SettingsStore(_tempPath);
|
||||
store.SaveDisplay(DisplaySettings.Default with { Resolution = "2560x1440" });
|
||||
store.SaveAudio(AudioSettings.Default with { Master = 0.5f });
|
||||
store.SaveGameplay(GameplaySettings.Default with { LockUI = true });
|
||||
|
||||
// All three load correctly from the same file.
|
||||
Assert.Equal("2560x1440", store.LoadDisplay().Resolution);
|
||||
Assert.Equal(0.5f, store.LoadAudio().Master);
|
||||
Assert.True(store.LoadGameplay().LockUI);
|
||||
store.SaveDisplay(DisplaySettings.Default with { Resolution = "1920x1080" });
|
||||
|
||||
var raw = File.ReadAllText(_tempPath);
|
||||
Assert.Contains("\"gameplay\"", raw);
|
||||
Assert.Contains("\"lockUI\": true", raw);
|
||||
Assert.Contains("\"showHelm\": false", raw);
|
||||
Assert.Contains("1920x1080", raw);
|
||||
}
|
||||
|
||||
// -- Chat section round-trip ------------------------------------------
|
||||
|
|
@ -387,17 +381,15 @@ public sealed class SettingsStoreTests : System.IDisposable
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void All_four_sections_coexist_in_one_settings_json()
|
||||
public void All_three_sections_coexist_in_one_settings_json()
|
||||
{
|
||||
var store = new SettingsStore(_tempPath);
|
||||
store.SaveDisplay(DisplaySettings.Default with { Resolution = "2560x1440" });
|
||||
store.SaveAudio(AudioSettings.Default with { Master = 0.5f });
|
||||
store.SaveGameplay(GameplaySettings.Default with { LockUI = true });
|
||||
store.SaveChat(ChatSettings.Default with { HearTradeChat = false, FontSize = 14f });
|
||||
|
||||
Assert.Equal("2560x1440", store.LoadDisplay().Resolution);
|
||||
Assert.Equal(0.5f, store.LoadAudio().Master);
|
||||
Assert.True(store.LoadGameplay().LockUI);
|
||||
Assert.False(store.LoadChat().HearTradeChat);
|
||||
Assert.Equal(14f, store.LoadChat().FontSize);
|
||||
}
|
||||
|
|
@ -468,19 +460,17 @@ public sealed class SettingsStoreTests : System.IDisposable
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void All_five_sections_coexist_in_one_settings_json()
|
||||
public void All_four_sections_coexist_in_one_settings_json()
|
||||
{
|
||||
var store = new SettingsStore(_tempPath);
|
||||
store.SaveDisplay(DisplaySettings.Default with { Resolution = "2560x1440" });
|
||||
store.SaveAudio(AudioSettings.Default with { Master = 0.5f });
|
||||
store.SaveGameplay(GameplaySettings.Default with { LockUI = true });
|
||||
store.SaveChat(ChatSettings.Default with { HearTradeChat = false });
|
||||
store.SaveCharacter("+Acdream",
|
||||
CharacterSettings.Default with { DefaultChatChannel = "Fellowship" });
|
||||
|
||||
Assert.Equal("2560x1440", store.LoadDisplay().Resolution);
|
||||
Assert.Equal(0.5f, store.LoadAudio().Master);
|
||||
Assert.True(store.LoadGameplay().LockUI);
|
||||
Assert.False(store.LoadChat().HearTradeChat);
|
||||
Assert.Equal("Fellowship", store.LoadCharacter("+Acdream").DefaultChatChannel);
|
||||
}
|
||||
|
|
@ -489,7 +479,7 @@ public sealed class SettingsStoreTests : System.IDisposable
|
|||
public void WindowPositions_RoundTripPerCharacterAndPreserveSettings()
|
||||
{
|
||||
var store = new SettingsStore(_tempPath);
|
||||
store.SaveGameplay(GameplaySettings.Default with { LockUI = true });
|
||||
store.SaveDisplay(DisplaySettings.Default with { Resolution = "2560x1440" });
|
||||
store.SaveWindowPosition("Alice", "radar", new UiWindowPosition(321.5f, 18f));
|
||||
store.SaveWindowPosition("Bob", "radar", new UiWindowPosition(44f, 55f));
|
||||
|
||||
|
|
@ -498,14 +488,14 @@ public sealed class SettingsStoreTests : System.IDisposable
|
|||
Assert.Equal(new UiWindowPosition(44f, 55f),
|
||||
store.LoadWindowPosition("Bob", "radar"));
|
||||
Assert.Null(store.LoadWindowPosition("Alice", "inventory"));
|
||||
Assert.True(store.LoadGameplay().LockUI);
|
||||
Assert.Equal("2560x1440", store.LoadDisplay().Resolution);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WindowLayouts_RoundTripPerCharacterResolutionAndPreserveSettings()
|
||||
{
|
||||
var store = new SettingsStore(_tempPath);
|
||||
store.SaveGameplay(GameplaySettings.Default with { LockUI = true });
|
||||
store.SaveDisplay(DisplaySettings.Default with { Resolution = "2560x1440" });
|
||||
var alice1080 = new UiWindowLayout(10f, 20f, 500f, 300f, true, false, true);
|
||||
var alice1440 = new UiWindowLayout(30f, 40f, 650f, 420f, false, true, false);
|
||||
var bob1080 = new UiWindowLayout(50f, 60f, 310f, 132f, true, false, false);
|
||||
|
|
@ -518,7 +508,7 @@ public sealed class SettingsStoreTests : System.IDisposable
|
|||
Assert.Equal(alice1440, store.LoadWindowLayout("Alice", "2560x1440", "chat", default));
|
||||
Assert.Equal(bob1080, store.LoadWindowLayout("Bob", "1920x1080", "toolbar", default));
|
||||
Assert.Null(store.LoadWindowLayout("Alice", "1920x1080", "inventory", default));
|
||||
Assert.True(store.LoadGameplay().LockUI);
|
||||
Assert.Equal("2560x1440", store.LoadDisplay().Resolution);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -1,682 +0,0 @@
|
|||
using System.IO;
|
||||
using System.Linq;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
using AcDream.UI.Abstractions.Panels.Settings;
|
||||
using AcDream.UI.Abstractions.Tests.Input;
|
||||
using Silk.NET.Input;
|
||||
|
||||
namespace AcDream.UI.Abstractions.Tests.Panels.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// K.3: <see cref="SettingsVM"/> owns the click-to-rebind state machine
|
||||
/// for the Settings panel. It holds a <b>draft</b> copy of the active
|
||||
/// <see cref="KeyBindings"/>; rebinds modify the draft. Save commits to
|
||||
/// the supplied callback (which writes to disk + replaces the live
|
||||
/// dispatcher's table); Cancel reverts the draft.
|
||||
/// </summary>
|
||||
public sealed class SettingsVMTests
|
||||
{
|
||||
private static (SettingsVM vm, FakeKeyboardSource kb, InputDispatcher dispatcher, KeyBindings persisted, System.Collections.Generic.List<KeyBindings> savedHistory, System.Collections.Generic.List<DisplaySettings> savedDisplayHistory, System.Collections.Generic.List<AudioSettings> savedAudioHistory, System.Collections.Generic.List<GameplaySettings> savedGameplayHistory, System.Collections.Generic.List<ChatSettings> savedChatHistory, System.Collections.Generic.List<CharacterSettings> savedCharacterHistory)
|
||||
Build(KeyBindings? persisted = null, DisplaySettings? persistedDisplay = null, AudioSettings? persistedAudio = null, GameplaySettings? persistedGameplay = null, ChatSettings? persistedChat = null, CharacterSettings? persistedCharacter = null)
|
||||
{
|
||||
persisted ??= MakeMinimalBindings();
|
||||
var kb = new FakeKeyboardSource();
|
||||
var mouse = new FakeMouseSource();
|
||||
var dispatcher = InputDispatcher.CreateDetached(kb, mouse, persisted);
|
||||
dispatcher.Attach();
|
||||
var savedHistory = new System.Collections.Generic.List<KeyBindings>();
|
||||
var savedDisplayHistory = new System.Collections.Generic.List<DisplaySettings>();
|
||||
var savedAudioHistory = new System.Collections.Generic.List<AudioSettings>();
|
||||
var savedGameplayHistory = new System.Collections.Generic.List<GameplaySettings>();
|
||||
var savedChatHistory = new System.Collections.Generic.List<ChatSettings>();
|
||||
var savedCharacterHistory = new System.Collections.Generic.List<CharacterSettings>();
|
||||
var vm = new SettingsVM(
|
||||
persisted, dispatcher,
|
||||
b => savedHistory.Add(b),
|
||||
persistedDisplay ?? DisplaySettings.Default,
|
||||
d => savedDisplayHistory.Add(d),
|
||||
persistedAudio ?? AudioSettings.Default,
|
||||
a => savedAudioHistory.Add(a),
|
||||
persistedGameplay ?? GameplaySettings.Default,
|
||||
g => savedGameplayHistory.Add(g),
|
||||
persistedChat ?? ChatSettings.Default,
|
||||
c => savedChatHistory.Add(c),
|
||||
persistedCharacter ?? CharacterSettings.Default,
|
||||
ch => savedCharacterHistory.Add(ch));
|
||||
return (vm, kb, dispatcher, persisted, savedHistory, savedDisplayHistory, savedAudioHistory, savedGameplayHistory, savedChatHistory, savedCharacterHistory);
|
||||
}
|
||||
|
||||
private static KeyBindings MakeMinimalBindings()
|
||||
{
|
||||
var b = new KeyBindings();
|
||||
b.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward));
|
||||
b.Add(new Binding(new KeyChord(Key.A, ModifierMask.None), InputAction.MovementTurnLeft));
|
||||
b.Add(new Binding(new KeyChord(Key.S, ModifierMask.None), InputAction.MovementStop));
|
||||
return b;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_clones_persisted_into_draft()
|
||||
{
|
||||
var (vm, _, _, persisted, _, _, _, _, _, _) = Build();
|
||||
Assert.Equal(persisted.All.Count, vm.Draft.All.Count);
|
||||
Assert.False(vm.HasUnsavedChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeginRebind_enters_capture_mode()
|
||||
{
|
||||
var (vm, _, dispatcher, _, _, _, _, _, _, _) = Build();
|
||||
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
|
||||
|
||||
vm.BeginRebind(InputAction.MovementForward, original);
|
||||
|
||||
Assert.True(dispatcher.IsCapturing);
|
||||
Assert.Equal(InputAction.MovementForward, vm.RebindInProgress);
|
||||
Assert.Equal(original, vm.RebindOriginal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeginRebind_then_chord_with_no_conflict_applies_rebind()
|
||||
{
|
||||
var (vm, kb, _, _, _, _, _, _, _, _) = Build();
|
||||
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
|
||||
|
||||
vm.BeginRebind(InputAction.MovementForward, original);
|
||||
// User presses Q — not bound to anything in our minimal table.
|
||||
kb.EmitKeyDown(Key.Q, ModifierMask.None);
|
||||
|
||||
Assert.Null(vm.RebindInProgress);
|
||||
Assert.Null(vm.PendingConflict);
|
||||
var binds = vm.Draft.ForAction(InputAction.MovementForward).ToList();
|
||||
Assert.Single(binds);
|
||||
Assert.Equal(new KeyChord(Key.Q, ModifierMask.None), binds[0].Chord);
|
||||
Assert.True(vm.HasUnsavedChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeginRebind_then_Escape_cancels_with_no_change()
|
||||
{
|
||||
var (vm, kb, _, _, _, _, _, _, _, _) = Build();
|
||||
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
|
||||
|
||||
vm.BeginRebind(InputAction.MovementForward, original);
|
||||
kb.EmitKeyDown(Key.Escape, ModifierMask.None);
|
||||
|
||||
Assert.Null(vm.RebindInProgress);
|
||||
Assert.Null(vm.PendingConflict);
|
||||
var binds = vm.Draft.ForAction(InputAction.MovementForward).ToList();
|
||||
Assert.Single(binds);
|
||||
Assert.Equal(new KeyChord(Key.W, ModifierMask.None), binds[0].Chord);
|
||||
Assert.False(vm.HasUnsavedChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeginRebind_with_conflict_surfaces_PendingConflict()
|
||||
{
|
||||
var (vm, kb, _, _, _, _, _, _, _, _) = Build();
|
||||
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
|
||||
|
||||
// Bind chord that conflicts with MovementTurnLeft (which has Key.A).
|
||||
vm.BeginRebind(InputAction.MovementForward, original);
|
||||
kb.EmitKeyDown(Key.A, ModifierMask.None);
|
||||
|
||||
Assert.NotNull(vm.PendingConflict);
|
||||
var c = vm.PendingConflict!.Value;
|
||||
Assert.Equal(InputAction.MovementForward, c.NewAction);
|
||||
Assert.Equal(new KeyChord(Key.A, ModifierMask.None), c.NewChord);
|
||||
Assert.Equal(InputAction.MovementTurnLeft, c.ConflictingAction);
|
||||
// Rebind has NOT been applied yet — still on W.
|
||||
var binds = vm.Draft.ForAction(InputAction.MovementForward).ToList();
|
||||
Assert.Equal(new KeyChord(Key.W, ModifierMask.None), binds[0].Chord);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveConflict_replace_true_removes_conflict_and_applies_rebind()
|
||||
{
|
||||
var (vm, kb, _, _, _, _, _, _, _, _) = Build();
|
||||
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
|
||||
|
||||
vm.BeginRebind(InputAction.MovementForward, original);
|
||||
kb.EmitKeyDown(Key.A, ModifierMask.None);
|
||||
vm.ResolveConflict(replace: true);
|
||||
|
||||
Assert.Null(vm.PendingConflict);
|
||||
Assert.Null(vm.RebindInProgress);
|
||||
// MovementForward now bound to A.
|
||||
var fwd = vm.Draft.ForAction(InputAction.MovementForward).ToList();
|
||||
Assert.Single(fwd);
|
||||
Assert.Equal(new KeyChord(Key.A, ModifierMask.None), fwd[0].Chord);
|
||||
// MovementTurnLeft no longer bound to A (conflict removed).
|
||||
var left = vm.Draft.ForAction(InputAction.MovementTurnLeft).ToList();
|
||||
Assert.Empty(left);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveConflict_replace_false_cancels_rebind()
|
||||
{
|
||||
var (vm, kb, _, _, _, _, _, _, _, _) = Build();
|
||||
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
|
||||
|
||||
vm.BeginRebind(InputAction.MovementForward, original);
|
||||
kb.EmitKeyDown(Key.A, ModifierMask.None);
|
||||
vm.ResolveConflict(replace: false);
|
||||
|
||||
Assert.Null(vm.PendingConflict);
|
||||
Assert.Null(vm.RebindInProgress);
|
||||
// MovementForward still bound to W.
|
||||
var fwd = vm.Draft.ForAction(InputAction.MovementForward).ToList();
|
||||
Assert.Equal(new KeyChord(Key.W, ModifierMask.None), fwd[0].Chord);
|
||||
// MovementTurnLeft still bound to A.
|
||||
var left = vm.Draft.ForAction(InputAction.MovementTurnLeft).ToList();
|
||||
Assert.Equal(new KeyChord(Key.A, ModifierMask.None), left[0].Chord);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetActionToDefault_restores_single_action_to_RetailDefaults()
|
||||
{
|
||||
// Build a draft that's been mutated for MovementForward; ensure
|
||||
// ResetActionToDefault restores W (and Up-arrow per retail).
|
||||
var (vm, kb, _, _, _, _, _, _, _, _) = Build(KeyBindings.RetailDefaults());
|
||||
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
|
||||
vm.BeginRebind(InputAction.MovementForward, original);
|
||||
// F7 is unbound in retail-default (only Ctrl+F7 is acdream debug);
|
||||
// pick it deliberately to avoid triggering a conflict prompt that
|
||||
// would block the rebind from applying.
|
||||
kb.EmitKeyDown(Key.F7, ModifierMask.None);
|
||||
|
||||
Assert.True(vm.HasUnsavedChanges);
|
||||
|
||||
vm.ResetActionToDefault(InputAction.MovementForward);
|
||||
|
||||
var fwd = vm.Draft.ForAction(InputAction.MovementForward).ToList();
|
||||
Assert.Contains(fwd, x => x.Chord == new KeyChord(Key.W, ModifierMask.None));
|
||||
Assert.Contains(fwd, x => x.Chord == new KeyChord(Key.Up, ModifierMask.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetAllToDefaults_replaces_entire_draft()
|
||||
{
|
||||
var (vm, _, _, _, _, _, _, _, _, _) = Build();
|
||||
vm.ResetAllToDefaults();
|
||||
|
||||
// Should now include retail-default size set (~149 bindings).
|
||||
Assert.True(vm.Draft.All.Count >= 100);
|
||||
Assert.True(vm.HasUnsavedChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_invokes_callback_with_draft()
|
||||
{
|
||||
var (vm, kb, _, _, savedHistory, _, _, _, _, _) = Build();
|
||||
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
|
||||
vm.BeginRebind(InputAction.MovementForward, original);
|
||||
kb.EmitKeyDown(Key.Q, ModifierMask.None);
|
||||
|
||||
vm.Save();
|
||||
|
||||
Assert.Single(savedHistory);
|
||||
var saved = savedHistory[0];
|
||||
var fwd = saved.ForAction(InputAction.MovementForward).ToList();
|
||||
Assert.Equal(new KeyChord(Key.Q, ModifierMask.None), fwd[0].Chord);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cancel_reverts_draft_to_persisted()
|
||||
{
|
||||
var (vm, kb, _, _, _, _, _, _, _, _) = Build();
|
||||
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
|
||||
vm.BeginRebind(InputAction.MovementForward, original);
|
||||
kb.EmitKeyDown(Key.Q, ModifierMask.None);
|
||||
Assert.True(vm.HasUnsavedChanges);
|
||||
|
||||
vm.Cancel();
|
||||
|
||||
Assert.False(vm.HasUnsavedChanges);
|
||||
var fwd = vm.Draft.ForAction(InputAction.MovementForward).ToList();
|
||||
Assert.Equal(new KeyChord(Key.W, ModifierMask.None), fwd[0].Chord);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cancel_during_active_capture_clears_dispatcher_capture_state()
|
||||
{
|
||||
var (vm, _, dispatcher, _, _, _, _, _, _, _) = Build();
|
||||
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
|
||||
vm.BeginRebind(InputAction.MovementForward, original);
|
||||
|
||||
Assert.True(dispatcher.IsCapturing);
|
||||
vm.Cancel();
|
||||
Assert.False(dispatcher.IsCapturing);
|
||||
Assert.Null(vm.RebindInProgress);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasUnsavedChanges_false_initially_and_after_save_sync()
|
||||
{
|
||||
var (vm, _, _, _, _, _, _, _, _, _) = Build();
|
||||
Assert.False(vm.HasUnsavedChanges);
|
||||
}
|
||||
|
||||
// -- Display tab state ------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void DisplayDraft_initial_value_matches_persisted()
|
||||
{
|
||||
var custom = DisplaySettings.Default with { FieldOfView = 90f, ShowFps = true };
|
||||
var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedDisplay: custom);
|
||||
Assert.Equal(custom, vm.DisplayDraft);
|
||||
Assert.False(vm.HasUnsavedChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetDisplay_marks_unsaved_changes()
|
||||
{
|
||||
var (vm, _, _, _, _, _, _, _, _, _) = Build();
|
||||
// Default ShowFps is false → flip to true to ensure the with-
|
||||
// expression actually mutates a field.
|
||||
vm.SetDisplay(vm.DisplayDraft with { ShowFps = true });
|
||||
Assert.True(vm.HasUnsavedChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyExternalDisplayChange_updates_both_snapshots_and_preserves_other_drafts()
|
||||
{
|
||||
var (vm, _, _, _, _, _, _, _, _, _) = Build();
|
||||
|
||||
vm.SetDisplay(vm.DisplayDraft with { FieldOfView = 90f });
|
||||
Assert.True(vm.HasUnsavedChanges);
|
||||
|
||||
vm.ApplyExternalDisplayChange(display => display with { ShowFps = true });
|
||||
|
||||
Assert.True(vm.DisplayDraft.ShowFps);
|
||||
Assert.Equal(90f, vm.DisplayDraft.FieldOfView);
|
||||
Assert.True(vm.HasUnsavedChanges);
|
||||
|
||||
vm.Cancel();
|
||||
Assert.True(vm.DisplayDraft.ShowFps);
|
||||
Assert.Equal(DisplaySettings.Default.FieldOfView, vm.DisplayDraft.FieldOfView);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_invokes_display_callback_with_draft()
|
||||
{
|
||||
var (vm, _, _, _, _, savedDisplayHistory, _, _, _, _) = Build();
|
||||
vm.SetDisplay(vm.DisplayDraft with { Resolution = "2560x1440", FieldOfView = 100f });
|
||||
|
||||
vm.Save();
|
||||
|
||||
Assert.Single(savedDisplayHistory);
|
||||
Assert.Equal("2560x1440", savedDisplayHistory[0].Resolution);
|
||||
Assert.Equal(100f, savedDisplayHistory[0].FieldOfView);
|
||||
Assert.False(vm.HasUnsavedChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cancel_reverts_display_draft_to_persisted()
|
||||
{
|
||||
var custom = DisplaySettings.Default with { FieldOfView = 90f };
|
||||
var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedDisplay: custom);
|
||||
vm.SetDisplay(vm.DisplayDraft with { FieldOfView = 30f, ShowFps = true });
|
||||
Assert.True(vm.HasUnsavedChanges);
|
||||
|
||||
vm.Cancel();
|
||||
|
||||
Assert.Equal(custom, vm.DisplayDraft);
|
||||
Assert.False(vm.HasUnsavedChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetAllToDefaults_resets_display_to_default()
|
||||
{
|
||||
var custom = DisplaySettings.Default with { FieldOfView = 30f, ShowFps = true };
|
||||
var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedDisplay: custom);
|
||||
Assert.NotEqual(DisplaySettings.Default, vm.DisplayDraft);
|
||||
|
||||
vm.ResetAllToDefaults();
|
||||
|
||||
Assert.Equal(DisplaySettings.Default, vm.DisplayDraft);
|
||||
Assert.True(vm.HasUnsavedChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_then_Cancel_does_not_revert()
|
||||
{
|
||||
// After Save the persisted snapshot equals the draft, so Cancel
|
||||
// is a no-op. This guards the Save/Cancel ordering — a regression
|
||||
// would surface as Cancel reverting to pre-Save values.
|
||||
var (vm, _, _, _, _, _, _, _, _, _) = Build();
|
||||
vm.SetDisplay(vm.DisplayDraft with { ShowFps = true });
|
||||
vm.Save();
|
||||
Assert.False(vm.HasUnsavedChanges);
|
||||
|
||||
vm.Cancel();
|
||||
|
||||
Assert.True(vm.DisplayDraft.ShowFps);
|
||||
Assert.False(vm.HasUnsavedChanges);
|
||||
}
|
||||
|
||||
// -- Audio tab state --------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void AudioDraft_initial_value_matches_persisted()
|
||||
{
|
||||
var custom = AudioSettings.Default with { Master = 0.3f, Ambient = 0.1f };
|
||||
var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedAudio: custom);
|
||||
Assert.Equal(custom, vm.AudioDraft);
|
||||
Assert.False(vm.HasUnsavedChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetAudio_marks_unsaved_changes()
|
||||
{
|
||||
var (vm, _, _, _, _, _, _, _, _, _) = Build();
|
||||
vm.SetAudio(vm.AudioDraft with { Master = 0.5f });
|
||||
Assert.True(vm.HasUnsavedChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_invokes_audio_callback_with_draft()
|
||||
{
|
||||
var (vm, _, _, _, _, _, savedAudioHistory, _, _, _) = Build();
|
||||
vm.SetAudio(vm.AudioDraft with { Master = 0.4f, Sfx = 0.6f });
|
||||
|
||||
vm.Save();
|
||||
|
||||
Assert.Single(savedAudioHistory);
|
||||
Assert.Equal(0.4f, savedAudioHistory[0].Master);
|
||||
Assert.Equal(0.6f, savedAudioHistory[0].Sfx);
|
||||
Assert.False(vm.HasUnsavedChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cancel_reverts_audio_draft_to_persisted()
|
||||
{
|
||||
var custom = AudioSettings.Default with { Ambient = 0.2f };
|
||||
var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedAudio: custom);
|
||||
vm.SetAudio(vm.AudioDraft with { Ambient = 0.9f, Master = 0.3f });
|
||||
Assert.True(vm.HasUnsavedChanges);
|
||||
|
||||
vm.Cancel();
|
||||
|
||||
Assert.Equal(custom, vm.AudioDraft);
|
||||
Assert.False(vm.HasUnsavedChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetAllToDefaults_resets_audio_to_default()
|
||||
{
|
||||
var custom = AudioSettings.Default with { Master = 0.1f };
|
||||
var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedAudio: custom);
|
||||
Assert.NotEqual(AudioSettings.Default, vm.AudioDraft);
|
||||
|
||||
vm.ResetAllToDefaults();
|
||||
|
||||
Assert.Equal(AudioSettings.Default, vm.AudioDraft);
|
||||
Assert.True(vm.HasUnsavedChanges);
|
||||
}
|
||||
|
||||
// -- Gameplay tab state -----------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GameplayDraft_initial_value_matches_persisted()
|
||||
{
|
||||
var custom = GameplaySettings.Default with { LockUI = true };
|
||||
var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedGameplay: custom);
|
||||
Assert.Equal(custom, vm.GameplayDraft);
|
||||
Assert.False(vm.HasUnsavedChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetGameplay_marks_unsaved_changes()
|
||||
{
|
||||
var (vm, _, _, _, _, _, _, _, _, _) = Build();
|
||||
vm.SetGameplay(vm.GameplayDraft with { LockUI = true });
|
||||
Assert.True(vm.HasUnsavedChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyExternalGameplayChange_updates_both_snapshots_and_preserves_other_drafts()
|
||||
{
|
||||
var (vm, _, _, _, _, _, _, _, _, _) = Build();
|
||||
bool persistedLockUI = !GameplaySettings.Default.LockUI;
|
||||
|
||||
vm.SetGameplay(vm.GameplayDraft with
|
||||
{
|
||||
ShowTooltips = !GameplaySettings.Default.ShowTooltips,
|
||||
CoordinatesOnRadar = !GameplaySettings.Default.CoordinatesOnRadar,
|
||||
});
|
||||
|
||||
vm.ApplyExternalGameplayChange(gameplay => gameplay with
|
||||
{
|
||||
LockUI = persistedLockUI,
|
||||
});
|
||||
|
||||
Assert.Equal(persistedLockUI, vm.GameplayDraft.LockUI);
|
||||
Assert.Equal(
|
||||
!GameplaySettings.Default.ShowTooltips,
|
||||
vm.GameplayDraft.ShowTooltips);
|
||||
Assert.Equal(
|
||||
!GameplaySettings.Default.CoordinatesOnRadar,
|
||||
vm.GameplayDraft.CoordinatesOnRadar);
|
||||
Assert.True(vm.HasUnsavedChanges);
|
||||
|
||||
vm.Cancel();
|
||||
|
||||
Assert.Equal(persistedLockUI, vm.GameplayDraft.LockUI);
|
||||
Assert.Equal(
|
||||
GameplaySettings.Default.ShowTooltips,
|
||||
vm.GameplayDraft.ShowTooltips);
|
||||
Assert.Equal(
|
||||
GameplaySettings.Default.CoordinatesOnRadar,
|
||||
vm.GameplayDraft.CoordinatesOnRadar);
|
||||
Assert.False(vm.HasUnsavedChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_invokes_gameplay_callback_with_draft()
|
||||
{
|
||||
var (vm, _, _, _, _, _, _, savedGameplayHistory, _, _) = Build();
|
||||
vm.SetGameplay(vm.GameplayDraft with
|
||||
{
|
||||
LockUI = true,
|
||||
ShowTooltips = false,
|
||||
UseMouseTurning = true,
|
||||
});
|
||||
|
||||
vm.Save();
|
||||
|
||||
Assert.Single(savedGameplayHistory);
|
||||
Assert.True(savedGameplayHistory[0].LockUI);
|
||||
Assert.False(savedGameplayHistory[0].ShowTooltips);
|
||||
Assert.True(savedGameplayHistory[0].UseMouseTurning);
|
||||
Assert.False(vm.HasUnsavedChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cancel_reverts_gameplay_draft_to_persisted()
|
||||
{
|
||||
var custom = GameplaySettings.Default with { LockUI = true };
|
||||
var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedGameplay: custom);
|
||||
vm.SetGameplay(vm.GameplayDraft with { LockUI = false, ShowHelm = false });
|
||||
Assert.True(vm.HasUnsavedChanges);
|
||||
|
||||
vm.Cancel();
|
||||
|
||||
Assert.Equal(custom, vm.GameplayDraft);
|
||||
Assert.False(vm.HasUnsavedChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetAllToDefaults_resets_gameplay_to_default()
|
||||
{
|
||||
var custom = GameplaySettings.Default with { LockUI = true };
|
||||
var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedGameplay: custom);
|
||||
Assert.NotEqual(GameplaySettings.Default, vm.GameplayDraft);
|
||||
|
||||
vm.ResetAllToDefaults();
|
||||
|
||||
Assert.Equal(GameplaySettings.Default, vm.GameplayDraft);
|
||||
Assert.True(vm.HasUnsavedChanges);
|
||||
}
|
||||
|
||||
// -- Chat tab state ---------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ChatDraft_initial_value_matches_persisted()
|
||||
{
|
||||
var custom = ChatSettings.Default with { HearTradeChat = false, FontSize = 14f };
|
||||
var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedChat: custom);
|
||||
Assert.Equal(custom, vm.ChatDraft);
|
||||
Assert.False(vm.HasUnsavedChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetChat_marks_unsaved_changes()
|
||||
{
|
||||
var (vm, _, _, _, _, _, _, _, _, _) = Build();
|
||||
vm.SetChat(vm.ChatDraft with { FontSize = 16f });
|
||||
Assert.True(vm.HasUnsavedChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_invokes_chat_callback_with_draft()
|
||||
{
|
||||
var (vm, _, _, _, _, _, _, _, savedChatHistory, _) = Build();
|
||||
vm.SetChat(vm.ChatDraft with { HearTradeChat = false, ShowTimestamps = false });
|
||||
|
||||
vm.Save();
|
||||
|
||||
Assert.Single(savedChatHistory);
|
||||
Assert.False(savedChatHistory[0].HearTradeChat);
|
||||
Assert.False(savedChatHistory[0].ShowTimestamps);
|
||||
Assert.False(vm.HasUnsavedChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cancel_reverts_chat_draft_to_persisted()
|
||||
{
|
||||
var custom = ChatSettings.Default with { HearLFGChat = false };
|
||||
var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedChat: custom);
|
||||
vm.SetChat(vm.ChatDraft with { HearLFGChat = true, AppearOffline = true });
|
||||
Assert.True(vm.HasUnsavedChanges);
|
||||
|
||||
vm.Cancel();
|
||||
|
||||
Assert.Equal(custom, vm.ChatDraft);
|
||||
Assert.False(vm.HasUnsavedChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetAllToDefaults_resets_chat_to_default()
|
||||
{
|
||||
var custom = ChatSettings.Default with { HearGeneralChat = false, FontSize = 18f };
|
||||
var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedChat: custom);
|
||||
Assert.NotEqual(ChatSettings.Default, vm.ChatDraft);
|
||||
|
||||
vm.ResetAllToDefaults();
|
||||
|
||||
Assert.Equal(ChatSettings.Default, vm.ChatDraft);
|
||||
Assert.True(vm.HasUnsavedChanges);
|
||||
}
|
||||
|
||||
// -- Character tab state ----------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void CharacterDraft_initial_value_matches_persisted()
|
||||
{
|
||||
var custom = CharacterSettings.Default with { AutoAttack = true, DefaultChatChannel = "Allegiance" };
|
||||
var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedCharacter: custom);
|
||||
Assert.Equal(custom, vm.CharacterDraft);
|
||||
Assert.False(vm.HasUnsavedChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetCharacter_marks_unsaved_changes()
|
||||
{
|
||||
var (vm, _, _, _, _, _, _, _, _, _) = Build();
|
||||
vm.SetCharacter(vm.CharacterDraft with { AutoAttack = true });
|
||||
Assert.True(vm.HasUnsavedChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_invokes_character_callback_with_draft()
|
||||
{
|
||||
var (vm, _, _, _, _, _, _, _, _, savedCharacterHistory) = Build();
|
||||
vm.SetCharacter(vm.CharacterDraft with
|
||||
{
|
||||
DefaultChatChannel = "Fellowship",
|
||||
AutoAttack = true,
|
||||
ConfirmSalvage = false,
|
||||
});
|
||||
|
||||
vm.Save();
|
||||
|
||||
Assert.Single(savedCharacterHistory);
|
||||
Assert.Equal("Fellowship", savedCharacterHistory[0].DefaultChatChannel);
|
||||
Assert.True(savedCharacterHistory[0].AutoAttack);
|
||||
Assert.False(savedCharacterHistory[0].ConfirmSalvage);
|
||||
Assert.False(vm.HasUnsavedChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cancel_reverts_character_draft_to_persisted()
|
||||
{
|
||||
var custom = CharacterSettings.Default with { AutoAttack = true };
|
||||
var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedCharacter: custom);
|
||||
vm.SetCharacter(vm.CharacterDraft with { AutoAttack = false, DefaultChatChannel = "Trade" });
|
||||
Assert.True(vm.HasUnsavedChanges);
|
||||
|
||||
vm.Cancel();
|
||||
|
||||
Assert.Equal(custom, vm.CharacterDraft);
|
||||
Assert.False(vm.HasUnsavedChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetAllToDefaults_resets_character_to_default()
|
||||
{
|
||||
var custom = CharacterSettings.Default with { AutoAttack = true, DefaultChatChannel = "Trade" };
|
||||
var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedCharacter: custom);
|
||||
Assert.NotEqual(CharacterSettings.Default, vm.CharacterDraft);
|
||||
|
||||
vm.ResetAllToDefaults();
|
||||
|
||||
Assert.Equal(CharacterSettings.Default, vm.CharacterDraft);
|
||||
Assert.True(vm.HasUnsavedChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadCharacterContext_swaps_persisted_and_draft_atomically()
|
||||
{
|
||||
// Simulates the post-EnterWorld toon swap — host loads the
|
||||
// chosen toon's bag from disk and pushes it via
|
||||
// LoadCharacterContext. BOTH persisted and draft must update
|
||||
// so HasUnsavedChanges stays false; otherwise the user would
|
||||
// see a "pending changes" indicator on every login.
|
||||
var (vm, _, _, _, _, _, _, _, _, _) = Build();
|
||||
var newToonBag = CharacterSettings.Default with { DefaultChatChannel = "Allegiance", AutoAttack = true };
|
||||
|
||||
vm.LoadCharacterContext(newToonBag);
|
||||
|
||||
Assert.Equal(newToonBag, vm.CharacterDraft);
|
||||
Assert.False(vm.HasUnsavedChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadCharacterContext_clears_pending_unsaved_character_edits()
|
||||
{
|
||||
// If the user had pending character edits from the previous
|
||||
// toon (or pre-login session), swapping to a new toon's bag
|
||||
// must wipe them — Save is per-toon, and bleed-through would
|
||||
// write the pre-login bag's edits to the new toon's slot.
|
||||
var (vm, _, _, _, _, _, _, _, _, _) = Build();
|
||||
vm.SetCharacter(vm.CharacterDraft with { AutoAttack = true });
|
||||
Assert.True(vm.HasUnsavedChanges);
|
||||
|
||||
vm.LoadCharacterContext(CharacterSettings.Default with { DefaultChatChannel = "Fellowship" });
|
||||
|
||||
Assert.Equal("Fellowship", vm.CharacterDraft.DefaultChatChannel);
|
||||
Assert.False(vm.CharacterDraft.AutoAttack);
|
||||
Assert.False(vm.HasUnsavedChanges);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue