feat(launcher): stabilize prepared content updates
Some checks failed
CI / linux-portable (push) Failing after 3m12s
CI / windows-gate (push) Failing after 6m35s
CI / release (push) Has been skipped

This commit is contained in:
Erik 2026-08-25 19:17:13 +02:00
parent f160f3fee1
commit af9327a17b
42 changed files with 3706 additions and 147 deletions

View file

@ -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();
}
}