Phase L.0 polish — the Display + Character tabs were persisting to disk
but didn't yet drive runtime behavior. This commit flips the live
switches.
DISPLAY ↔ GL window:
· FOV slider (degrees) → camera FovY (radians) on Orbit + Fly + Chase,
pushed every frame so dragging is visible immediately. Brainstorm
said FOV is a live-preview slider; this delivers it.
· VSync → _window.VSync, change-detected per-frame so flipping the
checkbox is instant. Applied at startup too so saved-VSync takes
effect before the first frame.
· Resolution → _window.Size on Save (TryParseResolution parses
"WIDTHxHEIGHT"). Live preview would be too jarring; resize is on
Save only.
· Fullscreen → _window.WindowState (Silk.NET borderless mode), also
on Save only.
· ShowFps → wraps the title-bar perf string. true → full perf line;
false → just "acdream" for a cleaner alt-tab. Default true matches
pre-L.0 behavior.
Defaults rebalanced — FieldOfView 75→60° (matches Orbit/Fly/Chase
FovY = π/3), VSync true→false (matches the previous WindowOptions),
ShowFps false→true (preserves the existing perf-in-title behavior).
Net effect: a user who never opens Display tab + later opens it +
Saves without touching anything sees ZERO visual change. Tests pinned
to the new defaults.
ApplyDisplayWindowState helper consolidates the window-side
mutations. Called from the SettingsVM construction site (apply
persisted at startup) and from the onSaveDisplay callback (apply
saved on demand). Malformed resolution strings are silently ignored
to avoid crashing mid-session if settings.json gets hand-edited.
CHARACTER ↔ active toon:
· _activeToonKey field replaces the hard-coded "default" — starts as
"default" (used for any pre-login Settings interaction), gets
swapped to the actual character.Name immediately after EnterWorld
in BeginLiveSessionAsync.
· onSaveCharacter callback closes over _activeToonKey by reference
(lambda captures `this`), so saves always write to the current
toon's slot without rebinding the lambda.
· After EnterWorld lands the chosen toon's name, the host loads
that toon's bag via SettingsStore.LoadCharacter and calls a new
SettingsVM.LoadCharacterContext to swap BOTH persisted snapshot
AND draft atomically — HasUnsavedChanges stays false on login so
the user doesn't see a "pending changes" indicator just because
they switched toons.
Per-toon storage already worked at the SettingsStore layer (commit
73749d1); this commit just plumbs the actual character name through
to the toonKey instead of always using "default".
2 new tests for LoadCharacterContext: atomic persisted+draft swap,
and pending edits getting wiped on swap (so pre-login bleed-through
can't write to the new toon's slot).
dotnet build green (0 warnings); dotnet test 1,309 / 1,309 green
(243 Core.Net + 393 UI.Abstractions + 673 Core).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
624 lines
24 KiB
C#
624 lines
24 KiB
C#
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 = new InputDispatcher(kb, mouse, persisted);
|
|
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 true → flip to false to ensure the with-
|
|
// expression actually mutates a field.
|
|
vm.SetDisplay(vm.DisplayDraft with { ShowFps = false });
|
|
Assert.True(vm.HasUnsavedChanges);
|
|
}
|
|
|
|
[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, Music = 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 { Music = 0.2f };
|
|
var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedAudio: custom);
|
|
vm.SetAudio(vm.AudioDraft with { Music = 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 { AutoTarget = false, 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 Save_invokes_gameplay_callback_with_draft()
|
|
{
|
|
var (vm, _, _, _, _, _, _, savedGameplayHistory, _, _) = Build();
|
|
vm.SetGameplay(vm.GameplayDraft with
|
|
{
|
|
AutoTarget = false,
|
|
ShowTooltips = false,
|
|
UseMouseTurning = true,
|
|
});
|
|
|
|
vm.Save();
|
|
|
|
Assert.Single(savedGameplayHistory);
|
|
Assert.False(savedGameplayHistory[0].AutoTarget);
|
|
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 { AutoTarget = false, 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);
|
|
}
|
|
}
|