using AcDream.Launcher.Core.Updates;
using AcDream.Launcher.ViewModels;
namespace AcDream.Launcher.Tests;
///
/// LU2/LU3. The launcher asks about updates exactly once, at startup, and only
/// when something is actually out of date. These tests pin that shape and the
/// launcher-before-client ordering; the six-button panel they replace (Check
/// again / Rollback / Stage launcher / Install client / Cancel / Close) is
/// gone, so the tests for it are gone with it rather than skipped.
///
public sealed class LauncherUpdateViewModelTests
{
[Fact]
public async Task NothingOutOfDateNeverShowsTheDialog()
{
var updater = new FakeUpdater
{
ClientUpdateAvailable = false,
LauncherUpdateAvailable = false,
};
using LauncherUpdateViewModel viewModel = Create(updater);
await viewModel.StartupCheckAsync();
Assert.False(viewModel.IsOpen);
Assert.Equal(0, updater.InstallCalls);
Assert.Equal(0, updater.StageCalls);
}
[Fact]
public async Task AClientUpdateAsksOnceAndInstallsOnUpdate()
{
var updater = new FakeUpdater
{
ClientUpdateAvailable = true,
LauncherUpdateAvailable = false,
};
int clientChanged = 0;
using LauncherUpdateViewModel viewModel = Create(
updater,
onClientChanged: () => clientChanged++);
await viewModel.StartupCheckAsync();
Assert.True(viewModel.IsOpen);
Assert.Contains("game", viewModel.Body, StringComparison.OrdinalIgnoreCase);
Assert.Contains("2.0.0", viewModel.Body, StringComparison.Ordinal);
await viewModel.UpdateCommand.ExecuteAsync();
Assert.Equal(1, updater.InstallCalls);
Assert.Equal(0, updater.StageCalls);
Assert.Equal(1, clientChanged);
Assert.False(viewModel.IsOpen);
Assert.Null(viewModel.Error);
}
///
/// The launcher goes first even when both are behind: a client release can
/// declare a minimum launcher version, so updating the launcher first is
/// what makes the client update installable at all.
///
[Fact]
public async Task ALauncherUpdateStagesThenRestartsWithoutTouchingTheClient()
{
var updater = new FakeUpdater
{
ClientUpdateAvailable = true,
LauncherUpdateAvailable = true,
};
int shutdowns = 0;
int applyCalls = 0;
using LauncherUpdateViewModel viewModel = Create(
updater,
applyLauncherUpdateAsync: _ =>
{
applyCalls++;
return Task.FromResult(true);
},
requestShutdown: () => shutdowns++);
await viewModel.StartupCheckAsync();
Assert.True(viewModel.IsOpen);
await viewModel.UpdateCommand.ExecuteAsync();
Assert.Equal(1, updater.StageCalls);
Assert.Equal(1, applyCalls);
Assert.Equal(1, shutdowns);
// The client is deliberately NOT touched in the same pass: the updated
// launcher checks again on its own next start.
Assert.Equal(0, updater.InstallCalls);
}
[Fact]
public async Task ALauncherUpdateThatCannotRestartTellsTheUserToReopen()
{
var updater = new FakeUpdater
{
ClientUpdateAvailable = false,
LauncherUpdateAvailable = true,
};
int shutdowns = 0;
using LauncherUpdateViewModel viewModel = Create(
updater,
applyLauncherUpdateAsync: _ => Task.FromResult(false),
requestShutdown: () => shutdowns++);
await viewModel.StartupCheckAsync();
await viewModel.UpdateCommand.ExecuteAsync();
Assert.Equal(1, updater.StageCalls);
Assert.Equal(0, shutdowns);
Assert.Contains("reopen", viewModel.Status, StringComparison.OrdinalIgnoreCase);
}
///
/// A friend with no internet must still reach their characters. An
/// unreachable feed is not an error the user has to dismiss.
///
[Fact]
public async Task AnUnreachableFeedStaysSilent()
{
var updater = new FakeUpdater
{
CheckHandler = _ => Task.FromException(
new LauncherUpdateException("The update feed is unreachable.")),
};
using LauncherUpdateViewModel viewModel = Create(updater);
await viewModel.StartupCheckAsync();
Assert.False(viewModel.IsOpen);
Assert.False(viewModel.HasError);
}
[Fact]
public async Task NotNowClosesWithoutUpdatingAnything()
{
var updater = new FakeUpdater { ClientUpdateAvailable = true };
using LauncherUpdateViewModel viewModel = Create(updater);
await viewModel.StartupCheckAsync();
Assert.True(viewModel.IsOpen);
viewModel.NotNowCommand.Execute(null);
Assert.False(viewModel.IsOpen);
Assert.Equal(0, updater.InstallCalls);
Assert.Equal(0, updater.StageCalls);
}
[Fact]
public async Task UpdatingIsRefusedWhileASessionIsRunning()
{
var updater = new FakeUpdater { ClientUpdateAvailable = true };
using LauncherUpdateViewModel viewModel = Create(updater, canMutate: () => false);
await viewModel.StartupCheckAsync();
Assert.True(viewModel.IsOpen);
Assert.False(viewModel.UpdateCommand.CanExecute(null));
}
[Fact]
public async Task AFailedInstallReportsTheReasonAndLeavesTheDialogOpen()
{
var updater = new FakeUpdater
{
ClientUpdateAvailable = true,
InstallHandler = (_, _) => Task.FromException(
new LauncherUpdateException("The download did not match its digest.")),
};
using LauncherUpdateViewModel viewModel = Create(updater);
await viewModel.StartupCheckAsync();
await viewModel.UpdateCommand.ExecuteAsync();
Assert.True(viewModel.IsOpen);
Assert.True(viewModel.HasError);
Assert.Contains("digest", viewModel.Error!, StringComparison.Ordinal);
}
private static LauncherUpdateViewModel Create(
FakeUpdater updater,
Action? onClientChanged = null,
Func? canMutate = null,
Func>? applyLauncherUpdateAsync = null,
Action? requestShutdown = null) =>
new(
updater,
new ImmediateUiDispatcher(),
onClientChanged ?? (() => { }),
canOpen: () => true,
canMutate: canMutate ?? (() => true),
applyLauncherUpdateAsync: applyLauncherUpdateAsync,
requestShutdown: requestShutdown);
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");
public ClientVersionResolution CurrentClient { get; private set; } =
Resolution(One);
public bool ClientUpdateAvailable { get; init; }
public bool LauncherUpdateAvailable { get; init; }
public bool MinimumSatisfied { get; init; } = true;
public int InstallCalls { get; private set; }
public int StageCalls { get; private set; }
public Func>? CheckHandler
{
get;
init;
}
public Func<
IProgress?,
CancellationToken,
Task>? InstallHandler { get; init; }
public Task InitializeAsync(
CancellationToken cancellationToken = default) =>
Task.FromResult(CurrentClient);
public Task CheckAsync(
CancellationToken cancellationToken = default) =>
CheckHandler?.Invoke(cancellationToken) ?? Task.FromResult(CreateCheck());
public async Task InstallClientAsync(
LauncherUpdateCheckResult check,
IProgress? 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);
return CurrentClient;
}
public Task StageLauncherAsync(
LauncherUpdateCheckResult check,
IProgress? 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."));
}
public Task RollbackClientAsync(
IProgress? progress = null,
CancellationToken cancellationToken = default) =>
Task.FromResult(CurrentClient);
private LauncherUpdateCheckResult CreateCheck()
{
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 { ["win-x64"] = artifact },
new Dictionary { ["win-x64"] = artifact });
return new LauncherUpdateCheckResult(
manifest,
"win-x64",
One,
CurrentClient.Version,
ClientUpdateAvailable,
LauncherUpdateAvailable,
MinimumSatisfied,
"Fixture check.");
}
private static ClientVersionResolution Resolution(LauncherVersion version) =>
new(
ClientVersionState.Verified,
"Fixture client verified.",
version,
Path.Combine("fixture", version.Value),
null,
null);
}
}