feat(launcher): stabilize prepared content updates
This commit is contained in:
parent
f160f3fee1
commit
af9327a17b
42 changed files with 3706 additions and 147 deletions
|
|
@ -258,6 +258,9 @@ public sealed class ContentEffectsAudioCompositionTests
|
|||
Dependencies = new ContentEffectsAudioDependencies(
|
||||
"test-dat",
|
||||
"test.pak",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
ResidencyBudgetOptions.Default,
|
||||
new PhysicsDataCache(),
|
||||
false,
|
||||
|
|
@ -386,6 +389,9 @@ public sealed class ContentEffectsAudioCompositionTests
|
|||
public IDatReaderWriter OpenDatCollection(string datDirectory) => _dats;
|
||||
public IPreparedAssetSource OpenPreparedAssetSource(
|
||||
string path,
|
||||
string? overlayPath,
|
||||
uint? baseRecipeVersion,
|
||||
uint? effectiveRecipeVersion,
|
||||
IDatReaderWriter dats,
|
||||
Action<string> diagnostic)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -137,6 +137,56 @@ public sealed class RuntimeOptionsSessionConfigTests
|
|||
options.PreparedAssetPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessContentCarriesOptionalOverlayAndBothRecipeIdentities()
|
||||
{
|
||||
var config = new SessionConfiguration
|
||||
{
|
||||
Version = 1,
|
||||
Process = new SessionProcessSettings
|
||||
{
|
||||
Content = new SessionContentDescriptor
|
||||
{
|
||||
DatDirectory = "D:\\dats",
|
||||
PreparedAssetPath = "D:\\content\\acdream.pak",
|
||||
PreparedAssetOverlayPath =
|
||||
"D:\\content\\acdream-update-5.pak",
|
||||
PreparedAssetBaseRecipeVersion = 4,
|
||||
PreparedAssetEffectiveRecipeVersion = 5,
|
||||
},
|
||||
},
|
||||
};
|
||||
var session = new SessionDescriptor
|
||||
{
|
||||
Id = "overlay-session",
|
||||
Endpoint = new SessionEndpointDescriptor
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 9000,
|
||||
},
|
||||
Account = "account",
|
||||
Credential = new SessionCredentialDescriptor
|
||||
{
|
||||
Provider = SessionCredentialProviderKind.Environment,
|
||||
Reference = "X",
|
||||
},
|
||||
};
|
||||
|
||||
RuntimeOptions options = RuntimeOptions.FromSessionConfig(
|
||||
"D:\\dats",
|
||||
_ => null,
|
||||
"session.json",
|
||||
config,
|
||||
session,
|
||||
"password");
|
||||
|
||||
Assert.Equal(
|
||||
"D:\\content\\acdream-update-5.pak",
|
||||
options.PreparedAssetOverlayPath);
|
||||
Assert.Equal(4u, options.PreparedAssetBaseRecipeVersion);
|
||||
Assert.Equal(5u, options.PreparedAssetEffectiveRecipeVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnvironmentFlowLeavesEveryNewFieldAtItsNothingConfiguredDefault()
|
||||
{
|
||||
|
|
@ -149,5 +199,8 @@ public sealed class RuntimeOptionsSessionConfigTests
|
|||
Assert.Null(options.Plugins);
|
||||
Assert.Empty(options.LoginCommands);
|
||||
Assert.Equal(500, options.LoginCommandDelayMs);
|
||||
Assert.Null(options.PreparedAssetOverlayPath);
|
||||
Assert.Null(options.PreparedAssetBaseRecipeVersion);
|
||||
Assert.Null(options.PreparedAssetEffectiveRecipeVersion);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
209
tests/AcDream.Content.Tests/LayeredPreparedAssetSourceTests.cs
Normal file
209
tests/AcDream.Content.Tests/LayeredPreparedAssetSourceTests.cs
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
using System.Collections.Immutable;
|
||||
using System.Numerics;
|
||||
using AcDream.Content.Pak;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.Content.Tests;
|
||||
|
||||
public sealed class LayeredPreparedAssetSourceTests : IDisposable
|
||||
{
|
||||
private static readonly PreparedAssetCatalogIdentity Identity =
|
||||
new(10, 20, 30, 40, PakFormat.CurrentBakeToolVersion);
|
||||
|
||||
private readonly List<string> _paths = [];
|
||||
|
||||
[Fact]
|
||||
public void OverlayWinsAndMissingRenderAndCollisionKeysFallBackToBase()
|
||||
{
|
||||
const uint replaced = 0x0100_0001u;
|
||||
const uint baseOnly = 0x0100_0002u;
|
||||
string basePath = WritePak(
|
||||
(replaced, 1f),
|
||||
(baseOnly, 2f));
|
||||
string overlayPath = WritePak((replaced, 9f));
|
||||
|
||||
using var source = Open(basePath, overlayPath);
|
||||
PreparedAssetReadResult overlaid =
|
||||
source.Read(PreparedAssetRequest.GfxObj(replaced));
|
||||
PreparedAssetReadResult fallback =
|
||||
source.Read(PreparedAssetRequest.GfxObj(baseOnly));
|
||||
|
||||
Assert.Equal(9f, overlaid.Data!.Vertices[0].Position.X);
|
||||
Assert.Equal(2f, fallback.Data!.Vertices[0].Position.X);
|
||||
Assert.Equal(
|
||||
PreparedAssetPresence.Available,
|
||||
source.Probe(PakAssetType.GfxObjMesh, baseOnly));
|
||||
|
||||
PreparedCollisionReadResult<FlatSetupCollision> overlayCollision =
|
||||
source.ReadSetupCollision(replaced);
|
||||
PreparedCollisionReadResult<FlatSetupCollision> baseCollision =
|
||||
source.ReadSetupCollision(baseOnly);
|
||||
Assert.Equal(9f, overlayCollision.Data!.Height);
|
||||
Assert.Equal(2f, baseCollision.Data!.Height);
|
||||
|
||||
// Replaced reads touch only the overlay; fallback reads touch overlay
|
||||
// then base. The public counters therefore describe real mapped work.
|
||||
Assert.Equal(3, source.Stats.Reads);
|
||||
Assert.Equal(2, source.Stats.Loaded);
|
||||
Assert.Equal(1, source.Stats.Missing);
|
||||
Assert.Equal(3, source.CollisionStats.Reads);
|
||||
Assert.Equal(2, source.CollisionStats.Loaded);
|
||||
Assert.Equal(1, source.CollisionStats.Missing);
|
||||
Assert.Equal(
|
||||
new FileInfo(basePath).Length + new FileInfo(overlayPath).Length,
|
||||
source.MappedVirtualBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CorruptOverlayKeyIsAuthoritativeAndNeverFallsBack()
|
||||
{
|
||||
const uint fileId = 0x0100_0001u;
|
||||
string basePath = WriteRenderOnlyPak((fileId, 1f));
|
||||
string overlayPath = WriteRenderOnlyPak((fileId, 9f));
|
||||
FlipFirstBlobByte(overlayPath);
|
||||
|
||||
using var source = Open(basePath, overlayPath);
|
||||
PreparedAssetReadResult result =
|
||||
source.Read(PreparedAssetRequest.GfxObj(fileId));
|
||||
|
||||
Assert.Equal(PreparedAssetReadStatus.Corrupt, result.Status);
|
||||
Assert.Null(result.Data);
|
||||
Assert.Equal(1, source.Stats.Reads);
|
||||
Assert.Equal(1, source.Stats.Corrupt);
|
||||
Assert.Equal(0, source.Stats.Loaded);
|
||||
Assert.Equal(
|
||||
PreparedAssetPresence.Corrupt,
|
||||
source.Probe(PakAssetType.GfxObjMesh, fileId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisposeReleasesBothMappedPackagesAndIsIdempotent()
|
||||
{
|
||||
string basePath = WriteRenderOnlyPak((1u, 1f));
|
||||
string overlayPath = WriteRenderOnlyPak((2u, 2f));
|
||||
var source = Open(basePath, overlayPath);
|
||||
|
||||
source.Dispose();
|
||||
source.Dispose();
|
||||
|
||||
using var baseExclusive = new FileStream(
|
||||
basePath,
|
||||
FileMode.Open,
|
||||
FileAccess.ReadWrite,
|
||||
FileShare.None);
|
||||
using var overlayExclusive = new FileStream(
|
||||
overlayPath,
|
||||
FileMode.Open,
|
||||
FileAccess.ReadWrite,
|
||||
FileShare.None);
|
||||
Assert.True(baseExclusive.CanWrite);
|
||||
Assert.True(overlayExclusive.CanWrite);
|
||||
Assert.Throws<ObjectDisposedException>(() => _ = source.Stats);
|
||||
}
|
||||
|
||||
private LayeredPreparedAssetSource Open(string basePath, string overlayPath) =>
|
||||
new(
|
||||
new PakPreparedAssetSource(basePath, Identity),
|
||||
new PakPreparedAssetSource(overlayPath, Identity));
|
||||
|
||||
private string WritePak(params (uint FileId, float Marker)[] entries)
|
||||
{
|
||||
string path = NewPath();
|
||||
using var writer = new PakWriter(path, Header());
|
||||
foreach ((uint fileId, float marker) in entries)
|
||||
{
|
||||
writer.AddBlob(
|
||||
PakKey.Compose(PakAssetType.GfxObjMesh, fileId),
|
||||
Mesh(fileId, marker));
|
||||
writer.AddBlob(
|
||||
PakKey.Compose(PakAssetType.SetupCollision, fileId),
|
||||
FlatCollisionAssetSerializer.Serialize(Setup(marker)));
|
||||
}
|
||||
writer.Finish();
|
||||
return path;
|
||||
}
|
||||
|
||||
private string WriteRenderOnlyPak(params (uint FileId, float Marker)[] entries)
|
||||
{
|
||||
string path = NewPath();
|
||||
using var writer = new PakWriter(path, Header());
|
||||
foreach ((uint fileId, float marker) in entries)
|
||||
{
|
||||
writer.AddBlob(
|
||||
PakKey.Compose(PakAssetType.GfxObjMesh, fileId),
|
||||
Mesh(fileId, marker));
|
||||
}
|
||||
writer.Finish();
|
||||
return path;
|
||||
}
|
||||
|
||||
private string NewPath()
|
||||
{
|
||||
string path = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"acdream-layered-prepared-{Guid.NewGuid():N}.pak");
|
||||
_paths.Add(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
private static PakHeader Header() =>
|
||||
new()
|
||||
{
|
||||
PortalIteration = Identity.PortalIteration,
|
||||
CellIteration = Identity.CellIteration,
|
||||
HighResIteration = Identity.HighResIteration,
|
||||
LanguageIteration = Identity.LanguageIteration,
|
||||
BakeToolVersion = Identity.BakeToolVersion,
|
||||
};
|
||||
|
||||
private static ObjectMeshData Mesh(uint objectId, float marker) =>
|
||||
new()
|
||||
{
|
||||
ObjectId = objectId,
|
||||
Vertices =
|
||||
[
|
||||
new(
|
||||
new Vector3(marker, 2, 3),
|
||||
Vector3.UnitZ,
|
||||
new Vector2(0.25f, 0.75f)),
|
||||
],
|
||||
};
|
||||
|
||||
private static FlatSetupCollision Setup(float marker) =>
|
||||
new(
|
||||
ImmutableArray<FlatCollisionCylinder>.Empty,
|
||||
ImmutableArray<FlatCollisionSphere>.Empty,
|
||||
marker,
|
||||
marker,
|
||||
marker,
|
||||
marker);
|
||||
|
||||
private static void FlipFirstBlobByte(string path)
|
||||
{
|
||||
using var stream = new FileStream(
|
||||
path,
|
||||
FileMode.Open,
|
||||
FileAccess.ReadWrite,
|
||||
FileShare.None);
|
||||
stream.Position = PakHeader.Size + 4;
|
||||
int value = stream.ReadByte();
|
||||
Assert.NotEqual(-1, value);
|
||||
stream.Position = PakHeader.Size + 4;
|
||||
stream.WriteByte((byte)(value ^ 0xFF));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (string path in _paths)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort test cleanup.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -87,6 +87,27 @@ public sealed class SessionConfigurationSharedFixtureTests
|
|||
Assert.Empty(session.Plugins);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HeadlessReaderAcceptsOptionalLayeredPreparedContent()
|
||||
{
|
||||
string json = LauncherCoreSessionConfigFixture.Compose();
|
||||
json = json.Replace(
|
||||
"\"preparedAssetPath\": \"composer-dats/acdream.pak\"",
|
||||
"\"preparedAssetPath\": \"composer-dats/acdream.pak\",\n"
|
||||
+ " \"preparedAssetOverlayPath\": \"composer-dats/update.pak\",\n"
|
||||
+ " \"preparedAssetBaseRecipeVersion\": 4,\n"
|
||||
+ " \"preparedAssetEffectiveRecipeVersion\": 5",
|
||||
StringComparison.Ordinal);
|
||||
using TemporaryFile file = TemporaryFile.Create(json);
|
||||
|
||||
HeadlessContentDescriptor content = Assert.IsType<HeadlessContentDescriptor>(
|
||||
HeadlessConfigurationLoader.Load(file.Path).Process.Content);
|
||||
|
||||
Assert.Equal("composer-dats/update.pak", content.PreparedAssetOverlayPath);
|
||||
Assert.Equal(4u, content.PreparedAssetBaseRecipeVersion);
|
||||
Assert.Equal(5u, content.PreparedAssetEffectiveRecipeVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HeadlessReaderAcceptsTheProductionShapedSharedFixture()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -43,4 +43,27 @@ public sealed class BakeProcessRunnerTests
|
|||
Assert.False(startInfo.Environment.ContainsKey(
|
||||
BakePublicationGuardPaths.NonceEnvironmentVariable));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilteredOverlayArgumentsUseBakeToolsPinnedHexLists()
|
||||
{
|
||||
var request = new BakeProcessRequest(
|
||||
"acdream-bake",
|
||||
"retail-dats",
|
||||
"data/pak/update.pak",
|
||||
4,
|
||||
DatIds: [0x01000001u, 0x02000002u],
|
||||
Landblocks: [0x0A, 0xFE]);
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
"--dat-dir", "retail-dats",
|
||||
"--out", "data/pak/update.pak",
|
||||
"--threads", "4",
|
||||
"--progress-json",
|
||||
"--ids", "0x01000001,0x02000002",
|
||||
"--landblocks", "0x0A,0xFE",
|
||||
],
|
||||
request.Arguments);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,156 @@
|
|||
using System.Buffers.Binary;
|
||||
using AcDream.Launcher.Core.Integrity;
|
||||
using AcDream.Launcher.Core.Installation;
|
||||
using AcDream.Launcher.Core.Launching;
|
||||
using AcDream.Platform;
|
||||
|
||||
namespace AcDream.Launcher.Core.Tests.Installation;
|
||||
|
||||
public sealed class LauncherContentStateStoreTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"acdream-content-state-tests",
|
||||
Guid.NewGuid().ToString("N"));
|
||||
private readonly ApplicationPathSet _paths;
|
||||
|
||||
public LauncherContentStateStoreTests()
|
||||
{
|
||||
_paths = new ApplicationPathSet(
|
||||
Path.Combine(_root, "config"),
|
||||
Path.Combine(_root, "data"),
|
||||
Path.Combine(_root, "cache"),
|
||||
null);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_root))
|
||||
{
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MatchingSidecarResolvesWithoutStartupHashAndForceVerifyHashesBoth()
|
||||
{
|
||||
int hashCalls = 0;
|
||||
var store = new LauncherContentStateStore(
|
||||
_paths,
|
||||
async (path, cancellationToken) =>
|
||||
{
|
||||
hashCalls++;
|
||||
return await FileIntegrity.ComputeSha256HexAsync(
|
||||
path,
|
||||
cancellationToken);
|
||||
});
|
||||
string basePath = Path.Combine(_paths.DataDirectory, "pak", "acdream.pak");
|
||||
string overlayPath = Path.Combine(
|
||||
_paths.DataDirectory,
|
||||
"pak",
|
||||
"acdream-update-5-test.pak");
|
||||
WritePakHeader(basePath, recipe: 4);
|
||||
WritePakHeader(overlayPath, recipe: 5);
|
||||
LauncherInstallRecord record = await RecordAsync(basePath, recipe: 4);
|
||||
var overlay = new LauncherContentOverlay(
|
||||
Path.GetFileName(overlayPath),
|
||||
await FileIntegrity.ComputeSha256HexAsync(overlayPath),
|
||||
new FileInfo(overlayPath).Length,
|
||||
5);
|
||||
var state = new LauncherContentState(
|
||||
LauncherContentState.CurrentSchemaVersion,
|
||||
record.PreparedAssetSha256,
|
||||
5,
|
||||
overlay);
|
||||
await store.SaveAtomicallyAsync(record, state);
|
||||
|
||||
(LauncherContentState? quick, string? quickError) =
|
||||
await store.LoadAsync(record);
|
||||
|
||||
Assert.Null(quickError);
|
||||
Assert.Equal(state, quick);
|
||||
Assert.Equal(0, hashCalls);
|
||||
|
||||
(LauncherContentState? verified, string? verifyError) =
|
||||
await store.LoadAsync(record, forceFullVerification: true);
|
||||
Assert.Null(verifyError);
|
||||
Assert.Equal(state, verified);
|
||||
Assert.Equal(2, hashCalls);
|
||||
Assert.Empty(Directory.EnumerateFiles(
|
||||
Path.GetDirectoryName(store.StatePath)!,
|
||||
"content.current.json.*.tmp"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("../escape.pak")]
|
||||
[InlineData("nested/escape.pak")]
|
||||
[InlineData("C:\\escape.pak")]
|
||||
[InlineData("not-a-pak.txt")]
|
||||
public async Task OverlayPathMustBeOneContainedPakFilename(string path)
|
||||
{
|
||||
var store = new LauncherContentStateStore(_paths);
|
||||
string basePath = Path.Combine(_paths.DataDirectory, "pak", "acdream.pak");
|
||||
WritePakHeader(basePath, recipe: 4);
|
||||
LauncherInstallRecord record = await RecordAsync(basePath, recipe: 4);
|
||||
var state = new LauncherContentState(
|
||||
LauncherContentState.CurrentSchemaVersion,
|
||||
record.PreparedAssetSha256,
|
||||
5,
|
||||
new LauncherContentOverlay(path, new string('a', 64), 64, 5));
|
||||
|
||||
InvalidDataException error = await Assert.ThrowsAsync<InvalidDataException>(
|
||||
() => store.SaveAtomicallyAsync(record, state));
|
||||
|
||||
Assert.Contains("filename", error.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.False(File.Exists(store.StatePath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BaseDigestBindingRejectsSidecarFromPriorFullRebuild()
|
||||
{
|
||||
var store = new LauncherContentStateStore(_paths);
|
||||
string basePath = Path.Combine(_paths.DataDirectory, "pak", "acdream.pak");
|
||||
WritePakHeader(basePath, recipe: 4);
|
||||
LauncherInstallRecord record = await RecordAsync(basePath, recipe: 4);
|
||||
var state = new LauncherContentState(
|
||||
LauncherContentState.CurrentSchemaVersion,
|
||||
new string('b', 64),
|
||||
5,
|
||||
new LauncherContentOverlay(
|
||||
"acdream-update-5-test.pak",
|
||||
new string('a', 64),
|
||||
64,
|
||||
5));
|
||||
|
||||
InvalidDataException error = await Assert.ThrowsAsync<InvalidDataException>(
|
||||
() => store.SaveAtomicallyAsync(record, state));
|
||||
|
||||
Assert.Contains("base pak", error.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
internal static void WritePakHeader(string path, uint recipe)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
byte[] bytes = new byte[64];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(0, 4), 0x4B504341u);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(4, 4), 1);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(8, 4), 100);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(12, 4), 200);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(16, 4), 300);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(20, 4), 400);
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(bytes.AsSpan(24, 8), 64);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(36, 4), recipe);
|
||||
File.WriteAllBytes(path, bytes);
|
||||
}
|
||||
|
||||
internal static async Task<LauncherInstallRecord> RecordAsync(
|
||||
string basePath,
|
||||
uint recipe,
|
||||
string? datDirectory = null) =>
|
||||
new(
|
||||
datDirectory ?? Path.GetDirectoryName(basePath)!,
|
||||
basePath,
|
||||
await FileIntegrity.ComputeSha256HexAsync(basePath),
|
||||
new FileInfo(basePath).Length,
|
||||
recipe);
|
||||
}
|
||||
|
|
@ -85,7 +85,7 @@ public sealed class LauncherInstallRecordStoreTests : IDisposable
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StaleBakeToolVersionIsRejectedBeforeHashing()
|
||||
public async Task StaleBakeToolVersionPromptsForKnownMigrationBeforeHashing()
|
||||
{
|
||||
int hashCalls = 0;
|
||||
var store = new LauncherInstallRecordStore(
|
||||
|
|
@ -112,8 +112,12 @@ public sealed class LauncherInstallRecordStoreTests : IDisposable
|
|||
}));
|
||||
|
||||
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
|
||||
Assert.Equal(InstallRecordVerificationState.Invalid, verification.State);
|
||||
Assert.Contains("Bake tool version", verification.Status, StringComparison.Ordinal);
|
||||
Assert.Equal(
|
||||
InstallRecordVerificationState.ContentUpdateRequired,
|
||||
verification.State);
|
||||
Assert.NotNull(verification.Record);
|
||||
Assert.Equal(ContentWorkKind.FullRebuild, verification.RequiredContentWork?.Kind);
|
||||
Assert.Contains("World data update", verification.Status, StringComparison.Ordinal);
|
||||
Assert.Equal(0, hashCalls);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -75,12 +75,14 @@ public sealed class LauncherInstallerTests : IDisposable
|
|||
Assert.Equal(Path.GetFullPath(_bakeExecutable), observedRequest.ExecutablePath);
|
||||
Assert.Equal(Path.GetFullPath(_dats), observedRequest.DatDirectory);
|
||||
Assert.Equal(
|
||||
Path.Combine(_paths.DataDirectory, "pak", "acdream.pak"),
|
||||
LauncherInstaller.GetFullRebuildCandidatePath(
|
||||
Path.Combine(_paths.DataDirectory, "pak", "acdream.pak")),
|
||||
observedRequest.OutputPath);
|
||||
Assert.Equal(
|
||||
[
|
||||
"--dat-dir", Path.GetFullPath(_dats),
|
||||
"--out", Path.Combine(_paths.DataDirectory, "pak", "acdream.pak"),
|
||||
"--out", LauncherInstaller.GetFullRebuildCandidatePath(
|
||||
Path.Combine(_paths.DataDirectory, "pak", "acdream.pak")),
|
||||
"--threads", "7",
|
||||
"--progress-json",
|
||||
],
|
||||
|
|
@ -145,6 +147,54 @@ public sealed class LauncherInstallerTests : IDisposable
|
|||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullContentMigrationPersistsClientGateAcrossLauncherRestart()
|
||||
{
|
||||
var runner = new FakeBakeProcessRunner(async (request, output, _) =>
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(request.OutputPath)!);
|
||||
await File.WriteAllTextAsync(request.OutputPath, "replacement package");
|
||||
long bytes = new FileInfo(request.OutputPath).Length;
|
||||
output($"{{\"v\":1,\"e\":\"started\",\"bakeToolVersion\":"
|
||||
+ $"{LauncherInstallRecordStore.CurrentBakeToolVersion}}}\n");
|
||||
output($"{{\"v\":1,\"e\":\"completed\",\"bakeToolVersion\":"
|
||||
+ $"{LauncherInstallRecordStore.CurrentBakeToolVersion},"
|
||||
+ $"\"outputBytes\":{bytes},\"failures\":0}}\n");
|
||||
return new BakeProcessResult(0, string.Empty);
|
||||
});
|
||||
var installer = new LauncherInstaller(
|
||||
_paths,
|
||||
_bakeExecutable,
|
||||
processRunner: runner);
|
||||
var migration = new ContentMigrationPlan(
|
||||
LauncherInstallRecordStore.CurrentBakeToolVersion - 1,
|
||||
LauncherInstallRecordStore.CurrentBakeToolVersion,
|
||||
ContentWorkKind.FullRebuild,
|
||||
"fixture full migration");
|
||||
|
||||
LauncherInstallResult result = await installer.ApplyContentUpdateAsync(
|
||||
_dats,
|
||||
2,
|
||||
migration);
|
||||
|
||||
var stateStore = new LauncherContentStateStore(_paths);
|
||||
Assert.True(result.Record.RequiresClientCompatibilityConfirmation);
|
||||
Assert.True(File.Exists(stateStore.ClientCompatibilityPendingPath));
|
||||
|
||||
var restarted = new LauncherInstaller(
|
||||
_paths,
|
||||
_bakeExecutable,
|
||||
processRunner: runner);
|
||||
InstallRecordVerification discovered = await restarted.LoadExistingAsync();
|
||||
Assert.True(discovered.IsVerified);
|
||||
Assert.True(discovered.Record!.RequiresClientCompatibilityConfirmation);
|
||||
|
||||
restarted.ConfirmClientCompatibility();
|
||||
discovered = await restarted.LoadExistingAsync();
|
||||
Assert.False(discovered.Record!.RequiresClientCompatibilityConfirmation);
|
||||
Assert.False(File.Exists(stateStore.ClientCompatibilityPendingPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FailedChildRestoresPriorVerifiedPakAndRecord()
|
||||
{
|
||||
|
|
@ -361,12 +411,15 @@ public sealed class LauncherInstallerTests : IDisposable
|
|||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => operationB);
|
||||
Assert.False(runnerBEntered);
|
||||
Assert.Equal(
|
||||
"installer A in progress",
|
||||
await File.ReadAllTextAsync(storeA.PreparedAssetPath));
|
||||
Assert.Equal(
|
||||
"previous verified package",
|
||||
await File.ReadAllTextAsync(backupPath));
|
||||
await File.ReadAllTextAsync(storeA.PreparedAssetPath));
|
||||
Assert.False(File.Exists(backupPath));
|
||||
Assert.Equal(
|
||||
"installer A in progress",
|
||||
await File.ReadAllTextAsync(
|
||||
LauncherInstaller.GetFullRebuildCandidatePath(
|
||||
storeA.PreparedAssetPath)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
@ -547,12 +600,14 @@ public sealed class LauncherInstallerTests : IDisposable
|
|||
await File.ReadAllTextAsync(childPid),
|
||||
System.Globalization.CultureInfo.InvariantCulture);
|
||||
using Process orphan = Process.GetProcessById(orphanPid);
|
||||
Assert.True(File.Exists(
|
||||
Assert.False(File.Exists(
|
||||
LauncherInstallRecordStore.GetBackupPath(
|
||||
store.PreparedAssetPath)));
|
||||
string candidatePath = LauncherInstaller.GetFullRebuildCandidatePath(
|
||||
store.PreparedAssetPath);
|
||||
Assert.True(File.Exists(
|
||||
BakePublicationGuardPaths.GetAuthorizationPath(
|
||||
store.PreparedAssetPath)));
|
||||
candidatePath)));
|
||||
|
||||
parent.Kill(entireProcessTree: false);
|
||||
await parent.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
|
||||
|
|
@ -580,7 +635,7 @@ public sealed class LauncherInstallerTests : IDisposable
|
|||
recovered = await recovery.WaitAsync(TimeSpan.FromSeconds(15));
|
||||
Assert.False(File.Exists(
|
||||
BakePublicationGuardPaths.GetAuthorizationPath(
|
||||
store.PreparedAssetPath)));
|
||||
candidatePath)));
|
||||
File.WriteAllText(release, "release");
|
||||
}
|
||||
|
||||
|
|
@ -622,7 +677,7 @@ public sealed class LauncherInstallerTests : IDisposable
|
|||
Assert.False(backupAfterRecovery);
|
||||
Assert.False(File.Exists(
|
||||
BakePublicationGuardPaths.GetAuthorizationPath(
|
||||
store.PreparedAssetPath)));
|
||||
candidatePath)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,218 @@
|
|||
using System.Text.Json;
|
||||
using AcDream.Launcher.Core.Integrity;
|
||||
using AcDream.Launcher.Core.Installation;
|
||||
using AcDream.Launcher.Core.Launching;
|
||||
using AcDream.Platform;
|
||||
|
||||
namespace AcDream.Launcher.Core.Tests.Installation;
|
||||
|
||||
public sealed class LauncherOverlayInstallerTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"acdream-overlay-installer-tests",
|
||||
Guid.NewGuid().ToString("N"));
|
||||
private readonly ApplicationPathSet _paths;
|
||||
private readonly string _dats;
|
||||
private readonly string _bakeExecutable;
|
||||
|
||||
public LauncherOverlayInstallerTests()
|
||||
{
|
||||
_paths = new ApplicationPathSet(
|
||||
Path.Combine(_root, "config"),
|
||||
Path.Combine(_root, "data"),
|
||||
Path.Combine(_root, "cache"),
|
||||
null);
|
||||
_dats = Path.Combine(_root, "dats");
|
||||
foreach (string name in DatDirectoryLocator.RequiredFileNames)
|
||||
{
|
||||
Directory.CreateDirectory(_dats);
|
||||
File.WriteAllText(Path.Combine(_dats, name), name);
|
||||
}
|
||||
|
||||
_bakeExecutable = Path.Combine(_root, "bin", "acdream-bake");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(_bakeExecutable)!);
|
||||
File.WriteAllText(_bakeExecutable, "fixture");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_root))
|
||||
{
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FilteredBakePublishesOneOverlayWithoutRewritingBase()
|
||||
{
|
||||
var recordStore = new LauncherInstallRecordStore(_paths);
|
||||
LauncherInstallRecord baseRecord = await CreateBaseRecordAsync(recordStore);
|
||||
byte[] baseBefore = await File.ReadAllBytesAsync(
|
||||
recordStore.PreparedAssetPath);
|
||||
BakeProcessRequest? observed = null;
|
||||
var runner = new FakeRunner(async (request, output, _) =>
|
||||
{
|
||||
observed = request;
|
||||
LauncherContentStateStoreTests.WritePakHeader(
|
||||
request.OutputPath,
|
||||
LauncherInstallRecordStore.CurrentBakeToolVersion);
|
||||
long bytes = new FileInfo(request.OutputPath).Length;
|
||||
output($"{{\"v\":1,\"e\":\"started\",\"bakeToolVersion\":5}}\n");
|
||||
output($"{{\"v\":1,\"e\":\"completed\",\"bakeToolVersion\":5,"
|
||||
+ $"\"outputBytes\":{bytes},\"failures\":0}}\n");
|
||||
await Task.Yield();
|
||||
return new BakeProcessResult(0, string.Empty);
|
||||
});
|
||||
var installer = new LauncherInstaller(
|
||||
_paths,
|
||||
_bakeExecutable,
|
||||
recordStore: recordStore,
|
||||
processRunner: runner);
|
||||
var migration = new ContentMigrationPlan(
|
||||
4,
|
||||
5,
|
||||
ContentWorkKind.Overlay,
|
||||
"bounded fixture update",
|
||||
[0x01001234u, 0x02005678u],
|
||||
[0xAB]);
|
||||
|
||||
LauncherInstallResult result = await installer.ApplyContentUpdateAsync(
|
||||
_dats,
|
||||
3,
|
||||
migration);
|
||||
|
||||
Assert.Equal(baseBefore, await File.ReadAllBytesAsync(
|
||||
recordStore.PreparedAssetPath));
|
||||
Assert.NotNull(observed);
|
||||
Assert.Equal(
|
||||
new[] { "--ids", "0x01001234,0x02005678" },
|
||||
observed.Arguments.SkipWhile(value => value != "--ids").Take(2));
|
||||
Assert.Equal(
|
||||
new[] { "--landblocks", "0xAB" },
|
||||
observed.Arguments.SkipWhile(value => value != "--landblocks").Take(2));
|
||||
Assert.NotNull(result.Record.PreparedAssetOverlayPath);
|
||||
Assert.True(File.Exists(result.Record.PreparedAssetOverlayPath));
|
||||
Assert.Equal(5u, result.Record.ResolvedBakeToolVersion);
|
||||
Assert.True(result.Record.RequiresClientCompatibilityConfirmation);
|
||||
Assert.False(File.Exists(
|
||||
new LauncherContentStateStore(_paths).OverlayCandidatePath));
|
||||
|
||||
var restartedInstaller = new LauncherInstaller(
|
||||
_paths,
|
||||
_bakeExecutable,
|
||||
recordStore: recordStore,
|
||||
processRunner: runner);
|
||||
InstallRecordVerification discovered =
|
||||
await restartedInstaller.LoadExistingAsync();
|
||||
Assert.True(discovered.IsVerified);
|
||||
Assert.True(discovered.Record?.RequiresClientCompatibilityConfirmation);
|
||||
Assert.Equal(
|
||||
result.Record.PreparedAssetOverlayPath,
|
||||
discovered.Record?.PreparedAssetOverlayPath);
|
||||
|
||||
restartedInstaller.ConfirmClientCompatibility();
|
||||
discovered = await restartedInstaller.LoadExistingAsync();
|
||||
Assert.True(discovered.IsVerified);
|
||||
Assert.False(discovered.Record?.RequiresClientCompatibilityConfirmation);
|
||||
|
||||
string json = SessionConfigComposer.Serialize(
|
||||
SessionConfigComposer.Compose(
|
||||
new() { Name = "server", Host = "localhost", Port = 9000 },
|
||||
new() { Account = "account", Password = "secret" },
|
||||
new()
|
||||
{
|
||||
Name = "character",
|
||||
LaunchMode = AcDream.Launcher.Core.Profiles.LaunchMode.Gui,
|
||||
},
|
||||
discovered.Record!,
|
||||
_paths,
|
||||
"overlay-session").Document);
|
||||
Assert.Contains("preparedAssetOverlayPath", json, StringComparison.Ordinal);
|
||||
Assert.Contains("preparedAssetBaseRecipeVersion", json, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(baseRecord.PreparedAssetSha256, json, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancellationLeavesBaseRecordAndSidecarUntouched()
|
||||
{
|
||||
var recordStore = new LauncherInstallRecordStore(_paths);
|
||||
LauncherInstallRecord baseRecord = await CreateBaseRecordAsync(recordStore);
|
||||
string recordBefore = await File.ReadAllTextAsync(recordStore.RecordPath);
|
||||
byte[] baseBefore = await File.ReadAllBytesAsync(
|
||||
recordStore.PreparedAssetPath);
|
||||
var entered = new TaskCompletionSource(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var runner = new FakeRunner(async (request, _, cancellationToken) =>
|
||||
{
|
||||
LauncherContentStateStoreTests.WritePakHeader(request.OutputPath, 5);
|
||||
entered.SetResult();
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
||||
return new BakeProcessResult(0, string.Empty);
|
||||
});
|
||||
var installer = new LauncherInstaller(
|
||||
_paths,
|
||||
_bakeExecutable,
|
||||
recordStore: recordStore,
|
||||
processRunner: runner);
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
Task<LauncherInstallResult> operation = installer.ApplyContentUpdateAsync(
|
||||
_dats,
|
||||
2,
|
||||
new ContentMigrationPlan(
|
||||
4,
|
||||
5,
|
||||
ContentWorkKind.Overlay,
|
||||
"bounded fixture update",
|
||||
[0x01001234u]),
|
||||
cancellationToken: cancellation.Token);
|
||||
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
cancellation.Cancel();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => operation);
|
||||
Assert.Equal(baseBefore, await File.ReadAllBytesAsync(
|
||||
recordStore.PreparedAssetPath));
|
||||
Assert.Equal(recordBefore, await File.ReadAllTextAsync(recordStore.RecordPath));
|
||||
var stateStore = new LauncherContentStateStore(_paths);
|
||||
Assert.False(File.Exists(stateStore.StatePath));
|
||||
Assert.False(File.Exists(stateStore.OverlayCandidatePath));
|
||||
Assert.False(File.Exists(stateStore.ClientCompatibilityPendingPath));
|
||||
Assert.Equal(
|
||||
InstallRecordVerificationState.ContentUpdateRequired,
|
||||
(await installer.LoadExistingAsync()).State);
|
||||
Assert.Equal(baseRecord, (await recordStore.LoadAndVerifyAsync()).Record);
|
||||
}
|
||||
|
||||
private async Task<LauncherInstallRecord> CreateBaseRecordAsync(
|
||||
LauncherInstallRecordStore recordStore)
|
||||
{
|
||||
LauncherContentStateStoreTests.WritePakHeader(
|
||||
recordStore.PreparedAssetPath,
|
||||
recipe: 4);
|
||||
LauncherInstallRecord baseRecord =
|
||||
await LauncherContentStateStoreTests.RecordAsync(
|
||||
recordStore.PreparedAssetPath,
|
||||
recipe: 4,
|
||||
datDirectory: Path.GetFullPath(_dats));
|
||||
Directory.CreateDirectory(_paths.DataDirectory);
|
||||
await File.WriteAllTextAsync(
|
||||
recordStore.RecordPath,
|
||||
JsonSerializer.Serialize(baseRecord, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
}));
|
||||
return baseRecord;
|
||||
}
|
||||
|
||||
private sealed class FakeRunner(
|
||||
Func<BakeProcessRequest, Action<string>, CancellationToken, Task<BakeProcessResult>> handler)
|
||||
: IBakeProcessRunner
|
||||
{
|
||||
public Task<BakeProcessResult> RunAsync(
|
||||
BakeProcessRequest request,
|
||||
Action<string> onStandardOutput,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
handler(request, onStandardOutput, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -67,6 +67,21 @@ public sealed class PreparedAssetVerificationCacheTests : IDisposable
|
|||
Assert.Equal(1, _hashCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CacheMissReportsTheExceptionalLongReadBeforeHashing()
|
||||
{
|
||||
LauncherInstallRecordStore store = await CreateInstalledStoreAsync();
|
||||
var statuses = new List<string>();
|
||||
|
||||
Assert.True((await store.LoadAndVerifyAsync(
|
||||
progress: new ImmediateProgress(statuses.Add))).IsVerified);
|
||||
|
||||
Assert.Contains(
|
||||
statuses,
|
||||
status => status.Contains("whole world-data pak", StringComparison.Ordinal)
|
||||
&& status.Contains("30 seconds", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForcedFullVerificationHashesEvenWithAValidCache()
|
||||
{
|
||||
|
|
@ -264,4 +279,9 @@ public sealed class PreparedAssetVerificationCacheTests : IDisposable
|
|||
LauncherInstallRecordStore.CurrentBakeToolVersion));
|
||||
return store;
|
||||
}
|
||||
|
||||
private sealed class ImmediateProgress(Action<string> report) : IProgress<string>
|
||||
{
|
||||
public void Report(string value) => report(value);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,34 @@ public sealed class SessionConfigComposerTests
|
|||
LoginCommands = ["/tell someone, hi"],
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void PendingClientCompatibilityCannotComposePlayOrProbeSession()
|
||||
{
|
||||
LauncherInstallRecord pending = Install with
|
||||
{
|
||||
RequiresClientCompatibilityConfirmation = true,
|
||||
};
|
||||
|
||||
InvalidOperationException play = Assert.Throws<InvalidOperationException>(() =>
|
||||
SessionConfigComposer.Compose(
|
||||
Server(),
|
||||
Account(),
|
||||
Character(LaunchMode.Gui),
|
||||
pending,
|
||||
Paths,
|
||||
"pending-play"));
|
||||
InvalidOperationException probe = Assert.Throws<InvalidOperationException>(() =>
|
||||
SessionConfigComposer.ComposeProbe(
|
||||
Server(),
|
||||
Account(),
|
||||
pending,
|
||||
Paths,
|
||||
"pending-probe"));
|
||||
|
||||
Assert.Contains("matching client", play.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("matching client", probe.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GuiModeIncludesCharacterSelectorAndOmitsPolicy()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -152,6 +152,27 @@ public sealed class LauncherUpdateViewModelTests
|
|||
Assert.Equal(0, updater.StageCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnotherStartupQuestionDefersButDoesNotLoseTheUpdatePrompt()
|
||||
{
|
||||
var updater = new FakeUpdater { ClientUpdateAvailable = true };
|
||||
bool canOpen = false;
|
||||
using var viewModel = new LauncherUpdateViewModel(
|
||||
updater,
|
||||
new ImmediateUiDispatcher(),
|
||||
() => { },
|
||||
canOpen: () => canOpen,
|
||||
canMutate: () => true);
|
||||
|
||||
await viewModel.StartupCheckAsync();
|
||||
Assert.False(viewModel.IsOpen);
|
||||
|
||||
canOpen = true;
|
||||
viewModel.TryOpenPendingUpdate();
|
||||
|
||||
Assert.True(viewModel.IsOpen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdatingIsRefusedWhileASessionIsRunning()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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