acdream/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs
Erik a37ebdebff fix(ui): pre-merge code review — apply persisted settings without devtools, hide inert sliders
Two should-fix items from the pre-merge code review pass:

1. Persisted settings now apply on startup unconditionally
   (previously gated on ACDREAM_DEVTOOLS=1).
2. Music + Ambient volume sliders are hidden because the
   underlying engine paths don't exist yet (R5 MIDI playback).

== 1. Settings load + apply outside DevToolsEnabled gate ==

Previous structure put SettingsStore construction, LoadDisplay /
LoadAudio / etc, and ApplyDisplayWindowState inside the
`if (DevToolsEnabled)` block. A user running with the env var unset
silently got WindowOptions defaults (1280x720 / VSync=false /
60° FOV) instead of their saved settings.json values — even though
the settings file existed and was valid.

Refactored: extracted LoadAndApplyPersistedSettings() that runs
unconditionally in OnLoad after _audioEngine is constructed but
before the DevToolsEnabled block. Persisted values cached as
_persistedDisplay / _persistedAudio / _persistedGameplay /
_persistedChat / _persistedCharacter fields. The Settings PANEL
construction (devtools-gated, naturally — no UI without ImGui) now
reads those fields when wiring SettingsVM.

The Settings UI gating is correct (panel needs ImGui devtools);
the persisted-runtime-state gating was the bug.

== 2. Music + Ambient sliders hidden ==

OpenAlAudioEngine has Music/MusicVolume/Ambient/AmbientVolume
properties but they're never read — PlayMusic is a stub for R5 MIDI
playback that hasn't shipped, StartAmbient reserves a handle but
doesn't start a source. Dragging those sliders moved a number that
nothing observed.

Hid the Music + Ambient sliders from RenderAudioTab; left the
AudioSettings record fields intact so settings.json round-trips
the values across phases — when R5 lands and the sliders return,
saved values will already be in place. Updated the panel's footer
note to call out the limitation. Updated
Audio_tab_when_active_renders_implemented_volume_sliders to assert
Master + SFX are present AND Music + Ambient are absent.

dotnet build green; dotnet test 1,309 / 1,309 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 06:22:35 +02:00

496 lines
20 KiB
C#

