diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index a9a3e39..51ca85f 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -909,6 +909,7 @@ public sealed class GameWindow : IDisposable var persistedDisplay = settingsStore.LoadDisplay(); var persistedAudio = settingsStore.LoadAudio(); var persistedGameplay = settingsStore.LoadGameplay(); + var persistedChat = settingsStore.LoadChat(); // Apply persisted audio to the engine BEFORE the panel // host starts pushing per-frame so the first frame uses @@ -987,6 +988,25 @@ public sealed class GameWindow : IDisposable { Console.WriteLine($"settings: gameplay save failed: {ex.Message}"); } + }, + persistedChat: persistedChat, + onSaveChat: chat => + { + try + { + settingsStore.SaveChat(chat); + Console.WriteLine( + "settings: chat saved to " + + AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); + // Channel filters affect client-side display + // only this phase. ChatPanel will read them + // off SettingsVM.ChatDraft when filtering is + // wired into the chat-line render path. + } + catch (Exception ex) + { + Console.WriteLine($"settings: chat save failed: {ex.Message}"); + } }); _settingsPanel = new AcDream.UI.Abstractions.Panels.Settings.SettingsPanel(_settingsVm); _panelHost.Register(_settingsPanel); diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs new file mode 100644 index 0000000..74972fc --- /dev/null +++ b/src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs @@ -0,0 +1,44 @@ +namespace AcDream.UI.Abstractions.Panels.Settings; + +/// +/// Chat-related preferences persisted to settings.json. Mixes +/// retail's CharacterOptions2 chat-channel filter bits (Hear*Chat +/// + TimeStamp + FilterLanguage + AppearOffline) with a few visual +/// preferences (font size) that don't have a retail bitfield. +/// See docs/research/named-retail/acclient.h:3451+ for the +/// retail bit values. +/// +/// +/// L.0 scope: local-only like the rest of L.0. The Hear*Chat +/// flags affect client-side display filtering of the existing +/// channels — the server still streams every line; the client decides +/// what to render. Server-sync arrives in a later phase that flips the +/// retail-faithful "tell server which channels I'm subscribed to" +/// switch. +/// +/// +public sealed record ChatSettings( + // CharacterOptions2 (32-bit) channel filters. + bool HearGeneralChat, // 0x100 — General channel + bool HearTradeChat, // 0x200 — Trade channel + bool HearLFGChat, // 0x400 — LFG channel + bool HearRoleplayChat, // 0x800 — RP channel + bool HearSocietyChat, // 0x80000 — Society chat (CD/EW/RB) + bool AppearOffline, // 0x1000 — hide /who status + bool ShowTimestamps, // 0x40 — TimeStamp prefix on chat lines + bool FilterProfanity, // 0x20000 — FilterLanguage (Turbine's profanity filter) + // Visual / UX (no retail bitfield). + float FontSize) // chat panel font, 10..20 pt +{ + /// Sensible starting values matching the retail "all on" stance. + public static ChatSettings Default { get; } = new( + HearGeneralChat: true, + HearTradeChat: true, + HearLFGChat: true, + HearRoleplayChat: true, + HearSocietyChat: true, + AppearOffline: false, + ShowTimestamps: true, + FilterProfanity: true, + FontSize: 12f); +} diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs index 6b30883..fef6291 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs @@ -98,7 +98,7 @@ public sealed class SettingsPanel : IPanel } if (renderer.BeginTabItem("Chat")) { - RenderPlaceholder(renderer, "Chat"); + RenderChatTab(renderer); renderer.EndTabItem(); } if (renderer.BeginTabItem("Character")) @@ -356,6 +356,67 @@ public sealed class SettingsPanel : IPanel + "follow-up phase."); } + /// + /// Render the Chat tab — channel filters (Hear*Chat), display + /// preferences (timestamps / profanity filter / appear offline), + /// and a font-size slider. Channel filters affect client-side + /// display only this phase — the server still sends every line, + /// the client decides what to render. + /// + private void RenderChatTab(IPanelRenderer renderer) + { + var c = _vm.ChatDraft; + + renderer.Text("Channel filters"); + renderer.Separator(); + + bool general = c.HearGeneralChat; + if (renderer.Checkbox("General", ref general)) + _vm.SetChat(c with { HearGeneralChat = general }); + + bool trade = c.HearTradeChat; + if (renderer.Checkbox("Trade", ref trade)) + _vm.SetChat(c with { HearTradeChat = trade }); + + bool lfg = c.HearLFGChat; + if (renderer.Checkbox("LFG (looking for group)", ref lfg)) + _vm.SetChat(c with { HearLFGChat = lfg }); + + bool rp = c.HearRoleplayChat; + if (renderer.Checkbox("Roleplay", ref rp)) + _vm.SetChat(c with { HearRoleplayChat = rp }); + + bool society = c.HearSocietyChat; + if (renderer.Checkbox("Society (CD / EW / RB)", ref society)) + _vm.SetChat(c with { HearSocietyChat = society }); + + renderer.Spacing(); + renderer.Text("Display"); + renderer.Separator(); + + bool timestamps = c.ShowTimestamps; + if (renderer.Checkbox("Show timestamps", ref timestamps)) + _vm.SetChat(c with { ShowTimestamps = timestamps }); + + bool profanity = c.FilterProfanity; + if (renderer.Checkbox("Filter profanity", ref profanity)) + _vm.SetChat(c with { FilterProfanity = profanity }); + + bool offline = c.AppearOffline; + if (renderer.Checkbox("Appear offline (hide from /who)", ref offline)) + _vm.SetChat(c with { AppearOffline = offline }); + + float fontSize = c.FontSize; + if (renderer.SliderFloat("Font size (pt)", ref fontSize, 10f, 20f)) + _vm.SetChat(c with { FontSize = fontSize }); + + renderer.Spacing(); + renderer.TextWrapped( + "Channel filters hide messages from the chat window without " + + "changing your server-side subscriptions. Save persists; " + + "Cancel reverts."); + } + private void RenderSection(IPanelRenderer renderer, string label, InputAction[] actions) { // Movement defaults open; other sections collapsed for first-run UX. diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs index 8a25aa4..277b0d0 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs @@ -167,6 +167,56 @@ public sealed class SettingsStore public void SaveGameplay(GameplaySettings gameplay) => SaveSection("gameplay", BuildGameplayObject(gameplay)); + /// Load Chat settings. Same fall-back behaviour as . + public ChatSettings LoadChat() + { + if (!File.Exists(_path)) return ChatSettings.Default; + try + { + using var stream = File.OpenRead(_path); + var doc = JsonDocument.Parse(stream); + var root = doc.RootElement; + if (!root.TryGetProperty("chat", out var chat) + || chat.ValueKind != JsonValueKind.Object) + return ChatSettings.Default; + + var d = ChatSettings.Default; + return new ChatSettings( + HearGeneralChat: ReadBool (chat, "hearGeneralChat", d.HearGeneralChat), + HearTradeChat: ReadBool (chat, "hearTradeChat", d.HearTradeChat), + HearLFGChat: ReadBool (chat, "hearLFGChat", d.HearLFGChat), + HearRoleplayChat: ReadBool (chat, "hearRoleplayChat", d.HearRoleplayChat), + HearSocietyChat: ReadBool (chat, "hearSocietyChat", d.HearSocietyChat), + AppearOffline: ReadBool (chat, "appearOffline", d.AppearOffline), + ShowTimestamps: ReadBool (chat, "showTimestamps", d.ShowTimestamps), + FilterProfanity: ReadBool (chat, "filterProfanity", d.FilterProfanity), + FontSize: ReadFloat(chat, "fontSize", d.FontSize)); + } + catch (Exception ex) + { + Console.WriteLine($"settings: failed to load {_path}: {ex.Message} — using defaults"); + return ChatSettings.Default; + } + } + + /// Save Chat settings, preserving all other top-level keys. + public void SaveChat(ChatSettings chat) + => SaveSection("chat", BuildChatObject(chat)); + + private static SortedDictionary BuildChatObject(ChatSettings c) + => new(StringComparer.Ordinal) + { + ["appearOffline"] = c.AppearOffline, + ["filterProfanity"] = c.FilterProfanity, + ["fontSize"] = c.FontSize, + ["hearGeneralChat"] = c.HearGeneralChat, + ["hearLFGChat"] = c.HearLFGChat, + ["hearRoleplayChat"] = c.HearRoleplayChat, + ["hearSocietyChat"] = c.HearSocietyChat, + ["hearTradeChat"] = c.HearTradeChat, + ["showTimestamps"] = c.ShowTimestamps, + }; + private static SortedDictionary BuildGameplayObject(GameplaySettings g) => new(StringComparer.Ordinal) { diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs index 4368ca7..159f75d 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs @@ -46,6 +46,11 @@ public sealed class SettingsVM private GameplaySettings _gameplayDraft; private readonly Action _onSaveGameplay; + // L.0 — Chat tab (CharacterOptions2 channel filters + visual prefs). + private ChatSettings _chatPersisted; + private ChatSettings _chatDraft; + private readonly Action _onSaveChat; + /// The action currently being rebound, or null when idle. public InputAction? RebindInProgress { get; private set; } @@ -70,7 +75,8 @@ public sealed class SettingsVM => !KeyBindingsEqual(_persisted, _draft) || _displayPersisted != _displayDraft || _audioPersisted != _audioDraft - || _gameplayPersisted != _gameplayDraft; + || _gameplayPersisted != _gameplayDraft + || _chatPersisted != _chatDraft; /// The current Display draft. Panel reads from here; /// mutation goes through . @@ -84,6 +90,10 @@ public sealed class SettingsVM /// mutation goes through . public GameplaySettings GameplayDraft => _gameplayDraft; + /// The current Chat draft. Panel reads from here; + /// mutation goes through . + public ChatSettings ChatDraft => _chatDraft; + public SettingsVM( KeyBindings persisted, InputDispatcher dispatcher, @@ -93,7 +103,9 @@ public sealed class SettingsVM AudioSettings persistedAudio, Action onSaveAudio, GameplaySettings persistedGameplay, - Action onSaveGameplay) + Action onSaveGameplay, + ChatSettings persistedChat, + Action onSaveChat) { _persisted = persisted ?? throw new ArgumentNullException(nameof(persisted)); _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); @@ -104,10 +116,13 @@ public sealed class SettingsVM _onSaveAudio = onSaveAudio ?? throw new ArgumentNullException(nameof(onSaveAudio)); _gameplayPersisted = persistedGameplay ?? throw new ArgumentNullException(nameof(persistedGameplay)); _onSaveGameplay = onSaveGameplay ?? throw new ArgumentNullException(nameof(onSaveGameplay)); + _chatPersisted = persistedChat ?? throw new ArgumentNullException(nameof(persistedChat)); + _onSaveChat = onSaveChat ?? throw new ArgumentNullException(nameof(onSaveChat)); _draft = CloneBindings(persisted); _displayDraft = persistedDisplay; _audioDraft = persistedAudio; _gameplayDraft = persistedGameplay; + _chatDraft = persistedChat; } /// @@ -144,6 +159,17 @@ public sealed class SettingsVM _gameplayDraft = value ?? throw new ArgumentNullException(nameof(value)); } + /// + /// Replace the entire Chat draft with . + /// Local-only this phase — values persist on Save but the Hear*Chat + /// flags affect client-side display filtering, not server-side + /// channel subscriptions. + /// + public void SetChat(ChatSettings value) + { + _chatDraft = value ?? throw new ArgumentNullException(nameof(value)); + } + /// /// Begin rebinding . The supplied /// binding will be removed when the new @@ -255,6 +281,7 @@ public sealed class SettingsVM _displayDraft = DisplaySettings.Default; _audioDraft = AudioSettings.Default; _gameplayDraft = GameplaySettings.Default; + _chatDraft = ChatSettings.Default; } /// @@ -271,10 +298,12 @@ public sealed class SettingsVM _onSaveDisplay(_displayDraft); _onSaveAudio(_audioDraft); _onSaveGameplay(_gameplayDraft); + _onSaveChat(_chatDraft); _persisted = CloneBindings(_draft); _displayPersisted = _displayDraft; _audioPersisted = _audioDraft; _gameplayPersisted = _gameplayDraft; + _chatPersisted = _chatDraft; } /// @@ -288,6 +317,7 @@ public sealed class SettingsVM _displayDraft = _displayPersisted; _audioDraft = _audioPersisted; _gameplayDraft = _gameplayPersisted; + _chatDraft = _chatPersisted; CancelRebind(); } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/ChatSettingsTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/ChatSettingsTests.cs new file mode 100644 index 0000000..12ec900 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/ChatSettingsTests.cs @@ -0,0 +1,43 @@ +using AcDream.UI.Abstractions.Panels.Settings; + +namespace AcDream.UI.Abstractions.Tests.Panels.Settings; + +/// +/// L.0: default-pin tests. +/// +public sealed class ChatSettingsTests +{ + [Fact] + public void Default_values_are_all_channels_on_with_timestamps_and_filter() + { + var d = ChatSettings.Default; + Assert.True(d.HearGeneralChat); + Assert.True(d.HearTradeChat); + Assert.True(d.HearLFGChat); + Assert.True(d.HearRoleplayChat); + Assert.True(d.HearSocietyChat); + Assert.False(d.AppearOffline); + Assert.True(d.ShowTimestamps); + Assert.True(d.FilterProfanity); + Assert.Equal(12f, d.FontSize); + } + + [Fact] + public void Equality_is_value_based() + { + var a = ChatSettings.Default; + var b = ChatSettings.Default with { HearTradeChat = false }; + var c = ChatSettings.Default with { HearTradeChat = false }; + Assert.NotEqual(a, b); + Assert.Equal(b, c); + } + + [Fact] + public void With_expression_clones_one_field() + { + var d = ChatSettings.Default with { FontSize = 16f }; + Assert.Equal(16f, d.FontSize); + Assert.True(d.HearGeneralChat); + Assert.True(d.ShowTimestamps); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs index 78450b1..72a1ba4 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs @@ -33,7 +33,8 @@ public sealed class SettingsPanelTests persisted, dispatcher, _ => { }, DisplaySettings.Default, _ => { }, AudioSettings.Default, _ => { }, - GameplaySettings.Default, _ => { }); + GameplaySettings.Default, _ => { }, + ChatSettings.Default, _ => { }); var panel = new SettingsPanel(vm); return (panel, vm, kb, dispatcher); } @@ -228,17 +229,17 @@ public sealed class SettingsPanelTests [Fact] public void Placeholder_tabs_render_coming_soon_text_when_active() { - // Chat is still a placeholder (next in build order). Display, - // Audio, and Gameplay have shipped — they have real widgets, - // not "coming soon" text. + // Character is still a placeholder (last on the build order). + // Display / Audio / Gameplay / Chat have shipped — they have + // real widgets, not "coming soon" text. var (panel, _, _, _) = Build(); - var r = new FakePanelRenderer { ActiveTabLabel = "Chat" }; + var r = new FakePanelRenderer { ActiveTabLabel = "Character" }; 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.Contains(wrapped, t => t.Contains("Chat settings coming soon")); + Assert.Contains(wrapped, t => t.Contains("Character settings coming soon")); } // -- Display tab content --------------------------------------------- @@ -359,6 +360,48 @@ public sealed class SettingsPanelTests 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() { diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs index cdb0baa..dac57aa 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs @@ -240,4 +240,48 @@ public sealed class SettingsStoreTests : System.IDisposable Assert.Equal(0.5f, store.LoadAudio().Master); Assert.True(store.LoadGameplay().LockUI); } + + // -- Chat section round-trip ------------------------------------------ + + [Fact] + public void LoadChat_returns_defaults_when_file_is_missing() + { + var store = new SettingsStore(_tempPath); + Assert.Equal(ChatSettings.Default, store.LoadChat()); + } + + [Fact] + public void SaveChat_then_LoadChat_round_trips_all_fields() + { + var store = new SettingsStore(_tempPath); + var original = new ChatSettings( + HearGeneralChat: false, + HearTradeChat: false, + HearLFGChat: false, + HearRoleplayChat: true, + HearSocietyChat: true, + AppearOffline: true, + ShowTimestamps: false, + FilterProfanity: false, + FontSize: 16f); + + store.SaveChat(original); + Assert.Equal(original, store.LoadChat()); + } + + [Fact] + 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, 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); + } } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs index 940e449..14b2341 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs @@ -16,8 +16,8 @@ namespace AcDream.UI.Abstractions.Tests.Panels.Settings; /// 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) - Build(KeyBindings? persisted = null, DisplaySettings? persistedDisplay = null, AudioSettings? persistedAudio = null, GameplaySettings? persistedGameplay = null) + 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) + Build(KeyBindings? persisted = null, DisplaySettings? persistedDisplay = null, AudioSettings? persistedAudio = null, GameplaySettings? persistedGameplay = null, ChatSettings? persistedChat = null) { persisted ??= MakeMinimalBindings(); var kb = new FakeKeyboardSource(); @@ -27,6 +27,7 @@ public sealed class SettingsVMTests 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 vm = new SettingsVM( persisted, dispatcher, b => savedHistory.Add(b), @@ -35,8 +36,10 @@ public sealed class SettingsVMTests persistedAudio ?? AudioSettings.Default, a => savedAudioHistory.Add(a), persistedGameplay ?? GameplaySettings.Default, - g => savedGameplayHistory.Add(g)); - return (vm, kb, dispatcher, persisted, savedHistory, savedDisplayHistory, savedAudioHistory, savedGameplayHistory); + g => savedGameplayHistory.Add(g), + persistedChat ?? ChatSettings.Default, + c => savedChatHistory.Add(c)); + return (vm, kb, dispatcher, persisted, savedHistory, savedDisplayHistory, savedAudioHistory, savedGameplayHistory, savedChatHistory); } private static KeyBindings MakeMinimalBindings() @@ -51,7 +54,7 @@ public sealed class SettingsVMTests [Fact] public void Constructor_clones_persisted_into_draft() { - var (vm, _, _, persisted, _, _, _, _) = Build(); + var (vm, _, _, persisted, _, _, _, _, _) = Build(); Assert.Equal(persisted.All.Count, vm.Draft.All.Count); Assert.False(vm.HasUnsavedChanges); } @@ -59,7 +62,7 @@ public sealed class SettingsVMTests [Fact] public void BeginRebind_enters_capture_mode() { - var (vm, _, dispatcher, _, _, _, _, _) = Build(); + var (vm, _, dispatcher, _, _, _, _, _, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); vm.BeginRebind(InputAction.MovementForward, original); @@ -72,7 +75,7 @@ public sealed class SettingsVMTests [Fact] public void BeginRebind_then_chord_with_no_conflict_applies_rebind() { - var (vm, kb, _, _, _, _, _, _) = Build(); + var (vm, kb, _, _, _, _, _, _, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); vm.BeginRebind(InputAction.MovementForward, original); @@ -90,7 +93,7 @@ public sealed class SettingsVMTests [Fact] public void BeginRebind_then_Escape_cancels_with_no_change() { - var (vm, kb, _, _, _, _, _, _) = Build(); + var (vm, kb, _, _, _, _, _, _, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); vm.BeginRebind(InputAction.MovementForward, original); @@ -107,7 +110,7 @@ public sealed class SettingsVMTests [Fact] public void BeginRebind_with_conflict_surfaces_PendingConflict() { - var (vm, kb, _, _, _, _, _, _) = Build(); + var (vm, kb, _, _, _, _, _, _, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); // Bind chord that conflicts with MovementTurnLeft (which has Key.A). @@ -127,7 +130,7 @@ public sealed class SettingsVMTests [Fact] public void ResolveConflict_replace_true_removes_conflict_and_applies_rebind() { - var (vm, kb, _, _, _, _, _, _) = Build(); + var (vm, kb, _, _, _, _, _, _, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); vm.BeginRebind(InputAction.MovementForward, original); @@ -148,7 +151,7 @@ public sealed class SettingsVMTests [Fact] public void ResolveConflict_replace_false_cancels_rebind() { - var (vm, kb, _, _, _, _, _, _) = Build(); + var (vm, kb, _, _, _, _, _, _, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); vm.BeginRebind(InputAction.MovementForward, original); @@ -170,7 +173,7 @@ public sealed class SettingsVMTests { // 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 (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); @@ -190,7 +193,7 @@ public sealed class SettingsVMTests [Fact] public void ResetAllToDefaults_replaces_entire_draft() { - var (vm, _, _, _, _, _, _, _) = Build(); + var (vm, _, _, _, _, _, _, _, _) = Build(); vm.ResetAllToDefaults(); // Should now include retail-default size set (~149 bindings). @@ -201,7 +204,7 @@ public sealed class SettingsVMTests [Fact] public void Save_invokes_callback_with_draft() { - var (vm, kb, _, _, savedHistory, _, _, _) = Build(); + var (vm, kb, _, _, savedHistory, _, _, _, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); vm.BeginRebind(InputAction.MovementForward, original); kb.EmitKeyDown(Key.Q, ModifierMask.None); @@ -217,7 +220,7 @@ public sealed class SettingsVMTests [Fact] public void Cancel_reverts_draft_to_persisted() { - var (vm, kb, _, _, _, _, _, _) = Build(); + var (vm, kb, _, _, _, _, _, _, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); vm.BeginRebind(InputAction.MovementForward, original); kb.EmitKeyDown(Key.Q, ModifierMask.None); @@ -233,7 +236,7 @@ public sealed class SettingsVMTests [Fact] public void Cancel_during_active_capture_clears_dispatcher_capture_state() { - var (vm, _, dispatcher, _, _, _, _, _) = Build(); + var (vm, _, dispatcher, _, _, _, _, _, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); vm.BeginRebind(InputAction.MovementForward, original); @@ -246,7 +249,7 @@ public sealed class SettingsVMTests [Fact] public void HasUnsavedChanges_false_initially_and_after_save_sync() { - var (vm, _, _, _, _, _, _, _) = Build(); + var (vm, _, _, _, _, _, _, _, _) = Build(); Assert.False(vm.HasUnsavedChanges); } @@ -256,7 +259,7 @@ public sealed class SettingsVMTests public void DisplayDraft_initial_value_matches_persisted() { var custom = DisplaySettings.Default with { FieldOfView = 90f, ShowFps = true }; - var (vm, _, _, _, _, _, _, _) = Build(persistedDisplay: custom); + var (vm, _, _, _, _, _, _, _, _) = Build(persistedDisplay: custom); Assert.Equal(custom, vm.DisplayDraft); Assert.False(vm.HasUnsavedChanges); } @@ -264,7 +267,7 @@ public sealed class SettingsVMTests [Fact] public void SetDisplay_marks_unsaved_changes() { - var (vm, _, _, _, _, _, _, _) = Build(); + var (vm, _, _, _, _, _, _, _, _) = Build(); vm.SetDisplay(vm.DisplayDraft with { ShowFps = true }); Assert.True(vm.HasUnsavedChanges); } @@ -272,7 +275,7 @@ public sealed class SettingsVMTests [Fact] public void Save_invokes_display_callback_with_draft() { - var (vm, _, _, _, _, savedDisplayHistory, _, _) = Build(); + var (vm, _, _, _, _, savedDisplayHistory, _, _, _) = Build(); vm.SetDisplay(vm.DisplayDraft with { Resolution = "2560x1440", FieldOfView = 100f }); vm.Save(); @@ -287,7 +290,7 @@ public sealed class SettingsVMTests public void Cancel_reverts_display_draft_to_persisted() { var custom = DisplaySettings.Default with { FieldOfView = 90f }; - var (vm, _, _, _, _, _, _, _) = Build(persistedDisplay: custom); + var (vm, _, _, _, _, _, _, _, _) = Build(persistedDisplay: custom); vm.SetDisplay(vm.DisplayDraft with { FieldOfView = 30f, ShowFps = true }); Assert.True(vm.HasUnsavedChanges); @@ -301,7 +304,7 @@ public sealed class SettingsVMTests public void ResetAllToDefaults_resets_display_to_default() { var custom = DisplaySettings.Default with { FieldOfView = 30f, ShowFps = true }; - var (vm, _, _, _, _, _, _, _) = Build(persistedDisplay: custom); + var (vm, _, _, _, _, _, _, _, _) = Build(persistedDisplay: custom); Assert.NotEqual(DisplaySettings.Default, vm.DisplayDraft); vm.ResetAllToDefaults(); @@ -316,7 +319,7 @@ public sealed class SettingsVMTests // 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(); + var (vm, _, _, _, _, _, _, _, _) = Build(); vm.SetDisplay(vm.DisplayDraft with { ShowFps = true }); vm.Save(); Assert.False(vm.HasUnsavedChanges); @@ -333,7 +336,7 @@ public sealed class SettingsVMTests public void AudioDraft_initial_value_matches_persisted() { var custom = AudioSettings.Default with { Master = 0.3f, Music = 0.1f }; - var (vm, _, _, _, _, _, _, _) = Build(persistedAudio: custom); + var (vm, _, _, _, _, _, _, _, _) = Build(persistedAudio: custom); Assert.Equal(custom, vm.AudioDraft); Assert.False(vm.HasUnsavedChanges); } @@ -341,7 +344,7 @@ public sealed class SettingsVMTests [Fact] public void SetAudio_marks_unsaved_changes() { - var (vm, _, _, _, _, _, _, _) = Build(); + var (vm, _, _, _, _, _, _, _, _) = Build(); vm.SetAudio(vm.AudioDraft with { Master = 0.5f }); Assert.True(vm.HasUnsavedChanges); } @@ -349,7 +352,7 @@ public sealed class SettingsVMTests [Fact] public void Save_invokes_audio_callback_with_draft() { - var (vm, _, _, _, _, _, savedAudioHistory, _) = Build(); + var (vm, _, _, _, _, _, savedAudioHistory, _, _) = Build(); vm.SetAudio(vm.AudioDraft with { Master = 0.4f, Sfx = 0.6f }); vm.Save(); @@ -364,7 +367,7 @@ public sealed class SettingsVMTests public void Cancel_reverts_audio_draft_to_persisted() { var custom = AudioSettings.Default with { Music = 0.2f }; - var (vm, _, _, _, _, _, _, _) = Build(persistedAudio: custom); + var (vm, _, _, _, _, _, _, _, _) = Build(persistedAudio: custom); vm.SetAudio(vm.AudioDraft with { Music = 0.9f, Master = 0.3f }); Assert.True(vm.HasUnsavedChanges); @@ -378,7 +381,7 @@ public sealed class SettingsVMTests public void ResetAllToDefaults_resets_audio_to_default() { var custom = AudioSettings.Default with { Master = 0.1f }; - var (vm, _, _, _, _, _, _, _) = Build(persistedAudio: custom); + var (vm, _, _, _, _, _, _, _, _) = Build(persistedAudio: custom); Assert.NotEqual(AudioSettings.Default, vm.AudioDraft); vm.ResetAllToDefaults(); @@ -393,7 +396,7 @@ public sealed class SettingsVMTests public void GameplayDraft_initial_value_matches_persisted() { var custom = GameplaySettings.Default with { AutoTarget = false, LockUI = true }; - var (vm, _, _, _, _, _, _, _) = Build(persistedGameplay: custom); + var (vm, _, _, _, _, _, _, _, _) = Build(persistedGameplay: custom); Assert.Equal(custom, vm.GameplayDraft); Assert.False(vm.HasUnsavedChanges); } @@ -401,7 +404,7 @@ public sealed class SettingsVMTests [Fact] public void SetGameplay_marks_unsaved_changes() { - var (vm, _, _, _, _, _, _, _) = Build(); + var (vm, _, _, _, _, _, _, _, _) = Build(); vm.SetGameplay(vm.GameplayDraft with { LockUI = true }); Assert.True(vm.HasUnsavedChanges); } @@ -409,7 +412,7 @@ public sealed class SettingsVMTests [Fact] public void Save_invokes_gameplay_callback_with_draft() { - var (vm, _, _, _, _, _, _, savedGameplayHistory) = Build(); + var (vm, _, _, _, _, _, _, savedGameplayHistory, _) = Build(); vm.SetGameplay(vm.GameplayDraft with { AutoTarget = false, @@ -430,7 +433,7 @@ public sealed class SettingsVMTests public void Cancel_reverts_gameplay_draft_to_persisted() { var custom = GameplaySettings.Default with { LockUI = true }; - var (vm, _, _, _, _, _, _, _) = Build(persistedGameplay: custom); + var (vm, _, _, _, _, _, _, _, _) = Build(persistedGameplay: custom); vm.SetGameplay(vm.GameplayDraft with { LockUI = false, ShowHelm = false }); Assert.True(vm.HasUnsavedChanges); @@ -444,7 +447,7 @@ public sealed class SettingsVMTests public void ResetAllToDefaults_resets_gameplay_to_default() { var custom = GameplaySettings.Default with { AutoTarget = false, LockUI = true }; - var (vm, _, _, _, _, _, _, _) = Build(persistedGameplay: custom); + var (vm, _, _, _, _, _, _, _, _) = Build(persistedGameplay: custom); Assert.NotEqual(GameplaySettings.Default, vm.GameplayDraft); vm.ResetAllToDefaults(); @@ -452,4 +455,64 @@ public sealed class SettingsVMTests 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); + } }