diff --git a/src/TeamsISO.App/Services/ThemeManager.cs b/src/TeamsISO.App/Services/ThemeManager.cs index 1ec2c9a..da9db30 100644 --- a/src/TeamsISO.App/Services/ThemeManager.cs +++ b/src/TeamsISO.App/Services/ThemeManager.cs @@ -25,7 +25,11 @@ namespace TeamsISO.App.Services; /// public sealed class ThemeManager { - public static ThemeManager Current { get; } = new(); + public static ThemeManager Current { get; } = new( + isSystemDark: ReadSystemDarkFromRegistry, + loadPreference: TryLoadPreferenceFromDisk, + savePreference: TrySavePreferenceToDisk, + subscribeToSystemPreference: true); private const string DarkUri = "/Themes/Theme.Dark.xaml"; private const string LightUri = "/Themes/Theme.Light.xaml"; @@ -33,27 +37,44 @@ public sealed class ThemeManager private const string PreferenceKeyDark = "Dark"; private const string PreferenceKeyLight = "Light"; - private ThemeManager() + // Test seams. The production singleton wires these to the real + // registry / UIPreferences. Tests construct via the internal ctor + // with their own stubs so they don't touch HKCU or %LOCALAPPDATA%. + private readonly Func _isSystemDark; + private readonly Action _savePreference; + + internal ThemeManager( + Func isSystemDark, + Func loadPreference, + Action savePreference, + bool subscribeToSystemPreference) { - // Hydrate preference from disk on first access. UIPreferences.Load() - // is best-effort — disk failures fall back to defaults so the app - // always boots into a deterministic theme. + _isSystemDark = isSystemDark; + _savePreference = savePreference; + + // Hydrate preference from the seam on first access. Disk / load + // failures fall back to defaults so the app always boots into a + // deterministic theme. try { - var prefs = UIPreferences.Load(); - if (IsValidPreference(prefs.Theme)) + var loaded = loadPreference(); + if (IsValidPreference(loaded)) { - _preference = prefs.Theme; + _preference = loaded!; } } catch { - // Defensive — singleton ctor must not throw or the app loses theming. + // Defensive — ctor must not throw or the app loses theming. } // Re-evaluate when Windows app-mode flips, but only when the // operator hasn't pinned a preference. The explicit choice wins. - SystemEvents.UserPreferenceChanged += OnSystemPreferenceChanged; + // Tests opt out so they don't latch into a process-wide event. + if (subscribeToSystemPreference) + { + SystemEvents.UserPreferenceChanged += OnSystemPreferenceChanged; + } } private string _preference = PreferenceKeySystem; @@ -73,7 +94,7 @@ public sealed class ThemeManager { PreferenceKeyDark => PreferenceKeyDark, PreferenceKeyLight => PreferenceKeyLight, - _ => IsSystemDark() ? PreferenceKeyDark : PreferenceKeyLight, + _ => _isSystemDark() ? PreferenceKeyDark : PreferenceKeyLight, }; /// @@ -89,7 +110,7 @@ public sealed class ThemeManager } _preference = preference; - try { UIPreferences.SetTheme(preference); } + try { _savePreference(preference); } catch { /* persistence is best-effort */ } Apply(); } @@ -160,9 +181,10 @@ public sealed class ThemeManager /// /// Read Windows' AppsUseLightTheme registry value. 1 = light, 0 = dark. /// Returns true (dark) on any read failure — the dark scene is the - /// default per DESIGN.md so a missing value still lands somewhere sensible. + /// default per DESIGN.md so a missing value still lands somewhere + /// sensible. Backs the singleton's _isSystemDark seam. /// - private static bool IsSystemDark() + private static bool ReadSystemDarkFromRegistry() { try { @@ -180,6 +202,31 @@ public sealed class ThemeManager return true; } + /// + /// Load the operator's persisted theme preference from + /// %LOCALAPPDATA%\TeamsISO\ui-prefs.json. Returns null on any read + /// failure (missing file, corrupt JSON, schema mismatch) so the + /// caller falls back to the in-memory default of "System". Backs + /// the singleton's loadPreference seam. + /// + private static string? TryLoadPreferenceFromDisk() + { + try { return UIPreferences.Load().Theme; } + catch { return null; } + } + + /// + /// Persist the operator's theme preference to ui-prefs.json. Errors + /// are swallowed — persistence is best-effort and a single failed + /// save shouldn't break the in-session UI experience. Backs the + /// singleton's savePreference seam. + /// + private static void TrySavePreferenceToDisk(string preference) + { + try { UIPreferences.SetTheme(preference); } + catch { /* best-effort */ } + } + private void OnSystemPreferenceChanged(object? sender, UserPreferenceChangedEventArgs e) { if (e.Category != UserPreferenceCategory.General) return; diff --git a/src/TeamsISO.App/ViewModels/CommandPaletteViewModel.cs b/src/TeamsISO.App/ViewModels/CommandPaletteViewModel.cs index d6124e0..ba56211 100644 --- a/src/TeamsISO.App/ViewModels/CommandPaletteViewModel.cs +++ b/src/TeamsISO.App/ViewModels/CommandPaletteViewModel.cs @@ -109,7 +109,7 @@ public sealed class CommandPaletteViewModel : ObservableObject : Visible.FirstOrDefault(); } - private static bool Matches(PaletteCommand c, string query) + internal static bool Matches(PaletteCommand c, string query) { if (c.Label.Contains(query, StringComparison.OrdinalIgnoreCase)) return true; if (c.Category.Contains(query, StringComparison.OrdinalIgnoreCase)) return true; diff --git a/src/tests/TeamsISO.App.Tests/Services/ThemeManagerTests.cs b/src/tests/TeamsISO.App.Tests/Services/ThemeManagerTests.cs new file mode 100644 index 0000000..1a02596 --- /dev/null +++ b/src/tests/TeamsISO.App.Tests/Services/ThemeManagerTests.cs @@ -0,0 +1,156 @@ +using FluentAssertions; +using TeamsISO.App.Services; + +namespace TeamsISO.App.Tests.Services; + +// Unit tests for ThemeManager — exercise the resolve / set / toggle +// state machine behind the test-only constructor that takes stub seams +// instead of touching HKCU and %LOCALAPPDATA%. Apply() and the +// SystemEvents subscription are intentionally NOT exercised here: +// they require Application.Current and a real WPF dispatcher, both of +// which would couple these tests to the host runtime. +public sealed class ThemeManagerTests +{ + private static ThemeManager NewManager( + bool systemDark = true, + string? initialPreference = null, + Action? captureSave = null) => + new ThemeManager( + isSystemDark: () => systemDark, + loadPreference: () => initialPreference, + savePreference: captureSave ?? (_ => { }), + subscribeToSystemPreference: false); + + [Fact] + public void Set_DarkThenLight_RoundTripsPreferenceAndResolution() + { + var saves = new List(); + var tm = NewManager(systemDark: false, captureSave: saves.Add); + + tm.Set("Dark"); + tm.Preference.Should().Be("Dark"); + tm.ResolveTheme().Should().Be("Dark"); + + tm.Set("Light"); + tm.Preference.Should().Be("Light"); + tm.ResolveTheme().Should().Be("Light"); + + saves.Should().Equal("Dark", "Light"); + } + + [Theory] + [InlineData(true, "Dark")] + [InlineData(false, "Light")] + public void ResolveTheme_FollowsSystem_WhenPreferenceIsSystem(bool isSystemDark, string expected) + { + var tm = NewManager(systemDark: isSystemDark, initialPreference: "System"); + tm.Preference.Should().Be("System"); + tm.ResolveTheme().Should().Be(expected); + } + + [Fact] + public void Toggle_FromSystemDark_PinsToOppositeOfCurrent() + { + // System currently resolves to Dark → toggle should flip + // *preference* to Light (the opposite of the currently-displayed + // theme), not back to System. The point of the click is a + // visible change. + var tm = NewManager(systemDark: true, initialPreference: "System"); + + tm.Toggle(); + + tm.Preference.Should().Be("Light"); + tm.ResolveTheme().Should().Be("Light"); + } + + [Fact] + public void Toggle_FromSystemLight_PinsToOppositeOfCurrent() + { + var tm = NewManager(systemDark: false, initialPreference: "System"); + + tm.Toggle(); + + tm.Preference.Should().Be("Dark"); + tm.ResolveTheme().Should().Be("Dark"); + } + + [Fact] + public void Toggle_FromDark_FlipsToLight() + { + var tm = NewManager(initialPreference: "Dark"); + + tm.Toggle(); + + tm.Preference.Should().Be("Light"); + } + + [Fact] + public void Toggle_FromLight_FlipsToDark() + { + var tm = NewManager(initialPreference: "Light"); + + tm.Toggle(); + + tm.Preference.Should().Be("Dark"); + } + + [Theory] + [InlineData("invalid")] + [InlineData("dark")] // case-sensitive — we accept exactly Dark + [InlineData("LIGHT")] + [InlineData("")] + public void Set_RejectsInvalidPreferenceWithArgumentException(string bad) + { + var tm = NewManager(); + + var act = () => tm.Set(bad); + + act.Should().Throw() + .WithParameterName("preference"); + } + + [Fact] + public void Constructor_DefaultsToSystem_WhenLoadReturnsNull() + { + // Simulates a fresh install / corrupt prefs file: loadPreference + // returns null; the manager falls back to the in-memory default + // of "System" rather than throwing. + var tm = NewManager(initialPreference: null); + + tm.Preference.Should().Be("System"); + } + + [Fact] + public void Constructor_DefaultsToSystem_WhenLoadReturnsInvalidValue() + { + // A prefs file written by a future version with an unknown + // value mustn't poison the in-memory state — invalid loads + // fall back to the default, same as a missing file. + var tm = NewManager(initialPreference: "Rainbow"); + + tm.Preference.Should().Be("System"); + } + + [Fact] + public void Constructor_HonoursPersistedPreference() + { + var tm = NewManager(initialPreference: "Dark"); + + tm.Preference.Should().Be("Dark"); + } + + [Fact] + public void Constructor_SurvivesLoadException() + { + // The production singleton hits disk via UIPreferences.Load; a + // disk fault must NOT escape the ctor or the app loses theming + // entirely. Verify the swallow. + var tm = new ThemeManager( + isSystemDark: () => true, + loadPreference: () => throw new InvalidOperationException("disk faulted"), + savePreference: _ => { }, + subscribeToSystemPreference: false); + + tm.Preference.Should().Be("System"); + } +} diff --git a/src/tests/TeamsISO.App.Tests/ViewModels/CommandPaletteMatchesTests.cs b/src/tests/TeamsISO.App.Tests/ViewModels/CommandPaletteMatchesTests.cs new file mode 100644 index 0000000..466bbd3 --- /dev/null +++ b/src/tests/TeamsISO.App.Tests/ViewModels/CommandPaletteMatchesTests.cs @@ -0,0 +1,94 @@ +using FluentAssertions; +using TeamsISO.App.ViewModels; + +namespace TeamsISO.App.Tests.ViewModels; + +// Unit tests for the CommandPaletteViewModel.Matches predicate — the +// case-insensitive Contains check across Label / Category / Keywords +// that powers the v2 Ctrl+K filter. +// +// We don't build a full CommandPaletteViewModel here (that requires a +// MainViewModel + IIsoController fake — out of scope). Matches is the +// behaviorally-relevant unit; pinning it across a representative +// query set guards against accidental regressions when someone adds a +// scoring algorithm or swaps Contains for StartsWith. +public sealed class CommandPaletteMatchesTests +{ + private static PaletteCommand Cmd(string category, string label, string? keywords = null) => + new(category, label, keywords, Shortcut: null, Invoke: () => { }); + + [Theory] + // Label substrings — the dominant match path + [InlineData("Quick", "Stop all ISOs", null, "stop", true)] + [InlineData("Quick", "Stop all ISOs", null, "STOP", true)] // case-insensitive + [InlineData("Quick", "Stop all ISOs", null, "all", true)] + [InlineData("Quick", "Stop all ISOs", null, "ISO", true)] + // Category match — operator types the section name + [InlineData("Teams", "Mute / unmute", null, "teams", true)] + [InlineData("App", "Help", null, "app", true)] + // Keywords match — synonym path. The Network/topology command has + // "ndi groups isolate" in its keywords blob. + [InlineData("Network", "Apply transcoder topology", "ndi groups isolate", "ndi", true)] + [InlineData("Network", "Apply transcoder topology", "ndi groups isolate", "isolate", true)] + // No-match — none of label/category/keywords contain the query + [InlineData("Quick", "Stop all ISOs", null, "espresso", false)] + [InlineData("Teams", "Mute / unmute", "microphone audio toggle", "monitor", false)] + public void Matches_Predicate(string category, string label, string? keywords, string query, bool expected) + { + CommandPaletteViewModel.Matches(Cmd(category, label, keywords), query) + .Should().Be(expected); + } + + [Fact] + public void Matches_OperatorTypingShortToken_HitsExpectedCategorySpread() + { + // "mute" should match the Teams command but not the App theme + // commands — pins the cross-category selectivity that makes + // the palette useful at all. If a future change makes Matches + // too permissive (e.g. by indexing the Invoke delegate's + // method name), the second assertion catches it. + var muteCmd = Cmd("Teams", "Mute / unmute", keywords: "microphone audio silence toggle"); + var themeCmd = Cmd("App", "Theme: dark", keywords: "appearance night mode"); + + CommandPaletteViewModel.Matches(muteCmd, "mute").Should().BeTrue(); + CommandPaletteViewModel.Matches(themeCmd, "mute").Should().BeFalse(); + } + + [Fact] + public void Matches_AcrossTheFullPaletteVocabulary_StaysDeterministic() + { + // Sanity check: a representative slice of the palette's real + // commands gives stable matches for the most common operator + // queries. Pin the count of hits for each query so a careless + // refactor that flips the predicate's polarity blows up here + // instead of in production. + var commands = new[] + { + Cmd("Quick", "Enable all online", "ISOs enable everyone start everything live"), + Cmd("Quick", "Stop all ISOs", "panic stop everything kill disable"), + Cmd("Quick", "Refresh discovery", "rediscover sources rebuild finder NDI"), + Cmd("Teams", "Mute / unmute", "microphone audio silence toggle"), + Cmd("Teams", "Toggle camera", "video webcam on off"), + Cmd("Teams", "Leave call", "exit end disconnect quit"), + Cmd("Network", "Apply transcoder topology", "ndi groups isolate teamsiso-input private"), + Cmd("App", "Theme: dark", "appearance night mode"), + Cmd("App", "Theme: light", "appearance day mode bright"), + Cmd("App", "Theme: follow Windows", "system auto"), + Cmd("App", "Help", "shortcuts cheatsheet f1"), + }; + + int Hits(string q) => commands.Count(c => CommandPaletteViewModel.Matches(c, q)); + + Hits("theme").Should().Be(3, "three App theme commands carry 'Theme' in the label"); + Hits("stop").Should().Be(1); + Hits("ndi").Should().Be(2, "Refresh discovery (NDI in keywords) + Apply transcoder topology"); + // "App" matches case-insensitively against the four App-category + // commands AND substring-matches inside "Apply transcoder topology" — + // a real operator typing "app" would see five rows, which is + // exactly what Contains delivers. Pinning this so a future move + // to a stricter (StartsWith / token-boundary) algorithm has to + // re-decide that affordance deliberately. + Hits("App").Should().Be(5, "four App-category commands + 'Apply' transcoder topology"); + Hits("xyzzy").Should().Be(0); + } +}