Punch-list items 19–25 — covers six of the seven services + the
engine controller. TeamsLauncher fallback chain (item 21) is deferred:
it depends on Process.Start in ways that don't unit-test cleanly
without a process-launch seam that the May 2026 codebase doesn't
have yet.
Service seams added for testability (each marked internal + a
matching InternalsVisibleTo-equivalent grant via the existing
TeamsISO.App.Tests visibility):
* NotesService.DirectoryOverride — redirect %LOCALAPPDATA%\TeamsISO\Notes
* WindowStateStore.PathOverride — redirect window.json
* UpdateChecker.StateDirectoryOverride — redirect both the 24h
cooldown stamp and the no-update-check.flag
* UpdateChecker.TryParseSemVer — visibility bumped to internal
* OscBridge.DispatchAsync — visibility bumped to internal so tests
can drive route dispatch without spinning up the UDP receive loop
New test files (App.Tests):
* Services/NotesServiceTests.cs (6 cases) — header-once, timestamp
format, multi-append, whitespace trim + reject, today-path shape.
* Services/UpdateCheckerTests.cs (7 cases) — TryParseSemVer Theory
across the v?X.Y.Z(.N)(-suffix) inputs the real release stream
produces, semver ordering pin, CheckIfDueAsync short-circuit on
recent stamps (the throttle never fires HTTP — deterministic
offline), LaunchCheckEnabled round-trip via the opt-out flag.
* Services/PresetApplierTests.cs (6 cases) — the four enable/disable
state transitions, case-insensitive display-name join, partial
meeting (preset names participants not present), live participants
unnamed by the preset stay untouched.
* Services/PresetStoreCollection.cs — xUnit collection so any test
class that mutates OperatorPresetStore.PathOverride serializes
with siblings that do the same. OperatorPresetStoreTests now joins
the collection (the class comment claimed it didn't need one
because file paths were per-test-unique — true, but PathOverride
is shared static state, which is why the new PresetApplierTests
was clobbering its result on first run).
* Services/WindowStateStoreTests.cs (6 cases) — JSON round-trip
through the Snapshot record + all the bail paths (no file, too
small, too large, fully off-screen, garbage JSON). Full Window
property write coverage is deferred to branch 11 (needs STA).
* Services/OscBridgeDispatchTests.cs (5 cases) — /teamsiso/refresh-
discovery + unknown-address + /teamsiso/notes + clean bail when
the toggle/preset paths can't reach a dispatcher.
New test cases (Engine.Tests):
* Controller/IsoControllerTests.cs gains three cases —
SetRecording_TogglesEnabledAndStoresDirectory,
AddRecordingMarker_NoOpsCleanly_WhenNoActiveRecorders,
RefreshDiscovery_SetsRefreshFlagOnDiscoveryService.
Tests: 56 → 128 in App.Tests; 103 → 106 in Engine.Tests. Total
green: 234. Build clean (0 warnings, 0 errors).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds end-to-end-ish tests that boot the server on an OS-assigned free
port and exercise the route dispatch via HttpClient. Catches
regressions in the route table itself (which is the part of the
control surface that benefits least from unit tests — its bug
surface is the URL → handler mapping, not the handler bodies).
* src/tests/TeamsISO.App.Tests/Fakes/StubIsoController.cs — minimal
IIsoController stub that lets the App layer instantiate without
spinning up the engine + NDI runtime. EnableCalls / DisableCalls /
RefreshDiscoveryCalled flags make assertions on side effects easy.
* src/tests/TeamsISO.App.Tests/Services/ControlSurfaceServerTests.cs
(7 cases):
- GET / → 200 with the server-info JSON (product, endpoints).
- GET /unknown-path → 200 with body {error:"not found"}. Pinning
this odd-but-intentional behavior: the catch-all switch arm
returns NotFound() (an object) so response is non-null and the
pipeline writes 200 + that body instead of branching to the
404 path. The body is the disambiguator, matching the rest of
the surface's "200 + {ok:false,error:…}" convention.
- GET /participants → 200 with participants:[] when no view-model.
- POST /presets/refresh-discovery → 200 + StubIsoController.
RefreshDiscoveryCalled flips true (route → controller round-trip).
- POST /presets/{missing}/apply → 200 + ok:false +
error:"preset not found" (missing-preset path).
- GET /ui → 200 with text/html.
- OPTIONS /participants → 204 + Access-Control-Allow-Origin:*
(CORS preflight for browser-based controllers).
TeamsISO.App.Tests.csproj gains UseWPF=true so the test assembly
can transitively compile against the WPF types that
ControlSurfaceServer's signature touches (System.Windows.Threading,
Application.Current). Implicit-using set narrows under UseWPF, so
OscMessageTests gains an explicit `using System.IO` and the new
test file gains `using System.Net.Http`.
Tests: 56 → 90 in App.Tests; Engine.Tests unchanged at 103.
Total green: 193. Build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
The IN-CALL pill now reads 'IN CALL · Weekly Standup' (or 'IN CALL' if Teams' window doesn't expose a meeting title), so operators using auto-hide know WHICH meeting they're in without restoring the Teams window.
Implementation: TeamsLauncher.GetActiveWindowTitle uses EnumWindows + GetWindowTextW to read every Teams top-level window title (hidden windows too — title bar text is accessible even with SW_HIDE), picks the longest as a heuristic for 'most informative' (Teams creates several windows per process; the call window has the meaningful title). MainViewModel.ExtractMeetingTitle strips the ' | Microsoft Teams' / ' - Microsoft Teams' suffix variations and clamps overly long titles to 50 chars with an ellipsis.
10 new unit tests for ExtractMeetingTitle covering: standard formats with both separators, bare 'Microsoft Teams' (returns empty so the pill stays at 'IN CALL'), long-title truncation, outer-whitespace trimming, unrecognized formats passing through.
169/169 tests passing.