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:
parent
e96a30b76f
commit
fbcc56289e
4 changed files with 312 additions and 15 deletions
|
|
@ -25,7 +25,11 @@ namespace TeamsISO.App.Services;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ThemeManager
|
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 DarkUri = "/Themes/Theme.Dark.xaml";
|
||||||
private const string LightUri = "/Themes/Theme.Light.xaml";
|
private const string LightUri = "/Themes/Theme.Light.xaml";
|
||||||
|
|
@ -33,28 +37,45 @@ public sealed class ThemeManager
|
||||||
private const string PreferenceKeyDark = "Dark";
|
private const string PreferenceKeyDark = "Dark";
|
||||||
private const string PreferenceKeyLight = "Light";
|
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()
|
_isSystemDark = isSystemDark;
|
||||||
// is best-effort — disk failures fall back to defaults so the app
|
_savePreference = savePreference;
|
||||||
// always boots into a deterministic theme.
|
|
||||||
|
// 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
|
try
|
||||||
{
|
{
|
||||||
var prefs = UIPreferences.Load();
|
var loaded = loadPreference();
|
||||||
if (IsValidPreference(prefs.Theme))
|
if (IsValidPreference(loaded))
|
||||||
{
|
{
|
||||||
_preference = prefs.Theme;
|
_preference = loaded!;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
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
|
// Re-evaluate when Windows app-mode flips, but only when the
|
||||||
// operator hasn't pinned a preference. The explicit choice wins.
|
// operator hasn't pinned a preference. The explicit choice wins.
|
||||||
|
// Tests opt out so they don't latch into a process-wide event.
|
||||||
|
if (subscribeToSystemPreference)
|
||||||
|
{
|
||||||
SystemEvents.UserPreferenceChanged += OnSystemPreferenceChanged;
|
SystemEvents.UserPreferenceChanged += OnSystemPreferenceChanged;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private string _preference = PreferenceKeySystem;
|
private string _preference = PreferenceKeySystem;
|
||||||
|
|
||||||
|
|
@ -73,7 +94,7 @@ public sealed class ThemeManager
|
||||||
{
|
{
|
||||||
PreferenceKeyDark => PreferenceKeyDark,
|
PreferenceKeyDark => PreferenceKeyDark,
|
||||||
PreferenceKeyLight => PreferenceKeyLight,
|
PreferenceKeyLight => PreferenceKeyLight,
|
||||||
_ => IsSystemDark() ? PreferenceKeyDark : PreferenceKeyLight,
|
_ => _isSystemDark() ? PreferenceKeyDark : PreferenceKeyLight,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -89,7 +110,7 @@ public sealed class ThemeManager
|
||||||
}
|
}
|
||||||
|
|
||||||
_preference = preference;
|
_preference = preference;
|
||||||
try { UIPreferences.SetTheme(preference); }
|
try { _savePreference(preference); }
|
||||||
catch { /* persistence is best-effort */ }
|
catch { /* persistence is best-effort */ }
|
||||||
Apply();
|
Apply();
|
||||||
}
|
}
|
||||||
|
|
@ -160,9 +181,10 @@ public sealed class ThemeManager
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Read Windows' AppsUseLightTheme registry value. 1 = light, 0 = dark.
|
/// Read Windows' AppsUseLightTheme registry value. 1 = light, 0 = dark.
|
||||||
/// Returns true (dark) on any read failure — the dark scene is the
|
/// 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>
|
/// </summary>
|
||||||
private static bool IsSystemDark()
|
private static bool ReadSystemDarkFromRegistry()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -180,6 +202,31 @@ public sealed class ThemeManager
|
||||||
return true;
|
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)
|
private void OnSystemPreferenceChanged(object? sender, UserPreferenceChangedEventArgs e)
|
||||||
{
|
{
|
||||||
if (e.Category != UserPreferenceCategory.General) return;
|
if (e.Category != UserPreferenceCategory.General) return;
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,7 @@ public sealed class CommandPaletteViewModel : ObservableObject
|
||||||
: Visible.FirstOrDefault();
|
: 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.Label.Contains(query, StringComparison.OrdinalIgnoreCase)) return true;
|
||||||
if (c.Category.Contains(query, StringComparison.OrdinalIgnoreCase)) return true;
|
if (c.Category.Contains(query, StringComparison.OrdinalIgnoreCase)) return true;
|
||||||
|
|
|
||||||
156
src/tests/TeamsISO.App.Tests/Services/ThemeManagerTests.cs
Normal file
156
src/tests/TeamsISO.App.Tests/Services/ThemeManagerTests.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue