acdream/tests/AcDream.Launcher.Tests/LauncherUpdateViewModelTests.cs

292 lines
10 KiB
C#

using AcDream.Launcher.Core.Updates;
using AcDream.Launcher.ViewModels;
namespace AcDream.Launcher.Tests;
public sealed class LauncherUpdateViewModelTests
{
[Fact]
public async Task StartupPollingIsOfflineTolerantAndDoesNotOpenErrorModal()
{
var updater = new FakeUpdater
{
CheckHandler = _ => Task.FromException<LauncherUpdateCheckResult>(
new LauncherUpdateException("fixture offline")),
};
using var viewModel = Create(updater);
await viewModel.StartupCheckAsync();
Assert.False(viewModel.IsOpen);
Assert.False(viewModel.HasError);
Assert.Contains("continuing offline", viewModel.Status, StringComparison.OrdinalIgnoreCase);
Assert.Equal(LauncherUpdatePhase.Failed, viewModel.Phase);
}
[Fact]
public async Task StartupUpdateOpensModalAndClientInstallProjectsProgressAndRefreshesVersions()
{
var updater = new FakeUpdater();
int changed = 0;
using var viewModel = Create(updater, () => changed++);
await viewModel.StartupCheckAsync();
Assert.True(viewModel.IsOpen);
Assert.Equal("2.0.0", viewModel.AvailableVersion);
Assert.Equal("1.0.0", viewModel.CurrentClientVersion);
Assert.True(viewModel.InstallClientCommand.CanExecute(null));
await viewModel.InstallClientCommand.ExecuteAsync();
Assert.Equal(1, updater.InstallCalls);
Assert.Equal(1, changed);
Assert.Equal("2.0.0", viewModel.CurrentClientVersion);
Assert.False(viewModel.IsClientUpdateAvailable);
Assert.Equal(100, viewModel.ProgressPercent);
Assert.False(viewModel.HasError);
}
[Fact]
public async Task ManualCheckShowsErrorsAndCanRetrySuccessfully()
{
var updater = new FakeUpdater();
int calls = 0;
updater.CheckHandler = _ => ++calls == 1
? Task.FromException<LauncherUpdateCheckResult>(
new LauncherUpdateException("malformed fixture manifest"))
: Task.FromResult(updater.CreateCheck());
using var viewModel = Create(updater);
await viewModel.OpenCommand.ExecuteAsync();
Assert.True(viewModel.IsOpen);
Assert.True(viewModel.HasError);
Assert.Contains("malformed", viewModel.Error, StringComparison.Ordinal);
await viewModel.CheckCommand.ExecuteAsync();
Assert.False(viewModel.HasError);
Assert.Equal(2, calls);
}
[Fact]
public async Task MinimumLauncherGateDisablesClientButAllowsVerifiedSelfUpdateStage()
{
var updater = new FakeUpdater
{
MinimumSatisfied = false,
};
using var viewModel = Create(updater);
await viewModel.OpenCommand.ExecuteAsync();
Assert.True(viewModel.IsLauncherMinimumBlocked);
Assert.False(viewModel.InstallClientCommand.CanExecute(null));
Assert.True(viewModel.StageLauncherCommand.CanExecute(null));
await viewModel.StageLauncherCommand.ExecuteAsync();
Assert.Equal(1, updater.StageCalls);
Assert.True(viewModel.IsLauncherRestartRequired);
Assert.Contains("next start", viewModel.LauncherRestartStatus, StringComparison.Ordinal);
Assert.False(viewModel.StageLauncherCommand.CanExecute(null));
Assert.False(viewModel.HasError);
}
[Fact]
public async Task MutationPermissionDisablesInstallStageAndRollbackWhileSessionsRun()
{
var updater = new FakeUpdater();
using var viewModel = Create(updater, canMutate: () => false);
await viewModel.OpenCommand.ExecuteAsync();
Assert.False(viewModel.InstallClientCommand.CanExecute(null));
Assert.False(viewModel.StageLauncherCommand.CanExecute(null));
Assert.False(viewModel.RollbackCommand.CanExecute(null));
Assert.True(viewModel.CheckCommand.CanExecute(null));
}
[Fact]
public async Task CancellationAndRollbackHaveExplicitSafeTerminalStates()
{
var updater = new FakeUpdater();
updater.InstallHandler = async (progress, token) =>
{
progress?.Report(new LauncherUpdateProgress(
LauncherUpdatePhase.DownloadingClient,
"downloading",
1,
100));
await Task.Delay(Timeout.InfiniteTimeSpan, token);
return updater.CurrentClient;
};
int changed = 0;
using var viewModel = Create(updater, () => changed++);
await viewModel.OpenCommand.ExecuteAsync();
Task install = viewModel.InstallClientCommand.ExecuteAsync();
await WaitUntilAsync(() => viewModel.IsBusy);
Assert.True(viewModel.CancelCommand.CanExecute(null));
viewModel.CancelCommand.Execute(null);
await install;
Assert.Equal(LauncherUpdatePhase.Cancelled, viewModel.Phase);
Assert.Contains("cancelled", viewModel.Status, StringComparison.OrdinalIgnoreCase);
Assert.False(viewModel.HasError);
await viewModel.RollbackCommand.ExecuteAsync();
Assert.Equal(1, updater.RollbackCalls);
Assert.Equal("0.9.0", viewModel.CurrentClientVersion);
Assert.Equal(1, changed);
}
private static LauncherUpdateViewModel Create(
FakeUpdater updater,
Action? changed = null,
Func<bool>? canMutate = null) => new(
updater,
new ImmediateUiDispatcher(),
changed ?? (() => { }),
canOpen: () => true,
canMutate: canMutate ?? (() => true));
private static async Task WaitUntilAsync(Func<bool> condition)
{
DateTimeOffset deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(5);
while (!condition())
{
if (DateTimeOffset.UtcNow >= deadline)
{
throw new TimeoutException("View model did not enter the expected state.");
}
await Task.Delay(10);
}
}
private sealed class FakeUpdater : ILauncherUpdater
{
private static readonly LauncherVersion One = LauncherVersion.Parse("1.0.0");
private static readonly LauncherVersion Two = LauncherVersion.Parse("2.0.0");
private static readonly LauncherVersion NineTenths = LauncherVersion.Parse("0.9.0");
public FakeUpdater()
{
CurrentClient = Resolution(One, "0.9.0");
}
public ClientVersionResolution CurrentClient { get; private set; }
public bool MinimumSatisfied { get; init; } = true;
public int InstallCalls { get; private set; }
public int StageCalls { get; private set; }
public int RollbackCalls { get; private set; }
public Func<CancellationToken, Task<LauncherUpdateCheckResult>>? CheckHandler
{
get;
set;
}
public Func<
IProgress<LauncherUpdateProgress>?,
CancellationToken,
Task<ClientVersionResolution>>? InstallHandler { get; set; }
public Task<ClientVersionResolution> InitializeAsync(
CancellationToken cancellationToken = default) => Task.FromResult(CurrentClient);
public Task<LauncherUpdateCheckResult> CheckAsync(
CancellationToken cancellationToken = default) =>
CheckHandler?.Invoke(cancellationToken) ?? Task.FromResult(CreateCheck());
public async Task<ClientVersionResolution> InstallClientAsync(
LauncherUpdateCheckResult check,
IProgress<LauncherUpdateProgress>? progress = null,
CancellationToken cancellationToken = default)
{
InstallCalls++;
if (InstallHandler is not null)
{
return await InstallHandler(progress, cancellationToken);
}
progress?.Report(new LauncherUpdateProgress(
LauncherUpdatePhase.DownloadingClient,
"Downloading fixture.",
5,
10));
CurrentClient = Resolution(Two, "1.0.0");
progress?.Report(new LauncherUpdateProgress(
LauncherUpdatePhase.Completed,
"Installed fixture.",
1,
1));
return CurrentClient;
}
public Task<SelfUpdateStageResult> StageLauncherAsync(
LauncherUpdateCheckResult check,
IProgress<LauncherUpdateProgress>? progress = null,
CancellationToken cancellationToken = default)
{
StageCalls++;
progress?.Report(new LauncherUpdateProgress(
LauncherUpdatePhase.StagingLauncher,
"Staged fixture.",
1,
1));
return Task.FromResult(new SelfUpdateStageResult(
Two,
"pending.json",
"Launcher staged for next start."));
}
public Task<ClientVersionResolution> RollbackClientAsync(
IProgress<LauncherUpdateProgress>? progress = null,
CancellationToken cancellationToken = default)
{
RollbackCalls++;
CurrentClient = Resolution(NineTenths, "1.0.0");
progress?.Report(new LauncherUpdateProgress(
LauncherUpdatePhase.Completed,
"Rolled back fixture.",
1,
1));
return Task.FromResult(CurrentClient);
}
public LauncherUpdateCheckResult CreateCheck()
{
bool available = CurrentClient.Version! < Two;
var artifact = new ReleaseArtifact(
new Uri("https://example.test/release.zip"),
new string('a', 64),
100);
var manifest = new ReleaseManifest(
Two,
MinimumSatisfied ? One : Two,
new Dictionary<string, ReleaseArtifact> { ["win-x64"] = artifact },
new Dictionary<string, ReleaseArtifact> { ["win-x64"] = artifact });
return new LauncherUpdateCheckResult(
manifest,
"win-x64",
One,
CurrentClient.Version,
available,
true,
MinimumSatisfied,
available ? "Fixture update available." : "Fixture is current.");
}
private static ClientVersionResolution Resolution(
LauncherVersion version,
string? previous) => new(
ClientVersionState.Verified,
"Fixture client verified.",
version,
Path.Combine("fixture", version.Value),
previous,
null);
}
}