Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions AudioQualityEnhancer.Tests/AppUpdateServiceTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
using System.Net;
using System.Net.Http;
using System.Text;
using AudioQualityEnhancer.Services;

namespace AudioQualityEnhancer.Tests;
Expand Down Expand Up @@ -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));
}

/// <summary>A cancelled check has to surface as a cancellation, not as "no update".</summary>
[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<OperationCanceledException>(
() => 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<HttpRequestMessage, HttpResponseMessage> _respond;

public FakeHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> respond)
{
_respond = respond;
}

protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(_respond(request));
}
}
}
5 changes: 5 additions & 0 deletions AudioQualityEnhancer.Tests/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -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)]
185 changes: 185 additions & 0 deletions AudioQualityEnhancer.Tests/CommandTests.cs
Original file line number Diff line number Diff line change
@@ -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<InvalidOperationException>(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<string>(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<string>(value =>
{
executions++;
received = value;
});

command.Execute(42);

Assert.Equal(1, executions);
Assert.Null(received);
}

[Fact]
public void RelayCommandOfT_EvaluatesThePredicateForNullAndForAValue()
{
var command = new RelayCommand<string>(_ => { }, 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<bool> condition)
{
for (var attempt = 0; attempt < 100 && !condition(); attempt++)
{
await Task.Delay(10);
}

Assert.True(condition(), "The condition was not met in time.");
}
}
40 changes: 40 additions & 0 deletions AudioQualityEnhancer.Tests/ResourceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,39 @@ public void GermanAndEnglishResources_ExposeTheSameKeys()
Assert.Empty(englishKeys.Except(germanKeys));
}

/// <summary>
/// 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.
/// </summary>
[Theory]
[InlineData("de")]
[InlineData("en")]
public void ComposedResourceKeys_ResolveForEveryEnumValue(string cultureName)
{
LocalizationService.Instance.Culture = CultureInfo.GetCultureInfo(cultureName);

var keys = new List<string>();
keys.AddRange(ComposeKeys<AudioAnalysisStatus>("AnalysisStatus_{0}", "AnalysisSummary_{0}"));
keys.AddRange(ComposeKeys<AudioInsightSeverity>("AnalysisSeverity_{0}"));
keys.AddRange(ComposeKeys<AudioAnalysisFindingKind>("AnalysisFinding_{0}_Title", "AnalysisFinding_{0}_Message"));
keys.AddRange(ComposeKeys<AudioComparisonStatus>("ValidationStatus_{0}", "ValidationSummary_{0}"));
keys.AddRange(ComposeKeys<AudioComparisonFindingKind>("ValidationFinding_{0}_Title", "ValidationFinding_{0}_Message"));
keys.AddRange(ComposeKeys<BatchProcessingStatus>("BatchStatus_{0}"));

// A recommendation only accompanies an actual finding, so the "no issues" kind
// deliberately has no recommendation text.
keys.AddRange(Enum.GetValues<AudioAnalysisFindingKind>()
.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")]
Expand Down Expand Up @@ -150,6 +183,13 @@ public void VisibleModelTexts_DoNotContainMissingResourceMarkers(string cultureN
}
}

private static IEnumerable<string> ComposeKeys<TEnum>(params string[] formats)
where TEnum : struct, Enum
{
return Enum.GetValues<TEnum>()
.SelectMany(value => formats.Select(format => string.Format(CultureInfo.InvariantCulture, format, value)));
}

private static IReadOnlySet<string> LoadResourceKeys(string fileName)
{
var path = Path.Combine(TestPaths.RepositoryRoot, "Resources", fileName);
Expand Down
Loading