test: services — NotesService, UpdateChecker, PresetApplier, OscBridge, IsoController
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>
This commit is contained in:
parent
d91f95379b
commit
6505a3cab0
12 changed files with 749 additions and 15 deletions
|
|
@ -19,8 +19,16 @@ public static class NotesService
|
|||
{
|
||||
private static readonly object _gate = new();
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal static string? DirectoryOverride { get; set; }
|
||||
|
||||
private static string NotesDirectory =>
|
||||
Path.Combine(
|
||||
DirectoryOverride ?? Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"TeamsISO", "Notes");
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -164,15 +164,26 @@ public static class UpdateChecker
|
|||
return result;
|
||||
}
|
||||
|
||||
private static string CooldownPath =>
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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");
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static Version? TryParseSemVer(string s)
|
||||
internal static Version? TryParseSemVer(string s)
|
||||
{
|
||||
var trimmed = s.TrimStart('v', 'V');
|
||||
var dash = trimmed.IndexOf('-');
|
||||
|
|
|
|||
|
|
@ -13,7 +13,15 @@ namespace TeamsISO.App.Services;
|
|||
/// </summary>
|
||||
public static class WindowStateStore
|
||||
{
|
||||
private static readonly string Path =
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal static string? PathOverride { get; set; }
|
||||
|
||||
private static string Path => PathOverride ??
|
||||
System.IO.Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"TeamsISO",
|
||||
|
|
|
|||
99
src/tests/TeamsISO.App.Tests/Services/NotesServiceTests.cs
Normal file
99
src/tests/TeamsISO.App.Tests/Services/NotesServiceTests.cs
Normal file
|
|
@ -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** — <text>" 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -5,15 +5,18 @@ using TeamsISO.App.Services;
|
|||
namespace TeamsISO.App.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="OperatorPresetStore"/>. Each test redirects the
|
||||
/// store's file path to a per-test temp path via the internal
|
||||
/// Unit tests for <see cref="OperatorPresetStore"/>. Each test redirects
|
||||
/// the store's file path to a per-test temp path via the internal
|
||||
/// <c>PathOverride</c> hook so the operator's real
|
||||
/// <c>%LOCALAPPDATA%\TeamsISO\presets.json</c> 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 <see cref="PresetStoreCollection"/> with any other
|
||||
/// class that mutates <see cref="OperatorPresetStore.PathOverride"/> —
|
||||
/// xUnit's parallel execution would otherwise let a sibling class's
|
||||
/// ctor clobber our path mid-test.
|
||||
/// </summary>
|
||||
[Collection(PresetStoreCollection.Name)]
|
||||
public sealed class OperatorPresetStoreTests : IDisposable
|
||||
{
|
||||
private readonly string _tempPath;
|
||||
|
|
|
|||
113
src/tests/TeamsISO.App.Tests/Services/OscBridgeDispatchTests.cs
Normal file
113
src/tests/TeamsISO.App.Tests/Services/OscBridgeDispatchTests.cs
Normal file
|
|
@ -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<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();
|
||||
}
|
||||
}
|
||||
164
src/tests/TeamsISO.App.Tests/Services/PresetApplierTests.cs
Normal file
164
src/tests/TeamsISO.App.Tests/Services/PresetApplierTests.cs
Normal file
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
namespace TeamsISO.App.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Serializes any test class that mutates
|
||||
/// <c>OperatorPresetStore.PathOverride</c> — 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.
|
||||
/// </summary>
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class PresetStoreCollection
|
||||
{
|
||||
public const string Name = "PresetStore (PathOverride mutators)";
|
||||
}
|
||||
118
src/tests/TeamsISO.App.Tests/Services/UpdateCheckerTests.cs
Normal file
118
src/tests/TeamsISO.App.Tests/Services/UpdateCheckerTests.cs
Normal file
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
126
src/tests/TeamsISO.App.Tests/Services/WindowStateStoreTests.cs
Normal file
126
src/tests/TeamsISO.App.Tests/Services/WindowStateStoreTests.cs
Normal file
|
|
@ -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<WindowStateStore.Snapshot>(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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<IReadOnlyList<Participant>>();
|
||||
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<Guid> WaitForFirstParticipantAsync(IsoController controller)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<Guid>();
|
||||
|
|
|
|||
Loading…
Reference in a new issue