feat(launcher): LU2/LU3 — one update question at startup, and it restarts itself
The update surface was a panel the user had to reason about: Check again, Rollback client, Stage launcher, Install client, Cancel, Close, plus an installed/available version table, a minimum-launcher-version sentence, and a "restart required" banner they had to act on. Reaching it meant knowing to press "Check for updates" in the header. Now: the feed is checked once at startup. If nothing is out of date, nothing appears. If something is, one dialog says what is new and offers Update or Not now. Launcher before client, deliberately. A client release can declare a minimum launcher version, so updating the launcher first is what makes the client update installable at all — and it means nobody is ever shown "install launcher X or newer before the client update", which is not a sentence a player should have to read. A launcher update now restarts into the new build by itself. That reuses the existing, proven handoff rather than inventing a second one: LauncherSelfUpdate Bootstrap.TryApplyStagedUpdateNowAsync starts the staged payload in helper mode against the CURRENT process, exactly as ordinary startup does, and the launcher then shuts down. Restarting by spawning a fresh copy of the current launcher and letting its startup notice the staged plan would look simpler and be wrong: the helper would wait on the new copy while the old one still held its own executable mapped, so the file replacement could fail. The staged-helper launch is extracted into one private method both paths call, so they cannot drift. Deleted: the header "Check for updates" button, OpenCommand, CheckCommand, InstallClientCommand, StageLauncherCommand, RollbackCommand, CloseCommand, the version table, IsLauncherMinimumBlocked/MinimumLauncherStatus, the restart banner, and LauncherUpdatePhase plumbing through the view model. NOT deleted — none of the safety changed: manifest validation, bounded verified download, safe ZIP extraction, versioned install with an atomic current.json switch, the update session barrier, and rollback all still live in AcDream.Launcher.Core/Updates. Rollback simply has no button; it remains reachable as Core API with its own tests. The complexity the user objected to was the panel, not the machinery underneath it. An unreachable feed stays silent. A friend with no internet must still reach their characters, so a failed startup check shows nothing at all rather than an error to dismiss. Tests: LauncherUpdateViewModelTests rewritten against the new surface (8 tests — nothing-to-do stays silent, client update installs, launcher update stages then restarts without touching the client, no-restart-seam fallback, silent offline, Not now, refused while a session runs, failed install reports why). Tests for the deleted commands are removed with them, not skipped. Launcher 59 passed, Launcher.Core 335 passed. Campaign LU slices LU2 and LU3, landed together because the new prompt replaces the old one in the same files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
00d1278228
commit
a01ff42640
9 changed files with 600 additions and 516 deletions
|
|
@ -3,176 +3,212 @@ using AcDream.Launcher.ViewModels;
|
|||
|
||||
namespace AcDream.Launcher.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class LauncherUpdateViewModelTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task StartupPollingIsOfflineTolerantAndDoesNotOpenErrorModal()
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A friend with no internet must still reach their characters. An
|
||||
/// unreachable feed is not an error the user has to dismiss.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AnUnreachableFeedStaysSilent()
|
||||
{
|
||||
var updater = new FakeUpdater
|
||||
{
|
||||
CheckHandler = _ => Task.FromException<LauncherUpdateCheckResult>(
|
||||
new LauncherUpdateException("fixture offline")),
|
||||
new LauncherUpdateException("The update feed is unreachable.")),
|
||||
};
|
||||
using var viewModel = Create(updater);
|
||||
using LauncherUpdateViewModel 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()
|
||||
public async Task NotNowClosesWithoutUpdatingAnything()
|
||||
{
|
||||
var updater = new FakeUpdater();
|
||||
int changed = 0;
|
||||
using var viewModel = Create(updater, () => changed++);
|
||||
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.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);
|
||||
Assert.False(viewModel.UpdateCommand.CanExecute(null));
|
||||
}
|
||||
|
||||
[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()
|
||||
public async Task AFailedInstallReportsTheReasonAndLeavesTheDialogOpen()
|
||||
{
|
||||
var updater = new FakeUpdater
|
||||
{
|
||||
MinimumSatisfied = false,
|
||||
ClientUpdateAvailable = true,
|
||||
InstallHandler = (_, _) => Task.FromException<ClientVersionResolution>(
|
||||
new LauncherUpdateException("The download did not match its digest.")),
|
||||
};
|
||||
using var viewModel = Create(updater);
|
||||
using LauncherUpdateViewModel viewModel = Create(updater);
|
||||
|
||||
await viewModel.OpenCommand.ExecuteAsync();
|
||||
await viewModel.StartupCheckAsync();
|
||||
await viewModel.UpdateCommand.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);
|
||||
Assert.True(viewModel.IsOpen);
|
||||
Assert.True(viewModel.HasError);
|
||||
Assert.Contains("digest", viewModel.Error!, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static LauncherUpdateViewModel Create(
|
||||
FakeUpdater updater,
|
||||
Action? changed = null,
|
||||
Func<bool>? canMutate = null) => new(
|
||||
Action? onClientChanged = null,
|
||||
Func<bool>? canMutate = null,
|
||||
Func<CancellationToken, Task<bool>>? applyLauncherUpdateAsync = null,
|
||||
Action? requestShutdown = null) =>
|
||||
new(
|
||||
updater,
|
||||
new ImmediateUiDispatcher(),
|
||||
changed ?? (() => { }),
|
||||
onClientChanged ?? (() => { }),
|
||||
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);
|
||||
}
|
||||
}
|
||||
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");
|
||||
private static readonly LauncherVersion NineTenths = LauncherVersion.Parse("0.9.0");
|
||||
|
||||
public FakeUpdater()
|
||||
{
|
||||
CurrentClient = Resolution(One, "0.9.0");
|
||||
}
|
||||
public ClientVersionResolution CurrentClient { get; private set; } =
|
||||
Resolution(One);
|
||||
|
||||
public ClientVersionResolution CurrentClient { get; private set; }
|
||||
public bool ClientUpdateAvailable { get; init; }
|
||||
|
||||
public bool LauncherUpdateAvailable { get; init; }
|
||||
|
||||
public bool MinimumSatisfied { get; init; } = true;
|
||||
|
||||
|
|
@ -180,21 +216,20 @@ public sealed class LauncherUpdateViewModelTests
|
|||
|
||||
public int StageCalls { get; private set; }
|
||||
|
||||
public int RollbackCalls { get; private set; }
|
||||
|
||||
public Func<CancellationToken, Task<LauncherUpdateCheckResult>>? CheckHandler
|
||||
{
|
||||
get;
|
||||
set;
|
||||
init;
|
||||
}
|
||||
|
||||
public Func<
|
||||
IProgress<LauncherUpdateProgress>?,
|
||||
CancellationToken,
|
||||
Task<ClientVersionResolution>>? InstallHandler { get; set; }
|
||||
Task<ClientVersionResolution>>? InstallHandler { get; init; }
|
||||
|
||||
public Task<ClientVersionResolution> InitializeAsync(
|
||||
CancellationToken cancellationToken = default) => Task.FromResult(CurrentClient);
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(CurrentClient);
|
||||
|
||||
public Task<LauncherUpdateCheckResult> CheckAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
|
|
@ -216,12 +251,7 @@ public sealed class LauncherUpdateViewModelTests
|
|||
"Downloading fixture.",
|
||||
5,
|
||||
10));
|
||||
CurrentClient = Resolution(Two, "1.0.0");
|
||||
progress?.Report(new LauncherUpdateProgress(
|
||||
LauncherUpdatePhase.Completed,
|
||||
"Installed fixture.",
|
||||
1,
|
||||
1));
|
||||
CurrentClient = Resolution(Two);
|
||||
return CurrentClient;
|
||||
}
|
||||
|
||||
|
|
@ -239,26 +269,16 @@ public sealed class LauncherUpdateViewModelTests
|
|||
return Task.FromResult(new SelfUpdateStageResult(
|
||||
Two,
|
||||
"pending.json",
|
||||
"Launcher staged for next start."));
|
||||
"Launcher staged."));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(CurrentClient);
|
||||
|
||||
public LauncherUpdateCheckResult CreateCheck()
|
||||
private LauncherUpdateCheckResult CreateCheck()
|
||||
{
|
||||
bool available = CurrentClient.Version! < Two;
|
||||
var artifact = new ReleaseArtifact(
|
||||
new Uri("https://example.test/release.zip"),
|
||||
new string('a', 64),
|
||||
|
|
@ -273,20 +293,19 @@ public sealed class LauncherUpdateViewModelTests
|
|||
"win-x64",
|
||||
One,
|
||||
CurrentClient.Version,
|
||||
available,
|
||||
true,
|
||||
ClientUpdateAvailable,
|
||||
LauncherUpdateAvailable,
|
||||
MinimumSatisfied,
|
||||
available ? "Fixture update available." : "Fixture is current.");
|
||||
"Fixture check.");
|
||||
}
|
||||
|
||||
private static ClientVersionResolution Resolution(
|
||||
LauncherVersion version,
|
||||
string? previous) => new(
|
||||
private static ClientVersionResolution Resolution(LauncherVersion version) =>
|
||||
new(
|
||||
ClientVersionState.Verified,
|
||||
"Fixture client verified.",
|
||||
version,
|
||||
Path.Combine("fixture", version.Value),
|
||||
previous,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,10 @@ public sealed class LauncherWindowViewModelTests
|
|||
|
||||
Assert.True(viewModel.IsFirstRunRequired);
|
||||
Assert.Contains("SHA-256", viewModel.FirstRunWizardShell.Body, StringComparison.Ordinal);
|
||||
Assert.Contains("pinned eriknihlen/acdream", viewModel.UpdatePrompt.Body, StringComparison.Ordinal);
|
||||
// LU3: the update question says nothing and shows nothing until the
|
||||
// startup check actually finds something out of date.
|
||||
Assert.False(viewModel.UpdatePrompt.IsOpen);
|
||||
Assert.Empty(viewModel.UpdatePrompt.Body);
|
||||
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
|
||||
Assert.True(viewModel.FirstRunWizardShell.IsOpen);
|
||||
viewModel.FirstRunWizardShell.CloseCommand.Execute(null);
|
||||
|
|
@ -306,7 +309,9 @@ public sealed class LauncherWindowViewModelTests
|
|||
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
|
||||
Assert.True(viewModel.IsModalOpen);
|
||||
Assert.False(viewModel.AddServerCommand.CanExecute(null));
|
||||
Assert.False(viewModel.UpdatePrompt.OpenCommand.CanExecute(null));
|
||||
// LU3: there is no user-openable update panel any more — the update
|
||||
// question only ever appears by itself, at startup.
|
||||
Assert.False(viewModel.UpdatePrompt.IsOpen);
|
||||
Assert.False(Assert.Single(viewModel.Sessions).StopCommand.CanExecute(null));
|
||||
|
||||
// ICommand.Execute cannot bypass the modal gate.
|
||||
|
|
|
|||
|
|
@ -225,20 +225,23 @@ public sealed class MainWindowViewTests
|
|||
|
||||
private static async Task OpeningAndClosingTheUpdatePromptFocusesAndRunsTheCloseFallbackWithoutThrowing()
|
||||
{
|
||||
using LauncherWindowViewModel viewModel = CreateViewModel();
|
||||
// LU3: the update question has no Open command any more — it appears
|
||||
// by itself, once, when the startup check finds something out of date.
|
||||
using LauncherWindowViewModel viewModel = CreateViewModel(
|
||||
new UpdateAvailableUpdater());
|
||||
var window = new MainWindow { DataContext = viewModel };
|
||||
try
|
||||
{
|
||||
window.Show();
|
||||
|
||||
await viewModel.UpdatePrompt.OpenCommand.ExecuteAsync();
|
||||
await viewModel.UpdatePrompt.StartupCheckAsync();
|
||||
Assert.True(viewModel.UpdatePrompt.IsOpen);
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
Control closeButton = (Control)GetNamedField(window, "UpdateCloseButton")!;
|
||||
Assert.Same(closeButton, CurrentFocus(window));
|
||||
|
||||
viewModel.UpdatePrompt.CloseCommand.Execute(null);
|
||||
viewModel.UpdatePrompt.NotNowCommand.Execute(null);
|
||||
Assert.False(viewModel.UpdatePrompt.IsOpen);
|
||||
|
||||
// See the comment in the editor-kind theory above: this pump is
|
||||
|
|
@ -315,15 +318,98 @@ public sealed class MainWindowViewTests
|
|||
.GetField(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
|
||||
?.GetValue(window);
|
||||
|
||||
private static LauncherWindowViewModel CreateViewModel()
|
||||
private static LauncherWindowViewModel CreateViewModel(
|
||||
AcDream.Launcher.Core.Updates.ILauncherUpdater? updater = null)
|
||||
{
|
||||
var viewModel = new LauncherWindowViewModel(
|
||||
new StubOrchestrator(),
|
||||
new ImmediateUiDispatcher());
|
||||
new ImmediateUiDispatcher(),
|
||||
installer: null,
|
||||
updater: updater);
|
||||
viewModel.Initialize();
|
||||
return viewModel;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// LU3: the smallest updater that makes the startup check open the update
|
||||
/// question, so the dialog's focus behavior stays covered now that it has
|
||||
/// no Open command.
|
||||
/// </summary>
|
||||
private sealed class UpdateAvailableUpdater
|
||||
: AcDream.Launcher.Core.Updates.ILauncherUpdater
|
||||
{
|
||||
private static readonly AcDream.Launcher.Core.Updates.LauncherVersion One =
|
||||
AcDream.Launcher.Core.Updates.LauncherVersion.Parse("1.0.0");
|
||||
private static readonly AcDream.Launcher.Core.Updates.LauncherVersion Two =
|
||||
AcDream.Launcher.Core.Updates.LauncherVersion.Parse("2.0.0");
|
||||
|
||||
public AcDream.Launcher.Core.Updates.ClientVersionResolution CurrentClient =>
|
||||
new(
|
||||
AcDream.Launcher.Core.Updates.ClientVersionState.Verified,
|
||||
"Fixture client verified.",
|
||||
One,
|
||||
Path.Combine("fixture", One.Value),
|
||||
null,
|
||||
null);
|
||||
|
||||
public Task<AcDream.Launcher.Core.Updates.ClientVersionResolution>
|
||||
InitializeAsync(CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(CurrentClient);
|
||||
|
||||
public Task<AcDream.Launcher.Core.Updates.LauncherUpdateCheckResult> CheckAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var artifact = new AcDream.Launcher.Core.Updates.ReleaseArtifact(
|
||||
new Uri("https://example.test/release.zip"),
|
||||
new string('a', 64),
|
||||
100);
|
||||
var manifest = new AcDream.Launcher.Core.Updates.ReleaseManifest(
|
||||
Two,
|
||||
One,
|
||||
new Dictionary<string, AcDream.Launcher.Core.Updates.ReleaseArtifact>
|
||||
{
|
||||
["win-x64"] = artifact,
|
||||
},
|
||||
new Dictionary<string, AcDream.Launcher.Core.Updates.ReleaseArtifact>
|
||||
{
|
||||
["win-x64"] = artifact,
|
||||
});
|
||||
return Task.FromResult(
|
||||
new AcDream.Launcher.Core.Updates.LauncherUpdateCheckResult(
|
||||
manifest,
|
||||
"win-x64",
|
||||
One,
|
||||
One,
|
||||
IsClientUpdateAvailable: true,
|
||||
IsLauncherUpdateAvailable: false,
|
||||
IsLauncherMinimumSatisfied: true,
|
||||
"Fixture update available."));
|
||||
}
|
||||
|
||||
public Task<AcDream.Launcher.Core.Updates.ClientVersionResolution>
|
||||
InstallClientAsync(
|
||||
AcDream.Launcher.Core.Updates.LauncherUpdateCheckResult check,
|
||||
IProgress<AcDream.Launcher.Core.Updates.LauncherUpdateProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(CurrentClient);
|
||||
|
||||
public Task<AcDream.Launcher.Core.Updates.SelfUpdateStageResult>
|
||||
StageLauncherAsync(
|
||||
AcDream.Launcher.Core.Updates.LauncherUpdateCheckResult check,
|
||||
IProgress<AcDream.Launcher.Core.Updates.LauncherUpdateProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(new AcDream.Launcher.Core.Updates.SelfUpdateStageResult(
|
||||
Two,
|
||||
"pending.json",
|
||||
"Launcher staged."));
|
||||
|
||||
public Task<AcDream.Launcher.Core.Updates.ClientVersionResolution>
|
||||
RollbackClientAsync(
|
||||
IProgress<AcDream.Launcher.Core.Updates.LauncherUpdateProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(CurrentClient);
|
||||
}
|
||||
|
||||
private static string FindRepositoryRoot()
|
||||
{
|
||||
foreach (string start in new[] { AppContext.BaseDirectory, Environment.CurrentDirectory })
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue