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();