feat(launcher): stabilize prepared content updates
This commit is contained in:
parent
f160f3fee1
commit
af9327a17b
42 changed files with 3706 additions and 147 deletions
|
|
@ -2,6 +2,7 @@ using AcDream.Launcher.Core.Orchestration;
|
|||
using AcDream.Launcher.Core.Profiles;
|
||||
using AcDream.Launcher.Core.Launching;
|
||||
using AcDream.Launcher.Core.Installation;
|
||||
using AcDream.Launcher.Core.Updates;
|
||||
using AcDream.Launcher.ViewModels;
|
||||
|
||||
namespace AcDream.Launcher.Tests;
|
||||
|
|
@ -9,7 +10,7 @@ namespace AcDream.Launcher.Tests;
|
|||
public sealed class LauncherWindowViewModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void InitializeProjectsHierarchySessionsAndFutureWorkflowShells()
|
||||
public async Task InitializeProjectsHierarchySessionsAndFutureWorkflowShells()
|
||||
{
|
||||
using var orchestrator = new FakeLauncherOrchestrator();
|
||||
using var viewModel = new LauncherWindowViewModel(
|
||||
|
|
@ -35,6 +36,11 @@ public sealed class LauncherWindowViewModelTests
|
|||
Assert.Equal("Character select", session.State);
|
||||
Assert.True(session.IsActive);
|
||||
|
||||
Assert.True(viewModel.IsInstallationChecking);
|
||||
Assert.True(viewModel.ShowInstallationBanner);
|
||||
Assert.False(viewModel.IsFirstRunRequired);
|
||||
await viewModel.StartBackgroundInitializationAsync();
|
||||
Assert.False(viewModel.IsInstallationChecking);
|
||||
Assert.True(viewModel.IsFirstRunRequired);
|
||||
Assert.Contains("SHA-256", viewModel.FirstRunWizardShell.Body, StringComparison.Ordinal);
|
||||
// LU3: the update question says nothing and shows nothing until the
|
||||
|
|
@ -47,6 +53,254 @@ public sealed class LauncherWindowViewModelTests
|
|||
Assert.False(viewModel.FirstRunWizardShell.IsOpen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackgroundStartupWaitsForWindowSignalAndOrdersContentBeforeUpdates()
|
||||
{
|
||||
using var orchestrator = new FakeLauncherOrchestrator
|
||||
{
|
||||
Session = FakeLauncherOrchestrator.CreateSession(
|
||||
LauncherActivityState.Exited,
|
||||
"Exited cleanly."),
|
||||
};
|
||||
var contentCompletion = new TaskCompletionSource<InstallRecordVerification>(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var installer = new FakeLauncherInstaller
|
||||
{
|
||||
LoadExistingHandler = _ => contentCompletion.Task,
|
||||
};
|
||||
var updater = new StartupOrderUpdater();
|
||||
using var viewModel = new LauncherWindowViewModel(
|
||||
orchestrator,
|
||||
new ImmediateUiDispatcher(),
|
||||
installer,
|
||||
updater);
|
||||
|
||||
viewModel.Initialize();
|
||||
|
||||
// App calls the next method only from MainWindow.Opened. Constructing
|
||||
// and initializing the shell cannot start pak I/O or update recovery.
|
||||
Assert.Empty(installer.LoadExistingCalls);
|
||||
Assert.Equal(0, updater.InitializeCalls);
|
||||
Assert.Equal(0, updater.CheckCalls);
|
||||
Assert.True(viewModel.IsInstallationChecking);
|
||||
|
||||
Task startup = viewModel.StartBackgroundInitializationAsync();
|
||||
Assert.Equal([false], installer.LoadExistingCalls);
|
||||
Assert.Equal(0, updater.InitializeCalls);
|
||||
Assert.Equal(0, updater.CheckCalls);
|
||||
Assert.False(startup.IsCompleted);
|
||||
|
||||
contentCompletion.SetResult(new InstallRecordVerification(
|
||||
InstallRecordVerificationState.Verified,
|
||||
installer.Record,
|
||||
"Client content verified without a startup hash."));
|
||||
await startup;
|
||||
|
||||
Assert.Equal(installer.Record, orchestrator.InstalledRecord);
|
||||
Assert.False(viewModel.IsInstallationChecking);
|
||||
Assert.False(viewModel.ShowInstallationBanner);
|
||||
Assert.Equal(1, updater.InitializeCalls);
|
||||
Assert.Equal(1, updater.CheckCalls);
|
||||
Assert.Same(startup, viewModel.StartBackgroundInitializationAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequiredWorldDataWorkIsExplainedAndNeverStartsWithoutConfirmation()
|
||||
{
|
||||
using var orchestrator = new FakeLauncherOrchestrator
|
||||
{
|
||||
Session = FakeLauncherOrchestrator.CreateSession(
|
||||
LauncherActivityState.Exited,
|
||||
"Exited cleanly."),
|
||||
};
|
||||
var installer = new FakeLauncherInstaller();
|
||||
var stale = installer.Record with
|
||||
{
|
||||
BakeToolVersion = LauncherInstallRecordStore.CurrentBakeToolVersion - 1,
|
||||
PreparedAssetSize = 28L * 1024 * 1024 * 1024,
|
||||
};
|
||||
ContentMigrationPlan migration = ContentMigrationCatalog.Resolve(
|
||||
stale.BakeToolVersion,
|
||||
LauncherInstallRecordStore.CurrentBakeToolVersion);
|
||||
installer.NextVerification = new InstallRecordVerification(
|
||||
InstallRecordVerificationState.ContentUpdateRequired,
|
||||
stale,
|
||||
$"World data update required: {migration.Reason}.",
|
||||
migration);
|
||||
using var viewModel = new LauncherWindowViewModel(
|
||||
orchestrator,
|
||||
new ImmediateUiDispatcher(),
|
||||
installer,
|
||||
new StartupOrderUpdater());
|
||||
|
||||
viewModel.Initialize();
|
||||
await viewModel.StartBackgroundInitializationAsync();
|
||||
|
||||
Assert.Same(stale, orchestrator.InstalledRecord);
|
||||
Assert.True(viewModel.FirstRunWizardShell.IsOpen);
|
||||
Assert.True(viewModel.FirstRunWizardShell.IsContentUpdate);
|
||||
Assert.Equal("World data update required", viewModel.InstallationBannerTitle);
|
||||
Assert.Contains("complete replacement pak", viewModel.FirstRunWizardShell.Body);
|
||||
Assert.Contains("existing package stays", viewModel.FirstRunWizardShell.Body);
|
||||
Assert.Equal("Rebuild world data", viewModel.FirstRunWizardShell.StartActionText);
|
||||
Assert.Null(installer.InstallRequest);
|
||||
|
||||
viewModel.FirstRunWizardShell.CloseCommand.Execute(null);
|
||||
Assert.Null(installer.InstallRequest);
|
||||
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
|
||||
await viewModel.FirstRunWizardShell.StartCommand.ExecuteAsync();
|
||||
|
||||
Assert.NotNull(installer.InstallRequest);
|
||||
Assert.Equal(installer.Record, orchestrator.InstalledRecord);
|
||||
Assert.True(viewModel.FirstRunWizardShell.IsCompleted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreparedWorldDataWaitsForMatchingClientAndNotNowCannotPublishIt()
|
||||
{
|
||||
using var orchestrator = new FakeLauncherOrchestrator
|
||||
{
|
||||
Session = FakeLauncherOrchestrator.CreateSession(
|
||||
LauncherActivityState.Exited,
|
||||
"Exited cleanly."),
|
||||
};
|
||||
var installer = new FakeLauncherInstaller();
|
||||
var stale = installer.Record with
|
||||
{
|
||||
BakeToolVersion = LauncherInstallRecordStore.CurrentBakeToolVersion - 1,
|
||||
};
|
||||
ContentMigrationPlan migration = ContentMigrationCatalog.Resolve(
|
||||
stale.BakeToolVersion,
|
||||
LauncherInstallRecordStore.CurrentBakeToolVersion);
|
||||
installer.NextVerification = new InstallRecordVerification(
|
||||
InstallRecordVerificationState.ContentUpdateRequired,
|
||||
stale,
|
||||
$"World data update required: {migration.Reason}.",
|
||||
migration);
|
||||
var updater = new StartupOrderUpdater { ClientUpdateAvailable = true };
|
||||
using var viewModel = new LauncherWindowViewModel(
|
||||
orchestrator,
|
||||
new ImmediateUiDispatcher(),
|
||||
installer,
|
||||
updater);
|
||||
|
||||
viewModel.Initialize();
|
||||
await viewModel.StartBackgroundInitializationAsync();
|
||||
await viewModel.FirstRunWizardShell.StartCommand.ExecuteAsync();
|
||||
|
||||
Assert.Null(orchestrator.InstalledRecord);
|
||||
Assert.Contains(
|
||||
"Play stays disabled",
|
||||
viewModel.FirstRunWizardShell.CompletedBody,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"matching game update",
|
||||
orchestrator.InstallationStatus,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
viewModel.FirstRunWizardShell.AcknowledgeCompletionCommand.Execute(null);
|
||||
Assert.True(viewModel.UpdatePrompt.IsOpen);
|
||||
viewModel.UpdatePrompt.NotNowCommand.Execute(null);
|
||||
Assert.Null(orchestrator.InstalledRecord);
|
||||
Assert.Equal("Game update required", viewModel.InstallationBannerTitle);
|
||||
|
||||
viewModel.UpdatePrompt.TryOpenPendingUpdate();
|
||||
await viewModel.UpdatePrompt.UpdateCommand.ExecuteAsync();
|
||||
|
||||
Assert.Equal(1, updater.InstallCalls);
|
||||
Assert.Equal(installer.Record, orchestrator.InstalledRecord);
|
||||
Assert.False(viewModel.ShowInstallationBanner);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ContentBuiltDuringStartupWaitsForCompatibilityCheckResult()
|
||||
{
|
||||
using var orchestrator = new FakeLauncherOrchestrator
|
||||
{
|
||||
Session = FakeLauncherOrchestrator.CreateSession(
|
||||
LauncherActivityState.Exited,
|
||||
"Exited cleanly."),
|
||||
};
|
||||
var installer = new FakeLauncherInstaller();
|
||||
var stale = installer.Record with
|
||||
{
|
||||
BakeToolVersion = LauncherInstallRecordStore.CurrentBakeToolVersion - 1,
|
||||
};
|
||||
ContentMigrationPlan migration = ContentMigrationCatalog.Resolve(
|
||||
stale.BakeToolVersion,
|
||||
LauncherInstallRecordStore.CurrentBakeToolVersion);
|
||||
installer.NextVerification = new InstallRecordVerification(
|
||||
InstallRecordVerificationState.ContentUpdateRequired,
|
||||
stale,
|
||||
$"World data update required: {migration.Reason}.",
|
||||
migration);
|
||||
var checkGate = new TaskCompletionSource<bool>(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var updater = new StartupOrderUpdater { CheckGate = checkGate };
|
||||
using var viewModel = new LauncherWindowViewModel(
|
||||
orchestrator,
|
||||
new ImmediateUiDispatcher(),
|
||||
installer,
|
||||
updater);
|
||||
|
||||
viewModel.Initialize();
|
||||
Task startup = viewModel.StartBackgroundInitializationAsync();
|
||||
Assert.True(viewModel.FirstRunWizardShell.IsOpen);
|
||||
Assert.False(startup.IsCompleted);
|
||||
|
||||
await viewModel.FirstRunWizardShell.StartCommand.ExecuteAsync();
|
||||
Assert.Null(orchestrator.InstalledRecord);
|
||||
|
||||
checkGate.SetResult(true);
|
||||
await startup;
|
||||
|
||||
Assert.Equal(installer.Record, orchestrator.InstalledRecord);
|
||||
Assert.Contains(
|
||||
"play now",
|
||||
viewModel.FirstRunWizardShell.CompletedBody,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RestartRestoresPendingClientGateUntilClientInstallCompletes()
|
||||
{
|
||||
using var orchestrator = new FakeLauncherOrchestrator
|
||||
{
|
||||
Session = FakeLauncherOrchestrator.CreateSession(
|
||||
LauncherActivityState.Exited,
|
||||
"Exited cleanly."),
|
||||
};
|
||||
var installer = new FakeLauncherInstaller();
|
||||
LauncherInstallRecord gatedRecord = installer.Record with
|
||||
{
|
||||
RequiresClientCompatibilityConfirmation = true,
|
||||
};
|
||||
installer.NextVerification = new InstallRecordVerification(
|
||||
InstallRecordVerificationState.Verified,
|
||||
gatedRecord,
|
||||
"World data is verified; matching client confirmation is pending.");
|
||||
var updater = new StartupOrderUpdater { ClientUpdateAvailable = true };
|
||||
using var viewModel = new LauncherWindowViewModel(
|
||||
orchestrator,
|
||||
new ImmediateUiDispatcher(),
|
||||
installer,
|
||||
updater);
|
||||
|
||||
viewModel.Initialize();
|
||||
await viewModel.StartBackgroundInitializationAsync();
|
||||
|
||||
Assert.Null(orchestrator.InstalledRecord);
|
||||
Assert.Equal("Game update required", viewModel.InstallationBannerTitle);
|
||||
Assert.True(viewModel.UpdatePrompt.IsOpen);
|
||||
|
||||
await viewModel.UpdatePrompt.UpdateCommand.ExecuteAsync();
|
||||
|
||||
Assert.Equal(1, installer.ConfirmCompatibilityCalls);
|
||||
Assert.NotNull(orchestrator.InstalledRecord);
|
||||
Assert.False(
|
||||
orchestrator.InstalledRecord!.RequiresClientCompatibilityConfirmation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProfileCommandsExposeServerAccountCharacterCrudDialogsAndClearPasswords()
|
||||
{
|
||||
|
|
@ -336,8 +590,8 @@ public sealed class LauncherWindowViewModelTests
|
|||
var installer = new FakeLauncherInstaller();
|
||||
using var viewModel = CreateInitialized(orchestrator, installer);
|
||||
|
||||
// LU1: nothing has asked for a verification yet. Startup's own
|
||||
// verification happens in the composition root, not here.
|
||||
// Content discovery is armed by MainWindow.Opened, which this focused
|
||||
// view-model setup deliberately has not signalled.
|
||||
Assert.Empty(installer.LoadExistingCalls);
|
||||
|
||||
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
|
||||
|
|
@ -659,6 +913,9 @@ public sealed class LauncherWindowViewModelTests
|
|||
|
||||
public LauncherInstallRecord? InstalledRecord { get; private set; }
|
||||
|
||||
public string InstallationStatus { get; private set; } =
|
||||
"No installed client is configured.";
|
||||
|
||||
public void LoadProfiles() => LoadCalled = true;
|
||||
|
||||
public LauncherStateSnapshot GetSnapshot() => new(
|
||||
|
|
@ -666,9 +923,7 @@ public sealed class LauncherWindowViewModelTests
|
|||
[Session],
|
||||
Platform,
|
||||
IsInstallationReady: InstalledRecord is not null,
|
||||
InstallationStatus: InstalledRecord is null
|
||||
? "No installed client is configured."
|
||||
: "Client content verified.");
|
||||
InstallationStatus);
|
||||
|
||||
public LauncherCapability GetLaunchCapability(LaunchMode mode) =>
|
||||
Platform.ForLaunchMode(mode);
|
||||
|
|
@ -685,6 +940,18 @@ public sealed class LauncherWindowViewModelTests
|
|||
public void SetInstallRecord(LauncherInstallRecord? installRecord)
|
||||
{
|
||||
InstalledRecord = installRecord;
|
||||
InstallationStatus = installRecord is null
|
||||
? "No installed client is configured."
|
||||
: "Client content verified.";
|
||||
StateChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
public void SetInstallationState(
|
||||
LauncherInstallRecord? installRecord,
|
||||
string installationStatus)
|
||||
{
|
||||
InstalledRecord = installRecord;
|
||||
InstallationStatus = installationStatus;
|
||||
StateChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
|
|
@ -836,6 +1103,8 @@ public sealed class LauncherWindowViewModelTests
|
|||
|
||||
public (string DatDirectory, int Threads)? InstallRequest { get; private set; }
|
||||
|
||||
public int ConfirmCompatibilityCalls { get; private set; }
|
||||
|
||||
public Func<
|
||||
string,
|
||||
int,
|
||||
|
|
@ -884,12 +1153,17 @@ public sealed class LauncherWindowViewModelTests
|
|||
/// called with, oldest first.</summary>
|
||||
public List<bool> LoadExistingCalls { get; } = [];
|
||||
|
||||
public Func<CancellationToken, Task<InstallRecordVerification>>?
|
||||
LoadExistingHandler { get; set; }
|
||||
|
||||
public Task<InstallRecordVerification> LoadExistingAsync(
|
||||
CancellationToken cancellationToken = default,
|
||||
bool forceFullVerification = false)
|
||||
{
|
||||
LoadExistingCalls.Add(forceFullVerification);
|
||||
return Task.FromResult(NextVerification);
|
||||
return LoadExistingHandler is null
|
||||
? Task.FromResult(NextVerification)
|
||||
: LoadExistingHandler(cancellationToken);
|
||||
}
|
||||
|
||||
public Task<LauncherInstallResult> InstallAsync(
|
||||
|
|
@ -919,5 +1193,88 @@ public sealed class LauncherWindowViewModelTests
|
|||
"Verifying package."));
|
||||
return Task.FromResult(new LauncherInstallResult(Record));
|
||||
}
|
||||
|
||||
public void ConfirmClientCompatibility() => ConfirmCompatibilityCalls++;
|
||||
}
|
||||
|
||||
private sealed class StartupOrderUpdater : ILauncherUpdater
|
||||
{
|
||||
private static readonly LauncherVersion Version = LauncherVersion.Parse("1.0.0");
|
||||
private readonly ClientVersionResolution _resolution = new(
|
||||
ClientVersionState.Missing,
|
||||
"No versioned client is installed.",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
|
||||
public int InitializeCalls { get; private set; }
|
||||
|
||||
public int CheckCalls { get; private set; }
|
||||
|
||||
public int InstallCalls { get; private set; }
|
||||
|
||||
public bool ClientUpdateAvailable { get; init; }
|
||||
|
||||
public TaskCompletionSource<bool>? CheckGate { get; init; }
|
||||
|
||||
public ClientVersionResolution CurrentClient => _resolution;
|
||||
|
||||
public Task<ClientVersionResolution> InitializeAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
InitializeCalls++;
|
||||
return Task.FromResult(_resolution);
|
||||
}
|
||||
|
||||
public async Task<LauncherUpdateCheckResult> CheckAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
CheckCalls++;
|
||||
if (CheckGate is not null)
|
||||
{
|
||||
_ = await CheckGate.Task.WaitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
var artifact = new ReleaseArtifact(
|
||||
new Uri("https://updates.example.test/acdream.zip"),
|
||||
new string('a', 64),
|
||||
1);
|
||||
var manifest = new ReleaseManifest(
|
||||
Version,
|
||||
Version,
|
||||
new Dictionary<string, ReleaseArtifact> { ["win-x64"] = artifact },
|
||||
new Dictionary<string, ReleaseArtifact> { ["win-x64"] = artifact });
|
||||
return new LauncherUpdateCheckResult(
|
||||
manifest,
|
||||
"win-x64",
|
||||
Version,
|
||||
Version,
|
||||
IsClientUpdateAvailable: ClientUpdateAvailable,
|
||||
IsLauncherUpdateAvailable: false,
|
||||
IsLauncherMinimumSatisfied: true,
|
||||
"Everything is current.");
|
||||
}
|
||||
|
||||
public Task<ClientVersionResolution> InstallClientAsync(
|
||||
LauncherUpdateCheckResult check,
|
||||
IProgress<LauncherUpdateProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
InstallCalls++;
|
||||
return Task.FromResult(_resolution);
|
||||
}
|
||||
|
||||
public Task<SelfUpdateStageResult> StageLauncherAsync(
|
||||
LauncherUpdateCheckResult check,
|
||||
IProgress<LauncherUpdateProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<ClientVersionResolution> RollbackClientAsync(
|
||||
IProgress<LauncherUpdateProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue