Add tests for installing to an existing game install

This commit is contained in:
Pat Hartl 2026-07-21 17:45:17 -05:00
parent a5ab9aba58
commit e23a5c3eca

View file

@ -0,0 +1,197 @@
using System.Reflection;
using System.Text;
using LANCommander.SDK.Enums;
using LANCommander.SDK.Helpers;
using LANCommander.SDK.Services;
using SdkGame = LANCommander.SDK.Models.Game;
using ManifestGame = LANCommander.SDK.Models.Manifest.Game;
namespace LANCommander.SDK.Tests.Install;
/// <summary>
/// Simulates two independent users (separate authentication sessions / launcher databases)
/// installing the same game into an overlapping install root.
///
/// Scenario:
/// - User A installs "Quake" with install root {root}\Games, landing in {root}\Games\Quake.
/// - User B, on a completely separate GameClient session, chooses the same {root}\Games root.
/// - User B's install should:
/// 1. Resolve to (detect) User A's existing installation directory.
/// 2. Validate the files already present in that directory.
///
/// Both users are modelled as distinct <see cref="GameClient"/> instances. GameClient's
/// directory resolution (<see cref="GameClient.GetInstallDirectory"/>) and local file
/// verification are file-system only for a main game, so no server/API is required.
/// </summary>
public class GameInstallationSharedDirectoryTests : IDisposable
{
private readonly string _tempDir;
private readonly string _sharedRoot;
private readonly Guid _quakeId = Guid.NewGuid();
// Files User A "installs" into the shared directory, relative to the install directory.
private static readonly string[] GameFiles =
{
"quake.exe",
"id1/pak0.pak",
"id1/config.cfg",
};
// User A's session/launcher.
private readonly GameClient _userAClient = CreateClient();
// User B's session/launcher — a separate authentication session and launcher DB.
private readonly GameClient _userBClient = CreateClient();
// Resolved install directory produced by User A's install ({sharedRoot}\Quake).
private readonly string _userAInstallDirectory;
public GameInstallationSharedDirectoryTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), $"lc-shared-install-tests-{Guid.NewGuid()}");
// Stands in for "C:\Games" — the install root both users select.
_sharedRoot = Path.Combine(_tempDir, "Games");
Directory.CreateDirectory(_sharedRoot);
// ── User A performs the initial install ──────────────────────────────
_userAInstallDirectory = _userAClient.GetInstallDirectory(MakeQuake(), _sharedRoot)
.GetAwaiter().GetResult();
InstallGameFiles(_userAInstallDirectory);
WriteFileList(_userAInstallDirectory, _quakeId, GameFiles);
ManifestHelper.Write(
new ManifestGame
{
Id = _quakeId,
Title = "Quake",
Type = GameType.MainGame,
Version = "1.0.0",
},
_userAInstallDirectory);
}
public void Dispose()
{
if (Directory.Exists(_tempDir))
Directory.Delete(_tempDir, true);
}
// ── Detection ─────────────────────────────────────────────────────────────
[Fact]
public async Task UserB_ResolvesToUserAExistingInstallDirectory()
{
// User A installed to {sharedRoot}\Quake.
Assert.Equal(Path.Combine(_sharedRoot, "Quake"), _userAInstallDirectory);
// User B, on a separate session, selects the same shared root and should resolve
// to the exact same directory User A already installed into.
var userBInstallDirectory = await _userBClient.GetInstallDirectory(MakeQuake(), _sharedRoot);
Assert.Equal(_userAInstallDirectory, userBInstallDirectory);
Assert.True(Directory.Exists(userBInstallDirectory));
}
[Fact]
public async Task UserB_DetectsExistingInstallation_ViaManifest()
{
var userBInstallDirectory = await _userBClient.GetInstallDirectory(MakeQuake(), _sharedRoot);
// The existing installation is detected through the manifest User A wrote.
Assert.True(ManifestHelper.Exists(userBInstallDirectory, _quakeId));
}
// ── Validation ────────────────────────────────────────────────────────────
[Fact]
public async Task UserB_ValidatesExistingFiles_AllPresent()
{
var userBInstallDirectory = await _userBClient.GetInstallDirectory(MakeQuake(), _sharedRoot);
var verified = await VerifyLocalFiles(_userBClient, userBInstallDirectory, _quakeId);
// Every file User A installed is validated as present and can be skipped on re-download.
Assert.Equal(GameFiles.Length, verified.Count);
foreach (var file in GameFiles)
Assert.Contains(file, verified);
}
[Fact]
public async Task UserB_Validation_DetectsMissingFile()
{
var userBInstallDirectory = await _userBClient.GetInstallDirectory(MakeQuake(), _sharedRoot);
// Simulate a partially-present/corrupted existing install by removing a file.
File.Delete(Path.Combine(userBInstallDirectory, "id1/pak0.pak".Replace('/', Path.DirectorySeparatorChar)));
var verified = await VerifyLocalFiles(_userBClient, userBInstallDirectory, _quakeId);
// The missing file is not validated, so User B's install would re-fetch it.
Assert.DoesNotContain("id1/pak0.pak", verified);
Assert.Contains("quake.exe", verified);
Assert.Equal(GameFiles.Length - 1, verified.Count);
}
// ── Helpers ────────────────────────────────────────────────────────────────
private SdkGame MakeQuake() => new()
{
Id = _quakeId,
Title = "Quake",
Type = GameType.MainGame,
BaseGameId = Guid.Empty,
};
/// <summary>
/// Builds a GameClient whose dependencies are unused for main-game directory
/// resolution and local file verification, so they are safe to leave null.
/// </summary>
private static GameClient CreateClient() =>
new(null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!);
private static void InstallGameFiles(string installDirectory)
{
foreach (var file in GameFiles)
{
var localPath = Path.Combine(installDirectory, file.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(localPath)!);
File.WriteAllText(localPath, $"contents of {file}");
}
}
/// <summary>
/// Writes a FileList.txt matching the production format ("path | CRC32HEX")
/// that GameClient produces after an extraction.
/// </summary>
private static void WriteFileList(string installDirectory, Guid gameId, IEnumerable<string> files)
{
var fileListPath = GameClient.GetMetadataFilePath(installDirectory, gameId, "FileList.txt");
Directory.CreateDirectory(Path.GetDirectoryName(fileListPath)!);
var builder = new StringBuilder();
foreach (var file in files)
builder.AppendLine($"{file} | DEADBEEF");
File.WriteAllText(fileListPath, builder.ToString());
}
/// <summary>
/// Invokes GameClient's private local-file verification (the VerifyFiles install task),
/// which reads FileList.txt and returns entries confirmed present on disk.
/// </summary>
private static async Task<HashSet<string>> VerifyLocalFiles(GameClient client, string installDirectory, Guid gameId)
{
var method = typeof(GameClient).GetMethod(
"VerifyLocalFilesAsync",
BindingFlags.NonPublic | BindingFlags.Instance);
Assert.NotNull(method);
var task = (Task<HashSet<string>>)method!.Invoke(
client,
new object[] { installDirectory, gameId, CancellationToken.None })!;
return await task;
}
}