feat(ui): Audio tab — live volume sliders driving OpenAL engine
Phase L.0 (cont.) — second tab on the Settings shell, in the Easy-wins
build order. Audio is the live-preview poster child: dragging a slider
is audible immediately, Save persists, Cancel reverts and the engine
catches up on the next frame.
AudioSettings record: Master / Music / Sfx / Ambient (all 0..1 floats).
Defaults match the OpenAlAudioEngine constructor values exactly so a
user who never opens the tab gets identical behaviour to the
pre-Phase-L env-var-only world (Master=1.0, Music=0.7, Sfx=1.0,
Ambient=0.8).
SettingsStore grows LoadAudio / SaveAudio + a generic SaveSection
helper that consolidates the unknown-top-level-key preservation logic.
Display and Audio sections coexist in settings.json:
{ "version": 1, "display": { ... }, "audio": { ... } }
Saving one section preserves the other on disk; a future Gameplay /
Chat / Character section drops in the same way without touching
existing data.
SettingsVM gains a parallel audio state machine (audioPersisted /
audioDraft / SetAudio / onSaveAudio callback). HasUnsavedChanges
covers all three buckets now (keybinds + display + audio); Save /
Cancel / ResetAll are atomic across all of them.
GameWindow wiring is the live-preview mechanism — every render frame
pushes the VM's AudioDraft into _audioEngine.MasterVolume etc. Cheap
(four float assignments) and unconditional. SetListener still applies
MasterVolume each frame too via the existing Phase E.2 code path, so
listener gain stays in sync. Persisted audio is applied to the engine
ONCE at startup before the first frame so the user's saved values
take effect before any sound plays — startup-time apply happens during
the same SettingsVM construction site that does the LoadDisplay +
LoadAudio.
SettingsPanel.RenderAudioTab replaces the L.0-shell placeholder — four
SliderFloat calls clamped to [0, 1], plus a footer note explaining the
live-preview UX. The "Coming soon" placeholder test was retargeted
from "Audio" to "Gameplay" since Audio is no longer a placeholder.
16 new tests:
· AudioSettings record (3) — defaults pin engine constants, value
equality, with-expressions
· SettingsStore audio round-trip (5) — missing-file → defaults,
round-trip all fields, partial-file per-field fallback, save-audio-
preserves-display, save-display-preserves-audio
· SettingsVM audio state (5) — initial draft tracks persisted,
SetAudio marks dirty, Save invokes audio callback, Cancel reverts,
ResetAllToDefaults covers audio
· SettingsPanel audio tab (3) — four sliders render only when active,
no SliderFloat emitted on inactive tabs, slider range is [0, 1]
dotnet build green (0 warnings); dotnet test 1,262 / 1,262 green
(243 Core.Net + 346 UI.Abstractions + 673 Core).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
382f0ad3fa
commit
53b1878c5c
9 changed files with 461 additions and 47 deletions
|
|
@ -901,12 +901,24 @@ public sealed class GameWindow : IDisposable
|
||||||
// the same OnLoad path (see _inputDispatcher field).
|
// the same OnLoad path (see _inputDispatcher field).
|
||||||
if (_inputDispatcher is not null)
|
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,
|
// chat / character tabs). Coexists with keybinds.json,
|
||||||
// which keeps its own load/save path.
|
// which keeps its own load/save path.
|
||||||
var settingsStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore(
|
var settingsStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore(
|
||||||
AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
|
AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
|
||||||
var persistedDisplay = settingsStore.LoadDisplay();
|
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(
|
_settingsVm = new AcDream.UI.Abstractions.Panels.Settings.SettingsVM(
|
||||||
persisted: _keyBindings,
|
persisted: _keyBindings,
|
||||||
|
|
@ -941,6 +953,21 @@ public sealed class GameWindow : IDisposable
|
||||||
{
|
{
|
||||||
Console.WriteLine($"settings: display save failed: {ex.Message}");
|
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);
|
_settingsPanel = new AcDream.UI.Abstractions.Panels.Settings.SettingsPanel(_settingsVm);
|
||||||
_panelHost.Register(_settingsPanel);
|
_panelHost.Register(_settingsPanel);
|
||||||
|
|
@ -4105,6 +4132,22 @@ public sealed class GameWindow : IDisposable
|
||||||
System.Numerics.Matrix4x4.Invert(camera.View, out var invView);
|
System.Numerics.Matrix4x4.Invert(camera.View, out var invView);
|
||||||
var camPos = new System.Numerics.Vector3(invView.M41, invView.M42, invView.M43);
|
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
|
// Phase E.2 audio: update listener pose so 3D sounds pan/attenuate
|
||||||
// correctly relative to where we're looking.
|
// correctly relative to where we're looking.
|
||||||
if (_audioEngine is not null && _audioEngine.IsAvailable)
|
if (_audioEngine is not null && _audioEngine.IsAvailable)
|
||||||
|
|
|
||||||
28
src/AcDream.UI.Abstractions/Panels/Settings/AudioSettings.cs
Normal file
28
src/AcDream.UI.Abstractions/Panels/Settings/AudioSettings.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
namespace AcDream.UI.Abstractions.Panels.Settings;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audio mixer preferences persisted to <c>settings.json</c>. Drives the
|
||||||
|
/// existing Phase E.2 OpenAL engine — the host wires these values into
|
||||||
|
/// <c>OpenAlAudioEngine.MasterVolume</c> / <c>SfxVolume</c> /
|
||||||
|
/// <c>MusicVolume</c> / <c>AmbientVolume</c> on Save and on startup.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// 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.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed record AudioSettings(
|
||||||
|
float Master,
|
||||||
|
float Music,
|
||||||
|
float Sfx,
|
||||||
|
float Ambient)
|
||||||
|
{
|
||||||
|
/// <summary>Values used on first launch. Mirror the engine's
|
||||||
|
/// constructor-default Volume properties.</summary>
|
||||||
|
public static AudioSettings Default { get; } = new(
|
||||||
|
Master: 1.0f,
|
||||||
|
Music: 0.7f,
|
||||||
|
Sfx: 1.0f,
|
||||||
|
Ambient: 0.8f);
|
||||||
|
}
|
||||||
|
|
@ -88,7 +88,7 @@ public sealed class SettingsPanel : IPanel
|
||||||
}
|
}
|
||||||
if (renderer.BeginTabItem("Audio"))
|
if (renderer.BeginTabItem("Audio"))
|
||||||
{
|
{
|
||||||
RenderPlaceholder(renderer, "Audio");
|
RenderAudioTab(renderer);
|
||||||
renderer.EndTabItem();
|
renderer.EndTabItem();
|
||||||
}
|
}
|
||||||
if (renderer.BeginTabItem("Gameplay"))
|
if (renderer.BeginTabItem("Gameplay"))
|
||||||
|
|
@ -239,6 +239,39 @@ public sealed class SettingsPanel : IPanel
|
||||||
+ "preview live as you drag; Cancel reverts to the saved value.");
|
+ "preview live as you drag; Cancel reverts to the saved value.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Render the Audio tab — four volume sliders (Master / Music / SFX /
|
||||||
|
/// Ambient). Volumes update <i>live</i>: 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.
|
||||||
|
/// </summary>
|
||||||
|
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)
|
private void RenderSection(IPanelRenderer renderer, string label, InputAction[] actions)
|
||||||
{
|
{
|
||||||
// Movement defaults open; other sections collapsed for first-run UX.
|
// Movement defaults open; other sections collapsed for first-run UX.
|
||||||
|
|
|
||||||
|
|
@ -82,11 +82,79 @@ public sealed class SettingsStore
|
||||||
/// builds don't silently drop them.
|
/// builds don't silently drop them.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void SaveDisplay(DisplaySettings display)
|
public void SaveDisplay(DisplaySettings display)
|
||||||
|
=> SaveSection("display", BuildDisplayObject(display));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Load Audio settings. Same fall-back behaviour as
|
||||||
|
/// <see cref="LoadDisplay"/>: missing file → defaults, missing fields
|
||||||
|
/// → per-field defaults, corrupt JSON → defaults.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Save Audio settings, preserving every other top-level key
|
||||||
|
/// (display, future gameplay/chat/character). Same round-trip
|
||||||
|
/// guarantee as <see cref="SaveDisplay"/>.
|
||||||
|
/// </summary>
|
||||||
|
public void SaveAudio(AudioSettings audio)
|
||||||
|
=> SaveSection("audio", BuildAudioObject(audio));
|
||||||
|
|
||||||
|
private static SortedDictionary<string, object> 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<string, object> BuildAudioObject(AudioSettings a)
|
||||||
|
=> new(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["ambient"] = a.Ambient,
|
||||||
|
["master"] = a.Master,
|
||||||
|
["music"] = a.Music,
|
||||||
|
["sfx"] = a.Sfx,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private void SaveSection(string sectionName, SortedDictionary<string, object> sectionPayload)
|
||||||
{
|
{
|
||||||
var dir = Path.GetDirectoryName(_path);
|
var dir = Path.GetDirectoryName(_path);
|
||||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
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<string, string>(StringComparer.Ordinal);
|
var preservedKeys = new SortedDictionary<string, string>(StringComparer.Ordinal);
|
||||||
if (File.Exists(_path))
|
if (File.Exists(_path))
|
||||||
{
|
{
|
||||||
|
|
@ -96,7 +164,7 @@ public sealed class SettingsStore
|
||||||
var doc = JsonDocument.Parse(stream);
|
var doc = JsonDocument.Parse(stream);
|
||||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
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();
|
preservedKeys[prop.Name] = prop.Value.GetRawText();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -108,28 +176,19 @@ public sealed class SettingsStore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var displayObj = new SortedDictionary<string, object>(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();
|
var sb = new System.Text.StringBuilder();
|
||||||
sb.Append('{').AppendLine();
|
sb.Append('{').AppendLine();
|
||||||
sb.Append(" \"display\": ")
|
// Preserved keys come first (sorted by name) then the section, then
|
||||||
.Append(JsonSerializer.Serialize(displayObj, new JsonSerializerOptions { WriteIndented = true })
|
// version last. Preserves alphabetical-style top-level ordering.
|
||||||
.Replace("\n", "\n "))
|
|
||||||
.Append(',').AppendLine();
|
|
||||||
foreach (var kv in preservedKeys)
|
foreach (var kv in preservedKeys)
|
||||||
{
|
{
|
||||||
sb.Append(" \"").Append(kv.Key).Append("\": ")
|
sb.Append(" \"").Append(kv.Key).Append("\": ")
|
||||||
.Append(kv.Value).Append(',').AppendLine();
|
.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(" \"version\": ").Append(CurrentSchemaVersion).AppendLine();
|
||||||
sb.Append('}').AppendLine();
|
sb.Append('}').AppendLine();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,11 @@ public sealed class SettingsVM
|
||||||
private DisplaySettings _displayDraft;
|
private DisplaySettings _displayDraft;
|
||||||
private readonly Action<DisplaySettings> _onSaveDisplay;
|
private readonly Action<DisplaySettings> _onSaveDisplay;
|
||||||
|
|
||||||
|
// L.0 — Audio tab. Same shape as Display.
|
||||||
|
private AudioSettings _audioPersisted;
|
||||||
|
private AudioSettings _audioDraft;
|
||||||
|
private readonly Action<AudioSettings> _onSaveAudio;
|
||||||
|
|
||||||
/// <summary>The action currently being rebound, or null when idle.</summary>
|
/// <summary>The action currently being rebound, or null when idle.</summary>
|
||||||
public InputAction? RebindInProgress { get; private set; }
|
public InputAction? RebindInProgress { get; private set; }
|
||||||
|
|
||||||
|
|
@ -58,26 +63,36 @@ public sealed class SettingsVM
|
||||||
/// rebinds are pending.</summary>
|
/// rebinds are pending.</summary>
|
||||||
public bool HasUnsavedChanges
|
public bool HasUnsavedChanges
|
||||||
=> !KeyBindingsEqual(_persisted, _draft)
|
=> !KeyBindingsEqual(_persisted, _draft)
|
||||||
|| _displayPersisted != _displayDraft;
|
|| _displayPersisted != _displayDraft
|
||||||
|
|| _audioPersisted != _audioDraft;
|
||||||
|
|
||||||
/// <summary>The current Display draft. Panel reads from here;
|
/// <summary>The current Display draft. Panel reads from here;
|
||||||
/// mutation goes through <see cref="SetDisplay"/>.</summary>
|
/// mutation goes through <see cref="SetDisplay"/>.</summary>
|
||||||
public DisplaySettings DisplayDraft => _displayDraft;
|
public DisplaySettings DisplayDraft => _displayDraft;
|
||||||
|
|
||||||
|
/// <summary>The current Audio draft. Panel reads from here;
|
||||||
|
/// mutation goes through <see cref="SetAudio"/>.</summary>
|
||||||
|
public AudioSettings AudioDraft => _audioDraft;
|
||||||
|
|
||||||
public SettingsVM(
|
public SettingsVM(
|
||||||
KeyBindings persisted,
|
KeyBindings persisted,
|
||||||
InputDispatcher dispatcher,
|
InputDispatcher dispatcher,
|
||||||
Action<KeyBindings> onSave,
|
Action<KeyBindings> onSave,
|
||||||
DisplaySettings persistedDisplay,
|
DisplaySettings persistedDisplay,
|
||||||
Action<DisplaySettings> onSaveDisplay)
|
Action<DisplaySettings> onSaveDisplay,
|
||||||
|
AudioSettings persistedAudio,
|
||||||
|
Action<AudioSettings> onSaveAudio)
|
||||||
{
|
{
|
||||||
_persisted = persisted ?? throw new ArgumentNullException(nameof(persisted));
|
_persisted = persisted ?? throw new ArgumentNullException(nameof(persisted));
|
||||||
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
|
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
|
||||||
_onSave = onSave ?? throw new ArgumentNullException(nameof(onSave));
|
_onSave = onSave ?? throw new ArgumentNullException(nameof(onSave));
|
||||||
_displayPersisted = persistedDisplay ?? throw new ArgumentNullException(nameof(persistedDisplay));
|
_displayPersisted = persistedDisplay ?? throw new ArgumentNullException(nameof(persistedDisplay));
|
||||||
_onSaveDisplay = onSaveDisplay ?? throw new ArgumentNullException(nameof(onSaveDisplay));
|
_onSaveDisplay = onSaveDisplay ?? throw new ArgumentNullException(nameof(onSaveDisplay));
|
||||||
|
_audioPersisted = persistedAudio ?? throw new ArgumentNullException(nameof(persistedAudio));
|
||||||
|
_onSaveAudio = onSaveAudio ?? throw new ArgumentNullException(nameof(onSaveAudio));
|
||||||
_draft = CloneBindings(persisted);
|
_draft = CloneBindings(persisted);
|
||||||
_displayDraft = persistedDisplay;
|
_displayDraft = persistedDisplay;
|
||||||
|
_audioDraft = persistedAudio;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -90,6 +105,18 @@ public sealed class SettingsVM
|
||||||
_displayDraft = value ?? throw new ArgumentNullException(nameof(value));
|
_displayDraft = value ?? throw new ArgumentNullException(nameof(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replace the entire Audio draft with <paramref name="value"/>.
|
||||||
|
/// Live audio preview is achieved at the host layer by pushing
|
||||||
|
/// <see cref="AudioDraft"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
public void SetAudio(AudioSettings value)
|
||||||
|
{
|
||||||
|
_audioDraft = value ?? throw new ArgumentNullException(nameof(value));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Begin rebinding <paramref name="action"/>. The supplied
|
/// Begin rebinding <paramref name="action"/>. The supplied
|
||||||
/// <paramref name="original"/> binding will be removed when the new
|
/// <paramref name="original"/> binding will be removed when the new
|
||||||
|
|
@ -199,6 +226,7 @@ public sealed class SettingsVM
|
||||||
{
|
{
|
||||||
_draft = KeyBindings.RetailDefaults();
|
_draft = KeyBindings.RetailDefaults();
|
||||||
_displayDraft = DisplaySettings.Default;
|
_displayDraft = DisplaySettings.Default;
|
||||||
|
_audioDraft = AudioSettings.Default;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -213,8 +241,10 @@ public sealed class SettingsVM
|
||||||
{
|
{
|
||||||
_onSave(_draft);
|
_onSave(_draft);
|
||||||
_onSaveDisplay(_displayDraft);
|
_onSaveDisplay(_displayDraft);
|
||||||
|
_onSaveAudio(_audioDraft);
|
||||||
_persisted = CloneBindings(_draft);
|
_persisted = CloneBindings(_draft);
|
||||||
_displayPersisted = _displayDraft;
|
_displayPersisted = _displayDraft;
|
||||||
|
_audioPersisted = _audioDraft;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -226,6 +256,7 @@ public sealed class SettingsVM
|
||||||
{
|
{
|
||||||
_draft = CloneBindings(_persisted);
|
_draft = CloneBindings(_persisted);
|
||||||
_displayDraft = _displayPersisted;
|
_displayDraft = _displayPersisted;
|
||||||
|
_audioDraft = _audioPersisted;
|
||||||
CancelRebind();
|
CancelRebind();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
using AcDream.UI.Abstractions.Panels.Settings;
|
||||||
|
|
||||||
|
namespace AcDream.UI.Abstractions.Tests.Panels.Settings;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// L.0: <see cref="AudioSettings"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -31,7 +31,8 @@ public sealed class SettingsPanelTests
|
||||||
var dispatcher = new InputDispatcher(kb, mouse, persisted);
|
var dispatcher = new InputDispatcher(kb, mouse, persisted);
|
||||||
var vm = new SettingsVM(
|
var vm = new SettingsVM(
|
||||||
persisted, dispatcher, _ => { },
|
persisted, dispatcher, _ => { },
|
||||||
DisplaySettings.Default, _ => { });
|
DisplaySettings.Default, _ => { },
|
||||||
|
AudioSettings.Default, _ => { });
|
||||||
var panel = new SettingsPanel(vm);
|
var panel = new SettingsPanel(vm);
|
||||||
return (panel, vm, kb, dispatcher);
|
return (panel, vm, kb, dispatcher);
|
||||||
}
|
}
|
||||||
|
|
@ -226,14 +227,17 @@ public sealed class SettingsPanelTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Placeholder_tabs_render_coming_soon_text_when_active()
|
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 (panel, _, _, _) = Build();
|
||||||
var r = new FakePanelRenderer { ActiveTabLabel = "Audio" };
|
var r = new FakePanelRenderer { ActiveTabLabel = "Gameplay" };
|
||||||
|
|
||||||
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
||||||
|
|
||||||
var wrapped = r.Calls.Where(c => c.Method == "TextWrapped")
|
var wrapped = r.Calls.Where(c => c.Method == "TextWrapped")
|
||||||
.Select(c => (string)c.Args[0]!).ToList();
|
.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 ---------------------------------------------
|
// -- Display tab content ---------------------------------------------
|
||||||
|
|
@ -284,6 +288,51 @@ public sealed class SettingsPanelTests
|
||||||
Assert.Contains("3840x2160", items);
|
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]
|
[Fact]
|
||||||
public void Save_Cancel_buttons_render_outside_the_tab_bar()
|
public void Save_Cancel_buttons_render_outside_the_tab_bar()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -116,4 +116,68 @@ public sealed class SettingsStoreTests : System.IDisposable
|
||||||
var path = SettingsStore.DefaultPath();
|
var path = SettingsStore.DefaultPath();
|
||||||
Assert.EndsWith("acdream" + Path.DirectorySeparatorChar + "settings.json", path);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ namespace AcDream.UI.Abstractions.Tests.Panels.Settings;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SettingsVMTests
|
public sealed class SettingsVMTests
|
||||||
{
|
{
|
||||||
private static (SettingsVM vm, FakeKeyboardSource kb, InputDispatcher dispatcher, KeyBindings persisted, System.Collections.Generic.List<KeyBindings> savedHistory, System.Collections.Generic.List<DisplaySettings> savedDisplayHistory)
|
private static (SettingsVM vm, FakeKeyboardSource kb, InputDispatcher dispatcher, KeyBindings persisted, System.Collections.Generic.List<KeyBindings> savedHistory, System.Collections.Generic.List<DisplaySettings> savedDisplayHistory, System.Collections.Generic.List<AudioSettings> savedAudioHistory)
|
||||||
Build(KeyBindings? persisted = null, DisplaySettings? persistedDisplay = null)
|
Build(KeyBindings? persisted = null, DisplaySettings? persistedDisplay = null, AudioSettings? persistedAudio = null)
|
||||||
{
|
{
|
||||||
persisted ??= MakeMinimalBindings();
|
persisted ??= MakeMinimalBindings();
|
||||||
var kb = new FakeKeyboardSource();
|
var kb = new FakeKeyboardSource();
|
||||||
|
|
@ -25,12 +25,15 @@ public sealed class SettingsVMTests
|
||||||
var dispatcher = new InputDispatcher(kb, mouse, persisted);
|
var dispatcher = new InputDispatcher(kb, mouse, persisted);
|
||||||
var savedHistory = new System.Collections.Generic.List<KeyBindings>();
|
var savedHistory = new System.Collections.Generic.List<KeyBindings>();
|
||||||
var savedDisplayHistory = new System.Collections.Generic.List<DisplaySettings>();
|
var savedDisplayHistory = new System.Collections.Generic.List<DisplaySettings>();
|
||||||
|
var savedAudioHistory = new System.Collections.Generic.List<AudioSettings>();
|
||||||
var vm = new SettingsVM(
|
var vm = new SettingsVM(
|
||||||
persisted, dispatcher,
|
persisted, dispatcher,
|
||||||
b => savedHistory.Add(b),
|
b => savedHistory.Add(b),
|
||||||
persistedDisplay ?? DisplaySettings.Default,
|
persistedDisplay ?? DisplaySettings.Default,
|
||||||
d => savedDisplayHistory.Add(d));
|
d => savedDisplayHistory.Add(d),
|
||||||
return (vm, kb, dispatcher, persisted, savedHistory, savedDisplayHistory);
|
persistedAudio ?? AudioSettings.Default,
|
||||||
|
a => savedAudioHistory.Add(a));
|
||||||
|
return (vm, kb, dispatcher, persisted, savedHistory, savedDisplayHistory, savedAudioHistory);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static KeyBindings MakeMinimalBindings()
|
private static KeyBindings MakeMinimalBindings()
|
||||||
|
|
@ -45,7 +48,7 @@ public sealed class SettingsVMTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Constructor_clones_persisted_into_draft()
|
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.Equal(persisted.All.Count, vm.Draft.All.Count);
|
||||||
Assert.False(vm.HasUnsavedChanges);
|
Assert.False(vm.HasUnsavedChanges);
|
||||||
}
|
}
|
||||||
|
|
@ -53,7 +56,7 @@ public sealed class SettingsVMTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BeginRebind_enters_capture_mode()
|
public void BeginRebind_enters_capture_mode()
|
||||||
{
|
{
|
||||||
var (vm, _, dispatcher, _, _, _) = Build();
|
var (vm, _, dispatcher, _, _, _, _) = Build();
|
||||||
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
|
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
|
||||||
|
|
||||||
vm.BeginRebind(InputAction.MovementForward, original);
|
vm.BeginRebind(InputAction.MovementForward, original);
|
||||||
|
|
@ -66,7 +69,7 @@ public sealed class SettingsVMTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BeginRebind_then_chord_with_no_conflict_applies_rebind()
|
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();
|
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
|
||||||
|
|
||||||
vm.BeginRebind(InputAction.MovementForward, original);
|
vm.BeginRebind(InputAction.MovementForward, original);
|
||||||
|
|
@ -84,7 +87,7 @@ public sealed class SettingsVMTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BeginRebind_then_Escape_cancels_with_no_change()
|
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();
|
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
|
||||||
|
|
||||||
vm.BeginRebind(InputAction.MovementForward, original);
|
vm.BeginRebind(InputAction.MovementForward, original);
|
||||||
|
|
@ -101,7 +104,7 @@ public sealed class SettingsVMTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BeginRebind_with_conflict_surfaces_PendingConflict()
|
public void BeginRebind_with_conflict_surfaces_PendingConflict()
|
||||||
{
|
{
|
||||||
var (vm, kb, _, _, _, _) = Build();
|
var (vm, kb, _, _, _, _, _) = Build();
|
||||||
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
|
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
|
||||||
|
|
||||||
// Bind chord that conflicts with MovementTurnLeft (which has Key.A).
|
// Bind chord that conflicts with MovementTurnLeft (which has Key.A).
|
||||||
|
|
@ -121,7 +124,7 @@ public sealed class SettingsVMTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ResolveConflict_replace_true_removes_conflict_and_applies_rebind()
|
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();
|
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
|
||||||
|
|
||||||
vm.BeginRebind(InputAction.MovementForward, original);
|
vm.BeginRebind(InputAction.MovementForward, original);
|
||||||
|
|
@ -142,7 +145,7 @@ public sealed class SettingsVMTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ResolveConflict_replace_false_cancels_rebind()
|
public void ResolveConflict_replace_false_cancels_rebind()
|
||||||
{
|
{
|
||||||
var (vm, kb, _, _, _, _) = Build();
|
var (vm, kb, _, _, _, _, _) = Build();
|
||||||
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
|
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
|
||||||
|
|
||||||
vm.BeginRebind(InputAction.MovementForward, original);
|
vm.BeginRebind(InputAction.MovementForward, original);
|
||||||
|
|
@ -164,7 +167,7 @@ public sealed class SettingsVMTests
|
||||||
{
|
{
|
||||||
// Build a draft that's been mutated for MovementForward; ensure
|
// Build a draft that's been mutated for MovementForward; ensure
|
||||||
// ResetActionToDefault restores W (and Up-arrow per retail).
|
// 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();
|
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
|
||||||
vm.BeginRebind(InputAction.MovementForward, original);
|
vm.BeginRebind(InputAction.MovementForward, original);
|
||||||
// F7 is unbound in retail-default (only Ctrl+F7 is acdream debug);
|
// F7 is unbound in retail-default (only Ctrl+F7 is acdream debug);
|
||||||
|
|
@ -184,7 +187,7 @@ public sealed class SettingsVMTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ResetAllToDefaults_replaces_entire_draft()
|
public void ResetAllToDefaults_replaces_entire_draft()
|
||||||
{
|
{
|
||||||
var (vm, _, _, _, _, _) = Build();
|
var (vm, _, _, _, _, _, _) = Build();
|
||||||
vm.ResetAllToDefaults();
|
vm.ResetAllToDefaults();
|
||||||
|
|
||||||
// Should now include retail-default size set (~149 bindings).
|
// Should now include retail-default size set (~149 bindings).
|
||||||
|
|
@ -195,7 +198,7 @@ public sealed class SettingsVMTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Save_invokes_callback_with_draft()
|
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();
|
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
|
||||||
vm.BeginRebind(InputAction.MovementForward, original);
|
vm.BeginRebind(InputAction.MovementForward, original);
|
||||||
kb.EmitKeyDown(Key.Q, ModifierMask.None);
|
kb.EmitKeyDown(Key.Q, ModifierMask.None);
|
||||||
|
|
@ -211,7 +214,7 @@ public sealed class SettingsVMTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Cancel_reverts_draft_to_persisted()
|
public void Cancel_reverts_draft_to_persisted()
|
||||||
{
|
{
|
||||||
var (vm, kb, _, _, _, _) = Build();
|
var (vm, kb, _, _, _, _, _) = Build();
|
||||||
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
|
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
|
||||||
vm.BeginRebind(InputAction.MovementForward, original);
|
vm.BeginRebind(InputAction.MovementForward, original);
|
||||||
kb.EmitKeyDown(Key.Q, ModifierMask.None);
|
kb.EmitKeyDown(Key.Q, ModifierMask.None);
|
||||||
|
|
@ -227,7 +230,7 @@ public sealed class SettingsVMTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Cancel_during_active_capture_clears_dispatcher_capture_state()
|
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();
|
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
|
||||||
vm.BeginRebind(InputAction.MovementForward, original);
|
vm.BeginRebind(InputAction.MovementForward, original);
|
||||||
|
|
||||||
|
|
@ -240,7 +243,7 @@ public sealed class SettingsVMTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void HasUnsavedChanges_false_initially_and_after_save_sync()
|
public void HasUnsavedChanges_false_initially_and_after_save_sync()
|
||||||
{
|
{
|
||||||
var (vm, _, _, _, _, _) = Build();
|
var (vm, _, _, _, _, _, _) = Build();
|
||||||
Assert.False(vm.HasUnsavedChanges);
|
Assert.False(vm.HasUnsavedChanges);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -250,7 +253,7 @@ public sealed class SettingsVMTests
|
||||||
public void DisplayDraft_initial_value_matches_persisted()
|
public void DisplayDraft_initial_value_matches_persisted()
|
||||||
{
|
{
|
||||||
var custom = DisplaySettings.Default with { FieldOfView = 90f, ShowFps = true };
|
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.Equal(custom, vm.DisplayDraft);
|
||||||
Assert.False(vm.HasUnsavedChanges);
|
Assert.False(vm.HasUnsavedChanges);
|
||||||
}
|
}
|
||||||
|
|
@ -258,7 +261,7 @@ public sealed class SettingsVMTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SetDisplay_marks_unsaved_changes()
|
public void SetDisplay_marks_unsaved_changes()
|
||||||
{
|
{
|
||||||
var (vm, _, _, _, _, _) = Build();
|
var (vm, _, _, _, _, _, _) = Build();
|
||||||
vm.SetDisplay(vm.DisplayDraft with { ShowFps = true });
|
vm.SetDisplay(vm.DisplayDraft with { ShowFps = true });
|
||||||
Assert.True(vm.HasUnsavedChanges);
|
Assert.True(vm.HasUnsavedChanges);
|
||||||
}
|
}
|
||||||
|
|
@ -266,7 +269,7 @@ public sealed class SettingsVMTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Save_invokes_display_callback_with_draft()
|
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.SetDisplay(vm.DisplayDraft with { Resolution = "2560x1440", FieldOfView = 100f });
|
||||||
|
|
||||||
vm.Save();
|
vm.Save();
|
||||||
|
|
@ -281,7 +284,7 @@ public sealed class SettingsVMTests
|
||||||
public void Cancel_reverts_display_draft_to_persisted()
|
public void Cancel_reverts_display_draft_to_persisted()
|
||||||
{
|
{
|
||||||
var custom = DisplaySettings.Default with { FieldOfView = 90f };
|
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 });
|
vm.SetDisplay(vm.DisplayDraft with { FieldOfView = 30f, ShowFps = true });
|
||||||
Assert.True(vm.HasUnsavedChanges);
|
Assert.True(vm.HasUnsavedChanges);
|
||||||
|
|
||||||
|
|
@ -295,7 +298,7 @@ public sealed class SettingsVMTests
|
||||||
public void ResetAllToDefaults_resets_display_to_default()
|
public void ResetAllToDefaults_resets_display_to_default()
|
||||||
{
|
{
|
||||||
var custom = DisplaySettings.Default with { FieldOfView = 30f, ShowFps = true };
|
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);
|
Assert.NotEqual(DisplaySettings.Default, vm.DisplayDraft);
|
||||||
|
|
||||||
vm.ResetAllToDefaults();
|
vm.ResetAllToDefaults();
|
||||||
|
|
@ -310,7 +313,7 @@ public sealed class SettingsVMTests
|
||||||
// After Save the persisted snapshot equals the draft, so Cancel
|
// After Save the persisted snapshot equals the draft, so Cancel
|
||||||
// is a no-op. This guards the Save/Cancel ordering — a regression
|
// is a no-op. This guards the Save/Cancel ordering — a regression
|
||||||
// would surface as Cancel reverting to pre-Save values.
|
// would surface as Cancel reverting to pre-Save values.
|
||||||
var (vm, _, _, _, _, _) = Build();
|
var (vm, _, _, _, _, _, _) = Build();
|
||||||
vm.SetDisplay(vm.DisplayDraft with { ShowFps = true });
|
vm.SetDisplay(vm.DisplayDraft with { ShowFps = true });
|
||||||
vm.Save();
|
vm.Save();
|
||||||
Assert.False(vm.HasUnsavedChanges);
|
Assert.False(vm.HasUnsavedChanges);
|
||||||
|
|
@ -320,4 +323,64 @@ public sealed class SettingsVMTests
|
||||||
Assert.True(vm.DisplayDraft.ShowFps);
|
Assert.True(vm.DisplayDraft.ShowFps);
|
||||||
Assert.False(vm.HasUnsavedChanges);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue