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( 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( 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? canMutate = null) => new( updater, new ImmediateUiDispatcher(), changed ?? (() => { }), canOpen: () => true, canMutate: canMutate ?? (() => true)); private static async Task WaitUntilAsync(Func 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>? CheckHandler { get; set; } public Func< IProgress?, CancellationToken, Task>? InstallHandler { get; set; } 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, "1.0.0"); progress?.Report(new LauncherUpdateProgress( LauncherUpdatePhase.Completed, "Installed fixture.", 1, 1)); 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 for next start.")); } public Task RollbackClientAsync( IProgress? 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 { ["win-x64"] = artifact }, new Dictionary { ["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); } }