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; /// /// K.3: owns the click-to-rebind state machine /// for the Settings panel. It holds a draft copy of the active /// ; rebinds modify the draft. Save commits to /// the supplied callback (which writes to disk + replaces the live /// dispatcher's table); Cancel reverts the draft. /// public sealed class SettingsVMTests { private static (SettingsVM vm, FakeKeyboardSource kb, InputDispatcher dispatcher, KeyBindings persisted, System.Collections.Generic.List savedHistory, System.Collections.Generic.List savedDisplayHistory, System.Collections.Generic.List savedAudioHistory, System.Collections.Generic.List savedGameplayHistory, System.Collections.Generic.List savedChatHistory, System.Collections.Generic.List 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(); var savedDisplayHistory = new System.Collections.Generic.List(); var savedAudioHistory = new System.Collections.Generic.List(); var savedGameplayHistory = new System.Collections.Generic.List(); var savedChatHistory = new System.Collections.Generic.List(); var savedCharacterHistory = new System.Collections.Generic.List(); 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); } }