292 lines
10 KiB
C#
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);
|
|
}
|
|
}
|