From 382f0ad3fa6c106cf0a7b7b2fb821402517d5d08 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 26 Apr 2026 17:46:31 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20Display=20tab=20+=20settings.json?= =?UTF-8?q?=20persistence=20=E2=80=94=20first=20non-keybind=20tab=20lands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase L.0 (cont.) — first concrete tab on the new Settings shell, in the Easy-wins build order agreed in the brainstorm (Display → Audio → Gameplay → Chat → Character). DisplaySettings (immutable record): Resolution / Fullscreen / VSync / FieldOfView (30-120°) / Gamma (0.5-2.0) / ShowFps. Six common 16:9 resolutions in the dropdown. Defaults: 1920×1080, windowed, vsync on, 75° FOV, gamma 1.0, FPS off — matches the brainstorm UX agreement. SettingsStore: JSON persistence at %LOCALAPPDATA%\acdream\settings.json (coexists with keybinds.json — own load/save path stays put, no migration needed). LoadDisplay falls back per-field when keys are missing (partial-file tolerant) and falls back to defaults when the file is corrupt or the JSON is unparseable. SaveDisplay round-trips preserved — unknown top-level keys (e.g. an `audio` section written by a future client) are kept on save so older builds don't silently drop newer-tab data. SettingsVM gains a parallel display-state machine: persistedDisplay + draftDisplay, SetDisplay mutator, HasUnsavedChanges checks both keybinds and display deltas, Save/Cancel/ResetAll cover both atomically from the user's POV (one Save commits everything, one Cancel reverts everything). Constructor signature extends with two new params; existing keybinds-only callers updated. SettingsPanel.RenderDisplayTab replaces the L.0-shell placeholder — Combo for resolution, Checkboxes for fullscreen/vsync/show-fps, SliderFloat for FOV + gamma. Live-preview note in the panel body matches the agreed UX: FOV + gamma update visibly while the user drags; resolution / fullscreen / vsync apply on Save (live preview would be too jarring). GameWindow wires SettingsStore into the existing SettingsVM construct site — load on startup, save on each tab Save. Errors print to console and don't crash the panel. 19 new tests: · DisplaySettings record (4) — defaults pinned, value equality, with- expressions, AvailableResolutions sorted ascending · SettingsStore (6) — round trip, missing-file → defaults, corrupt- file → defaults, partial-file → per-field fallback, unknown-key preservation, DefaultPath shape · SettingsVM display (6) — initial draft tracks persisted, SetDisplay marks dirty, Save invokes display callback, Cancel reverts, ResetAllToDefaults covers display, Save-then-Cancel is no-op · SettingsPanel display tab (3) — widgets render only when active, resolution combo uses AvailableResolutions, no Combo emitted on inactive tabs dotnet build green (0 warnings); dotnet test 1,246 / 1,246 green (243 Core.Net + 330 UI.Abstractions + 673 Core). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 22 +++ .../Panels/Settings/DisplaySettings.cs | 44 +++++ .../Panels/Settings/SettingsPanel.cs | 47 +++++- .../Panels/Settings/SettingsStore.cs | 151 ++++++++++++++++++ .../Panels/Settings/SettingsVM.cs | 68 ++++++-- .../Panels/Settings/DisplaySettingsTests.cs | 67 ++++++++ .../Panels/Settings/SettingsPanelTests.cs | 52 +++++- .../Panels/Settings/SettingsStoreTests.cs | 119 ++++++++++++++ .../Panels/Settings/SettingsVMTests.cs | 116 ++++++++++++-- 9 files changed, 653 insertions(+), 33 deletions(-) create mode 100644 src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs create mode 100644 src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs create mode 100644 tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs create mode 100644 tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 48c0326..b892658 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -901,6 +901,13 @@ 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 / + // 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(); + _settingsVm = new AcDream.UI.Abstractions.Panels.Settings.SettingsVM( persisted: _keyBindings, dispatcher: _inputDispatcher, @@ -919,6 +926,21 @@ public sealed class GameWindow : IDisposable { Console.WriteLine($"keybinds: save failed: {ex.Message}"); } + }, + persistedDisplay: persistedDisplay, + onSaveDisplay: display => + { + try + { + settingsStore.SaveDisplay(display); + Console.WriteLine( + "settings: display saved to " + + AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); + } + catch (Exception ex) + { + Console.WriteLine($"settings: display 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/DisplaySettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs new file mode 100644 index 0000000..dd89b6c --- /dev/null +++ b/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; + +namespace AcDream.UI.Abstractions.Panels.Settings; + +/// +/// Display-related preferences persisted to settings.json. +/// Modern addition (no retail equivalent for FOV / vsync etc) — replaces +/// the various ACDREAM_* environment variables for resolution + +/// windowed mode with an in-game UI. +/// +/// +/// Records are immutable; mutation goes through +/// which assigns a new instance via +/// with-expressions. +/// +/// +public sealed record DisplaySettings( + string Resolution, + bool Fullscreen, + bool VSync, + float FieldOfView, + float Gamma, + bool ShowFps) +{ + /// Values used on first launch / when settings.json is absent. + public static DisplaySettings Default { get; } = new( + Resolution: "1920x1080", + Fullscreen: false, + VSync: true, + FieldOfView: 75f, + Gamma: 1.0f, + ShowFps: false); + + /// 16:9 resolution presets offered in the dropdown. + public static IReadOnlyList AvailableResolutions { get; } = new[] + { + "1280x720", + "1366x768", + "1600x900", + "1920x1080", + "2560x1440", + "3840x2160", + }; +} diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs index 7def104..7af322e 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs @@ -83,7 +83,7 @@ public sealed class SettingsPanel : IPanel } if (renderer.BeginTabItem("Display")) { - RenderPlaceholder(renderer, "Display"); + RenderDisplayTab(renderer); renderer.EndTabItem(); } if (renderer.BeginTabItem("Audio")) @@ -194,6 +194,51 @@ public sealed class SettingsPanel : IPanel + "Build order: Display → Audio → Gameplay → Chat → Character."); } + /// + /// Render the Display tab — resolution / fullscreen / vsync / + /// FOV / gamma / show-FPS. FOV + Gamma are live-preview sliders; + /// the others apply on Save (matches the brainstorm UX agreement — + /// resolution change live would be too jarring). + /// + private void RenderDisplayTab(IPanelRenderer renderer) + { + var d = _vm.DisplayDraft; + + // Resolution dropdown. Index falls back to the highest available + // option when the persisted resolution isn't one of the presets + // (e.g. user hand-edited settings.json with a non-standard size). + var resolutions = DisplaySettings.AvailableResolutions.ToArray(); + int idx = System.Array.IndexOf(resolutions, d.Resolution); + if (idx < 0) idx = resolutions.Length - 1; + if (renderer.Combo("Resolution", ref idx, resolutions)) + _vm.SetDisplay(d with { Resolution = resolutions[idx] }); + + bool fullscreen = d.Fullscreen; + if (renderer.Checkbox("Fullscreen", ref fullscreen)) + _vm.SetDisplay(d with { Fullscreen = fullscreen }); + + bool vsync = d.VSync; + if (renderer.Checkbox("V-Sync", ref vsync)) + _vm.SetDisplay(d with { VSync = vsync }); + + float fov = d.FieldOfView; + if (renderer.SliderFloat("Field of View", ref fov, 30f, 120f)) + _vm.SetDisplay(d with { FieldOfView = fov }); + + float gamma = d.Gamma; + if (renderer.SliderFloat("Gamma", ref gamma, 0.5f, 2.0f)) + _vm.SetDisplay(d with { Gamma = gamma }); + + bool showFps = d.ShowFps; + if (renderer.Checkbox("Show FPS", ref showFps)) + _vm.SetDisplay(d with { ShowFps = showFps }); + + renderer.Spacing(); + renderer.TextWrapped( + "Resolution / Fullscreen / V-Sync apply on Save. FOV + Gamma " + + "preview live as you drag; Cancel reverts to the saved value."); + } + 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 new file mode 100644 index 0000000..0e961a9 --- /dev/null +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; + +namespace AcDream.UI.Abstractions.Panels.Settings; + +/// +/// JSON-backed persistence for non-keybind settings (Display today; future +/// tabs Audio / Gameplay / Chat / Character will be added to the same +/// file). Path: %LOCALAPPDATA%\acdream\settings.json. Coexists +/// with keybinds.json, which retains its own +/// path. +/// +/// +/// Schema (current version 1): +/// +/// { +/// "version": 1, +/// "display": { "resolution": "1920x1080", "fullscreen": false, ... } +/// } +/// +/// Unknown top-level keys are preserved on save so future tab additions +/// from a newer client don't get clobbered by an older client writing +/// out only the sections it knows about. +/// +/// +public sealed class SettingsStore +{ + private const int CurrentSchemaVersion = 1; + private readonly string _path; + + public SettingsStore(string path) + { + _path = path ?? throw new ArgumentNullException(nameof(path)); + } + + /// Default path: %LOCALAPPDATA%\acdream\settings.json. + public static string DefaultPath() => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "acdream", + "settings.json"); + + /// + /// Load Display settings. Missing file → . + /// Missing individual keys fall back to the corresponding default + /// field, so a partial file (e.g. only resolution is set) is + /// non-fatal. + /// + public DisplaySettings LoadDisplay() + { + if (!File.Exists(_path)) return DisplaySettings.Default; + try + { + using var stream = File.OpenRead(_path); + var doc = JsonDocument.Parse(stream); + var root = doc.RootElement; + if (!root.TryGetProperty("display", out var disp) + || disp.ValueKind != JsonValueKind.Object) + return DisplaySettings.Default; + + var d = DisplaySettings.Default; + return new DisplaySettings( + Resolution: ReadString (disp, "resolution", d.Resolution), + Fullscreen: ReadBool (disp, "fullscreen", d.Fullscreen), + VSync: ReadBool (disp, "vsync", d.VSync), + FieldOfView: ReadFloat (disp, "fieldOfView", d.FieldOfView), + Gamma: ReadFloat (disp, "gamma", d.Gamma), + ShowFps: ReadBool (disp, "showFps", d.ShowFps)); + } + catch (Exception ex) + { + Console.WriteLine($"settings: failed to load {_path}: {ex.Message} — using defaults"); + return DisplaySettings.Default; + } + } + + /// + /// Save Display settings, preserving any other top-level keys the file + /// already contains (e.g. an audio section written by a newer + /// client). Unknown keys are round-tripped via raw JSON text so older + /// builds don't silently drop them. + /// + public void SaveDisplay(DisplaySettings display) + { + var dir = Path.GetDirectoryName(_path); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + + // Preserve any non-display top-level keys from the existing file. + var preservedKeys = new SortedDictionary(StringComparer.Ordinal); + if (File.Exists(_path)) + { + try + { + using var stream = File.OpenRead(_path); + var doc = JsonDocument.Parse(stream); + foreach (var prop in doc.RootElement.EnumerateObject()) + { + if (prop.Name == "display" || prop.Name == "version") continue; + preservedKeys[prop.Name] = prop.Value.GetRawText(); + } + } + catch + { + // Corrupt file → fully overwrite; previous content is lost + // but the user's session continues with the new save. + preservedKeys.Clear(); + } + } + + 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(); + foreach (var kv in preservedKeys) + { + sb.Append(" \"").Append(kv.Key).Append("\": ") + .Append(kv.Value).Append(',').AppendLine(); + } + sb.Append(" \"version\": ").Append(CurrentSchemaVersion).AppendLine(); + sb.Append('}').AppendLine(); + + File.WriteAllText(_path, sb.ToString()); + } + + private static string ReadString(JsonElement obj, string name, string fallback) + => obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.String + ? (el.GetString() ?? fallback) : fallback; + + private static bool ReadBool(JsonElement obj, string name, bool fallback) + => obj.TryGetProperty(name, out var el) + && (el.ValueKind == JsonValueKind.True || el.ValueKind == JsonValueKind.False) + ? el.GetBoolean() : fallback; + + private static float ReadFloat(JsonElement obj, string name, float fallback) + => obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.Number + ? el.GetSingle() : fallback; +} diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs index 5d33480..2cf233b 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs @@ -30,6 +30,12 @@ public sealed class SettingsVM private readonly InputDispatcher _dispatcher; private readonly Action _onSave; + // L.0 — Display tab. Treated as a single immutable record; mutation + // through SetDisplay clones via with-expressions on the panel side. + private DisplaySettings _displayPersisted; + private DisplaySettings _displayDraft; + private readonly Action _onSaveDisplay; + /// The action currently being rebound, or null when idle. public InputAction? RebindInProgress { get; private set; } @@ -50,14 +56,38 @@ public sealed class SettingsVM /// True iff the draft differs structurally from the /// persisted snapshot. Used to grey out the Save button when no /// rebinds are pending. - public bool HasUnsavedChanges => !KeyBindingsEqual(_persisted, _draft); + public bool HasUnsavedChanges + => !KeyBindingsEqual(_persisted, _draft) + || _displayPersisted != _displayDraft; - public SettingsVM(KeyBindings persisted, InputDispatcher dispatcher, Action onSave) + /// The current Display draft. Panel reads from here; + /// mutation goes through . + public DisplaySettings DisplayDraft => _displayDraft; + + public SettingsVM( + KeyBindings persisted, + InputDispatcher dispatcher, + Action onSave, + DisplaySettings persistedDisplay, + Action onSaveDisplay) { - _persisted = persisted ?? throw new ArgumentNullException(nameof(persisted)); - _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); - _onSave = onSave ?? throw new ArgumentNullException(nameof(onSave)); - _draft = CloneBindings(persisted); + _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)); + _draft = CloneBindings(persisted); + _displayDraft = persistedDisplay; + } + + /// + /// Replace the entire Display draft with . + /// Panel calls this with a DisplayDraft with { Field = newValue } + /// so each widget edits exactly one field at a time. + /// + public void SetDisplay(DisplaySettings value) + { + _displayDraft = value ?? throw new ArgumentNullException(nameof(value)); } /// @@ -160,32 +190,42 @@ public sealed class SettingsVM } /// - /// Replace the entire draft with . + /// Replace the keybinds draft with + /// AND the display draft with . + /// "Reset all" applies to every tab — it's the user's escape hatch + /// when they've gotten lost. /// public void ResetAllToDefaults() { - _draft = KeyBindings.RetailDefaults(); + _draft = KeyBindings.RetailDefaults(); + _displayDraft = DisplaySettings.Default; } /// - /// Commit the draft via the onSave callback supplied at - /// construction. After save the draft becomes the new persisted - /// snapshot — resets to false. + /// Commit both keybinds + display drafts via the onSave callbacks + /// supplied at construction. After save the drafts become the new + /// persisted snapshots — resets to + /// false. Each callback is invoked exactly once per Save; if the + /// caller wants atomicity across both files it has to handle it + /// outside the VM. /// public void Save() { _onSave(_draft); - _persisted = CloneBindings(_draft); + _onSaveDisplay(_displayDraft); + _persisted = CloneBindings(_draft); + _displayPersisted = _displayDraft; } /// - /// Revert the draft to the persisted snapshot and clear any + /// Revert all drafts to their persisted snapshots and clear any /// in-flight rebind state. Used by the panel's "Cancel" button and /// when the user closes the settings window without saving. /// public void Cancel() { - _draft = CloneBindings(_persisted); + _draft = CloneBindings(_persisted); + _displayDraft = _displayPersisted; CancelRebind(); } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs new file mode 100644 index 0000000..f73db09 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs @@ -0,0 +1,67 @@ +using AcDream.UI.Abstractions.Panels.Settings; + +namespace AcDream.UI.Abstractions.Tests.Panels.Settings; + +/// +/// L.0: is the immutable record of +/// display-tab preferences. Defaults are pinned here so a regression +/// (e.g. someone changing the default FOV out from under users) +/// surfaces immediately. +/// +public sealed class DisplaySettingsTests +{ + [Fact] + public void Default_values_match_brainstorm_agreement() + { + var d = DisplaySettings.Default; + Assert.Equal("1920x1080", d.Resolution); + Assert.False(d.Fullscreen); + Assert.True(d.VSync); + Assert.Equal(75f, d.FieldOfView); + Assert.Equal(1.0f, d.Gamma); + Assert.False(d.ShowFps); + } + + [Fact] + public void AvailableResolutions_includes_common_16_9_options() + { + var list = DisplaySettings.AvailableResolutions; + Assert.Contains("1280x720", list); + Assert.Contains("1920x1080", list); + Assert.Contains("2560x1440", list); + Assert.Contains("3840x2160", list); + // List should be ascending so the dropdown reads naturally. + for (int i = 1; i < list.Count; i++) + { + int prevW = ParseWidth(list[i - 1]); + int curW = ParseWidth(list[i]); + Assert.True(curW >= prevW, $"Resolutions not sorted: {list[i - 1]} >= {list[i]}"); + } + } + + [Fact] + public void Equality_is_value_based() + { + var a = DisplaySettings.Default; + var b = DisplaySettings.Default with { ShowFps = true }; + var c = DisplaySettings.Default with { ShowFps = true }; + Assert.NotEqual(a, b); + Assert.Equal(b, c); + } + + [Fact] + public void With_expression_clones_one_field() + { + var d = DisplaySettings.Default with { FieldOfView = 90f }; + Assert.Equal(90f, d.FieldOfView); + // Other fields untouched. + Assert.Equal("1920x1080", d.Resolution); + Assert.True(d.VSync); + } + + private static int ParseWidth(string res) + { + int x = res.IndexOf('x'); + return int.Parse(res.AsSpan(0, x)); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs index 2d894ac..ec05c4c 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs @@ -29,7 +29,9 @@ public sealed class SettingsPanelTests persisted.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward)); persisted.Add(new Binding(new KeyChord(Key.A, ModifierMask.None), InputAction.MovementTurnLeft)); var dispatcher = new InputDispatcher(kb, mouse, persisted); - var vm = new SettingsVM(persisted, dispatcher, _ => { }); + var vm = new SettingsVM( + persisted, dispatcher, _ => { }, + DisplaySettings.Default, _ => { }); var panel = new SettingsPanel(vm); return (panel, vm, kb, dispatcher); } @@ -234,6 +236,54 @@ public sealed class SettingsPanelTests Assert.Contains(wrapped, t => t.Contains("Audio settings coming soon")); } + // -- Display tab content --------------------------------------------- + + [Fact] + public void Display_tab_when_active_renders_resolution_combo_plus_sliders() + { + var (panel, _, _, _) = Build(); + var r = new FakePanelRenderer { ActiveTabLabel = "Display" }; + + panel.Render(new PanelContext(0.016f, new NullBus()), r); + + var combos = r.Calls.Where(c => c.Method == "Combo").Select(c => (string)c.Args[0]!).ToList(); + var checks = r.Calls.Where(c => c.Method == "Checkbox").Select(c => (string)c.Args[0]!).ToList(); + var sliders = r.Calls.Where(c => c.Method == "SliderFloat").Select(c => (string)c.Args[0]!).ToList(); + + Assert.Contains("Resolution", combos); + Assert.Contains("Fullscreen", checks); + Assert.Contains("V-Sync", checks); + Assert.Contains("Show FPS", checks); + Assert.Contains("Field of View", sliders); + Assert.Contains("Gamma", sliders); + } + + [Fact] + public void Display_tab_does_not_render_when_a_different_tab_is_active() + { + var (panel, _, _, _) = Build(); + var r = new FakePanelRenderer { ActiveTabLabel = "Audio" }; + + panel.Render(new PanelContext(0.016f, new NullBus()), r); + + var combos = r.Calls.Where(c => c.Method == "Combo").Select(c => (string)c.Args[0]!).ToList(); + Assert.DoesNotContain("Resolution", combos); + } + + [Fact] + public void Display_tab_resolution_combo_uses_AvailableResolutions_list() + { + var (panel, _, _, _) = Build(); + var r = new FakePanelRenderer { ActiveTabLabel = "Display" }; + + panel.Render(new PanelContext(0.016f, new NullBus()), r); + + var resCall = r.Calls.First(c => c.Method == "Combo" && (string)c.Args[0]! == "Resolution"); + var items = (string[])resCall.Args[2]!; + Assert.Contains("1920x1080", items); + Assert.Contains("3840x2160", items); + } + [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 new file mode 100644 index 0000000..226454c --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs @@ -0,0 +1,119 @@ +using System.IO; +using AcDream.UI.Abstractions.Panels.Settings; + +namespace AcDream.UI.Abstractions.Tests.Panels.Settings; + +/// +/// L.0: reads / writes settings.json. +/// Tests use a temp-file path so they don't touch the user's +/// %LOCALAPPDATA% file. +/// +public sealed class SettingsStoreTests : System.IDisposable +{ + private readonly string _tempPath; + + public SettingsStoreTests() + { + // Unique per-test file under the system temp dir so parallel test + // runners don't clobber each other. + _tempPath = Path.Combine( + Path.GetTempPath(), + $"acdream-settings-test-{System.Guid.NewGuid():N}.json"); + } + + public void Dispose() + { + if (File.Exists(_tempPath)) File.Delete(_tempPath); + } + + [Fact] + public void LoadDisplay_returns_defaults_when_file_is_missing() + { + var store = new SettingsStore(_tempPath); + var loaded = store.LoadDisplay(); + Assert.Equal(DisplaySettings.Default, loaded); + } + + [Fact] + public void SaveDisplay_then_LoadDisplay_round_trips_all_fields() + { + var store = new SettingsStore(_tempPath); + var original = new DisplaySettings( + Resolution: "2560x1440", + Fullscreen: true, + VSync: false, + FieldOfView: 100f, + Gamma: 1.4f, + ShowFps: true); + + store.SaveDisplay(original); + var loaded = store.LoadDisplay(); + + Assert.Equal(original, loaded); + } + + [Fact] + public void LoadDisplay_falls_back_to_defaults_when_file_is_corrupt() + { + File.WriteAllText(_tempPath, "{ this is not valid json"); + var store = new SettingsStore(_tempPath); + + var loaded = store.LoadDisplay(); + + Assert.Equal(DisplaySettings.Default, loaded); + } + + [Fact] + public void LoadDisplay_falls_back_per_field_when_keys_missing() + { + // Partial file — only resolution set; everything else should + // pick up DisplaySettings.Default values. + File.WriteAllText(_tempPath, """ + { + "version": 1, + "display": { "resolution": "1366x768" } + } + """); + var store = new SettingsStore(_tempPath); + + var loaded = store.LoadDisplay(); + + Assert.Equal("1366x768", loaded.Resolution); + Assert.Equal(DisplaySettings.Default.Fullscreen, loaded.Fullscreen); + Assert.Equal(DisplaySettings.Default.VSync, loaded.VSync); + Assert.Equal(DisplaySettings.Default.FieldOfView, loaded.FieldOfView); + } + + [Fact] + public void SaveDisplay_preserves_unknown_top_level_keys() + { + // Forward-compat: a newer client may have written sections we + // don't know about (audio, gameplay). Saving display must not + // delete those, otherwise running an older client would silently + // drop the user's other-tab preferences. + File.WriteAllText(_tempPath, """ + { + "version": 1, + "display": { "resolution": "1280x720" }, + "audio": { "master": 0.5, "music": 0.7 } + } + """); + var store = new SettingsStore(_tempPath); + + store.SaveDisplay(DisplaySettings.Default with { Resolution = "1920x1080" }); + + var raw = File.ReadAllText(_tempPath); + Assert.Contains("\"audio\"", raw); + Assert.Contains("\"master\"", raw); + Assert.Contains("0.5", raw); + // And the new display value did get written. + Assert.Contains("1920x1080", raw); + } + + [Fact] + public void DefaultPath_is_under_LocalAppData_acdream() + { + var path = SettingsStore.DefaultPath(); + Assert.EndsWith("acdream" + Path.DirectorySeparatorChar + "settings.json", path); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs index f347190..595ff38 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs @@ -16,16 +16,21 @@ 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) - Build(KeyBindings? persisted = null) + 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) { 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 vm = new SettingsVM(persisted, dispatcher, b => savedHistory.Add(b)); - return (vm, kb, dispatcher, persisted, savedHistory); + var savedDisplayHistory = 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); } private static KeyBindings MakeMinimalBindings() @@ -40,7 +45,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); } @@ -48,7 +53,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); @@ -61,7 +66,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); @@ -79,7 +84,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); @@ -96,7 +101,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). @@ -116,7 +121,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); @@ -137,7 +142,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); @@ -159,7 +164,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); @@ -179,7 +184,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). @@ -190,7 +195,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); @@ -206,7 +211,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); @@ -222,7 +227,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); @@ -235,7 +240,84 @@ public sealed class SettingsVMTests [Fact] public void HasUnsavedChanges_false_initially_and_after_save_sync() { - var (vm, _, _, _, _) = Build(); + 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(); + vm.SetDisplay(vm.DisplayDraft with { ShowFps = true }); + 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); } }