diff --git a/src/TeamsISO.App/Services/NotesService.cs b/src/TeamsISO.App/Services/NotesService.cs
index 7bbd280..e53380f 100644
--- a/src/TeamsISO.App/Services/NotesService.cs
+++ b/src/TeamsISO.App/Services/NotesService.cs
@@ -19,8 +19,16 @@ public static class NotesService
{
private static readonly object _gate = new();
+ ///
+ /// Test-only seam — when set, overrides the default
+ /// %LOCALAPPDATA%\TeamsISO\Notes path. Lets tests write to a
+ /// tempdir without polluting the dev's real notes folder.
+ /// InternalsVisibleTo grants TeamsISO.App.Tests access.
+ ///
+ internal static string? DirectoryOverride { get; set; }
+
private static string NotesDirectory =>
- Path.Combine(
+ DirectoryOverride ?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"TeamsISO", "Notes");
diff --git a/src/TeamsISO.App/Services/OscBridge.cs b/src/TeamsISO.App/Services/OscBridge.cs
index e44f1b7..fc028c8 100644
--- a/src/TeamsISO.App/Services/OscBridge.cs
+++ b/src/TeamsISO.App/Services/OscBridge.cs
@@ -139,7 +139,10 @@ public sealed class OscBridge : IAsyncDisposable
}
}
- private async Task DispatchAsync(OscMessage msg)
+ // Internal so unit tests can construct an OscMessage and verify
+ // route dispatch reaches the right controller / TeamsControlBridge /
+ // NotesService call without driving the full UDP receive loop.
+ internal async Task DispatchAsync(OscMessage msg)
{
var addr = msg.Address;
switch (addr)
diff --git a/src/TeamsISO.App/Services/UpdateChecker.cs b/src/TeamsISO.App/Services/UpdateChecker.cs
index 38575ee..62b3661 100644
--- a/src/TeamsISO.App/Services/UpdateChecker.cs
+++ b/src/TeamsISO.App/Services/UpdateChecker.cs
@@ -164,15 +164,26 @@ public static class UpdateChecker
return result;
}
- private static string CooldownPath =>
+ ///
+ /// Test-only seam — when set, overrides the default
+ /// %LOCALAPPDATA%\TeamsISO path that holds the cooldown stamp +
+ /// the opt-out flag. Tests use this to write to a tempdir so
+ /// CheckIfDueAsync's throttle path can be exercised without
+ /// hitting real disk paths or the real network (the throttle
+ /// short-circuits before the HTTP call).
+ ///
+ internal static string? StateDirectoryOverride { get; set; }
+
+ private static string StateDirectory => StateDirectoryOverride ??
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
- "TeamsISO", "last-update-check.txt");
+ "TeamsISO");
+
+ private static string CooldownPath =>
+ Path.Combine(StateDirectory, "last-update-check.txt");
private static string OptOutPath =>
- Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
- "TeamsISO", "no-update-check.flag");
+ Path.Combine(StateDirectory, "no-update-check.flag");
///
/// Whether launch-time update checks are enabled. Inverted-flag-file storage:
@@ -219,9 +230,10 @@ public static class UpdateChecker
/// Parse a "vX.Y.Z" or "X.Y.Z(.N)" string into a Version. Strips any
/// pre-release suffix ("-alpha", "-beta") so the comparison is on
/// numeric components only — pre-release vs. release ordering is a
- /// follow-up if we need it.
+ /// follow-up if we need it. Internal so tests can pin parsing
+ /// behaviour without HTTP.
///
- private static Version? TryParseSemVer(string s)
+ internal static Version? TryParseSemVer(string s)
{
var trimmed = s.TrimStart('v', 'V');
var dash = trimmed.IndexOf('-');
diff --git a/src/TeamsISO.App/Services/WindowStateStore.cs b/src/TeamsISO.App/Services/WindowStateStore.cs
index dec4ecd..bacdaa5 100644
--- a/src/TeamsISO.App/Services/WindowStateStore.cs
+++ b/src/TeamsISO.App/Services/WindowStateStore.cs
@@ -13,7 +13,15 @@ namespace TeamsISO.App.Services;
///
public static class WindowStateStore
{
- private static readonly string Path =
+ ///
+ /// Test-only seam — when set, overrides the default
+ /// %LOCALAPPDATA%\TeamsISO\window.json path. Lets tests verify
+ /// the serialization round-trip without polluting the dev's
+ /// real placement state.
+ ///
+ internal static string? PathOverride { get; set; }
+
+ private static string Path => PathOverride ??
System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"TeamsISO",
diff --git a/src/tests/TeamsISO.App.Tests/Services/NotesServiceTests.cs b/src/tests/TeamsISO.App.Tests/Services/NotesServiceTests.cs
new file mode 100644
index 0000000..2fa58a3
--- /dev/null
+++ b/src/tests/TeamsISO.App.Tests/Services/NotesServiceTests.cs
@@ -0,0 +1,99 @@
+using System.IO;
+using FluentAssertions;
+using TeamsISO.App.Services;
+
+namespace TeamsISO.App.Tests.Services;
+
+// Unit tests for NotesService — the append-only show-notes log.
+// Uses the DirectoryOverride seam so writes land in a tempdir and
+// don't pollute the dev's real %LOCALAPPDATA%\TeamsISO\Notes folder.
+public sealed class NotesServiceTests : IDisposable
+{
+ private readonly string _tempDir;
+ private readonly string? _previousOverride;
+
+ public NotesServiceTests()
+ {
+ _tempDir = Path.Combine(Path.GetTempPath(), $"teamsiso-notes-{Guid.NewGuid():N}");
+ _previousOverride = NotesService.DirectoryOverride;
+ NotesService.DirectoryOverride = _tempDir;
+ }
+
+ public void Dispose()
+ {
+ NotesService.DirectoryOverride = _previousOverride;
+ try { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); }
+ catch { /* test cleanup is best-effort */ }
+ }
+
+ [Fact]
+ public void Append_WritesHeaderAndLine_OnFirstCall()
+ {
+ var ok = NotesService.Append("first note");
+
+ ok.Should().BeTrue();
+ File.Exists(NotesService.TodayPath).Should().BeTrue();
+ var content = File.ReadAllText(NotesService.TodayPath);
+ content.Should().StartWith("# TeamsISO show notes — ");
+ content.Should().Contain("— first note");
+ }
+
+ [Fact]
+ public void Append_PrependsTimestampPrefix_InCanonicalFormat()
+ {
+ NotesService.Append("checkpoint");
+
+ var content = File.ReadAllText(NotesService.TodayPath);
+ // Each appended line follows "- **HH:mm:ss** — " so a
+ // reader can scan the file as Markdown without preprocessing.
+ content.Should().MatchRegex(@"- \*\*\d{2}:\d{2}:\d{2}\*\* — checkpoint");
+ }
+
+ [Fact]
+ public void Append_AppendsAdditionalLines_AfterTheFirst()
+ {
+ NotesService.Append("alpha");
+ NotesService.Append("beta");
+ NotesService.Append("gamma");
+
+ var content = File.ReadAllText(NotesService.TodayPath);
+ content.Should().Contain("alpha");
+ content.Should().Contain("beta");
+ content.Should().Contain("gamma");
+ // Header written exactly once, not before every line.
+ var headerCount = content.Split("# TeamsISO show notes —").Length - 1;
+ headerCount.Should().Be(1);
+ }
+
+ [Fact]
+ public void Append_TrimsLeadingAndTrailingWhitespace()
+ {
+ NotesService.Append(" padded ");
+
+ var content = File.ReadAllText(NotesService.TodayPath);
+ content.Should().Contain("— padded");
+ content.Should().NotContain(" padded "); // leading-whitespace gone
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("\t\n")]
+ public void Append_RejectsEmptyOrWhitespaceText(string text)
+ {
+ var ok = NotesService.Append(text);
+
+ ok.Should().BeFalse();
+ File.Exists(NotesService.TodayPath).Should().BeFalse(
+ "an empty append shouldn't create the daily file");
+ }
+
+ [Fact]
+ public void TodayPath_ReflectsCurrentDate_AndOverride()
+ {
+ var path = NotesService.TodayPath;
+
+ Path.GetDirectoryName(path).Should().Be(_tempDir);
+ Path.GetFileName(path).Should().MatchRegex(@"\d{4}-\d{2}-\d{2}\.md");
+ }
+}
diff --git a/src/tests/TeamsISO.App.Tests/Services/OperatorPresetStoreTests.cs b/src/tests/TeamsISO.App.Tests/Services/OperatorPresetStoreTests.cs
index e95abf0..86519fd 100644
--- a/src/tests/TeamsISO.App.Tests/Services/OperatorPresetStoreTests.cs
+++ b/src/tests/TeamsISO.App.Tests/Services/OperatorPresetStoreTests.cs
@@ -5,15 +5,18 @@ using TeamsISO.App.Services;
namespace TeamsISO.App.Tests.Services;
///
-/// Unit tests for . Each test redirects the
-/// store's file path to a per-test temp path via the internal
+/// Unit tests for . Each test redirects
+/// the store's file path to a per-test temp path via the internal
/// PathOverride hook so the operator's real
/// %LOCALAPPDATA%\TeamsISO\presets.json is never touched.
///
-/// IDisposable on the test class cleans up the temp path after each test.
-/// We don't use [Collection] because each test's path is per-test-unique
-/// (Path.GetTempFileName) so parallel xUnit execution can't collide.
+/// IDisposable on the test class cleans up the temp path after each
+/// test. Shares with any other
+/// class that mutates —
+/// xUnit's parallel execution would otherwise let a sibling class's
+/// ctor clobber our path mid-test.
///
+[Collection(PresetStoreCollection.Name)]
public sealed class OperatorPresetStoreTests : IDisposable
{
private readonly string _tempPath;
diff --git a/src/tests/TeamsISO.App.Tests/Services/OscBridgeDispatchTests.cs b/src/tests/TeamsISO.App.Tests/Services/OscBridgeDispatchTests.cs
new file mode 100644
index 0000000..00dc69e
--- /dev/null
+++ b/src/tests/TeamsISO.App.Tests/Services/OscBridgeDispatchTests.cs
@@ -0,0 +1,113 @@
+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 — 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();
+ }
+}
diff --git a/src/tests/TeamsISO.App.Tests/Services/PresetApplierTests.cs b/src/tests/TeamsISO.App.Tests/Services/PresetApplierTests.cs
new file mode 100644
index 0000000..95150f0
--- /dev/null
+++ b/src/tests/TeamsISO.App.Tests/Services/PresetApplierTests.cs
@@ -0,0 +1,164 @@
+using System.IO;
+using FluentAssertions;
+using TeamsISO.App.Services;
+using TeamsISO.App.Tests.Fakes;
+using TeamsISO.App.ViewModels;
+using TeamsISO.Engine.Domain;
+
+namespace TeamsISO.App.Tests.Services;
+
+// PresetApplier reconciles a saved preset's per-display-name assignments
+// against the live participant view-model list. Tests pin the four
+// transitions (enable→stay, disable→stay, off→enable, on→disable) plus
+// the partial-meeting path where the preset references participants
+// who aren't currently present.
+//
+// We share a collection with OperatorPresetStoreTests because both
+// classes mutate OperatorPresetStore.PathOverride; xUnit's default
+// parallelism would otherwise let one class clobber the other's path
+// mid-run.
+[Collection(PresetStoreCollection.Name)]
+public sealed class PresetApplierTests : IDisposable
+{
+ private readonly string _tempPresets;
+ private readonly string? _previousPresetOverride;
+
+ public PresetApplierTests()
+ {
+ _tempPresets = Path.Combine(Path.GetTempPath(), $"teamsiso-presets-{Guid.NewGuid():N}.json");
+ _previousPresetOverride = OperatorPresetStore.PathOverride;
+ OperatorPresetStore.PathOverride = _tempPresets;
+ }
+
+ public void Dispose()
+ {
+ OperatorPresetStore.PathOverride = _previousPresetOverride;
+ try { if (File.Exists(_tempPresets)) File.Delete(_tempPresets); }
+ catch { /* cleanup best-effort */ }
+ }
+
+ private static ParticipantViewModel MakeParticipant(
+ StubIsoController controller, string displayName, bool isEnabled = false)
+ {
+ var participant = new Participant(
+ Id: Guid.NewGuid(),
+ DisplayName: displayName,
+ CurrentSource: null,
+ FirstSeen: DateTimeOffset.UtcNow,
+ LastSeen: DateTimeOffset.UtcNow);
+ return new ParticipantViewModel(controller, participant) { IsEnabled = isEnabled };
+ }
+
+ private static OperatorPresetStore.Preset Preset(params (string Name, bool Enabled, string? Custom)[] rows) =>
+ new(
+ Name: "test-preset",
+ SavedAt: DateTimeOffset.UtcNow,
+ Assignments: rows.Select(r =>
+ new OperatorPresetStore.Assignment(r.Name, r.Custom, r.Enabled)).ToList());
+
+ [Fact]
+ public async Task Apply_EnablesParticipantsThatPresetSaysEnabled_AndAreCurrentlyOff()
+ {
+ var controller = new StubIsoController();
+ var alice = MakeParticipant(controller, "Alice", isEnabled: false);
+ var bob = MakeParticipant(controller, "Bob", isEnabled: false);
+ var preset = Preset(("Alice", true, "ALICE_OUT"), ("Bob", true, null));
+
+ var result = await PresetApplier.ApplyAsync(preset, new[] { alice, bob }, controller, dispatcher: null);
+
+ result.Matched.Should().Be(2);
+ result.Changed.Should().Be(2);
+ result.Skipped.Should().Be(0);
+
+ controller.EnableCalls.Should().HaveCount(2);
+ controller.EnableCalls.Should().Contain(c => c.Id == alice.Id && c.Name == "ALICE_OUT");
+ controller.EnableCalls.Should().Contain(c => c.Id == bob.Id && c.Name == null);
+
+ alice.IsEnabled.Should().BeTrue();
+ bob.IsEnabled.Should().BeTrue();
+ alice.CustomName.Should().Be("ALICE_OUT");
+ bob.CustomName.Should().Be(string.Empty);
+ }
+
+ [Fact]
+ public async Task Apply_DisablesParticipantsThatPresetSaysOff_AndAreCurrentlyEnabled()
+ {
+ var controller = new StubIsoController();
+ var alice = MakeParticipant(controller, "Alice", isEnabled: true);
+ var preset = Preset(("Alice", false, null));
+
+ var result = await PresetApplier.ApplyAsync(preset, new[] { alice }, controller, dispatcher: null);
+
+ result.Matched.Should().Be(1);
+ result.Changed.Should().Be(1);
+ controller.DisableCalls.Should().ContainSingle().Which.Should().Be(alice.Id);
+ alice.IsEnabled.Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task Apply_NoControllerCall_WhenStateAlreadyMatchesPreset()
+ {
+ var controller = new StubIsoController();
+ var alice = MakeParticipant(controller, "Alice", isEnabled: true);
+ var preset = Preset(("Alice", true, null));
+
+ var result = await PresetApplier.ApplyAsync(preset, new[] { alice }, controller, dispatcher: null);
+
+ result.Matched.Should().Be(1);
+ result.Changed.Should().Be(0,
+ "the participant is already enabled; preset says enabled — no controller traffic");
+ controller.EnableCalls.Should().BeEmpty();
+ controller.DisableCalls.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task Apply_MatchesByDisplayName_CaseInsensitive()
+ {
+ // Operator typed "Alice" when saving the preset; the live
+ // participant comes back as "alice". The join must be case-
+ // insensitive or the preset never finds the row.
+ var controller = new StubIsoController();
+ var alice = MakeParticipant(controller, "alice", isEnabled: false);
+ var preset = Preset(("Alice", true, null));
+
+ var result = await PresetApplier.ApplyAsync(preset, new[] { alice }, controller, dispatcher: null);
+
+ result.Matched.Should().Be(1);
+ alice.IsEnabled.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task Apply_CountsSkipped_WhenPresetReferencesAbsentParticipants()
+ {
+ var controller = new StubIsoController();
+ var alice = MakeParticipant(controller, "Alice", isEnabled: false);
+ // Preset names Alice + a Bob who never joined.
+ var preset = Preset(("Alice", true, null), ("Bob", true, null));
+
+ var result = await PresetApplier.ApplyAsync(preset, new[] { alice }, controller, dispatcher: null);
+
+ result.Matched.Should().Be(1);
+ result.Skipped.Should().Be(1, "Bob is named in the preset but not in the meeting");
+ result.Changed.Should().Be(1);
+ }
+
+ [Fact]
+ public async Task Apply_IgnoresLiveParticipantsThatThePresetDoesntName()
+ {
+ // Carol joined the meeting but the saved preset only references
+ // Alice. Carol's row must NOT be touched (no enable / disable
+ // / customName change).
+ var controller = new StubIsoController();
+ var alice = MakeParticipant(controller, "Alice", isEnabled: false);
+ var carol = MakeParticipant(controller, "Carol", isEnabled: true);
+ var carolCustomBefore = carol.CustomName;
+ var preset = Preset(("Alice", true, null));
+
+ await PresetApplier.ApplyAsync(preset, new[] { alice, carol }, controller, dispatcher: null);
+
+ carol.IsEnabled.Should().BeTrue("Carol wasn't named, so her state stands");
+ carol.CustomName.Should().Be(carolCustomBefore);
+ controller.EnableCalls.Should().ContainSingle().Which.Id.Should().Be(alice.Id);
+ controller.DisableCalls.Should().BeEmpty();
+ }
+}
diff --git a/src/tests/TeamsISO.App.Tests/Services/PresetStoreCollection.cs b/src/tests/TeamsISO.App.Tests/Services/PresetStoreCollection.cs
new file mode 100644
index 0000000..0428c82
--- /dev/null
+++ b/src/tests/TeamsISO.App.Tests/Services/PresetStoreCollection.cs
@@ -0,0 +1,14 @@
+namespace TeamsISO.App.Tests.Services;
+
+///
+/// Serializes any test class that mutates
+/// OperatorPresetStore.PathOverride — without this, xUnit runs
+/// fixtures in parallel across the assembly and a sibling class can
+/// clobber the path mid-test, leading to flakes that look like data
+/// corruption.
+///
+[CollectionDefinition(Name)]
+public sealed class PresetStoreCollection
+{
+ public const string Name = "PresetStore (PathOverride mutators)";
+}
diff --git a/src/tests/TeamsISO.App.Tests/Services/UpdateCheckerTests.cs b/src/tests/TeamsISO.App.Tests/Services/UpdateCheckerTests.cs
new file mode 100644
index 0000000..4771b63
--- /dev/null
+++ b/src/tests/TeamsISO.App.Tests/Services/UpdateCheckerTests.cs
@@ -0,0 +1,118 @@
+using System.IO;
+using FluentAssertions;
+using TeamsISO.App.Services;
+
+namespace TeamsISO.App.Tests.Services;
+
+// UpdateChecker unit tests.
+//
+// We don't exercise CheckAsync (the real HTTP call against Forgejo) —
+// tests must not depend on the network. Coverage instead:
+// • TryParseSemVer: version-comparison parsing across the inputs the
+// real release stream produces.
+// • CheckIfDueAsync throttle: a recent cooldown stamp short-circuits
+// and returns null *before* CheckAsync runs (which would otherwise
+// fire an HTTP request).
+public sealed class UpdateCheckerTests : IDisposable
+{
+ private readonly string _tempDir;
+ private readonly string? _previousOverride;
+
+ public UpdateCheckerTests()
+ {
+ _tempDir = Path.Combine(Path.GetTempPath(), $"teamsiso-update-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(_tempDir);
+ _previousOverride = UpdateChecker.StateDirectoryOverride;
+ UpdateChecker.StateDirectoryOverride = _tempDir;
+ }
+
+ public void Dispose()
+ {
+ UpdateChecker.StateDirectoryOverride = _previousOverride;
+ try { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); }
+ catch { /* cleanup best-effort */ }
+ }
+
+ [Theory]
+ [InlineData("v1.2.3", "1.2.3")]
+ [InlineData("V1.2.3", "1.2.3")] // case-insensitive 'v' strip
+ [InlineData("1.2.3", "1.2.3")]
+ [InlineData("v1.2.3.4", "1.2.3.4")] // 4-segment .NET-style versions
+ [InlineData("v1.2.3-alpha", "1.2.3")] // pre-release suffix stripped
+ [InlineData("v1.2.3-beta.4", "1.2.3")]
+ public void TryParseSemVer_AcceptsExpectedForms(string input, string expected)
+ {
+ UpdateChecker.TryParseSemVer(input).Should().Be(Version.Parse(expected));
+ }
+
+ [Theory]
+ [InlineData("not-a-version")]
+ [InlineData("v.invalid")]
+ [InlineData("")]
+ public void TryParseSemVer_ReturnsNullOnGarbage(string input)
+ {
+ UpdateChecker.TryParseSemVer(input).Should().BeNull();
+ }
+
+ [Fact]
+ public void TryParseSemVer_OrderingIsSemantic()
+ {
+ // The CheckAsync comparison is "latest > current" — pin the
+ // ordering across the version arc the release process actually
+ // produces.
+ var older = UpdateChecker.TryParseSemVer("v0.1.0")!;
+ var newer = UpdateChecker.TryParseSemVer("v0.2.0")!;
+ var newest = UpdateChecker.TryParseSemVer("v1.0.0")!;
+
+ (newer > older).Should().BeTrue();
+ (newest > newer).Should().BeTrue();
+ (newest > older).Should().BeTrue();
+ (older > newer).Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task CheckIfDueAsync_ReturnsNull_WhenCooldownStampIsRecent()
+ {
+ // Pre-write a "we just checked" stamp. The throttle should
+ // short-circuit and return null without firing the HTTP call,
+ // which means the test passes deterministically offline.
+ File.WriteAllText(
+ Path.Combine(_tempDir, "last-update-check.txt"),
+ DateTimeOffset.UtcNow.ToString("o"));
+
+ var result = await UpdateChecker.CheckIfDueAsync(TimeSpan.FromHours(24));
+
+ result.Should().BeNull("a stamp inside the cooldown window suppresses the check");
+ }
+
+ [Fact]
+ public async Task CheckIfDueAsync_ReturnsNull_WhenStampIsOldButCooldownIsLargerThanGap()
+ {
+ // Edge case: stamp 1h old, cooldown 24h → still suppressed.
+ File.WriteAllText(
+ Path.Combine(_tempDir, "last-update-check.txt"),
+ DateTimeOffset.UtcNow.AddHours(-1).ToString("o"));
+
+ var result = await UpdateChecker.CheckIfDueAsync(TimeSpan.FromHours(24));
+
+ result.Should().BeNull();
+ }
+
+ [Fact]
+ public void LaunchCheckEnabled_RoundTrips()
+ {
+ // Default (no flag file) → enabled.
+ UpdateChecker.LaunchCheckEnabled.Should().BeTrue();
+
+ UpdateChecker.LaunchCheckEnabled = false;
+ UpdateChecker.LaunchCheckEnabled.Should().BeFalse(
+ "writing the opt-out flag should be visible immediately");
+ File.Exists(Path.Combine(_tempDir, "no-update-check.flag"))
+ .Should().BeTrue();
+
+ UpdateChecker.LaunchCheckEnabled = true;
+ UpdateChecker.LaunchCheckEnabled.Should().BeTrue();
+ File.Exists(Path.Combine(_tempDir, "no-update-check.flag"))
+ .Should().BeFalse("re-enabling should remove the opt-out flag");
+ }
+}
diff --git a/src/tests/TeamsISO.App.Tests/Services/WindowStateStoreTests.cs b/src/tests/TeamsISO.App.Tests/Services/WindowStateStoreTests.cs
new file mode 100644
index 0000000..e61aeda
--- /dev/null
+++ b/src/tests/TeamsISO.App.Tests/Services/WindowStateStoreTests.cs
@@ -0,0 +1,126 @@
+using System.IO;
+using System.Text.Json;
+using System.Windows;
+using FluentAssertions;
+using TeamsISO.App.Services;
+
+namespace TeamsISO.App.Tests.Services;
+
+// Round-trip tests for WindowStateStore.Save / TryApply. Constructing a
+// real WPF Window inside an xUnit fact is awkward (no Application.Run,
+// no dispatcher), so we exercise the JSON layer + the placement-validity
+// rejection logic by writing snapshots directly to disk and reading
+// them back. Save is exercised by serializing a Snapshot record
+// inline and asserting JsonSerializer can round-trip it through the
+// shape WindowStateStore writes.
+//
+// The full Window.Left/Width property writes inside TryApply aren't
+// covered here — they require a WPF Window instance, which means an
+// Application.Current + dispatcher. We instead cover the bail paths
+// (file missing, too-small, off-screen) which is where regressions
+// typically land.
+public sealed class WindowStateStoreTests : IDisposable
+{
+ private readonly string _tempPath;
+ private readonly string? _previousOverride;
+
+ public WindowStateStoreTests()
+ {
+ _tempPath = Path.Combine(Path.GetTempPath(), $"teamsiso-window-{Guid.NewGuid():N}.json");
+ _previousOverride = WindowStateStore.PathOverride;
+ WindowStateStore.PathOverride = _tempPath;
+ }
+
+ public void Dispose()
+ {
+ WindowStateStore.PathOverride = _previousOverride;
+ try { if (File.Exists(_tempPath)) File.Delete(_tempPath); }
+ catch { /* best-effort */ }
+ }
+
+ private static void WriteSnapshot(string path, WindowStateStore.Snapshot snap)
+ {
+ File.WriteAllText(path, JsonSerializer.Serialize(snap, new JsonSerializerOptions { WriteIndented = true }));
+ }
+
+ [Fact]
+ public void Snapshot_JsonRoundTrips_CleanlyThroughTheSameSerializerShape()
+ {
+ // Write a Snapshot record through the same JsonSerializer.Serialize
+ // call WindowStateStore.Save uses; read it back and verify all
+ // five fields survive. Coverage gap (Save's own Window reads)
+ // intentional — see file header.
+ var snap = new WindowStateStore.Snapshot(
+ Left: 120, Top: 80, Width: 1024, Height: 768, State: WindowState.Maximized);
+ WriteSnapshot(_tempPath, snap);
+
+ var roundTripped = JsonSerializer.Deserialize(File.ReadAllText(_tempPath));
+
+ roundTripped.Should().NotBeNull();
+ roundTripped!.Left.Should().Be(120);
+ roundTripped.Top.Should().Be(80);
+ roundTripped.Width.Should().Be(1024);
+ roundTripped.Height.Should().Be(768);
+ roundTripped.State.Should().Be(WindowState.Maximized);
+ }
+
+ [Fact]
+ public void TryApply_NoFile_ReturnsFalse()
+ {
+ File.Exists(_tempPath).Should().BeFalse();
+
+ // We can't construct a Window without STA; we *can* exercise
+ // the bail path that returns before any Window property is
+ // touched by passing null and catching the NRE through the
+ // store's own try/catch — which makes TryApply return false.
+ var result = WindowStateStore.TryApply(null!);
+
+ result.Should().BeFalse();
+ }
+
+ [Fact]
+ public void TryApply_TooSmallSnapshot_RejectsBeforeTouchingWindow()
+ {
+ // 100×100 is below the 320×240 floor. TryApply should return
+ // false without throwing on the null window.
+ WriteSnapshot(_tempPath, new WindowStateStore.Snapshot(0, 0, 100, 100, WindowState.Normal));
+
+ var result = WindowStateStore.TryApply(null!);
+
+ result.Should().BeFalse();
+ }
+
+ [Fact]
+ public void TryApply_AbsurdlyLargeSnapshot_RejectsBeforeTouchingWindow()
+ {
+ // 20000×20000 is above the safety ceiling. Again no throw.
+ WriteSnapshot(_tempPath, new WindowStateStore.Snapshot(0, 0, 20000, 20000, WindowState.Normal));
+
+ var result = WindowStateStore.TryApply(null!);
+
+ result.Should().BeFalse();
+ }
+
+ [Fact]
+ public void TryApply_FullyOffScreenSnapshot_RejectsBeforeTouchingWindow()
+ {
+ // Way off the virtual screen — no corner falls inside any
+ // monitor's working area.
+ WriteSnapshot(_tempPath, new WindowStateStore.Snapshot(
+ Left: -99999, Top: -99999, Width: 800, Height: 600, State: WindowState.Normal));
+
+ var result = WindowStateStore.TryApply(null!);
+
+ result.Should().BeFalse();
+ }
+
+ [Fact]
+ public void TryApply_GarbageJson_ReturnsFalseRatherThanThrowing()
+ {
+ File.WriteAllText(_tempPath, "{ this is not valid json");
+
+ var result = WindowStateStore.TryApply(null!);
+
+ result.Should().BeFalse();
+ }
+}
diff --git a/src/tests/TeamsISO.Engine.Tests/Controller/IsoControllerTests.cs b/src/tests/TeamsISO.Engine.Tests/Controller/IsoControllerTests.cs
index 5d90f28..700e3ee 100644
--- a/src/tests/TeamsISO.Engine.Tests/Controller/IsoControllerTests.cs
+++ b/src/tests/TeamsISO.Engine.Tests/Controller/IsoControllerTests.cs
@@ -182,6 +182,72 @@ public class IsoControllerTests : IDisposable
alerts.Should().Contain(a => a is EngineAlert.NdiRuntimeMismatch);
}
+ [Fact]
+ public async Task SetRecording_TogglesEnabledAndStoresDirectory()
+ {
+ await using var controller = NewController();
+
+ controller.RecordingEnabled.Should().BeFalse();
+ controller.RecordingDirectory.Should().BeNull();
+
+ controller.SetRecording(enabled: true, outputDirectory: @"D:\Recordings\Show1");
+
+ controller.RecordingEnabled.Should().BeTrue();
+ controller.RecordingDirectory.Should().Be(@"D:\Recordings\Show1");
+
+ controller.SetRecording(enabled: false, outputDirectory: null);
+
+ controller.RecordingEnabled.Should().BeFalse();
+ controller.RecordingDirectory.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task AddRecordingMarker_NoOpsCleanly_WhenNoActiveRecorders()
+ {
+ // No pipelines have ever started → no recorders are attached.
+ // AddRecordingMarker must not throw on the empty-recorder path
+ // (the UI Ctrl+M binding fires regardless of recording state).
+ await using var controller = NewController();
+
+ var act = () => controller.AddRecordingMarker("test marker");
+
+ act.Should().NotThrow();
+ }
+
+ [Fact]
+ public async Task RefreshDiscovery_SetsRefreshFlagOnDiscoveryService()
+ {
+ // RefreshDiscovery is a fire-and-forget that just sets a flag
+ // the discovery loop honours on its next tick. We exercise it
+ // and verify the loop subsequently re-emits the current source
+ // set as freshly-added (which is the observable contract).
+ await using var controller = NewController();
+ var seenLists = new List>();
+ using var sub = controller.Participants.Subscribe(p => seenLists.Add(p));
+
+ await controller.StartAsync(CancellationToken.None);
+ _interop.Sources.Add("PC1 (Teams - Jane)");
+
+ var deadline = DateTime.UtcNow.AddSeconds(2);
+ while (seenLists.LastOrDefault()?.Any() != true && DateTime.UtcNow < deadline)
+ await Task.Delay(20);
+ seenLists.Last().Should().HaveCount(1);
+
+ var emitsBefore = seenLists.Count;
+
+ // Trigger a refresh — the discovery loop should re-emit. We
+ // don't care exactly how many emissions land, just that the
+ // observable kept producing rather than stalling.
+ controller.RefreshDiscovery();
+
+ var refreshDeadline = DateTime.UtcNow.AddSeconds(2);
+ while (seenLists.Count <= emitsBefore && DateTime.UtcNow < refreshDeadline)
+ await Task.Delay(20);
+
+ seenLists.Count.Should().BeGreaterThan(emitsBefore,
+ "the refresh flag should drive a re-emission within the discovery interval");
+ }
+
private static async Task WaitForFirstParticipantAsync(IsoController controller)
{
var tcs = new TaskCompletionSource();