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>
113 lines
3.9 KiB
C#
113 lines
3.9 KiB
C#
using System.IO;
|
|
using FluentAssertions;
|
|
using TeamsISO.App.Services;
|
|
using TeamsISO.App.Tests.Fakes;
|
|
|
|
namespace TeamsISO.App.Tests.Services;
|
|
|
|
// Tests for the OscBridge.DispatchAsync routing. We construct
|
|
// OscMessage instances directly (skipping the UDP receive loop) and
|
|
// assert that the right address resolves to the right controller call.
|
|
//
|
|
// The toggle / preset paths require Application.Current.Dispatcher,
|
|
// which doesn't exist in xUnit's default execution context — those
|
|
// paths return early on the null check, so we verify the bail rather
|
|
// than the happy path. The full toggle path is covered in branch 11's
|
|
// integration test that boots a real dispatcher.
|
|
public sealed class OscBridgeDispatchTests : IDisposable
|
|
{
|
|
private readonly string _tempNotesDir;
|
|
private readonly string? _previousNotesOverride;
|
|
|
|
public OscBridgeDispatchTests()
|
|
{
|
|
_tempNotesDir = Path.Combine(Path.GetTempPath(), $"teamsiso-osc-{Guid.NewGuid():N}");
|
|
_previousNotesOverride = NotesService.DirectoryOverride;
|
|
NotesService.DirectoryOverride = _tempNotesDir;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
NotesService.DirectoryOverride = _previousNotesOverride;
|
|
try { if (Directory.Exists(_tempNotesDir)) Directory.Delete(_tempNotesDir, recursive: true); }
|
|
catch { /* best-effort */ }
|
|
}
|
|
|
|
private static (OscBridge Bridge, StubIsoController Controller) NewBridge()
|
|
{
|
|
var controller = new StubIsoController();
|
|
// OscBridge takes Func<MainViewModel?> — returning null exercises
|
|
// the "no VM yet" graceful path in handlers that need it.
|
|
var bridge = new OscBridge(controller, () => null, logger: null);
|
|
return (bridge, controller);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RefreshDiscoveryAddress_CallsControllerRefreshDiscovery()
|
|
{
|
|
var (bridge, controller) = NewBridge();
|
|
|
|
await bridge.DispatchAsync(new OscMessage { Address = "/teamsiso/refresh-discovery" });
|
|
|
|
controller.RefreshDiscoveryCalled.Should().BeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UnknownAddress_NoOpsCleanly()
|
|
{
|
|
var (bridge, controller) = NewBridge();
|
|
|
|
await bridge.DispatchAsync(new OscMessage { Address = "/teamsiso/nope/never" });
|
|
|
|
controller.RefreshDiscoveryCalled.Should().BeFalse();
|
|
controller.EnableCalls.Should().BeEmpty();
|
|
controller.DisableCalls.Should().BeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NotesAddress_AppendsViaNotesService()
|
|
{
|
|
var (bridge, _) = NewBridge();
|
|
|
|
await bridge.DispatchAsync(new OscMessage
|
|
{
|
|
Address = "/teamsiso/notes",
|
|
TypeTag = ",s",
|
|
Args = new object[] { "tracked through OSC" },
|
|
});
|
|
|
|
File.Exists(NotesService.TodayPath).Should().BeTrue();
|
|
File.ReadAllText(NotesService.TodayPath).Should().Contain("tracked through OSC");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StopAllAddress_NoOpsWhenViewModelIsNull()
|
|
{
|
|
// Without a view-model, the stop-all path returns before touching
|
|
// the controller. The point of this test is to pin that the bail
|
|
// is clean — no thrown exception, no controller traffic.
|
|
var (bridge, controller) = NewBridge();
|
|
|
|
await bridge.DispatchAsync(new OscMessage { Address = "/teamsiso/stop-all" });
|
|
|
|
controller.DisableCalls.Should().BeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task IsoByNameAddress_NoOpsWhenViewModelIsNull()
|
|
{
|
|
// /teamsiso/iso "Jane" 1 — verifies the bail when no VM is
|
|
// wired; doesn't fire EnableIsoAsync. The dispatcher-equipped
|
|
// version of this round-trip lives in branch 11.
|
|
var (bridge, controller) = NewBridge();
|
|
|
|
await bridge.DispatchAsync(new OscMessage
|
|
{
|
|
Address = "/teamsiso/iso",
|
|
TypeTag = ",sT",
|
|
Args = new object[] { "Jane", true },
|
|
});
|
|
|
|
controller.EnableCalls.Should().BeEmpty();
|
|
}
|
|
}
|