acdream/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs
Erik fec0d94148 refactor(settings): own two-phase runtime settings
Move pre-window loading, startup application, live settings mutation, toon context, quality reapply, and SettingsVM loans behind one RuntimeSettingsController. Preserve retail command behavior, ordered target publication, draft semantics, and retryable failure convergence while removing duplicate GameWindow state and feature bodies.

Co-authored-by: Codex <codex@openai.com>
2026-07-22 13:30:22 +02:00

682 lines
26 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 = 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, 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 ApplyExternalGameplayChange_updates_both_snapshots_and_preserves_other_drafts()
{
var (vm, _, _, _, _, _, _, _, _, _) = Build();
bool persistedAutoTarget = !GameplaySettings.Default.AutoTarget;
vm.SetGameplay(vm.GameplayDraft with
{
ShowTooltips = !GameplaySettings.Default.ShowTooltips,
CoordinatesOnRadar = !GameplaySettings.Default.CoordinatesOnRadar,
});
vm.ApplyExternalGameplayChange(gameplay => gameplay with
{
AutoTarget = persistedAutoTarget,
});
Assert.Equal(persistedAutoTarget, vm.GameplayDraft.AutoTarget);
Assert.Equal(
!GameplaySettings.Default.ShowTooltips,
vm.GameplayDraft.ShowTooltips);
Assert.Equal(
!GameplaySettings.Default.CoordinatesOnRadar,
vm.GameplayDraft.CoordinatesOnRadar);
Assert.True(vm.HasUnsavedChanges);
vm.Cancel();
Assert.Equal(persistedAutoTarget, vm.GameplayDraft.AutoTarget);
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
{
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);
}
}