diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index b892658..7d1214f 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -901,12 +901,24 @@ public sealed class GameWindow : IDisposable // the same OnLoad path (see _inputDispatcher field). if (_inputDispatcher is not null) { - // L.0 — settings.json (display + future audio / gameplay / + // L.0 — settings.json (display + audio + future gameplay / // chat / character tabs). Coexists with keybinds.json, // which keeps its own load/save path. var settingsStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore( AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); var persistedDisplay = settingsStore.LoadDisplay(); + var persistedAudio = settingsStore.LoadAudio(); + + // Apply persisted audio to the engine BEFORE the panel + // host starts pushing per-frame so the first frame uses + // the user's saved values instead of engine defaults. + if (_audioEngine is not null && _audioEngine.IsAvailable) + { + _audioEngine.MasterVolume = persistedAudio.Master; + _audioEngine.MusicVolume = persistedAudio.Music; + _audioEngine.SfxVolume = persistedAudio.Sfx; + _audioEngine.AmbientVolume = persistedAudio.Ambient; + } _settingsVm = new AcDream.UI.Abstractions.Panels.Settings.SettingsVM( persisted: _keyBindings, @@ -941,6 +953,21 @@ public sealed class GameWindow : IDisposable { Console.WriteLine($"settings: display save failed: {ex.Message}"); } + }, + persistedAudio: persistedAudio, + onSaveAudio: audio => + { + try + { + settingsStore.SaveAudio(audio); + Console.WriteLine( + "settings: audio saved to " + + AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); + } + catch (Exception ex) + { + Console.WriteLine($"settings: audio save failed: {ex.Message}"); + } }); _settingsPanel = new AcDream.UI.Abstractions.Panels.Settings.SettingsPanel(_settingsVm); _panelHost.Register(_settingsPanel); @@ -4105,6 +4132,22 @@ public sealed class GameWindow : IDisposable System.Numerics.Matrix4x4.Invert(camera.View, out var invView); var camPos = new System.Numerics.Vector3(invView.M41, invView.M42, invView.M43); + // L.0 Audio tab: push the SettingsVM's live AudioDraft into the + // engine each frame, so volume sliders preview audibly while + // the user drags. Cancel reverts the draft and the engine + // catches up on the very next frame; Save persists to + // settings.json without changing engine state (already + // applied). Cheap enough to run unconditionally on every + // tick — four float assignments. + if (_audioEngine is not null && _audioEngine.IsAvailable && _settingsVm is not null) + { + var a = _settingsVm.AudioDraft; + _audioEngine.MasterVolume = a.Master; + _audioEngine.MusicVolume = a.Music; + _audioEngine.SfxVolume = a.Sfx; + _audioEngine.AmbientVolume = a.Ambient; + } + // Phase E.2 audio: update listener pose so 3D sounds pan/attenuate // correctly relative to where we're looking. if (_audioEngine is not null && _audioEngine.IsAvailable) diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/AudioSettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/AudioSettings.cs new file mode 100644 index 0000000..43a1b47 --- /dev/null +++ b/src/AcDream.UI.Abstractions/Panels/Settings/AudioSettings.cs @@ -0,0 +1,28 @@ +namespace AcDream.UI.Abstractions.Panels.Settings; + +/// +/// Audio mixer preferences persisted to settings.json. Drives the +/// existing Phase E.2 OpenAL engine — the host wires these values into +/// OpenAlAudioEngine.MasterVolume / SfxVolume / +/// MusicVolume / AmbientVolume on Save and on startup. +/// +/// +/// Defaults match the engine's hard-coded starting values so a user +/// who never opens the Audio tab gets identical behaviour to the +/// previous env-var-only world. +/// +/// +public sealed record AudioSettings( + float Master, + float Music, + float Sfx, + float Ambient) +{ + /// Values used on first launch. Mirror the engine's + /// constructor-default Volume properties. + public static AudioSettings Default { get; } = new( + Master: 1.0f, + Music: 0.7f, + Sfx: 1.0f, + Ambient: 0.8f); +} diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs index 7af322e..7d8c78e 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs @@ -88,7 +88,7 @@ public sealed class SettingsPanel : IPanel } if (renderer.BeginTabItem("Audio")) { - RenderPlaceholder(renderer, "Audio"); + RenderAudioTab(renderer); renderer.EndTabItem(); } if (renderer.BeginTabItem("Gameplay")) @@ -239,6 +239,39 @@ public sealed class SettingsPanel : IPanel + "preview live as you drag; Cancel reverts to the saved value."); } + /// + /// Render the Audio tab — four volume sliders (Master / Music / SFX / + /// Ambient). Volumes update live: the host pushes the VM's + /// AudioDraft into the running OpenAL engine each frame, so dragging + /// a slider is audible immediately. Cancel reverts the draft and the + /// engine catches up on the next frame. + /// + private void RenderAudioTab(IPanelRenderer renderer) + { + var a = _vm.AudioDraft; + + float master = a.Master; + if (renderer.SliderFloat("Master", ref master, 0f, 1f)) + _vm.SetAudio(a with { Master = master }); + + float music = a.Music; + if (renderer.SliderFloat("Music", ref music, 0f, 1f)) + _vm.SetAudio(a with { Music = music }); + + float sfx = a.Sfx; + if (renderer.SliderFloat("SFX", ref sfx, 0f, 1f)) + _vm.SetAudio(a with { Sfx = sfx }); + + float ambient = a.Ambient; + if (renderer.SliderFloat("Ambient", ref ambient, 0f, 1f)) + _vm.SetAudio(a with { Ambient = ambient }); + + renderer.Spacing(); + renderer.TextWrapped( + "Volume changes preview live as you drag. Save persists the " + + "values to settings.json; Cancel reverts to the saved values."); + } + 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 0e961a9..ad07c35 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs @@ -82,11 +82,79 @@ public sealed class SettingsStore /// builds don't silently drop them. /// public void SaveDisplay(DisplaySettings display) + => SaveSection("display", BuildDisplayObject(display)); + + /// + /// Load Audio settings. Same fall-back behaviour as + /// : missing file → defaults, missing fields + /// → per-field defaults, corrupt JSON → defaults. + /// + public AudioSettings LoadAudio() + { + if (!File.Exists(_path)) return AudioSettings.Default; + try + { + using var stream = File.OpenRead(_path); + var doc = JsonDocument.Parse(stream); + var root = doc.RootElement; + if (!root.TryGetProperty("audio", out var audio) + || audio.ValueKind != JsonValueKind.Object) + return AudioSettings.Default; + + var d = AudioSettings.Default; + return new AudioSettings( + Master: ReadFloat(audio, "master", d.Master), + Music: ReadFloat(audio, "music", d.Music), + Sfx: ReadFloat(audio, "sfx", d.Sfx), + Ambient: ReadFloat(audio, "ambient", d.Ambient)); + } + catch (Exception ex) + { + Console.WriteLine($"settings: failed to load {_path}: {ex.Message} — using defaults"); + return AudioSettings.Default; + } + } + + /// + /// Save Audio settings, preserving every other top-level key + /// (display, future gameplay/chat/character). Same round-trip + /// guarantee as . + /// + public void SaveAudio(AudioSettings audio) + => SaveSection("audio", BuildAudioObject(audio)); + + private static SortedDictionary BuildDisplayObject(DisplaySettings d) + => new(StringComparer.Ordinal) + { + ["fieldOfView"] = d.FieldOfView, + ["fullscreen"] = d.Fullscreen, + ["gamma"] = d.Gamma, + ["resolution"] = d.Resolution, + ["showFps"] = d.ShowFps, + ["vsync"] = d.VSync, + }; + + private static SortedDictionary BuildAudioObject(AudioSettings a) + => new(StringComparer.Ordinal) + { + ["ambient"] = a.Ambient, + ["master"] = a.Master, + ["music"] = a.Music, + ["sfx"] = a.Sfx, + }; + + /// + /// Generic atomic-section save: writes the named section and preserves + /// all other top-level keys from the existing file, replacing only the + /// version + the targeted section. Avoids duplication between the + /// per-section Save methods. + /// + private void SaveSection(string sectionName, SortedDictionary sectionPayload) { var dir = Path.GetDirectoryName(_path); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); - // Preserve any non-display top-level keys from the existing file. + // Preserve any non-target top-level keys from the existing file. var preservedKeys = new SortedDictionary(StringComparer.Ordinal); if (File.Exists(_path)) { @@ -96,7 +164,7 @@ public sealed class SettingsStore var doc = JsonDocument.Parse(stream); foreach (var prop in doc.RootElement.EnumerateObject()) { - if (prop.Name == "display" || prop.Name == "version") continue; + if (prop.Name == sectionName || prop.Name == "version") continue; preservedKeys[prop.Name] = prop.Value.GetRawText(); } } @@ -108,28 +176,19 @@ public sealed class SettingsStore } } - var displayObj = new SortedDictionary(StringComparer.Ordinal) - { - ["fieldOfView"] = display.FieldOfView, - ["fullscreen"] = display.Fullscreen, - ["gamma"] = display.Gamma, - ["resolution"] = display.Resolution, - ["showFps"] = display.ShowFps, - ["vsync"] = display.VSync, - }; - - // Build the output by hand so preserved-keys keep their raw JSON. var sb = new System.Text.StringBuilder(); sb.Append('{').AppendLine(); - sb.Append(" \"display\": ") - .Append(JsonSerializer.Serialize(displayObj, new JsonSerializerOptions { WriteIndented = true }) - .Replace("\n", "\n ")) - .Append(',').AppendLine(); + // Preserved keys come first (sorted by name) then the section, then + // version last. Preserves alphabetical-style top-level ordering. foreach (var kv in preservedKeys) { sb.Append(" \"").Append(kv.Key).Append("\": ") .Append(kv.Value).Append(',').AppendLine(); } + sb.Append(" \"").Append(sectionName).Append("\": ") + .Append(JsonSerializer.Serialize(sectionPayload, new JsonSerializerOptions { WriteIndented = true }) + .Replace("\n", "\n ")) + .Append(',').AppendLine(); sb.Append(" \"version\": ").Append(CurrentSchemaVersion).AppendLine(); sb.Append('}').AppendLine(); diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs index 2cf233b..d90bf0a 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs @@ -36,6 +36,11 @@ public sealed class SettingsVM private DisplaySettings _displayDraft; private readonly Action _onSaveDisplay; + // L.0 — Audio tab. Same shape as Display. + private AudioSettings _audioPersisted; + private AudioSettings _audioDraft; + private readonly Action _onSaveAudio; + /// The action currently being rebound, or null when idle. public InputAction? RebindInProgress { get; private set; } @@ -58,26 +63,36 @@ public sealed class SettingsVM /// rebinds are pending. public bool HasUnsavedChanges => !KeyBindingsEqual(_persisted, _draft) - || _displayPersisted != _displayDraft; + || _displayPersisted != _displayDraft + || _audioPersisted != _audioDraft; /// The current Display draft. Panel reads from here; /// mutation goes through . public DisplaySettings DisplayDraft => _displayDraft; + /// The current Audio draft. Panel reads from here; + /// mutation goes through . + public AudioSettings AudioDraft => _audioDraft; + public SettingsVM( KeyBindings persisted, InputDispatcher dispatcher, Action onSave, DisplaySettings persistedDisplay, - Action onSaveDisplay) + Action onSaveDisplay, + AudioSettings persistedAudio, + Action onSaveAudio) { _persisted = persisted ?? throw new ArgumentNullException(nameof(persisted)); _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); _onSave = onSave ?? throw new ArgumentNullException(nameof(onSave)); _displayPersisted = persistedDisplay ?? throw new ArgumentNullException(nameof(persistedDisplay)); _onSaveDisplay = onSaveDisplay ?? throw new ArgumentNullException(nameof(onSaveDisplay)); + _audioPersisted = persistedAudio ?? throw new ArgumentNullException(nameof(persistedAudio)); + _onSaveAudio = onSaveAudio ?? throw new ArgumentNullException(nameof(onSaveAudio)); _draft = CloneBindings(persisted); _displayDraft = persistedDisplay; + _audioDraft = persistedAudio; } /// @@ -90,6 +105,18 @@ public sealed class SettingsVM _displayDraft = value ?? throw new ArgumentNullException(nameof(value)); } + /// + /// Replace the entire Audio draft with . + /// Live audio preview is achieved at the host layer by pushing + /// into the running OpenAL engine each frame + /// — this method only mutates VM state. Cancel reverts the draft and + /// the host's next-frame push restores the pre-edit engine volumes. + /// + public void SetAudio(AudioSettings value) + { + _audioDraft = value ?? throw new ArgumentNullException(nameof(value)); + } + /// /// Begin rebinding . The supplied /// binding will be removed when the new @@ -199,6 +226,7 @@ public sealed class SettingsVM { _draft = KeyBindings.RetailDefaults(); _displayDraft = DisplaySettings.Default; + _audioDraft = AudioSettings.Default; } /// @@ -213,8 +241,10 @@ public sealed class SettingsVM { _onSave(_draft); _onSaveDisplay(_displayDraft); + _onSaveAudio(_audioDraft); _persisted = CloneBindings(_draft); _displayPersisted = _displayDraft; + _audioPersisted = _audioDraft; } /// @@ -226,6 +256,7 @@ public sealed class SettingsVM { _draft = CloneBindings(_persisted); _displayDraft = _displayPersisted; + _audioDraft = _audioPersisted; CancelRebind(); } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/AudioSettingsTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/AudioSettingsTests.cs new file mode 100644 index 0000000..42d6f81 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/AudioSettingsTests.cs @@ -0,0 +1,44 @@ +using AcDream.UI.Abstractions.Panels.Settings; + +namespace AcDream.UI.Abstractions.Tests.Panels.Settings; + +/// +/// L.0: default-pin tests. Defaults must +/// match the OpenAL engine's hard-coded constructor values so a user +/// who has never opened the Audio tab gets identical behaviour to the +/// pre-Phase-L world. +/// +public sealed class AudioSettingsTests +{ + [Fact] + public void Default_values_match_engine_constructor_defaults() + { + // OpenAlAudioEngine ctor: Master=1.0, Music=0.7, Sfx=1.0, + // Ambient=0.8 — see src/AcDream.App/Audio/OpenAlAudioEngine.cs. + var d = AudioSettings.Default; + Assert.Equal(1.0f, d.Master); + Assert.Equal(0.7f, d.Music); + Assert.Equal(1.0f, d.Sfx); + Assert.Equal(0.8f, d.Ambient); + } + + [Fact] + public void Equality_is_value_based() + { + var a = AudioSettings.Default; + var b = AudioSettings.Default with { Master = 0.5f }; + var c = AudioSettings.Default with { Master = 0.5f }; + Assert.NotEqual(a, b); + Assert.Equal(b, c); + } + + [Fact] + public void With_expression_clones_one_field() + { + var d = AudioSettings.Default with { Music = 0.25f }; + Assert.Equal(0.25f, d.Music); + // Other fields untouched. + Assert.Equal(AudioSettings.Default.Master, d.Master); + Assert.Equal(AudioSettings.Default.Sfx, d.Sfx); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs index ec05c4c..4181f88 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs @@ -31,7 +31,8 @@ public sealed class SettingsPanelTests var dispatcher = new InputDispatcher(kb, mouse, persisted); var vm = new SettingsVM( persisted, dispatcher, _ => { }, - DisplaySettings.Default, _ => { }); + DisplaySettings.Default, _ => { }, + AudioSettings.Default, _ => { }); var panel = new SettingsPanel(vm); return (panel, vm, kb, dispatcher); } @@ -226,14 +227,17 @@ public sealed class SettingsPanelTests [Fact] public void Placeholder_tabs_render_coming_soon_text_when_active() { + // Gameplay is still a placeholder (next in build order). Display + // and Audio have shipped — they have real widgets, not "coming + // soon" text. var (panel, _, _, _) = Build(); - var r = new FakePanelRenderer { ActiveTabLabel = "Audio" }; + var r = new FakePanelRenderer { ActiveTabLabel = "Gameplay" }; 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("Audio settings coming soon")); + Assert.Contains(wrapped, t => t.Contains("Gameplay settings coming soon")); } // -- Display tab content --------------------------------------------- @@ -284,6 +288,51 @@ public sealed class SettingsPanelTests Assert.Contains("3840x2160", items); } + // -- Audio tab content ----------------------------------------------- + + [Fact] + public void Audio_tab_when_active_renders_four_volume_sliders() + { + var (panel, _, _, _) = Build(); + var r = new FakePanelRenderer { ActiveTabLabel = "Audio" }; + + panel.Render(new PanelContext(0.016f, new NullBus()), r); + + var sliders = r.Calls.Where(c => c.Method == "SliderFloat") + .Select(c => (string)c.Args[0]!).ToList(); + Assert.Contains("Master", sliders); + Assert.Contains("Music", sliders); + Assert.Contains("SFX", sliders); + Assert.Contains("Ambient", sliders); + } + + [Fact] + public void Audio_tab_does_not_render_when_a_different_tab_is_active() + { + var (panel, _, _, _) = Build(); + var r = new FakePanelRenderer { ActiveTabLabel = "Display" }; + + panel.Render(new PanelContext(0.016f, new NullBus()), r); + + var sliders = r.Calls.Where(c => c.Method == "SliderFloat") + .Select(c => (string)c.Args[0]!).ToList(); + Assert.DoesNotContain("Master", sliders); + Assert.DoesNotContain("Music", sliders); + } + + [Fact] + public void Audio_sliders_are_clamped_to_zero_one_range() + { + var (panel, _, _, _) = Build(); + var r = new FakePanelRenderer { ActiveTabLabel = "Audio" }; + + panel.Render(new PanelContext(0.016f, new NullBus()), r); + + var masterCall = r.Calls.First(c => c.Method == "SliderFloat" && (string)c.Args[0]! == "Master"); + Assert.Equal(0f, (float)masterCall.Args[2]!); + Assert.Equal(1f, (float)masterCall.Args[3]!); + } + [Fact] public void Save_Cancel_buttons_render_outside_the_tab_bar() { diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs index 226454c..bef09fc 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs @@ -116,4 +116,68 @@ public sealed class SettingsStoreTests : System.IDisposable var path = SettingsStore.DefaultPath(); Assert.EndsWith("acdream" + Path.DirectorySeparatorChar + "settings.json", path); } + + // -- Audio section round-trip ---------------------------------------- + + [Fact] + public void LoadAudio_returns_defaults_when_file_is_missing() + { + var store = new SettingsStore(_tempPath); + Assert.Equal(AudioSettings.Default, store.LoadAudio()); + } + + [Fact] + public void SaveAudio_then_LoadAudio_round_trips_all_fields() + { + var store = new SettingsStore(_tempPath); + var original = new AudioSettings(Master: 0.3f, Music: 0.45f, Sfx: 0.9f, Ambient: 0.6f); + + store.SaveAudio(original); + var loaded = store.LoadAudio(); + + Assert.Equal(original, loaded); + } + + [Fact] + public void LoadAudio_falls_back_per_field_when_keys_missing() + { + File.WriteAllText(_tempPath, """ + { + "version": 1, + "audio": { "master": 0.25 } + } + """); + var store = new SettingsStore(_tempPath); + + var loaded = store.LoadAudio(); + + Assert.Equal(0.25f, loaded.Master); + Assert.Equal(AudioSettings.Default.Music, loaded.Music); + Assert.Equal(AudioSettings.Default.Sfx, loaded.Sfx); + Assert.Equal(AudioSettings.Default.Ambient, loaded.Ambient); + } + + [Fact] + public void SaveAudio_preserves_display_section() + { + // Save display first, then audio — display values must survive. + var store = new SettingsStore(_tempPath); + store.SaveDisplay(DisplaySettings.Default with { Resolution = "2560x1440" }); + store.SaveAudio(AudioSettings.Default with { Master = 0.4f }); + + Assert.Equal("2560x1440", store.LoadDisplay().Resolution); + Assert.Equal(0.4f, store.LoadAudio().Master); + } + + [Fact] + public void SaveDisplay_after_SaveAudio_preserves_audio_section() + { + // Reverse order — audio must survive a subsequent display save. + var store = new SettingsStore(_tempPath); + store.SaveAudio(AudioSettings.Default with { Music = 0.1f }); + store.SaveDisplay(DisplaySettings.Default with { ShowFps = true }); + + Assert.Equal(0.1f, store.LoadAudio().Music); + Assert.True(store.LoadDisplay().ShowFps); + } } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs index 595ff38..25e5c69 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) - Build(KeyBindings? persisted = null, DisplaySettings? persistedDisplay = 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) + Build(KeyBindings? persisted = null, DisplaySettings? persistedDisplay = null, AudioSettings? persistedAudio = null) { persisted ??= MakeMinimalBindings(); var kb = new FakeKeyboardSource(); @@ -25,12 +25,15 @@ public sealed class SettingsVMTests 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 vm = new SettingsVM( persisted, dispatcher, b => savedHistory.Add(b), persistedDisplay ?? DisplaySettings.Default, - d => savedDisplayHistory.Add(d)); - return (vm, kb, dispatcher, persisted, savedHistory, savedDisplayHistory); + d => savedDisplayHistory.Add(d), + persistedAudio ?? AudioSettings.Default, + a => savedAudioHistory.Add(a)); + return (vm, kb, dispatcher, persisted, savedHistory, savedDisplayHistory, savedAudioHistory); } private static KeyBindings MakeMinimalBindings() @@ -45,7 +48,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); } @@ -53,7 +56,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); @@ -66,7 +69,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); @@ -84,7 +87,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); @@ -101,7 +104,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). @@ -121,7 +124,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); @@ -142,7 +145,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); @@ -164,7 +167,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); @@ -184,7 +187,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). @@ -195,7 +198,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); @@ -211,7 +214,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); @@ -227,7 +230,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); @@ -240,7 +243,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); } @@ -250,7 +253,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); } @@ -258,7 +261,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); } @@ -266,7 +269,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(); @@ -281,7 +284,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); @@ -295,7 +298,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(); @@ -310,7 +313,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); @@ -320,4 +323,64 @@ public sealed class SettingsVMTests 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); + } }