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>
118 lines
4.3 KiB
C#
118 lines
4.3 KiB
C#
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");
|
|
}
|
|
}
|