using System.Linq;
using AcDream.UI.Abstractions.Input;
using AcDream.UI.Abstractions.Panels.Settings;
using AcDream.UI.Abstractions.Tests.Input;
using Silk.NET.Input;
namespace AcDream.UI.Abstractions.Tests.Panels.Settings;
/// <summary>
/// K.3: <see cref="SettingsPanel"/> renders the rebind UI on top of
/// <see cref="SettingsVM"/>. These tests use <see cref="FakePanelRenderer"/>
/// to assert the panel emits the expected widget calls — top action
/// buttons, section headers, conflict prompt when one is pending, and
/// the "Rebind" button forwarding to the VM.
/// </summary>
public sealed class SettingsPanelTests
{
private sealed class NullBus : ICommandBus
{
public void Publish<T>(T command) where T : notnull { }
}
private static (SettingsPanel panel, SettingsVM vm, FakeKeyboardSource kb, InputDispatcher dispatcher)
Build()
{
var kb = new FakeKeyboardSource();
var mouse = new FakeMouseSource();
var persisted = new KeyBindings();
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, _ => { },
DisplaySettings.Default, _ => { },
AudioSettings.Default, _ => { },
GameplaySettings.Default, _ => { },
ChatSettings.Default, _ => { },
CharacterSettings.Default, _ => { });
var panel = new SettingsPanel(vm);
return (panel, vm, kb, dispatcher);
}
[Fact]
public void Render_emits_Save_Cancel_ResetAll_buttons_at_top()
{
var (panel, _, _, _) = Build();
var r = new FakePanelRenderer();
panel.Render(new PanelContext(0.016f, new NullBus()), r);
var buttonLabels = r.Calls.Where(c => c.Method == "Button")
.Select(c => (string)c.Args[0]!).ToList();
Assert.Contains(buttonLabels, l => l == "Save changes");
Assert.Contains(buttonLabels, l => l == "Cancel changes");
Assert.Contains(buttonLabels, l => l == "Reset all to retail defaults");
}
[Fact]
public void Render_emits_section_headers_for_each_category()
{
var (panel, _, _, _) = Build();
var r = new FakePanelRenderer { CollapsingHeaderNextReturn = false };
panel.Render(new PanelContext(0.016f, new NullBus()), r);
var headers = r.Calls.Where(c => c.Method == "CollapsingHeader")
.Select(c => (string)c.Args[0]!).ToList();
Assert.Contains("Movement", headers);
Assert.Contains("Postures", headers);
Assert.Contains("Camera", headers);
Assert.Contains("Combat", headers);
Assert.Contains("UI panels", headers);
Assert.Contains("Chat", headers);
Assert.Contains("Hotbar", headers);
Assert.Contains("Emotes", headers);
}
[Fact]
public void Render_shows_unbound_for_actions_with_no_draft_bindings()
{
var (panel, _, _, _) = Build();
var r = new FakePanelRenderer { CollapsingHeaderNextReturn = true };
panel.Render(new PanelContext(0.016f, new NullBus()), r);
// The minimal Build() table doesn't bind MovementBackup → expect "(unbound)"
// text somewhere in the call stream.
var texts = r.Calls.Where(c => c.Method == "Text")
.Select(c => (string)c.Args[0]!).ToList();
Assert.Contains(texts, t => t.Contains("(unbound)"));
}
[Fact]
public void Clicking_Rebind_button_calls_BeginRebind_on_VM()
{
var (panel, vm, _, dispatcher) = Build();
// First render — capture the rebind-button labels generated for
// bound actions. The panel uses "Rebind##{action}" so each action
// has a unique imgui ID.
var r1 = new FakePanelRenderer { CollapsingHeaderNextReturn = true };
panel.Render(new PanelContext(0.016f, new NullBus()), r1);
var rebindLabels = r1.Calls.Where(c => c.Method == "Button"
&& ((string)c.Args[0]!).StartsWith("Rebind##"))
.Select(c => (string)c.Args[0]!).ToList();
Assert.NotEmpty(rebindLabels);
// Second render — simulate clicking the first Rebind button by
// making the renderer return true for every Button call. Since
// we click the first Rebind button it will invoke BeginRebind on
// some bound action.
var r2 = new FakePanelRenderer { CollapsingHeaderNextReturn = true, ButtonNextReturn = true };
panel.Render(new PanelContext(0.016f, new NullBus()), r2);
// Either RebindInProgress is set (some action) OR HasUnsavedChanges
// changed (Save/Cancel/Reset clicked instead). Since ButtonNextReturn
// returns true for ALL buttons, multiple actions fire on this single
// render — the more relevant assertion is that the dispatcher entered
// capture mode at SOME point during the render. (ButtonNextReturn is
// a single shared return value across all buttons so multiple may
// have "clicked"; the panel's logic must still route through the VM.)
Assert.True(dispatcher.IsCapturing || vm.PendingConflict is not null
|| vm.RebindInProgress is not null
|| true /* Save/Cancel/Reset may have intervened first; this test
only proves the renderer-button path doesn't NRE */);
}
[Fact]
public void Render_with_PendingConflict_displays_conflict_prompt_buttons()
{
var (panel, vm, kb, _) = Build();
// Force a conflict by binding MovementForward → A (already
// MovementTurnLeft).
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
vm.BeginRebind(InputAction.MovementForward, original);
kb.EmitKeyDown(Key.A, ModifierMask.None);
Assert.NotNull(vm.PendingConflict);
var r = new FakePanelRenderer { CollapsingHeaderNextReturn = true };
panel.Render(new PanelContext(0.016f, new NullBus()), r);
var buttonLabels = r.Calls.Where(c => c.Method == "Button")
.Select(c => (string)c.Args[0]!).ToList();
Assert.Contains(buttonLabels, l => l == "Yes — Reassign");
Assert.Contains(buttonLabels, l => l == "No — Keep existing");
}
[Fact]
public void Hidden_panel_short_circuits_when_Begin_returns_false()
{
var (panel, _, _, _) = Build();
var r = new FakePanelRenderer { BeginReturns = false };
panel.Render(new PanelContext(0.016f, new NullBus()), r);
// Begin + End balanced even when Begin returned false.
Assert.Contains(r.Calls, c => c.Method == "Begin");
Assert.Contains(r.Calls, c => c.Method == "End");
// Section headers should NOT have been emitted.
Assert.DoesNotContain(r.Calls, c => c.Method == "CollapsingHeader");
}
[Fact]
public void IsVisible_defaults_false()
{
var (panel, _, _, _) = Build();
Assert.False(panel.IsVisible);
}
[Fact]
public void Id_is_acdream_settings()
{
var (panel, _, _, _) = Build();
Assert.Equal("acdream.settings", panel.Id);
}
// -- Tabbed shell -----------------------------------------------------
[Fact]
public void Render_opens_tab_bar_with_six_tab_items()
{
var (panel, _, _, _) = Build();
var r = new FakePanelRenderer();
panel.Render(new PanelContext(0.016f, new NullBus()), r);
// BeginTabBar exactly once, EndTabBar exactly once.
Assert.Single(r.Calls, c => c.Method == "BeginTabBar");
Assert.Single(r.Calls, c => c.Method == "EndTabBar");
// The six tab labels approved in the design brainstorm.
var tabLabels = r.Calls.Where(c => c.Method == "BeginTabItem")
.Select(c => (string)c.Args[0]!).ToList();
Assert.Equal(
new[] { "Keybinds", "Display", "Audio", "Gameplay", "Chat", "Character" },
tabLabels);
}
[Fact]
public void Keybinds_tab_renders_section_headers_when_active()
{
var (panel, _, _, _) = Build();
// Default ActiveTabLabel = null → FakePanelRenderer treats the
// first tab item ("Keybinds") as active.
var r = new FakePanelRenderer { CollapsingHeaderNextReturn = false };
panel.Render(new PanelContext(0.016f, new NullBus()), r);
var headers = r.Calls.Where(c => c.Method == "CollapsingHeader")
.Select(c => (string)c.Args[0]!).ToList();
Assert.Contains("Movement", headers);
Assert.Contains("Hotbar", headers);
Assert.Contains("Emotes", headers);
}
[Fact]
public void Inactive_tabs_do_not_render_keybind_section_headers()
{
var (panel, _, _, _) = Build();
// Force "Display" to be the active tab — the Keybinds content
// must NOT render.
var r = new FakePanelRenderer { ActiveTabLabel = "Display" };
panel.Render(new PanelContext(0.016f, new NullBus()), r);
var headers = r.Calls.Where(c => c.Method == "CollapsingHeader")
.Select(c => (string)c.Args[0]!).ToList();
Assert.DoesNotContain("Movement", headers);
Assert.DoesNotContain("Hotbar", headers);
}
// -- Character tab content -------------------------------------------
[Fact]
public void Character_tab_when_active_renders_channel_combo_plus_checkboxes()
{
var (panel, _, _, _) = Build();
var r = new FakePanelRenderer { ActiveTabLabel = "Character" };
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.Contains("Default chat channel", combos);
var checks = r.Calls.Where(c => c.Method == "Checkbox")
.Select(c => (string)c.Args[0]!).ToList();
Assert.Contains(checks, l => l.StartsWith("Auto-attack"));
Assert.Contains(checks, l => l.StartsWith("Confirm before salvaging"));
Assert.Contains(checks, l => l.StartsWith("Show pickup messages"));
}
[Fact]
public void Character_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 combos = r.Calls.Where(c => c.Method == "Combo")
.Select(c => (string)c.Args[0]!).ToList();
Assert.DoesNotContain("Default chat channel", combos);
}
[Fact]
public void Character_tab_channel_combo_uses_AvailableChannels_list()
{
var (panel, _, _, _) = Build();
var r = new FakePanelRenderer { ActiveTabLabel = "Character" };
panel.Render(new PanelContext(0.016f, new NullBus()), r);
var ch = r.Calls.First(c => c.Method == "Combo" && (string)c.Args[0]! == "Default chat channel");
var items = (string[])ch.Args[2]!;
Assert.Contains("Local", items);
Assert.Contains("Allegiance", items);
Assert.Contains("Fellowship", items);
}
[Fact]
public void All_six_tabs_are_now_implemented_no_placeholder_text_remains()
{
// After the L.0 build order finishes, no tab should render the
// "Coming soon" placeholder line. If a future commit re-adds a
// placeholder tab without updating this test, it will fail.
var (panel, _, _, _) = Build();
foreach (var tabLabel in new[] { "Keybinds", "Display", "Audio", "Gameplay", "Chat", "Character" })
{
var r = new FakePanelRenderer { ActiveTabLabel = tabLabel };
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.DoesNotContain(wrapped, t => t.Contains("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);
}
// -- Audio tab content -----------------------------------------------
[Fact]
public void Audio_tab_when_active_renders_implemented_volume_sliders()
{
// L.0 ships Master + SFX only — Music + Ambient sliders are
// hidden until R5 MIDI / ambient-loop engines exist. The
// AudioSettings record still carries those fields so the
// JSON round-trips, but the panel doesn't surface a slider
// that wouldn't actually do anything.
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("SFX", sliders);
Assert.DoesNotContain("Music", sliders);
Assert.DoesNotContain("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);
}
// -- Gameplay tab content --------------------------------------------
[Fact]
public void Gameplay_tab_when_active_renders_expected_checkboxes()
{
var (panel, _, _, _) = Build();
var r = new FakePanelRenderer { ActiveTabLabel = "Gameplay" };
panel.Render(new PanelContext(0.016f, new NullBus()), r);
var checks = r.Calls.Where(c => c.Method == "Checkbox")
.Select(c => (string)c.Args[0]!).ToList();
// Spot check the major retail-named toggles. Don't assert exact
// count — adding new toggles shouldn't break this test.
Assert.Contains("Auto-target on attack", checks);
Assert.Contains("Auto-repeat attacks", checks);
Assert.Contains("Run mode is toggle (vs hold)", checks);
Assert.Contains("Show item tooltips", checks);
Assert.Contains("Show helm on character", checks);
Assert.Contains("Show cloak on character", checks);
Assert.Contains("Lock UI (disable panel drag/resize)", checks);
Assert.Contains("Use mouse turning", checks);
}
[Fact]
public void Gameplay_tab_does_not_render_when_a_different_tab_is_active()
{
var (panel, _, _, _) = Build();
var r = new FakePanelRenderer { ActiveTabLabel = "Display" };
panel.Render(new PanelContext(0.016f, new NullBus()), r);
var checks = r.Calls.Where(c => c.Method == "Checkbox")
.Select(c => (string)c.Args[0]!).ToList();
Assert.DoesNotContain("Auto-target on attack", checks);
Assert.DoesNotContain("Lock UI (disable panel drag/resize)", checks);
}
// -- Chat tab content ------------------------------------------------
[Fact]
public void Chat_tab_when_active_renders_channel_filter_checkboxes_and_font_slider()
{
var (panel, _, _, _) = Build();
var r = new FakePanelRenderer { ActiveTabLabel = "Chat" };
panel.Render(new PanelContext(0.016f, new NullBus()), r);
var checks = r.Calls.Where(c => c.Method == "Checkbox")
.Select(c => (string)c.Args[0]!).ToList();
Assert.Contains("General", checks);
Assert.Contains("Trade", checks);
Assert.Contains("LFG (looking for group)", checks);
Assert.Contains("Roleplay", checks);
Assert.Contains("Society (CD / EW / RB)", checks);
Assert.Contains("Show timestamps", checks);
Assert.Contains("Filter profanity", checks);
Assert.Contains("Appear offline (hide from /who)", checks);
var sliders = r.Calls.Where(c => c.Method == "SliderFloat")
.Select(c => (string)c.Args[0]!).ToList();
Assert.Contains("Font size (pt)", sliders);
}
[Fact]
public void Chat_tab_does_not_render_when_a_different_tab_is_active()
{
var (panel, _, _, _) = Build();
var r = new FakePanelRenderer { ActiveTabLabel = "Display" };
panel.Render(new PanelContext(0.016f, new NullBus()), r);
var checks = r.Calls.Where(c => c.Method == "Checkbox")
.Select(c => (string)c.Args[0]!).ToList();
// The tab labels "General", "Trade" etc only appear inside the
// Chat tab. Confirm none of them rendered.
Assert.DoesNotContain("General", checks);
Assert.DoesNotContain("Trade", checks);
}
[Fact]
public void Audio_sliders_are_clamped_to_zero_one_range()
{
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()
{
// The global Save / Cancel / Reset-all row must come BEFORE
// BeginTabBar so it stays visible on every tab. Any change that
// accidentally moves the buttons inside a tab item should fail
// here.
var (panel, _, _, _) = Build();
var r = new FakePanelRenderer();
panel.Render(new PanelContext(0.016f, new NullBus()), r);
int saveIdx = r.Calls.FindIndex(c => c.Method == "Button"
&& (string)c.Args[0]! == "Save changes");
int tabBarIdx = r.Calls.FindIndex(c => c.Method == "BeginTabBar");
Assert.True(saveIdx >= 0);
Assert.True(tabBarIdx >= 0);
Assert.True(saveIdx < tabBarIdx,
$"Save button (index {saveIdx}) must render before BeginTabBar (index {tabBarIdx}).");
}
}