test: ThemeManager + CommandPaletteViewModel.Matches coverage

ThemeManager grows a test seam — its singleton ctor now delegates to
three internal seams (isSystemDark / loadPreference / savePreference)
that the production singleton fills with the real registry +
UIPreferences calls. Tests construct via the internal ctor with
stubs so they never touch HKCU or %LOCALAPPDATA% (which would
otherwise flake on CI or pollute the dev's UI state). Apply() and
the SystemEvents subscription are intentionally NOT exercised
here — both require Application.Current and a real dispatcher.

CommandPaletteViewModel.Matches changes from `private static` to
`internal static` — the predicate is the unit worth pinning, and
building a full CommandPaletteViewModel would require a fake
IIsoController + Dispatcher for one test.

New tests:

* src/tests/TeamsISO.App.Tests/Services/ThemeManagerTests.cs (11 cases):
  - Set Dark → Light round-trips Preference + ResolveTheme and
    persists via the savePreference seam.
  - ResolveTheme follows the system probe when Preference is
    System (true → Dark, false → Light).
  - Toggle from System pins to the opposite of the currently-
    resolved theme (not back to System) — explicit click should
    have visible effect.
  - Toggle from Dark flips Light; Toggle from Light flips Dark.
  - Set rejects invalid preferences (case-sensitive: lowercase
    "dark", "LIGHT", "", "invalid" all throw ArgumentException
    with ParamName=preference).
  - Constructor defaults to System when loadPreference returns
    null (fresh install / missing prefs file) or an invalid value
    (future schema collision).
  - Constructor swallows a load exception so the app doesn't lose
    theming when ui-prefs.json faults on read.

* src/tests/TeamsISO.App.Tests/ViewModels/CommandPaletteMatchesTests.cs
  (16 cases): Theory pinning case-insensitive label / category /
  keyword Contains, plus a full-vocabulary spread test counting
  hits for "theme" (3), "stop" (1), "ndi" (2), "App" (5 — four
  App-category cmds + the Apply transcoder topology substring
  match, called out in the assertion because a future move to a
  stricter algo has to re-decide that affordance deliberately),
  and "xyzzy" (0).

Tests: 56 → 83 in App.Tests; Engine.Tests unchanged at 103.
Total green: 186. Build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Zac Gaetano 2026-05-15 20:47:25 -04:00
parent e96a30b76f
commit fbcc56289e
4 changed files with 312 additions and 15 deletions

View file

@ -25,7 +25,11 @@ namespace TeamsISO.App.Services;
/// </summary>
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<bool> _isSystemDark;
private readonly Action<string> _savePreference;
internal ThemeManager(
Func<bool> isSystemDark,
Func<string?> loadPreference,
Action<string> 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,
};
/// <summary>
@ -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
/// <summary>
/// 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.
/// </summary>
private static bool IsSystemDark()
private static bool ReadSystemDarkFromRegistry()
{
try
{
@ -180,6 +202,31 @@ public sealed class ThemeManager
return true;
}
/// <summary>
/// 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.
/// </summary>
private static string? TryLoadPreferenceFromDisk()
{
try { return UIPreferences.Load().Theme; }
catch { return null; }
}
/// <summary>
/// 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.
/// </summary>
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;

View file

@ -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;

View file

@ -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<string>? captureSave = null) =>
new ThemeManager(
isSystemDark: () => systemDark,
loadPreference: () => initialPreference,
savePreference: captureSave ?? (_ => { }),
subscribeToSystemPreference: false);
[Fact]
public void Set_DarkThenLight_RoundTripsPreferenceAndResolution()
{
var saves = new List<string>();
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<ArgumentException>()
.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");
}
}

View file

@ -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);
}
}