diff --git a/AudioQualityEnhancer.Tests/AppUpdateServiceTests.cs b/AudioQualityEnhancer.Tests/AppUpdateServiceTests.cs
index 8c66edb..83530af 100644
--- a/AudioQualityEnhancer.Tests/AppUpdateServiceTests.cs
+++ b/AudioQualityEnhancer.Tests/AppUpdateServiceTests.cs
@@ -1,3 +1,6 @@
+using System.Net;
+using System.Net.Http;
+using System.Text;
using AudioQualityEnhancer.Services;
namespace AudioQualityEnhancer.Tests;
@@ -40,4 +43,119 @@ public void IsNewer_ComparesMajorMinorPatch(string current, string latest, bool
{
Assert.Equal(expected, AppUpdateService.IsNewer(Version.Parse(current), Version.Parse(latest)));
}
+
+ [Fact]
+ public async Task CheckAsync_ReportsANewerRelease()
+ {
+ const string releaseUrl = "https://github.com/Kentarohakase/AudioQualityEnhancer/releases/tag/v0.18.0";
+ using var client = CreateClient(Release("v0.18.0", releaseUrl));
+
+ var update = await new AppUpdateService(client).CheckAsync(new Version(0, 17, 0), CancellationToken.None);
+
+ Assert.NotNull(update);
+ Assert.Equal("0.18.0", update!.Version);
+ Assert.Equal(releaseUrl, update.Url);
+ }
+
+ [Fact]
+ public async Task CheckAsync_ReturnsNullWhenTheCurrentVersionIsUpToDate()
+ {
+ using var client = CreateClient(Release("v0.17.0", "https://github.com/Kentarohakase/AudioQualityEnhancer/releases/tag/v0.17.0"));
+
+ Assert.Null(await new AppUpdateService(client).CheckAsync(new Version(0, 17, 0), CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task CheckAsync_FallsBackToTheReleasesPageForAnUntrustedLink()
+ {
+ using var client = CreateClient(Release("v0.18.0", "http://example.com/download.exe"));
+
+ var update = await new AppUpdateService(client).CheckAsync(new Version(0, 17, 0), CancellationToken.None);
+
+ Assert.NotNull(update);
+ Assert.Equal(AppUpdateService.ReleasesPageUrl, update!.Url);
+ }
+
+ [Fact]
+ public async Task CheckAsync_ReturnsNullForAnErrorResponse()
+ {
+ using var client = CreateClient(new HttpResponseMessage(HttpStatusCode.NotFound));
+
+ Assert.Null(await new AppUpdateService(client).CheckAsync(new Version(0, 17, 0), CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task CheckAsync_ReturnsNullForAMalformedPayload()
+ {
+ using var client = CreateClient(Json("not json at all"));
+
+ Assert.Null(await new AppUpdateService(client).CheckAsync(new Version(0, 17, 0), CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task CheckAsync_ReturnsNullWhenTheTagIsMissing()
+ {
+ using var client = CreateClient(Json("""{"html_url":"https://github.com/Kentarohakase/AudioQualityEnhancer"}"""));
+
+ Assert.Null(await new AppUpdateService(client).CheckAsync(new Version(0, 17, 0), CancellationToken.None));
+ }
+
+ /// A cancelled check has to surface as a cancellation, not as "no update".
+ [Fact]
+ public async Task CheckAsync_PropagatesCancellation()
+ {
+ using var cancellation = new CancellationTokenSource();
+ var handler = new FakeHttpMessageHandler(_ =>
+ {
+ cancellation.Cancel();
+ throw new OperationCanceledException(cancellation.Token);
+ });
+
+ using var client = new HttpClient(handler);
+
+ await Assert.ThrowsAnyAsync(
+ () => new AppUpdateService(client).CheckAsync(new Version(0, 17, 0), cancellation.Token));
+ }
+
+ [Fact]
+ public async Task CheckAsync_ReturnsNullWhenTheHostIsUnreachable()
+ {
+ using var client = new HttpClient(new FakeHttpMessageHandler(_ => throw new HttpRequestException("offline")));
+
+ Assert.Null(await new AppUpdateService(client).CheckAsync(new Version(0, 17, 0), CancellationToken.None));
+ }
+
+ private static HttpClient CreateClient(HttpResponseMessage response)
+ {
+ return new HttpClient(new FakeHttpMessageHandler(_ => response));
+ }
+
+ private static HttpResponseMessage Release(string tag, string url)
+ {
+ return Json($$"""{"tag_name":"{{tag}}","html_url":"{{url}}"}""");
+ }
+
+ private static HttpResponseMessage Json(string payload)
+ {
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(payload, Encoding.UTF8, "application/json")
+ };
+ }
+
+ private sealed class FakeHttpMessageHandler : HttpMessageHandler
+ {
+ private readonly Func _respond;
+
+ public FakeHttpMessageHandler(Func respond)
+ {
+ _respond = respond;
+ }
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return Task.FromResult(_respond(request));
+ }
+ }
}
diff --git a/AudioQualityEnhancer.Tests/AssemblyInfo.cs b/AudioQualityEnhancer.Tests/AssemblyInfo.cs
new file mode 100644
index 0000000..92460ed
--- /dev/null
+++ b/AudioQualityEnhancer.Tests/AssemblyInfo.cs
@@ -0,0 +1,5 @@
+// LocalizationService.Instance.Culture is process wide state, and its setter also writes
+// CultureInfo.DefaultThreadCurrentUICulture. Several test classes change it while others
+// assert on localized text, so running classes in parallel makes those assertions depend
+// on timing. The whole suite takes a few seconds, so serializing it is the cheap fix.
+[assembly: CollectionBehavior(DisableTestParallelization = true)]
diff --git a/AudioQualityEnhancer.Tests/CommandTests.cs b/AudioQualityEnhancer.Tests/CommandTests.cs
new file mode 100644
index 0000000..93ea468
--- /dev/null
+++ b/AudioQualityEnhancer.Tests/CommandTests.cs
@@ -0,0 +1,185 @@
+using AudioQualityEnhancer.ViewModels;
+
+namespace AudioQualityEnhancer.Tests;
+
+public sealed class CommandTests
+{
+ [Fact]
+ public void AsyncRelayCommand_WithoutPredicate_CanExecute()
+ {
+ var command = new AsyncRelayCommand(() => Task.CompletedTask);
+
+ Assert.True(command.CanExecute(null));
+ }
+
+ [Fact]
+ public void AsyncRelayCommand_HonoursItsPredicate()
+ {
+ var allowed = false;
+ var command = new AsyncRelayCommand(() => Task.CompletedTask, () => allowed);
+
+ Assert.False(command.CanExecute(null));
+
+ allowed = true;
+ Assert.True(command.CanExecute(null));
+ }
+
+ [Fact]
+ public async Task AsyncRelayCommand_BlocksReentryWhileRunning()
+ {
+ var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var executions = 0;
+ var command = new AsyncRelayCommand(async () =>
+ {
+ executions++;
+ await gate.Task;
+ });
+
+ command.Execute(null);
+ Assert.False(command.CanExecute(null));
+
+ // A second click while the first run is still in flight must be ignored.
+ command.Execute(null);
+ Assert.Equal(1, executions);
+
+ gate.SetResult();
+ await WaitUntilAsync(() => command.CanExecute(null));
+ Assert.Equal(1, executions);
+ }
+
+ [Fact]
+ public async Task AsyncRelayCommand_ReportsFailureToTheErrorHandler()
+ {
+ Exception? reported = null;
+ var command = new AsyncRelayCommand(
+ () => throw new InvalidOperationException("boom"),
+ onError: exception => reported = exception);
+
+ command.Execute(null);
+ await WaitUntilAsync(() => reported is not null);
+
+ Assert.IsType(reported);
+ Assert.Equal("boom", reported!.Message);
+ }
+
+ [Fact]
+ public async Task AsyncRelayCommand_TreatsCancellationAsANormalOutcome()
+ {
+ Exception? reported = null;
+ var command = new AsyncRelayCommand(
+ () => throw new OperationCanceledException(),
+ onError: exception => reported = exception);
+
+ command.Execute(null);
+ await WaitUntilAsync(() => command.CanExecute(null));
+
+ Assert.Null(reported);
+ }
+
+ [Fact]
+ public async Task AsyncRelayCommand_BecomesExecutableAgainAfterAFailure()
+ {
+ var command = new AsyncRelayCommand(
+ () => throw new InvalidOperationException(),
+ onError: _ => { });
+
+ command.Execute(null);
+ await WaitUntilAsync(() => command.CanExecute(null));
+
+ Assert.True(command.CanExecute(null));
+ }
+
+ [Fact]
+ public void AsyncRelayCommand_DoesNotRunWhenItCannotExecute()
+ {
+ var executions = 0;
+ var command = new AsyncRelayCommand(
+ () =>
+ {
+ executions++;
+ return Task.CompletedTask;
+ },
+ () => false);
+
+ command.Execute(null);
+
+ Assert.Equal(0, executions);
+ }
+
+ [Fact]
+ public void RelayCommand_RaisesCanExecuteChanged()
+ {
+ var raised = 0;
+ var command = new RelayCommand(() => { });
+ command.CanExecuteChanged += (_, _) => raised++;
+
+ command.RaiseCanExecuteChanged();
+
+ Assert.Equal(1, raised);
+ }
+
+ [Fact]
+ public void RelayCommand_ExecutesAndHonoursItsPredicate()
+ {
+ var executions = 0;
+ var allowed = true;
+ var command = new RelayCommand(() => executions++, () => allowed);
+
+ Assert.True(command.CanExecute(null));
+ command.Execute(null);
+ Assert.Equal(1, executions);
+
+ allowed = false;
+ Assert.False(command.CanExecute(null));
+ }
+
+ [Fact]
+ public void RelayCommandOfT_PassesTheTypedParameter()
+ {
+ string? received = null;
+ var command = new RelayCommand(value => received = value);
+
+ command.Execute("preset");
+
+ Assert.Equal("preset", received);
+ }
+
+ [Fact]
+ public void RelayCommandOfT_PassesDefaultForAMismatchedParameter()
+ {
+ var executions = 0;
+ string? received = "unchanged";
+ var command = new RelayCommand(value =>
+ {
+ executions++;
+ received = value;
+ });
+
+ command.Execute(42);
+
+ Assert.Equal(1, executions);
+ Assert.Null(received);
+ }
+
+ [Fact]
+ public void RelayCommandOfT_EvaluatesThePredicateForNullAndForAValue()
+ {
+ var command = new RelayCommand(_ => { }, value => value is not null);
+
+ Assert.False(command.CanExecute(null));
+ Assert.True(command.CanExecute("preset"));
+
+ // A parameter of a different type is neither T nor null, so it cannot execute.
+ Assert.False(command.CanExecute(42));
+ }
+
+ private static async Task WaitUntilAsync(Func condition)
+ {
+ for (var attempt = 0; attempt < 100 && !condition(); attempt++)
+ {
+ await Task.Delay(10);
+ }
+
+ Assert.True(condition(), "The condition was not met in time.");
+ }
+}
diff --git a/AudioQualityEnhancer.Tests/ResourceTests.cs b/AudioQualityEnhancer.Tests/ResourceTests.cs
index 35bf4bd..ec1bbc6 100644
--- a/AudioQualityEnhancer.Tests/ResourceTests.cs
+++ b/AudioQualityEnhancer.Tests/ResourceTests.cs
@@ -18,6 +18,39 @@ public void GermanAndEnglishResources_ExposeTheSameKeys()
Assert.Empty(englishKeys.Except(germanKeys));
}
+ ///
+ /// Several texts are looked up under a key that is composed from an enum value, so a
+ /// new enum member ships "!Key!" to the user with no compiler error to stop it. This
+ /// walks every member of every enum used that way and resolves the keys it produces.
+ ///
+ [Theory]
+ [InlineData("de")]
+ [InlineData("en")]
+ public void ComposedResourceKeys_ResolveForEveryEnumValue(string cultureName)
+ {
+ LocalizationService.Instance.Culture = CultureInfo.GetCultureInfo(cultureName);
+
+ var keys = new List();
+ keys.AddRange(ComposeKeys("AnalysisStatus_{0}", "AnalysisSummary_{0}"));
+ keys.AddRange(ComposeKeys("AnalysisSeverity_{0}"));
+ keys.AddRange(ComposeKeys("AnalysisFinding_{0}_Title", "AnalysisFinding_{0}_Message"));
+ keys.AddRange(ComposeKeys("ValidationStatus_{0}", "ValidationSummary_{0}"));
+ keys.AddRange(ComposeKeys("ValidationFinding_{0}_Title", "ValidationFinding_{0}_Message"));
+ keys.AddRange(ComposeKeys("BatchStatus_{0}"));
+
+ // A recommendation only accompanies an actual finding, so the "no issues" kind
+ // deliberately has no recommendation text.
+ keys.AddRange(Enum.GetValues()
+ .Where(kind => kind != AudioAnalysisFindingKind.NoIssues)
+ .Select(kind => $"AnalysisRecommendation_{kind}"));
+
+ var unresolved = keys
+ .Where(key => MissingResourceRegex().IsMatch(LocalizationService.Instance[key]))
+ .ToArray();
+
+ Assert.Empty(unresolved);
+ }
+
[Theory]
[InlineData("de")]
[InlineData("en")]
@@ -150,6 +183,13 @@ public void VisibleModelTexts_DoNotContainMissingResourceMarkers(string cultureN
}
}
+ private static IEnumerable ComposeKeys(params string[] formats)
+ where TEnum : struct, Enum
+ {
+ return Enum.GetValues()
+ .SelectMany(value => formats.Select(format => string.Format(CultureInfo.InvariantCulture, format, value)));
+ }
+
private static IReadOnlySet LoadResourceKeys(string fileName)
{
var path = Path.Combine(TestPaths.RepositoryRoot, "Resources", fileName);
diff --git a/AudioQualityEnhancer.Tests/ResultTests.cs b/AudioQualityEnhancer.Tests/ResultTests.cs
new file mode 100644
index 0000000..eec93ab
--- /dev/null
+++ b/AudioQualityEnhancer.Tests/ResultTests.cs
@@ -0,0 +1,61 @@
+using AudioQualityEnhancer.Models;
+
+namespace AudioQualityEnhancer.Tests;
+
+public sealed class ResultTests
+{
+ [Fact]
+ public void Success_HasNoErrorMessageAndNoException()
+ {
+ var result = Result.Success();
+
+ Assert.True(result.IsSuccess);
+ Assert.False(result.IsFailure);
+ Assert.Null(result.ErrorMessage);
+ Assert.Null(result.Exception);
+ }
+
+ [Fact]
+ public void Failure_KeepsMessageAndException()
+ {
+ var exception = new InvalidOperationException("boom");
+ var result = Result.Failure("failed", exception);
+
+ Assert.True(result.IsFailure);
+ Assert.Equal("failed", result.ErrorMessage);
+ Assert.Same(exception, result.Exception);
+ }
+
+ [Fact]
+ public void SuccessOfT_CarriesTheValue()
+ {
+ var result = Result.Success("output.flac");
+
+ Assert.True(result.IsSuccess);
+ Assert.Equal("output.flac", result.Value);
+ }
+
+ ///
+ /// A failing step can still produce a partial result - result validation attaches the
+ /// report of a critical comparison, for instance. Consumers therefore have to check
+ /// the flag rather than assume a value means success.
+ ///
+ [Fact]
+ public void FailureOfT_CanStillCarryAValue()
+ {
+ var result = Result.Failure("critical", value: "output.flac");
+
+ Assert.True(result.IsFailure);
+ Assert.Equal("critical", result.ErrorMessage);
+ Assert.Equal("output.flac", result.Value);
+ }
+
+ [Fact]
+ public void FailureOfT_WithoutAValueYieldsTheDefault()
+ {
+ var result = Result.Failure("failed");
+
+ Assert.True(result.IsFailure);
+ Assert.Null(result.Value);
+ }
+}
diff --git a/AudioQualityEnhancer.Tests/ToolDiscoveryServiceTests.cs b/AudioQualityEnhancer.Tests/ToolDiscoveryServiceTests.cs
new file mode 100644
index 0000000..4e3558a
--- /dev/null
+++ b/AudioQualityEnhancer.Tests/ToolDiscoveryServiceTests.cs
@@ -0,0 +1,107 @@
+using AudioQualityEnhancer.Services;
+
+namespace AudioQualityEnhancer.Tests;
+
+public sealed class ToolDiscoveryServiceTests
+{
+ // where.exe is always on PATH on Windows and "/?" makes it print its help and exit 0,
+ // which is the same shape as the " -version" probe without needing FFmpeg.
+ // (cmd.exe is not usable here: "cmd /?" prints help but exits with 1.)
+ private const string PathTool = "where";
+ private const string PathToolProbeArgument = "/?";
+ private const string MissingTool = "audioqualityenhancer-missing-tool";
+
+ [Fact]
+ public void ResolveExecutable_ReturnsTheFullPathForAToolOnPath()
+ {
+ var service = new ToolDiscoveryService();
+
+ var path = service.ResolveExecutable(PathTool);
+
+ Assert.True(Path.IsPathFullyQualified(path));
+ Assert.True(File.Exists(path));
+ Assert.Equal("where.exe", Path.GetFileName(path));
+ }
+
+ ///
+ /// An unresolvable tool falls back to the bare executable name so the later process
+ /// start produces the normal "not found" error instead of an empty command line.
+ ///
+ [Fact]
+ public void ResolveExecutable_FallsBackToTheBareNameForAnUnknownTool()
+ {
+ var service = new ToolDiscoveryService();
+
+ Assert.Equal($"{MissingTool}.exe", service.ResolveExecutable(MissingTool));
+ }
+
+ [Fact]
+ public void ResolveExecutable_CachesTheLocation()
+ {
+ var service = new ToolDiscoveryService();
+
+ Assert.Equal(service.ResolveExecutable(PathTool), service.ResolveExecutable(PathTool));
+ }
+
+ [Fact]
+ public async Task GetStatusAsync_ReportsAToolFoundOnPath()
+ {
+ var service = new ToolDiscoveryService();
+
+ var status = await service.GetStatusAsync(PathTool, CancellationToken.None, PathToolProbeArgument);
+
+ Assert.True(status.IsAvailable);
+ Assert.Equal(PathTool, status.Name);
+ Assert.Equal("ToolSource_Path", status.SourceKey);
+ Assert.False(string.IsNullOrWhiteSpace(status.VersionLine));
+ Assert.Null(status.ErrorMessage);
+ }
+
+ [Fact]
+ public async Task GetStatusAsync_CachesASuccessfulProbe()
+ {
+ var service = new ToolDiscoveryService();
+
+ var first = await service.GetStatusAsync(PathTool, CancellationToken.None, PathToolProbeArgument);
+ var second = await service.GetStatusAsync(PathTool, CancellationToken.None, PathToolProbeArgument);
+
+ Assert.Same(first, second);
+ }
+
+ [Fact]
+ public async Task GetStatusAsync_ReportsAMissingToolAsUnavailable()
+ {
+ var service = new ToolDiscoveryService();
+
+ var status = await service.GetStatusAsync(MissingTool, CancellationToken.None);
+
+ Assert.False(status.IsAvailable);
+ Assert.Equal(MissingTool, status.Name);
+ Assert.False(string.IsNullOrWhiteSpace(status.ErrorMessage));
+ }
+
+ ///
+ /// Probing is bounded by a timeout, so repeating it for a tool that is missing or does
+ /// not answer would cost that timeout on every call. The failure is cached instead.
+ ///
+ [Fact]
+ public async Task GetStatusAsync_CachesAFailedProbe()
+ {
+ var service = new ToolDiscoveryService();
+
+ var first = await service.GetStatusAsync(MissingTool, CancellationToken.None);
+ var second = await service.GetStatusAsync(MissingTool, CancellationToken.None);
+
+ Assert.Same(first, second);
+ }
+
+ [Fact]
+ public void FailureCache_ExpiresSoonEnoughToPickUpAnInstalledTool()
+ {
+ // A tool installed while the app runs has to be picked up again, so the failure
+ // must not be remembered for the whole session - but long enough that a repeated
+ // status query does not pay the probe timeout again.
+ Assert.True(ToolDiscoveryService.FailedStatusCacheDuration > TimeSpan.Zero);
+ Assert.True(ToolDiscoveryService.FailedStatusCacheDuration <= TimeSpan.FromMinutes(5));
+ }
+}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0e9750b..f385792 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -24,6 +24,7 @@
- The queue no longer re-evaluates every button and rebuilds the summary on each FFmpeg progress tick, so the window stays responsive during a batch export.
- The startup checks for yt-dlp and for a new release are stopped when the window closes and no longer write into a closed window; an unexpected failure in either is logged instead of being swallowed.
- A tool that is missing or does not answer is remembered for half a minute instead of being probed again on every call, where each attempt cost the full probe timeout.
+- A cancelled update check is reported as a cancellation instead of as "no update available".
## 0.17.0 - 2026-06-13
diff --git a/Services/AppUpdateService.cs b/Services/AppUpdateService.cs
index 3e07e62..532c03c 100644
--- a/Services/AppUpdateService.cs
+++ b/Services/AppUpdateService.cs
@@ -58,6 +58,12 @@ internal AppUpdateService(HttpClient httpClient)
var url = root.TryGetProperty("html_url", out var urlElement) ? urlElement.GetString() : null;
return new AppUpdateInfo(latest.ToString(), ResolveReleaseUrl(url));
}
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ // A caller that cancels wants to know it was cancelled, not that there is
+ // no update. Its own timeout still surfaces as "no update" below.
+ throw;
+ }
catch
{
// Update checks are best effort and must never disrupt startup (offline, rate limit, ...).