diff --git a/src/CLI/ConsoleEventLogger.cs b/src/CLI/ConsoleEventLogger.cs index 1f2de1a..b22b3e3 100644 --- a/src/CLI/ConsoleEventLogger.cs +++ b/src/CLI/ConsoleEventLogger.cs @@ -1,4 +1,4 @@ -using Core; +using Core.API; using Core.Utils; namespace AMS2CM.CLI; diff --git a/src/CLI/Program.cs b/src/CLI/Program.cs index d06c0f9..25390e5 100644 --- a/src/CLI/Program.cs +++ b/src/CLI/Program.cs @@ -1,5 +1,5 @@ using AMS2CM.CLI; -using Core; +using Core.API; try { diff --git a/src/Core/BaseEventLogger.cs b/src/Core/API/BaseEventLogger.cs similarity index 85% rename from src/Core/BaseEventLogger.cs rename to src/Core/API/BaseEventLogger.cs index 3560bb6..db31033 100644 --- a/src/Core/BaseEventLogger.cs +++ b/src/Core/API/BaseEventLogger.cs @@ -1,6 +1,6 @@ using Core.Utils; -namespace Core; +namespace Core.API; /// /// This class is here because of the CLI. Move it into the GUI once the CLI @@ -27,14 +27,6 @@ public void PostProcessingStart() => LogMessage("Post-processing:"); public void ExtractingBootfiles(string? packageName) => LogMessage($"Extracting bootfiles from {packageName ?? "game"}"); - public void ExtractingBootfilesErrorMultiple(IReadOnlyCollection bootfilesPackageNames) - { - LogMessage("Multiple bootfiles found:"); - foreach (var packageName in bootfilesPackageNames) - { - LogMessage($"- {packageName}"); - } - } public void PostProcessingVehicles() => LogMessage("- Appending crd file entries"); public void PostProcessingTracks() => diff --git a/src/Core/Config.cs b/src/Core/API/Config.cs similarity index 93% rename from src/Core/Config.cs rename to src/Core/API/Config.cs index ed800d8..2645db0 100644 --- a/src/Core/Config.cs +++ b/src/Core/API/Config.cs @@ -2,7 +2,7 @@ using Core.SoftwareUpdates; using Microsoft.Extensions.Configuration; -namespace Core; +namespace Core.API; public class Config { @@ -34,7 +34,7 @@ public class GameConfig : Game.IConfig public string ProcessName { get; set; } = "Undefined"; } -public class ModInstallConfig : ModInstaller.IConfig, InstallationFactory.IConfig +public class ModInstallConfig : InstallationFactory.IConfig { public IEnumerable DirsAtRoot { get; set; } = Array.Empty(); public IEnumerable ExcludedFromInstall { get; set; } = Array.Empty(); diff --git a/src/Core/IModManager.cs b/src/Core/API/IModManager.cs similarity index 95% rename from src/Core/IModManager.cs rename to src/Core/API/IModManager.cs index aa96e47..bb59be1 100644 --- a/src/Core/IModManager.cs +++ b/src/Core/API/IModManager.cs @@ -1,4 +1,4 @@ -namespace Core; +namespace Core.API; public interface IModManager { diff --git a/src/Core/Init.cs b/src/Core/API/Init.cs similarity index 85% rename from src/Core/Init.cs rename to src/Core/API/Init.cs index a70f9d6..76fd535 100644 --- a/src/Core/Init.cs +++ b/src/Core/API/Init.cs @@ -1,9 +1,10 @@ -using Core.Games; +using Core.Backup; +using Core.Games; using Core.IO; using Core.Mods; using Core.State; -namespace Core; +namespace Core.API; public static class Init { @@ -18,7 +19,8 @@ public static IModManager CreateModManager(Config config) var modRepository = new ModRepository(modsDir); var installationFactory = new InstallationFactory(game, tempDir, config.ModInstall); var safeFileDelete = new WindowsRecyclingBin(); - var modInstaller = new ModInstaller(installationFactory, tempDir, config.ModInstall); + var backupStrategy = new SuffixBackupStrategy(); + var modInstaller = new ModInstaller(installationFactory, backupStrategy); return new ModManager(game, modRepository, modInstaller, statePersistence, safeFileDelete, tempDir); } } diff --git a/src/Core/ModManager.cs b/src/Core/API/ModManager.cs similarity index 71% rename from src/Core/ModManager.cs rename to src/Core/API/ModManager.cs index cf8fbde..a6a61c9 100644 --- a/src/Core/ModManager.cs +++ b/src/Core/API/ModManager.cs @@ -3,9 +3,9 @@ using Core.Mods; using Core.State; using Core.Utils; -using static Core.IModManager; +using static Core.API.IModManager; -namespace Core; +namespace Core.API; internal class ModManager : IModManager { @@ -51,7 +51,7 @@ public List FetchState() var availableModPackages = enabledModPackages.Merge(disabledModPackages); var bootfilesFailed = installedMods.Where(kv => BootfilesManager.IsBootFiles(kv.Key) && (kv.Value?.Partial ?? false)).Any(); - var isModInstalled = installedMods.SelectValues(modInstallationState => + var isModInstalled = installedMods.SelectValues(modInstallationState => modInstallationState is null ? false : ((modInstallationState.Partial || bootfilesFailed) ? null : true) ); var modsOutOfDate = installedMods.SelectValues((packageName, modInstallationState) => @@ -79,7 +79,7 @@ public List FetchState() }).ToList(); } - private static bool IsOutOfDate(ModPackage? modPackage, InternalModInstallationState? modInstallationState) + private static bool IsOutOfDate(ModPackage? modPackage, ModInstallationState? modInstallationState) { if (modPackage is null || modInstallationState is null) { @@ -138,57 +138,14 @@ public void InstallEnabledMods(IEventHandler eventHandler, CancellationToken can // Clean what left by a previous failed installation tempDir.Cleanup(); - if (RestoreOriginalState(eventHandler, cancellationToken)) - { - InstallAllModFiles(eventHandler, cancellationToken); - } + UpdateMods(modRepository.ListEnabledMods(), eventHandler, cancellationToken); tempDir.Cleanup(); } public void UninstallAllMods(IEventHandler eventHandler, CancellationToken cancellationToken = default) { CheckGameNotRunning(); - RestoreOriginalState(eventHandler, cancellationToken); - } - - private bool RestoreOriginalState(IEventHandler eventHandler, CancellationToken cancellationToken) - { - var previousInstallation = statePersistence.ReadState().Install; - var modsLeft = new Dictionary(previousInstallation.Mods); - try - { - modInstaller.UninstallPackages( - previousInstallation, - game.InstallationDirectory, - modInstallation => - { - if (modInstallation.Installed == IInstallation.State.NotInstalled) - { - modsLeft.Remove(modInstallation.PackageName); - } - else - { - modsLeft[modInstallation.PackageName] = new InternalModInstallationState( - FsHash: modInstallation.PackageFsHash, - Partial: modInstallation.Installed == IInstallation.State.PartiallyInstalled, - Files: modInstallation.InstalledFiles - ); - } - }, - eventHandler, - cancellationToken); - } - finally - { - statePersistence.WriteState(new InternalState( - Install: new( - Time: modsLeft.Any() ? previousInstallation.Time : null, - Mods: modsLeft - ) - )); - } - // Success if everything was uninstalled - return !modsLeft.Any(); + UpdateMods(Array.Empty(), eventHandler, cancellationToken); } private void CheckGameNotRunning() @@ -199,28 +156,49 @@ private void CheckGameNotRunning() } } - private void InstallAllModFiles(IEventHandler eventHandler, CancellationToken cancellationToken) + private void UpdateMods(IReadOnlyCollection packages, IEventHandler eventHandler, CancellationToken cancellationToken) { - var installedFilesByMod = new Dictionary(); + var previousState = statePersistence.ReadState().Install.Mods; + var currentState = new Dictionary(previousState); try { - modInstaller.InstallPackages( - modRepository.ListEnabledMods(), + modInstaller.Apply( + previousState, + packages, game.InstallationDirectory, - modInstallation => installedFilesByMod.Add(modInstallation.PackageName, new( + modInstallation => + { + switch (modInstallation.Installed) + { + case IInstallation.State.Installed: + case IInstallation.State.PartiallyInstalled: + currentState.Upsert(modInstallation.PackageName, + existing => existing with + { + Partial = modInstallation.Installed == IInstallation.State.PartiallyInstalled, + Files = modInstallation.InstalledFiles + }, + () => new ModInstallationState( + Time: DateTime.Now, FsHash: modInstallation.PackageFsHash, Partial: modInstallation.Installed == IInstallation.State.PartiallyInstalled, Files: modInstallation.InstalledFiles - )), + )); + break; + case IInstallation.State.NotInstalled: + currentState.Remove(modInstallation.PackageName); + break; + } + }, eventHandler, cancellationToken); } finally { - statePersistence.WriteState(new InternalState( + statePersistence.WriteState(new SavedState( Install: new( - Time: DateTime.UtcNow, - Mods: installedFilesByMod + Time: currentState.Values.Max(_ => _.Time), + Mods: currentState ) )); } diff --git a/src/Core/ModState.cs b/src/Core/API/ModState.cs similarity index 87% rename from src/Core/ModState.cs rename to src/Core/API/ModState.cs index b475ac1..f1f75fb 100644 --- a/src/Core/ModState.cs +++ b/src/Core/API/ModState.cs @@ -1,4 +1,4 @@ -namespace Core; +namespace Core.API; public record ModState( string PackageName, diff --git a/src/Core/Backup/IBackupStrategy.cs b/src/Core/Backup/IBackupStrategy.cs index 314ef2e..6442496 100644 --- a/src/Core/Backup/IBackupStrategy.cs +++ b/src/Core/Backup/IBackupStrategy.cs @@ -3,7 +3,6 @@ public interface IBackupStrategy { public void PerformBackup(string fullPath); - public void RestoreBackup(string fullPath); + public bool RestoreBackup(string fullPath); public void DeleteBackup(string fullPath); - public bool IsBackupFile(string fullPath); } diff --git a/src/Core/Backup/IInstallationBackupStrategy.cs b/src/Core/Backup/IInstallationBackupStrategy.cs new file mode 100644 index 0000000..499c666 --- /dev/null +++ b/src/Core/Backup/IInstallationBackupStrategy.cs @@ -0,0 +1,11 @@ +using Core.Mods; + +namespace Core.Backup; + +public interface IInstallationBackupStrategy +{ + public void PerformBackup(RootedPath path); + public bool RestoreBackup(RootedPath path); + public void DeleteBackup(RootedPath path); + public void AfterInstall(RootedPath path); +} diff --git a/src/Core/Backup/IModBackupStrategyProvider.cs b/src/Core/Backup/IModBackupStrategyProvider.cs new file mode 100644 index 0000000..d9daf22 --- /dev/null +++ b/src/Core/Backup/IModBackupStrategyProvider.cs @@ -0,0 +1,8 @@ +using Core.State; + +namespace Core.Backup; + +public interface IModBackupStrategyProvider +{ + IInstallationBackupStrategy BackupStrategy(ModInstallationState? state); +} diff --git a/src/Core/Backup/MoveFileBackupStrategy.cs b/src/Core/Backup/MoveFileBackupStrategy.cs index 5993c1a..7974e7e 100644 --- a/src/Core/Backup/MoveFileBackupStrategy.cs +++ b/src/Core/Backup/MoveFileBackupStrategy.cs @@ -2,26 +2,40 @@ namespace Core.Backup; -public class MoveFileBackupStrategy +public class MoveFileBackupStrategy : IBackupStrategy { + public interface IBackupFileNaming + { + public string ToBackup(string fullPath); + public bool IsBackup(string fullPath); + } private readonly IFileSystem fs; - private readonly Func generateBackupFilePath; + private readonly IBackupFileNaming backupFileNaming; + + public MoveFileBackupStrategy(IBackupFileNaming backupFileNaming) : + this(new FileSystem(), backupFileNaming) + { + } - public MoveFileBackupStrategy(IFileSystem fs, Func generateBackupFilePath) + public MoveFileBackupStrategy(IFileSystem fs, IBackupFileNaming backupFileNaming) { this.fs = fs; - this.generateBackupFilePath = generateBackupFilePath; + this.backupFileNaming = backupFileNaming; } - public void PerformBackup(string fullPath) + public virtual void PerformBackup(string fullPath) { + if (backupFileNaming.IsBackup(fullPath)) + { + throw new InvalidOperationException("Installing a backup file is forbidden"); + } if (!fs.File.Exists(fullPath)) { return; } - var backupFilePath = generateBackupFilePath(fullPath); + var backupFilePath = backupFileNaming.ToBackup(fullPath); if (fs.File.Exists(backupFilePath)) { fs.File.Delete(fullPath); @@ -32,18 +46,24 @@ public void PerformBackup(string fullPath) } } - public void RestoreBackup(string fullPath) + public bool RestoreBackup(string fullPath) { - var backupFilePath = generateBackupFilePath(fullPath); + if (fs.File.Exists(fullPath)) + { + fs.File.Delete(fullPath); + } + var backupFilePath = backupFileNaming.ToBackup(fullPath); if (fs.File.Exists(backupFilePath)) { fs.File.Move(backupFilePath, fullPath); } + + return true; } public void DeleteBackup(string fullPath) { - var backupFilePath = generateBackupFilePath(fullPath); + var backupFilePath = backupFileNaming.ToBackup(fullPath); if (fs.File.Exists(backupFilePath)) { fs.File.Delete(backupFilePath); diff --git a/src/Core/Backup/SkipUpdatedBackupStrategy.cs b/src/Core/Backup/SkipUpdatedBackupStrategy.cs new file mode 100644 index 0000000..e6bfe02 --- /dev/null +++ b/src/Core/Backup/SkipUpdatedBackupStrategy.cs @@ -0,0 +1,76 @@ +using System.IO.Abstractions; +using Core.Mods; +using Core.State; + +namespace Core.Backup; + +/// +/// It avoids restoring backups when game files have been updated by Steam. +/// +internal class SkipUpdatedBackupStrategy : IInstallationBackupStrategy +{ + internal class Provider : IModBackupStrategyProvider + { + private readonly IBackupStrategy defaultStrategy; + + public Provider(IBackupStrategy defaultStrategy) + { + this.defaultStrategy = defaultStrategy; + } + + public IInstallationBackupStrategy BackupStrategy(ModInstallationState? state) => + new SkipUpdatedBackupStrategy(defaultStrategy, state?.Time); + } + + private readonly IFileSystem fs; + private readonly IBackupStrategy inner; + private readonly DateTime? backupTimeUtc; + + private SkipUpdatedBackupStrategy( + IBackupStrategy backupStrategy, + DateTime? backupTimeUtc) : + this(new FileSystem(), backupStrategy, backupTimeUtc) + { + } + + internal SkipUpdatedBackupStrategy( + IFileSystem fs, + IBackupStrategy backupStrategy, + DateTime? backupTimeUtc) + { + this.fs = fs; + inner = backupStrategy; + this.backupTimeUtc = backupTimeUtc; + } + + public void DeleteBackup(RootedPath path) => + inner.DeleteBackup(path.Full); + + public void PerformBackup(RootedPath path) => + inner.PerformBackup(path.Full); + + public bool RestoreBackup(RootedPath path) + { + if (FileWasOverwritten(path)) + { + inner.DeleteBackup(path.Full); + return false; + } + + return inner.RestoreBackup(path.Full); + } + + private bool FileWasOverwritten(RootedPath path) => + backupTimeUtc is not null && + fs.File.Exists(path.Full) && + fs.File.GetCreationTimeUtc(path.Full) > backupTimeUtc; + + public void AfterInstall(RootedPath path) + { + var now = DateTime.UtcNow; + if (fs.File.Exists(path.Full) && fs.File.GetCreationTimeUtc(path.Full) > now) + { + fs.File.SetCreationTimeUtc(path.Full, now); + } + } +} diff --git a/src/Core/Backup/SuffixBackupStrategy.cs b/src/Core/Backup/SuffixBackupStrategy.cs index 0e7ae50..edb6f42 100644 --- a/src/Core/Backup/SuffixBackupStrategy.cs +++ b/src/Core/Backup/SuffixBackupStrategy.cs @@ -2,15 +2,17 @@ namespace Core.Backup; -public class SuffixBackupStrategy : MoveFileBackupStrategy, IBackupStrategy +public class SuffixBackupStrategy : MoveFileBackupStrategy { - public const string BackupSuffix = ".orig"; - - public SuffixBackupStrategy() : - base(new FileSystem(), _ => $"{_}{BackupSuffix}") + private class BackupFileNaming : IBackupFileNaming { + private const string BackupSuffix = ".orig"; + + public string ToBackup(string fullPath) => $"{fullPath}{BackupSuffix}"; + public bool IsBackup(string fullPath) => fullPath.EndsWith(BackupSuffix); } - public bool IsBackupFile(string fullPath) => - fullPath.EndsWith(BackupSuffix); + public SuffixBackupStrategy() : base(new BackupFileNaming()) + { + } } diff --git a/src/Core/IModInstaller.cs b/src/Core/IModInstaller.cs index c0bf2f6..0ea4444 100644 --- a/src/Core/IModInstaller.cs +++ b/src/Core/IModInstaller.cs @@ -2,8 +2,14 @@ using Core.State; namespace Core; + public interface IModInstaller { - void InstallPackages(IReadOnlyCollection packages, string installDir, Action afterInstall, ModInstaller.IEventHandler eventHandler, CancellationToken cancellationToken); - void UninstallPackages(InternalInstallationState currentState, string installDir, Action afterUninstall, ModInstaller.IEventHandler eventHandler, CancellationToken cancellationToken); + void Apply( + IReadOnlyDictionary currentState, + IReadOnlyCollection packages, + string installDir, + Action afterInstall, + ModInstaller.IEventHandler eventHandler, + CancellationToken cancellationToken); } diff --git a/src/Core/ModInstaller.cs b/src/Core/ModInstaller.cs index 5f8fbf7..f3a3591 100644 --- a/src/Core/ModInstaller.cs +++ b/src/Core/ModInstaller.cs @@ -9,11 +9,6 @@ namespace Core; public class ModInstaller : IModInstaller { - public interface IConfig - { - IEnumerable ExcludedFromInstall { get; } - } - public interface IEventHandler : IProgress { void InstallNoMods(); @@ -24,7 +19,6 @@ public interface IEventHandler : IProgress void PostProcessingNotRequired(); void PostProcessingStart(); void ExtractingBootfiles(string? packageName); - void ExtractingBootfilesErrorMultiple(IReadOnlyCollection bootfilesPackageNames); void PostProcessingVehicles(); void PostProcessingTracks(); void PostProcessingDrivelines(); @@ -43,61 +37,59 @@ public interface IProgress } private readonly IInstallationFactory installationFactory; - private readonly ITempDir tempDir; - private readonly Matcher filesToInstallMatcher; - private readonly IBackupStrategy backupStrategy; + private readonly IModBackupStrategyProvider modBackupStrategyProvider; - public ModInstaller(IInstallationFactory installationFactory, ITempDir tempDir, IConfig config) + public ModInstaller( + IInstallationFactory installationFactory, + IBackupStrategy backupStrategy) { this.installationFactory = installationFactory; - this.tempDir = tempDir; - filesToInstallMatcher = Matchers.ExcludingPatterns(config.ExcludedFromInstall); - backupStrategy = new SuffixBackupStrategy(); + modBackupStrategyProvider = new SkipUpdatedBackupStrategy.Provider(backupStrategy); + } + + public void Apply( + IReadOnlyDictionary currentState, + IReadOnlyCollection toInstall, + string installDir, + Action afterCallback, + IEventHandler eventHandler, + CancellationToken cancellationToken) + { + UninstallPackages(currentState, installDir, afterCallback, eventHandler, cancellationToken); + InstallPackages(toInstall, installDir, afterCallback, eventHandler, cancellationToken); } - public void UninstallPackages( - InternalInstallationState currentState, + private void UninstallPackages( + IReadOnlyDictionary currentState, string installDir, Action afterUninstall, IEventHandler eventHandler, CancellationToken cancellationToken) { - if (currentState.Mods.Any()) + if (currentState.Any()) { eventHandler.UninstallStart(); - var skipCreatedAfter = SkipCreatedAfter(eventHandler, currentState.Time); - var uninstallCallbacks = new ProcessingCallbacks - { - Accept = gamePath => - { - return skipCreatedAfter(gamePath); - }, - After = gamePath => - { - backupStrategy.RestoreBackup(gamePath.Full); - }, - NotAccepted = gamePath => - { - backupStrategy.DeleteBackup(gamePath.Full); - } - }; - foreach (var (packageName, modInstallationState) in currentState.Mods) + foreach (var (packageName, modInstallationState) in currentState) { if (cancellationToken.IsCancellationRequested) { break; } eventHandler.UninstallCurrent(packageName); + var backupStrategy = modBackupStrategyProvider.BackupStrategy(modInstallationState); var filesLeft = modInstallationState.Files.ToHashSet(StringComparer.OrdinalIgnoreCase); try { - UninstallFiles( - installDir, - filesLeft, - uninstallCallbacks - .AndAfter(_ => filesLeft.Remove(_.Relative)) - .AndNotAccepted(_ => filesLeft.Remove(_.Relative)) - ); + foreach (var relativePath in modInstallationState.Files) + { + var gamePath = new RootedPath(installDir, relativePath); + if (!backupStrategy.RestoreBackup(gamePath)) + { + eventHandler.UninstallSkipModified(gamePath.Relative); + } + filesLeft.Remove(gamePath.Relative); + } + DeleteEmptyDirectories(installDir, modInstallationState.Files); } finally { @@ -131,33 +123,7 @@ public void UninstallPackages( } } - private static void UninstallFiles(string dstPath, IEnumerable files, ProcessingCallbacks callbacks) - { - var fileList = files.ToList(); // It must be enumerated twice - foreach (var relativePath in fileList) - { - var gamePath = new RootedPath(dstPath, relativePath); - - if (!callbacks.Accept(gamePath)) - { - callbacks.NotAccepted(gamePath); - continue; - } - - callbacks.Before(gamePath); - - // Delete will fail if the parent directory does not exist - if (File.Exists(gamePath.Full)) - { - File.Delete(gamePath.Full); - } - - callbacks.After(gamePath); - } - DeleteEmptyDirectories(dstPath, fileList); - } - - private static void DeleteEmptyDirectories(string dstRootPath, IEnumerable filePaths) + private static void DeleteEmptyDirectories(string dstRootPath, IReadOnlyCollection filePaths) { var dirs = filePaths .Select(file => Path.Combine(dstRootPath, file)) @@ -174,7 +140,7 @@ private static void DeleteEmptyDirectories(string dstRootPath, IEnumerable AncestorsUpTo(string root, string path) + private static List AncestorsUpTo(string root, string path) { var ancestors = new List(); for (var dir = Directory.GetParent(path); dir is not null && dir.FullName != root; dir = dir.Parent) @@ -184,49 +150,38 @@ private static IEnumerable AncestorsUpTo(string root, string path) return ancestors; } - public void InstallPackages( - IReadOnlyCollection packages, + private void InstallPackages( + IReadOnlyCollection toInstall, string installDir, Action afterInstall, IEventHandler eventHandler, CancellationToken cancellationToken) { - var modPackages = packages.Where(_ => !BootfilesManager.IsBootFiles(_.PackageName)).Reverse(); + var modPackages = toInstall.Where(p => !BootfilesManager.IsBootFiles(p.PackageName)).Reverse().ToImmutableArray(); var modConfigs = new List(); var installedFiles = new HashSet(StringComparer.OrdinalIgnoreCase); var installCallbacks = new ProcessingCallbacks { - Accept = gamePath => - Whitelisted(gamePath) && - !backupStrategy.IsBackupFile(gamePath.Relative) && - !installedFiles.Contains(gamePath.Relative), - Before = gamePath => - { - backupStrategy.PerformBackup(gamePath.Full); - installedFiles.Add(gamePath.Relative); - }, - After = EnsureNotCreatedAfter(DateTime.UtcNow) + Accept = gamePath => !installedFiles.Contains(gamePath.Relative), + Before = gamePath => installedFiles.Add(gamePath.Relative), }; // Increase by one in case bootfiles are needed and another one to show that something is happening - var progress = new PercentOfTotal(modPackages.Count() + 2); + var progress = new PercentOfTotal(modPackages.Length + 2); if (modPackages.Any()) { eventHandler.InstallStart(); eventHandler.ProgressUpdate(progress.IncrementDone()); - foreach (var modPackage in modPackages) + foreach (var modPackage in modPackages.TakeWhile(_ => !cancellationToken.IsCancellationRequested)) { - if (cancellationToken.IsCancellationRequested) - { - break; - } eventHandler.InstallCurrent(modPackage.PackageName); - using var mod = installationFactory.ModInstaller(modPackage); + var backupStrategy = modBackupStrategyProvider.BackupStrategy(null); + var mod = installationFactory.ModInstaller(modPackage); try { - var modConfig = mod.Install(installDir, installCallbacks); + var modConfig = mod.Install(installDir, backupStrategy, installCallbacks); modConfigs.Add(modConfig); } finally @@ -236,13 +191,14 @@ public void InstallPackages( eventHandler.ProgressUpdate(progress.IncrementDone()); } - if (modConfigs.Where(_ => _.NotEmpty()).Any()) + if (modConfigs.Any(c => c.NotEmpty())) { eventHandler.PostProcessingStart(); - using var bootfilesMod = CreateBootfilesMod(packages, eventHandler); + var bootfilesMod = CreateBootfilesMod(toInstall, eventHandler); try { - bootfilesMod.Install(installDir, installCallbacks); + var backupStrategy = modBackupStrategyProvider.BackupStrategy(null); + bootfilesMod.Install(installDir, backupStrategy, installCallbacks); bootfilesMod.PostProcessing(installDir, modConfigs, eventHandler); } finally @@ -255,6 +211,7 @@ public void InstallPackages( { eventHandler.PostProcessingNotRequired(); } + eventHandler.InstallEnd(); eventHandler.ProgressUpdate(progress.IncrementDone()); } else @@ -264,53 +221,16 @@ public void InstallPackages( eventHandler.ProgressUpdate(progress.DoneAll()); } - private Predicate Whitelisted => - gamePath => filesToInstallMatcher.Match(gamePath.Relative).HasMatches; - - private static Predicate SkipCreatedAfter(IEventHandler eventHandler, DateTime? dateTimeUtc) - { - if (dateTimeUtc is null) - { - return _ => true; - } - - return gamePath => - { - var proceed = !File.Exists(gamePath.Full) || File.GetCreationTimeUtc(gamePath.Full) <= dateTimeUtc; - if (!proceed) - { - eventHandler.UninstallSkipModified(gamePath.Full); - } - return proceed; - }; - } - - private static Action EnsureNotCreatedAfter(DateTime dateTimeUtc) => gamePath => - { - if (File.Exists(gamePath.Full) && File.GetCreationTimeUtc(gamePath.Full) > dateTimeUtc) - { - File.SetCreationTimeUtc(gamePath.Full, dateTimeUtc); - } - }; - private BootfilesMod CreateBootfilesMod(IReadOnlyCollection packages, IEventHandler eventHandler) { - var bootfilesPackages = packages - .Where(_ => BootfilesManager.IsBootFiles(_.PackageName)); - switch (bootfilesPackages.Count()) + var bootfilesPackage = packages.FirstOrDefault(p => BootfilesManager.IsBootFiles(p.PackageName)); + if (bootfilesPackage is null) { - case 0: - eventHandler.ExtractingBootfiles(null); - return new BootfilesMod(installationFactory.GeneratedBootfilesInstaller()); - case 1: - var modPackage = bootfilesPackages.First(); - eventHandler.ExtractingBootfiles(modPackage.PackageName); - return new BootfilesMod(installationFactory.ModInstaller(modPackage)); - default: - var bootfilesPackageNames = bootfilesPackages.Select(_ => _.PackageName).ToImmutableList(); - eventHandler.ExtractingBootfilesErrorMultiple(bootfilesPackageNames); - throw new Exception("Too many bootfiles found"); + eventHandler.ExtractingBootfiles(null); + return new BootfilesMod(installationFactory.GeneratedBootfilesInstaller()); } + eventHandler.ExtractingBootfiles(bootfilesPackage.PackageName); + return new BootfilesMod(installationFactory.ModInstaller(bootfilesPackage)); } private class BootfilesMod : IInstaller @@ -335,26 +255,21 @@ public BootfilesMod(IInstaller inner) public int? PackageFsHash => inner.PackageFsHash; - public ConfigEntries Install(string dstPath, ProcessingCallbacks callbacks) + public ConfigEntries Install(string dstPath, IInstallationBackupStrategy backupStrategy, ProcessingCallbacks callbacks) { - inner.Install(dstPath, callbacks); + inner.Install(dstPath, backupStrategy, callbacks); return ConfigEntries.Empty; } public void PostProcessing(string dstPath, IReadOnlyList modConfigs, IEventHandler eventHandler) { eventHandler.PostProcessingVehicles(); - PostProcessor.AppendCrdFileEntries(dstPath, modConfigs.SelectMany(_ => _.CrdFileEntries)); + PostProcessor.AppendCrdFileEntries(dstPath, modConfigs.SelectMany(c => c.CrdFileEntries)); eventHandler.PostProcessingTracks(); - PostProcessor.AppendTrdFileEntries(dstPath, modConfigs.SelectMany(_ => _.TrdFileEntries)); + PostProcessor.AppendTrdFileEntries(dstPath, modConfigs.SelectMany(c => c.TrdFileEntries)); eventHandler.PostProcessingDrivelines(); - PostProcessor.AppendDrivelineRecords(dstPath, modConfigs.SelectMany(_ => _.DrivelineRecords)); + PostProcessor.AppendDrivelineRecords(dstPath, modConfigs.SelectMany(c => c.DrivelineRecords)); postProcessingDone = true; } - - public void Dispose() - { - inner.Dispose(); - } } } diff --git a/src/Core/Mods/BaseInstaller.cs b/src/Core/Mods/BaseInstaller.cs index f1c711a..0e6b209 100644 --- a/src/Core/Mods/BaseInstaller.cs +++ b/src/Core/Mods/BaseInstaller.cs @@ -1,10 +1,11 @@ -using Core.Utils; +using Core.Backup; +using Core.Utils; using Microsoft.Extensions.FileSystemGlobbing; namespace Core.Mods; /// -/// +/// /// /// Type used by the implementation during the install loop. internal abstract class BaseInstaller : IInstaller @@ -18,6 +19,7 @@ internal abstract class BaseInstaller : IInstaller public IReadOnlyCollection InstalledFiles => installedFiles; private readonly IRootFinder rootFinder; + private readonly Matcher filesToInstallMatcher; private readonly Matcher filesToConfigureMatcher; private readonly List installedFiles = new(); @@ -27,10 +29,11 @@ internal BaseInstaller(string packageName, int? packageFsHash, ITempDir tempDir, PackageFsHash = packageFsHash; stagingDir = new DirectoryInfo(Path.Combine(tempDir.BasePath, packageName)); rootFinder = new ContainedDirsRootFinder(config.DirsAtRoot); + filesToInstallMatcher = Matchers.ExcludingPatterns(config.ExcludedFromInstall); filesToConfigureMatcher = Matchers.ExcludingPatterns(config.ExcludedFromConfig); } - public ConfigEntries Install(string dstPath, ProcessingCallbacks callbacks) + public ConfigEntries Install(string dstPath, IInstallationBackupStrategy backupStrategy, ProcessingCallbacks callbacks) { if (Installed != IInstallation.State.NotInstalled) { @@ -59,15 +62,18 @@ public ConfigEntries Install(string dstPath, ProcessingCallbacks cal var (relativePath, removeFile) = NeedsRemoving(relativePathInMod); var gamePath = new RootedPath(dstPath, relativePath); - if (callbacks.Accept(gamePath)) + + if (Whitelisted(gamePath) && callbacks.Accept(gamePath)) { callbacks.Before(gamePath); + backupStrategy.PerformBackup(gamePath); if (!removeFile) { Directory.GetParent(gamePath.Full)?.Create(); InstallFile(gamePath, context); } installedFiles.Add(gamePath.Relative); + backupStrategy.AfterInstall(gamePath); callbacks.After(gamePath); } else @@ -96,7 +102,10 @@ public ConfigEntries Install(string dstPath, ProcessingCallbacks cal protected abstract void InstallFile(RootedPath destinationPath, TPassthrough context); - protected static (string, bool) NeedsRemoving(string filePath) + private bool Whitelisted(RootedPath path) => + filesToInstallMatcher.Match(path.Relative).HasMatches; + + private static (string, bool) NeedsRemoving(string filePath) { return filePath.EndsWith(BaseInstaller.RemoveFileSuffix) ? (filePath.RemoveSuffix(BaseInstaller.RemoveFileSuffix).Trim(), true) : @@ -106,8 +115,7 @@ protected static (string, bool) NeedsRemoving(string filePath) private ConfigEntries GenerateConfig() { var gameSupportedMod = FileEntriesToConfigure() - .Where(p => p.StartsWith(BaseInstaller.GameSupportedModDirectory)) - .Any(); + .Any(p => p.StartsWith(BaseInstaller.GameSupportedModDirectory)); return gameSupportedMod ? ConfigEntries.Empty : new(CrdFileEntries(), TrdFileEntries(), FindDrivelineRecords()); @@ -172,8 +180,6 @@ private List FindDrivelineRecords() return recordBlocks; } - - public abstract void Dispose(); } public static class BaseInstaller @@ -184,6 +190,12 @@ IEnumerable DirsAtRoot { get; } + + IEnumerable ExcludedFromInstall + { + get; + } + IEnumerable ExcludedFromConfig { get; diff --git a/src/Core/Mods/GeneratedBootfilesInstaller.cs b/src/Core/Mods/GeneratedBootfilesInstaller.cs index f8c36a3..74d8255 100644 --- a/src/Core/Mods/GeneratedBootfilesInstaller.cs +++ b/src/Core/Mods/GeneratedBootfilesInstaller.cs @@ -30,10 +30,6 @@ protected override void InstallFile(RootedPath destinationPath, FileInfo fileInf File.Move(fileInfo.FullName, destinationPath.Full); } - public override void Dispose() - { - } - #region Bootfiles Generation private void GenerateBootfiles() diff --git a/src/Core/Mods/IInstaller.cs b/src/Core/Mods/IInstaller.cs index b66a744..499e4cd 100644 --- a/src/Core/Mods/IInstaller.cs +++ b/src/Core/Mods/IInstaller.cs @@ -1,6 +1,8 @@ -namespace Core.Mods; +using Core.Backup; -public interface IInstaller : IInstallation, IDisposable +namespace Core.Mods; + +public interface IInstaller : IInstallation { - ConfigEntries Install(string dstPath, ProcessingCallbacks callbacks); + ConfigEntries Install(string dstPath, IInstallationBackupStrategy backupStrategy, ProcessingCallbacks callbacks); } diff --git a/src/Core/Mods/ModArchiveInstaller.cs b/src/Core/Mods/ModArchiveInstaller.cs index bcd9997..112e265 100644 --- a/src/Core/Mods/ModArchiveInstaller.cs +++ b/src/Core/Mods/ModArchiveInstaller.cs @@ -15,10 +15,6 @@ public ModArchiveInstaller(string packageName, int? packageFsHash, ITempDir temp this.archivePath = archivePath; } - public override void Dispose() - { - } - // LibArchive.Net is a mere wrapper around libarchive. It's better to avoid using // LINQ expressions as they can lead to or // being called out of order. diff --git a/src/Core/Mods/ModDirectoryInstaller.cs b/src/Core/Mods/ModDirectoryInstaller.cs index f427d5c..9fb824a 100644 --- a/src/Core/Mods/ModDirectoryInstaller.cs +++ b/src/Core/Mods/ModDirectoryInstaller.cs @@ -17,8 +17,4 @@ protected override void InstallFile(RootedPath destinationPath, FileInfo fileInf { File.Copy(fileInfo.FullName, destinationPath.Full); } - - public override void Dispose() - { - } } diff --git a/src/Core/Mods/ProcessingCallbacks.cs b/src/Core/Mods/ProcessingCallbacks.cs index 45559a2..a739a28 100644 --- a/src/Core/Mods/ProcessingCallbacks.cs +++ b/src/Core/Mods/ProcessingCallbacks.cs @@ -81,6 +81,15 @@ public ProcessingCallbacks AndNotAccepted(Action additional) => notAccepted = Combine(notAccepted, additional) }; + public ProcessingCallbacks AndFinally(Action additional) => + new() + { + accept = accept, + before = before, + after = Combine(after, additional), + notAccepted = Combine(notAccepted, additional) + }; + private static Predicate? Combine(Predicate? p1, Predicate p2) => p1 is null ? p2 : key => p1(key) && p2(key); diff --git a/src/Core/SoftwareUpdates/GitHubUpdateChecker.cs b/src/Core/SoftwareUpdates/GitHubUpdateChecker.cs index 99edb01..306bdaf 100644 --- a/src/Core/SoftwareUpdates/GitHubUpdateChecker.cs +++ b/src/Core/SoftwareUpdates/GitHubUpdateChecker.cs @@ -25,9 +25,10 @@ public async Task CheckUpdateAvailable() var client = new GitHubClient(new ProductHeaderValue(config.GitHubClientApp)); var release = await client.Repository.Release.GetLatest(config.GitHubOwner, config.GitHubRepo); + // Note: Version.Parse breaks the contract and can return null! var latestVersion = Version.Parse(release.Name); var currentVersion = Version.Parse(GitVersionInformation.MajorMinorPatch); - return latestVersion is not null && currentVersion < latestVersion; + return currentVersion < latestVersion; } catch { diff --git a/src/Core/State/IStatePersistence.cs b/src/Core/State/IStatePersistence.cs index ffea886..b87c7e2 100644 --- a/src/Core/State/IStatePersistence.cs +++ b/src/Core/State/IStatePersistence.cs @@ -2,6 +2,6 @@ public interface IStatePersistence { - public InternalState ReadState(); - public void WriteState(InternalState state); + public SavedState ReadState(); + public void WriteState(SavedState state); } diff --git a/src/Core/State/InternalState.cs b/src/Core/State/InternalState.cs deleted file mode 100644 index b11e5ea..0000000 --- a/src/Core/State/InternalState.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Collections.Immutable; - -namespace Core.State; - -public record InternalState( - InternalInstallationState Install - ) -{ - public static InternalState Empty() => new( - Install: InternalInstallationState.Empty() - ); -}; - -public record InternalInstallationState( - DateTime? Time, - IReadOnlyDictionary Mods -) -{ - public static InternalInstallationState Empty() => new( - Time: null, - Mods: ImmutableDictionary.Create() - ); -}; - -public record InternalModInstallationState( - // Unknown when partially installed or upgrading from a previous version - int? FsHash, - // TODO: needed for backward compatibility - // infer from null hash after the first install - bool Partial, - IReadOnlyCollection Files -); diff --git a/src/Core/State/JsonFileStatePersistence.cs b/src/Core/State/JsonFileStatePersistence.cs index 7531bc3..9bcc317 100644 --- a/src/Core/State/JsonFileStatePersistence.cs +++ b/src/Core/State/JsonFileStatePersistence.cs @@ -1,4 +1,5 @@ -using Newtonsoft.Json; +using Core.Utils; +using Newtonsoft.Json; namespace Core.State; @@ -22,13 +23,21 @@ public JsonFileStatePersistence(string modsDir) oldStateFile = Path.Combine(modsDir, OldStateFileName); } - public InternalState ReadState() + public SavedState ReadState() { // Always favour new state if present if (File.Exists(stateFile)) { var contents = File.ReadAllText(stateFile); - return JsonConvert.DeserializeObject(contents); + var state = JsonConvert.DeserializeObject(contents); + // Fill mod install time if not present (for migration) + return state with + { + Install = state.Install with + { + Mods = state.Install.Mods.SelectValues(_ => _ with { Time = _.Time ?? state.Install.Time }) + } + }; } // Fallback to old state when new state is not present @@ -37,21 +46,21 @@ public InternalState ReadState() var contents = File.ReadAllText(oldStateFile); var oldState = JsonConvert.DeserializeObject>>(contents); var installTime = File.GetLastWriteTimeUtc(oldStateFile); - return new InternalState( + return new SavedState( Install: new( Time: installTime, Mods: oldState.AsEnumerable().ToDictionary( kv => kv.Key, - kv => new InternalModInstallationState(FsHash: null, Partial: false, Files: kv.Value) + kv => new ModInstallationState(Time: installTime, FsHash: null, Partial: false, Files: kv.Value) ) ) ); } - return InternalState.Empty(); + return SavedState.Empty(); } - public void WriteState(InternalState state) + public void WriteState(SavedState state) { // Remove old state if upgrading from a previous version File.Delete(oldStateFile); diff --git a/src/Core/State/SavedState.cs b/src/Core/State/SavedState.cs new file mode 100644 index 0000000..f3fc549 --- /dev/null +++ b/src/Core/State/SavedState.cs @@ -0,0 +1,38 @@ +using System.Collections.Immutable; + +namespace Core.State; + +public record SavedState( + InstallationState Install + ) +{ + public static SavedState Empty() => new( + Install: InstallationState.Empty() + ); +}; + +public record InstallationState( + // TODO: needed for backward compatibility + DateTime? Time, + IReadOnlyDictionary Mods +) +{ + public static InstallationState Empty() => new( + Time: null, + Mods: ImmutableDictionary.Create() + ); +}; + +public record ModInstallationState( + // TODO: nullable for backward compatibility + DateTime? Time, + // Unknown when partially installed or upgrading from a previous version + int? FsHash, + // TODO: needed for backward compatibility + // infer from null hash after the first install + bool Partial, + IReadOnlyCollection Files +) +{ + public static ModInstallationState Empty => new(null, null, false, Array.Empty()); +} diff --git a/src/Core/Utils/DictionaryExtensions.cs b/src/Core/Utils/DictionaryExtensions.cs index aad2d8f..0e9b81c 100644 --- a/src/Core/Utils/DictionaryExtensions.cs +++ b/src/Core/Utils/DictionaryExtensions.cs @@ -1,4 +1,6 @@ -namespace Core.Utils; +using Core.Mods; + +namespace Core.Utils; public static class DictionaryExtensions { @@ -31,4 +33,10 @@ public static IReadOnlyDictionary SelectValues kv.Key, kv => f(kv.Key, kv.Value)); } + + public static void Upsert(this IDictionary dict, TKey key, Func updatedValue, Func insertedValue) + where TKey : notnull + { + dict[key] = dict.TryGetValue(key, out var existing) ? updatedValue(existing) : insertedValue(); + } } diff --git a/src/GUI/App.xaml.cs b/src/GUI/App.xaml.cs index 8b490a6..2f635b7 100644 --- a/src/GUI/App.xaml.cs +++ b/src/GUI/App.xaml.cs @@ -1,7 +1,7 @@ -using Core; +using Core.API; using Core.SoftwareUpdates; using Microsoft.UI.Xaml; -using static Core.IModManager; +using static Core.API.IModManager; namespace AMS2CM.GUI; diff --git a/src/GUI/MainWindow.xaml.cs b/src/GUI/MainWindow.xaml.cs index 1289b09..bfafbaa 100644 --- a/src/GUI/MainWindow.xaml.cs +++ b/src/GUI/MainWindow.xaml.cs @@ -1,5 +1,5 @@ using System.Collections.ObjectModel; -using Core; +using Core.API; using Core.SoftwareUpdates; using Core.Utils; using Microsoft.UI.Xaml; diff --git a/src/GUI/ModVM.cs b/src/GUI/ModVM.cs index a351084..c54a6fb 100644 --- a/src/GUI/ModVM.cs +++ b/src/GUI/ModVM.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using Core; +using Core.API; namespace AMS2CM.GUI; diff --git a/tests/Core.Tests/ModManagerIntegrationTest.cs b/tests/Core.Tests/API/ModManagerIntegrationTest.cs similarity index 76% rename from tests/Core.Tests/ModManagerIntegrationTest.cs rename to tests/Core.Tests/API/ModManagerIntegrationTest.cs index 0dd0425..1d656d4 100644 --- a/tests/Core.Tests/ModManagerIntegrationTest.cs +++ b/tests/Core.Tests/API/ModManagerIntegrationTest.cs @@ -1,11 +1,14 @@ using System.IO.Compression; +using Core.API; +using Core.Backup; using Core.Games; using Core.IO; using Core.Mods; using Core.State; +using Core.Tests.Base; using FluentAssertions; -namespace Core.Tests; +namespace Core.Tests.API; public class ModManagerIntegrationTest : AbstractFilesystemTest { @@ -14,6 +17,9 @@ public class ModManagerIntegrationTest : AbstractFilesystemTest private const string DirAtRoot = "DirAtRoot"; private const string FileExcludedFromInstall = "Excluded"; + // Randomness ensures that at least some test runs will fail if it's used + private static readonly DateTime? ValueNotUsed = Random.Shared.Next() > 0 ? DateTime.MaxValue : DateTime.MinValue; + private static readonly TimeSpan TimeTolerance = TimeSpan.FromMilliseconds(100); private readonly DirectoryInfo gameDir; @@ -25,11 +31,10 @@ public class ModManagerIntegrationTest : AbstractFilesystemTest private readonly Mock eventHandlerMock = new(); private readonly InMemoryStatePersistence persistedState; - private readonly InstallationFactory installationFactory; private readonly ModManager modManager; - public ModManagerIntegrationTest() : base() + public ModManagerIntegrationTest() { gameDir = testDir.CreateSubdirectory("Game"); modsDir = testDir.CreateSubdirectory("Packages"); @@ -42,7 +47,7 @@ public ModManagerIntegrationTest() : base() DirsAtRoot = [DirAtRoot], ExcludedFromInstall = [$"**\\{FileExcludedFromInstall}"] }; - installationFactory = new InstallationFactory( + var installationFactory = new InstallationFactory( gameMock.Object, tempDir, modInstallConfig); @@ -50,7 +55,7 @@ public ModManagerIntegrationTest() : base() modManager = new ModManager( gameMock.Object, modRepositoryMock.Object, - new ModInstaller(installationFactory, tempDir, modInstallConfig), + new ModInstaller(installationFactory, new SuffixBackupStrategy()), persistedState, safeFileDeleteMock.Object, tempDir); @@ -74,19 +79,19 @@ public void Uninstall_FailsIfGameRunning() [Fact] public void Uninstall_DeletesCreatedFilesAndDirectories() { - persistedState.InitState(new InternalState + persistedState.InitState(new SavedState ( Install: new( - Time: null, - Mods: new Dictionary + Time: ValueNotUsed, + Mods: new Dictionary { ["A"] = new( - FsHash: null, Partial: false, Files: [ + Time: null, FsHash: null, Partial: false, Files: [ Path.Combine("X", "ModAFile"), Path.Combine("Y", "ModAFile") ]), ["B"] = new( - FsHash: null, Partial: false, Files: [ + Time: null, FsHash: null, Partial: false, Files: [ Path.Combine("X", "ModBFile") ]) } @@ -106,14 +111,14 @@ public void Uninstall_DeletesCreatedFilesAndDirectories() public void Uninstall_SkipsFilesCreatedAfterInstallation() { var installationDateTime = DateTime.Now.Subtract(TimeSpan.FromDays(1)); - persistedState.InitState(new InternalState + persistedState.InitState(new SavedState ( Install: new( - Time: installationDateTime.ToUniversalTime(), - Mods: new Dictionary + Time: ValueNotUsed, + Mods: new Dictionary { [""] = new( - FsHash: null, Partial: false, Files: [ + Time: installationDateTime.ToUniversalTime(), FsHash: null, Partial: false, Files: [ "ModFile", "RecreatedFile", "AlreadyDeletedFile" @@ -126,7 +131,7 @@ public void Uninstall_SkipsFilesCreatedAfterInstallation() modManager.UninstallAllMods(eventHandlerMock.Object); - File.Exists(GamePath("ModFile")).Should().BeFalse(); + File.Exists(GamePath("ModFile")).Should().BeFalse(); // FIXME File.Exists(GamePath("RecreatedFile")).Should().BeTrue(); persistedState.Should().BeEmpty(); } @@ -136,22 +141,22 @@ public void Uninstall_StopsAfterAnyError() { // It must be after files are created var installationDateTime = DateTime.Now.AddMinutes(1); - persistedState.InitState(new InternalState( + persistedState.InitState(new SavedState( Install: new( - Time: installationDateTime.ToUniversalTime(), - Mods: new Dictionary + Time: ValueNotUsed, + Mods: new Dictionary { ["A"] = new( - FsHash: null, Partial: false, Files: [ + Time: installationDateTime.ToUniversalTime(), FsHash: null, Partial: false, Files: [ "ModAFile" ]), ["B"] = new( - FsHash: null, Partial: false, Files: [ + Time: installationDateTime.ToUniversalTime(), FsHash: null, Partial: false, Files: [ "ModBFile1", "ModBFile2" ]), ["C"] = new( - FsHash: null, Partial: false, Files: [ + Time: installationDateTime.ToUniversalTime(), FsHash: null, Partial: false, Files: [ "ModCFile" ]) } @@ -165,17 +170,17 @@ public void Uninstall_StopsAfterAnyError() modManager.Invoking(_ => _.UninstallAllMods(eventHandlerMock.Object)) .Should().Throw(); - persistedState.Should().Be(new InternalState( - Install: new InternalInstallationState( + persistedState.Should().Be(new SavedState( + Install: new InstallationState( Time: installationDateTime.ToUniversalTime(), - Mods: new Dictionary + Mods: new Dictionary { ["B"] = new( - FsHash: null, Partial: true, Files: [ + Time: installationDateTime.ToUniversalTime(), FsHash: null, Partial: true, Files: [ "ModBFile2" ]), ["C"] = new( - FsHash: null, Partial: false, Files: [ + Time: installationDateTime.ToUniversalTime(), FsHash: null, Partial: false, Files: [ "ModCFile" ]) } @@ -186,13 +191,13 @@ public void Uninstall_StopsAfterAnyError() [Fact] public void Uninstall_RestoresBackups() { - persistedState.InitState(new InternalState( + persistedState.InitState(new SavedState( Install: new( - Time: null, - Mods: new Dictionary + Time: ValueNotUsed, + Mods: new Dictionary { [""] = new( - FsHash: null, Partial: false, Files: [ + Time: null, FsHash: null, Partial: false, Files: [ "ModFile" ]) } @@ -212,13 +217,13 @@ public void Uninstall_SkipsRestoreIfModFileOverwritten() { // It must be after files are created var installationDateTime = DateTime.Now.AddMinutes(1); - persistedState.InitState(new InternalState( + persistedState.InitState(new SavedState( Install: new( - Time: installationDateTime.ToUniversalTime(), - Mods: new Dictionary + Time: ValueNotUsed, + Mods: new Dictionary { [""] = new( - FsHash: null, Partial: false, Files: [ + Time: installationDateTime.ToUniversalTime(), FsHash: null, Partial: false, Files: [ "ModFile" ]) } @@ -264,10 +269,10 @@ public void Install_InstallsContentFromRootDirectories() File.Exists(GamePath("C")).Should().BeTrue(); File.Exists(GamePath("D")).Should().BeFalse(); File.Exists(GamePath(Path.Combine("Baz", "D"))).Should().BeFalse(); - persistedState.Should().HaveInstalled(new Dictionary + persistedState.Should().HaveInstalled(new Dictionary { ["Package100"] = new( - FsHash: 100, Partial: false, Files: [ + Time: DateTime.UtcNow, FsHash: 100, Partial: false, Files: [ Path.Combine(DirAtRoot, "A"), Path.Combine(DirAtRoot, "B"), "C" @@ -289,10 +294,10 @@ public void Install_SkipsBlacklistedFiles() File.Exists(GamePath(Path.Combine("A", FileExcludedFromInstall))).Should().BeFalse(); File.Exists(GamePath(Path.Combine(DirAtRoot, "B"))).Should().BeTrue(); - persistedState.Should().HaveInstalled(new Dictionary + persistedState.Should().HaveInstalled(new Dictionary { ["Package100"] = new( - FsHash: 100, Partial: false, Files: [ + Time: DateTime.UtcNow, FsHash: 100, Partial: false, Files: [ Path.Combine(DirAtRoot, "B") ]), }); @@ -329,10 +334,10 @@ public void Install_GivesPriotiryToFilesLaterInTheModList() modManager.InstallEnabledMods(eventHandlerMock.Object); File.ReadAllText(GamePath(Path.Combine(DirAtRoot, "A"))).Should().Be("200"); - persistedState.Should().HaveInstalled(new Dictionary + persistedState.Should().HaveInstalled(new Dictionary { - ["Package100"] = new(FsHash: 100, Partial: false, Files: []), - ["Package200"] = new(FsHash: 200, Partial: false, Files: [ + ["Package100"] = new(Time: DateTime.UtcNow, FsHash: 100, Partial: false, Files: []), + ["Package200"] = new(Time: DateTime.UtcNow, FsHash: 200, Partial: false, Files: [ Path.Combine(DirAtRoot, "a") ]), }); @@ -350,9 +355,9 @@ public void Install_DuplicatesAreCaseInsensitive() modManager.InstallEnabledMods(eventHandlerMock.Object); - persistedState.Should().HaveInstalled(new Dictionary + persistedState.Should().HaveInstalled(new Dictionary { - ["Package100"] = new(FsHash: 100, Partial: false, Files: [ + ["Package100"] = new(Time: DateTime.UtcNow, FsHash: 100, Partial: false, Files: [ Path.Combine(DirAtRoot, "A") ]), }); @@ -383,17 +388,17 @@ public void Install_StopsAfterAnyError() File.ReadAllText(GamePath(Path.Combine(DirAtRoot, "B1"))).Should().Be("200"); File.Exists(GamePath(Path.Combine(DirAtRoot, "B3"))).Should().BeFalse(); File.Exists(GamePath(Path.Combine(DirAtRoot, "A"))).Should().BeFalse(); - persistedState.Should().Be(new InternalState( - Install: new InternalInstallationState( + persistedState.Should().Be(new SavedState( + Install: new InstallationState( Time: DateTime.UtcNow, - Mods: new Dictionary + Mods: new Dictionary { ["Package200"] = new( - FsHash: 200, Partial: true, Files: [ + Time: DateTime.UtcNow, FsHash: 200, Partial: true, Files: [ Path.Combine(DirAtRoot, "B1") ]), ["Package300"] = new( - FsHash: 300, Partial: false, Files: [ + Time: DateTime.UtcNow, FsHash: 300, Partial: false, Files: [ Path.Combine(DirAtRoot, "C") ]), } @@ -505,7 +510,7 @@ public void Install_ExtractsBootfilesFromGameByDefault() } [Fact] - public void Install_RejectsMultipleCustomBootfiles() + public void Install_ChoosesFirstOfMultipleCustomBootfiles() { modRepositoryMock.Setup(_ => _.ListEnabledMods()).Returns([ CreateModArchive(100, [Path.Combine(DirAtRoot, "Foo.crd")]), @@ -513,10 +518,9 @@ public void Install_RejectsMultipleCustomBootfiles() CreateCustomBootfiles(901) ]); - modManager.Invoking(_ => _.InstallEnabledMods(eventHandlerMock.Object)) - .Should().Throw().WithMessage("*many bootfiles*"); + modManager.InstallEnabledMods(eventHandlerMock.Object); - persistedState.Should().HaveInstalled(["Package100"]); + persistedState.Should().HaveInstalled(["Package100", "__bootfiles900"]); } #region Utility methods @@ -571,58 +575,82 @@ private string GamePath(string relativePath) => private class InMemoryStatePersistence : IStatePersistence { // Avoids bootfiles checks on uninstall - private static readonly InternalState SkipBootfilesCheck = new InternalState( + private static readonly SavedState SkipBootfilesCheck = new( Install: new( - Time: null, - Mods: new Dictionary + Time: ValueNotUsed, + Mods: new Dictionary { - ["INIT"] = new(FsHash: null, Partial: false, Files: []), + ["INIT"] = new(Time: null, FsHash: null, Partial: false, Files: []), } )); - private InternalState initState = SkipBootfilesCheck; - private InternalState? savedState; + private SavedState initState = SkipBootfilesCheck; + private SavedState? savedState; - public void InitState(InternalState state) => initState = state; + public void InitState(SavedState state) => initState = state; - public InternalState ReadState() => savedState ?? initState; + public SavedState ReadState() => savedState ?? initState; - public void WriteState(InternalState state) => savedState = state; + public void WriteState(SavedState state) => savedState = state; internal InMemoryStatePersistenceAssertions Should() => new(savedState); } private class InMemoryStatePersistenceAssertions { - private readonly InternalState? savedState; + private readonly SavedState? savedState; - internal InMemoryStatePersistenceAssertions(InternalState? savedState) + internal InMemoryStatePersistenceAssertions(SavedState? savedState) { this.savedState = savedState; } - internal void Be(InternalState expected) + internal void Be(SavedState expected) { - savedState.Should().NotBeNull(); - // Not a great solution, but .NET doesn't natively provide support for mocking the clock - (savedState!.Install.Time ?? DateTime.MinValue).Should().BeCloseTo((expected.Install.Time ?? DateTime.MinValue), TimeTolerance); + var writtenState = WrittenState(); + ValidateDateTime(expected.Install.Time, writtenState.Install.Time); HaveInstalled(expected.Install.Mods); } - internal void HaveInstalled(IReadOnlyDictionary expected) + internal void HaveInstalled(IReadOnlyDictionary expected) { - savedState.Should().NotBeNull(); - savedState?.Install.Mods.Should().BeEquivalentTo(expected); + var writtenState = WrittenState(); + var actualMods = writtenState.Install.Mods; + var expectedMods = expected.Select(mod => + { + var expectedTime = mod.Value.Time; + var actualTime = writtenState.Install.Mods.GetValueOrDefault(mod.Key)?.Time; + if (actualTime is null) + { + return mod; + } + ValidateDateTime(expectedTime, actualTime); + return new KeyValuePair(mod.Key, mod.Value with { Time = actualTime }); + }); + actualMods.Should().BeEquivalentTo(expectedMods); } internal void HaveInstalled(IEnumerable expected) { - savedState?.Install.Mods.Keys.Should().BeEquivalentTo(expected); + var writtenState = WrittenState(); + writtenState.Install.Mods.Keys.Should().BeEquivalentTo(expected); } + private SavedState WrittenState() + { + savedState.Should().NotBeNull("State was not written"); + return savedState!; + } + + /// + /// Not a great solution, but .NET doesn't natively provide support for mocking the clock! + /// + private void ValidateDateTime(DateTime? expected, DateTime? actual) => + (actual ?? DateTime.MinValue).Should().BeCloseTo((expected ?? DateTime.MinValue), TimeTolerance); + internal void BeEmpty() { - savedState.Should().BeEquivalentTo(InternalState.Empty()); + savedState.Should().BeEquivalentTo(SavedState.Empty()); } internal void HaveNotBeenWritten() diff --git a/tests/Core.Tests/Backup/MoveFileBackupStrategyTest.cs b/tests/Core.Tests/Backup/MoveFileBackupStrategyTest.cs index a0900ff..b19dcc7 100644 --- a/tests/Core.Tests/Backup/MoveFileBackupStrategyTest.cs +++ b/tests/Core.Tests/Backup/MoveFileBackupStrategyTest.cs @@ -7,42 +7,61 @@ namespace Core.Tests.Backup; [IntegrationTest] public class MoveFileBackupStrategyTest { - private static readonly string OriginalFile = "original"; - private static readonly string OriginalContents = "something"; - private static readonly string BackupFile = GenerateBackupFilePath(OriginalFile); + private const string OriginalFile = "original"; + private const string OriginalContents = "something"; - private static string GenerateBackupFilePath(string fullPath) => $"b{fullPath}"; + private readonly Mock backupFileNamingMock; + + private MoveFileBackupStrategy.IBackupFileNaming BackupFileNaming => backupFileNamingMock.Object; + private string BackupFile => BackupFileNaming.ToBackup(OriginalFile); + + public MoveFileBackupStrategyTest() + { + backupFileNamingMock = new(); + backupFileNamingMock.Setup(_ => _.ToBackup(It.IsAny())).Returns(_ => $"b{_}"); + } [Fact] - public void BackupFile_MovesOriginalToBackup() + public void PerformBackup_MovesOriginalToBackup() { var fs = new MockFileSystem(new Dictionary { { OriginalFile, OriginalContents }, }); + var mfbs = new MoveFileBackupStrategy(fs, BackupFileNaming); - var sbs = new MoveFileBackupStrategy(fs, GenerateBackupFilePath); - - sbs.PerformBackup(OriginalFile); + mfbs.PerformBackup(OriginalFile); fs.FileExists(OriginalFile).Should().BeFalse(); fs.File.ReadAllText(BackupFile).Should().Be(OriginalContents); } [Fact] - public void BackupFile_SkipsBackupIfFileNotPresent() + public void PerformBackup_ErrorsIfNameIsBackupName() { var fs = new MockFileSystem(); + backupFileNamingMock.Setup(_ => _.IsBackup(OriginalFile)).Returns(true); + var mfbs = new MoveFileBackupStrategy(fs, BackupFileNaming); + + mfbs.Invoking(_ => _.PerformBackup(OriginalFile)) + .Should().Throw(); + + fs.FileExists(BackupFile).Should().BeFalse(); + } - var sbs = new MoveFileBackupStrategy(fs, GenerateBackupFilePath); + [Fact] + public void PerformBackup_SkipsBackupIfFileNotPresent() + { + var fs = new MockFileSystem(); + var mfbs = new MoveFileBackupStrategy(fs, BackupFileNaming); - sbs.PerformBackup(OriginalFile); + mfbs.PerformBackup(OriginalFile); fs.FileExists(BackupFile).Should().BeFalse(); } [Fact] - public void BackupFile_KeepsExistingBackup() + public void PerformBackup_KeepsExistingBackup() { var oldBackupContents = "old backup"; var fs = new MockFileSystem(new Dictionary @@ -50,10 +69,9 @@ public void BackupFile_KeepsExistingBackup() { OriginalFile, OriginalContents }, { BackupFile, oldBackupContents }, }); + var mfbs = new MoveFileBackupStrategy(fs, BackupFileNaming); - var sbs = new MoveFileBackupStrategy(fs, GenerateBackupFilePath); - - sbs.PerformBackup(OriginalFile); + mfbs.PerformBackup(OriginalFile); fs.FileExists(OriginalFile).Should().BeFalse(); fs.File.ReadAllText(BackupFile).Should().Be(oldBackupContents); @@ -66,45 +84,57 @@ public void RestoreBackup_MovesBackupToOriginal() { { BackupFile, OriginalContents}, }); + var mfbs = new MoveFileBackupStrategy(fs, BackupFileNaming); - var sbs = new MoveFileBackupStrategy(fs, GenerateBackupFilePath); - - sbs.RestoreBackup(OriginalFile); + mfbs.RestoreBackup(OriginalFile); fs.File.ReadAllText(OriginalFile).Should().Be(OriginalContents); fs.FileExists(BackupFile).Should().BeFalse(); } [Fact] - public void RestoreBackup_LeavesOriginalFileIfNoBackup() + public void RestoreBackup_OverwritesOriginalFile() { var fs = new MockFileSystem(new Dictionary { { OriginalFile, "other contents" }, + { BackupFile, OriginalContents}, }); + var mfbs = new MoveFileBackupStrategy(fs, BackupFileNaming); - var sbs = new MoveFileBackupStrategy(fs, GenerateBackupFilePath); - - sbs.RestoreBackup(OriginalFile); + mfbs.RestoreBackup(OriginalFile); - fs.FileExists(OriginalFile).Should().BeTrue(); + fs.File.ReadAllText(OriginalFile).Should().Be(OriginalContents); + fs.FileExists(BackupFile).Should().BeFalse(); } [Fact] - public void RestoreBackup_ErrorsIfOriginalFileExists() + public void RestoreBackup_WhenNoOriginalFile() { var fs = new MockFileSystem(new Dictionary { - { OriginalFile, "other contents" }, { BackupFile, OriginalContents}, }); + var mfbs = new MoveFileBackupStrategy(fs, BackupFileNaming); + + mfbs.RestoreBackup(OriginalFile); + + fs.File.ReadAllText(OriginalFile).Should().Be(OriginalContents); + fs.FileExists(BackupFile).Should().BeFalse(); + } - var sbs = new MoveFileBackupStrategy(fs, GenerateBackupFilePath); + [Fact] + public void RestoreBackup_DeletesOriginalFileIfNoBackup() + { + var fs = new MockFileSystem(new Dictionary + { + { OriginalFile, "other contents" }, + }); + var mfbs = new MoveFileBackupStrategy(fs, BackupFileNaming); - sbs.Invoking(_ => _.RestoreBackup(OriginalFile)).Should().Throw(); + mfbs.RestoreBackup(OriginalFile); - fs.File.ReadAllText(OriginalFile).Should().NotBe(OriginalContents); - fs.FileExists(BackupFile).Should().BeTrue(); + fs.FileExists(OriginalFile).Should().BeFalse(); } [Fact] @@ -114,14 +144,13 @@ public void DeleteBackup_RemovesBackupIfItExists() { { BackupFile, OriginalContents}, }); + var mfbs = new MoveFileBackupStrategy(fs, BackupFileNaming); - var sbs = new MoveFileBackupStrategy(fs, GenerateBackupFilePath); - - sbs.DeleteBackup(OriginalFile); + mfbs.DeleteBackup(OriginalFile); fs.FileExists(OriginalFile).Should().BeFalse(); fs.FileExists(BackupFile).Should().BeFalse(); - sbs.DeleteBackup(OriginalFile); // Check that it does not error + mfbs.DeleteBackup(OriginalFile); // Check that it does not error } } diff --git a/tests/Core.Tests/Backup/SkipUpdatedBackupStrategyTest.cs b/tests/Core.Tests/Backup/SkipUpdatedBackupStrategyTest.cs new file mode 100644 index 0000000..1405ca9 --- /dev/null +++ b/tests/Core.Tests/Backup/SkipUpdatedBackupStrategyTest.cs @@ -0,0 +1,110 @@ +using System.IO.Abstractions.TestingHelpers; +using Core.Backup; +using Core.Mods; +using FluentAssertions; +using static Core.Backup.MoveFileBackupStrategy; + +namespace Core.Tests.Backup; + +[IntegrationTest] +public class SkipUpdatedBackupStrategyTest +{ + private readonly RootedPath OriginalFile = new("root", "original"); + + private readonly Mock innerStategyMock; + + public SkipUpdatedBackupStrategyTest() + { + innerStategyMock = new(); + } + + [Fact] + public void PerformBackup_ProxiesCallToInnerStategy() + { + var fs = new MockFileSystem(new Dictionary()); + var subs = new SkipUpdatedBackupStrategy(fs, innerStategyMock.Object, null); + + subs.PerformBackup(OriginalFile); + + innerStategyMock.Verify(_ => _.PerformBackup(OriginalFile.Full)); + } + + [Fact] + public void DeleteBackup_ProxiesCallToInnerStategy() + { + var fs = new MockFileSystem(new Dictionary()); + var subs = new SkipUpdatedBackupStrategy(fs, innerStategyMock.Object, null); + + subs.DeleteBackup(OriginalFile); + + innerStategyMock.Verify(_ => _.DeleteBackup(OriginalFile.Full)); + } + + [Fact] + public void RestoreBackup_ProxiesCallToInnerStategyIfNoBackupTime() + { + var fs = new MockFileSystem(new Dictionary()); + var subs = new SkipUpdatedBackupStrategy(fs, innerStategyMock.Object, null); + + subs.RestoreBackup(OriginalFile); + + innerStategyMock.Verify(_ => _.RestoreBackup(OriginalFile.Full)); + } + + [Fact] + public void RestoreBackup_ProxiesCallToInnerStategyIfNoOriginalFile() + { + var fs = new MockFileSystem(new Dictionary()); + var subs = new SkipUpdatedBackupStrategy(fs, innerStategyMock.Object, DateTime.UtcNow); + + subs.RestoreBackup(OriginalFile); + + innerStategyMock.Verify(_ => _.RestoreBackup(OriginalFile.Full)); + } + + [Fact] + public void RestoreBackup_DeletesBackupIfOverwritten() + { + var fileCreationTime = DateTime.UtcNow; + var backupTime = fileCreationTime.Subtract(TimeSpan.FromSeconds(1)); + var fs = new MockFileSystem(new Dictionary + { + { OriginalFile.Full, new MockFileData("") { CreationTime = fileCreationTime } }, + }); + var subs = new SkipUpdatedBackupStrategy(fs, innerStategyMock.Object, backupTime); + + subs.RestoreBackup(OriginalFile); + + innerStategyMock.Verify(_ => _.DeleteBackup(OriginalFile.Full)); + } + + [Fact] + public void RestoreBackup_ProxiesCallToInnerStategyIfNotOverwritten() + { + var backupTime = DateTime.UtcNow; + var fs = new MockFileSystem(new Dictionary + { + { OriginalFile.Full, new MockFileData("") { CreationTime = backupTime } }, + }); + var subs = new SkipUpdatedBackupStrategy(fs, innerStategyMock.Object, backupTime); + + subs.RestoreBackup(OriginalFile); + + innerStategyMock.Verify(_ => _.RestoreBackup(OriginalFile.Full)); + } + + [Fact] + public void AfterInstall_EnduresDateInThePast() + { + var futureDate = DateTime.UtcNow.AddDays(1); + var fs = new MockFileSystem(new Dictionary + { + { OriginalFile.Full, new MockFileData("") { CreationTime = futureDate } }, + }); + var subs = new SkipUpdatedBackupStrategy(fs, innerStategyMock.Object, null); + + subs.AfterInstall(OriginalFile); + + fs.File.GetCreationTimeUtc(OriginalFile.Full).Should().BeOnOrBefore(DateTime.UtcNow); + } +} diff --git a/tests/Core.Tests/AbstractFilesystemTest.cs b/tests/Core.Tests/Base/AbstractFilesystemTest.cs similarity index 97% rename from tests/Core.Tests/AbstractFilesystemTest.cs rename to tests/Core.Tests/Base/AbstractFilesystemTest.cs index 36457c3..e84e854 100644 --- a/tests/Core.Tests/AbstractFilesystemTest.cs +++ b/tests/Core.Tests/Base/AbstractFilesystemTest.cs @@ -1,4 +1,4 @@ -namespace Core.Tests; +namespace Core.Tests.Base; [IntegrationTest] public abstract class AbstractFilesystemTest : IDisposable diff --git a/tests/Core.Tests/ModInstallerIntegrationTest.cs b/tests/Core.Tests/ModInstallerIntegrationTest.cs new file mode 100644 index 0000000..2922a27 --- /dev/null +++ b/tests/Core.Tests/ModInstallerIntegrationTest.cs @@ -0,0 +1,256 @@ +using Core.Backup; +using Core.Mods; +using Core.State; +using Core.Tests.Base; +using Core.Utils; +using FluentAssertions; + +namespace Core.Tests; + +public class ModInstallerIntegrationTest : AbstractFilesystemTest +{ + #region Initialisation + + private class TestException : Exception {} + + private record InstallationResult( + int? PackageFsHash, + HashSet InstalledFiles, + IInstallation.State Installed + ) + { + internal InstallationResult(IInstallation installation) : this( + installation.PackageFsHash, + installation.InstalledFiles.ToHashSet(), + installation.Installed) { } + } + + private readonly Mock installationFactoryMock; + private readonly Mock backupStrategyMock; + private readonly ModInstaller modInstaller; + + private readonly Mock eventHandlerMock; + + private readonly Dictionary recordedState; + + public ModInstallerIntegrationTest() + { + installationFactoryMock = new Mock(); + backupStrategyMock = new Mock(); + modInstaller = new ModInstaller( + installationFactoryMock.Object, + backupStrategyMock.Object); + eventHandlerMock = new Mock(); + recordedState = new(); + } + + #endregion + + [Fact] + public void Apply_NoMods() + { + modInstaller.Apply( + new Dictionary(), + [], + "", + RecordState, + eventHandlerMock.Object, + CancellationToken.None); + + recordedState.Should().BeEmpty(); + } + + [Fact] + public void Apply_UninstallsMods() + { + modInstaller.Apply( + new Dictionary{ + ["A"] = new( + Time: null, + FsHash: 42, + Partial: false, + Files: ["AF"]) + }, + [], + testDir.FullName, + RecordState, + eventHandlerMock.Object, + CancellationToken.None); + + recordedState.Should().BeEquivalentTo(new Dictionary{ + ["A"] = new(42, [], IInstallation.State.NotInstalled), + }); + + backupStrategyMock.Verify(_ => _.RestoreBackup(TestPath("AF"))); + backupStrategyMock.VerifyNoOtherCalls(); + + eventHandlerMock.Verify(_ => _.UninstallStart()); + eventHandlerMock.Verify(_ => _.UninstallCurrent("A")); + eventHandlerMock.Verify(_ => _.UninstallSkipModified("AF")); + eventHandlerMock.Verify(_ => _.UninstallEnd()); + eventHandlerMock.Verify(_ => _.InstallNoMods()); + eventHandlerMock.Verify(_ => _.ProgressUpdate(It.IsAny())); + eventHandlerMock.VerifyNoOtherCalls(); + } + + [Fact] + public void Apply_UninstallStopsIfBackupFails() + { + backupStrategyMock.Setup(_ => _.RestoreBackup(TestPath("Fail"))).Throws(); + + modInstaller.Invoking(_ => _.Apply( + new Dictionary + { + ["A"] = new( + Time: null, + FsHash: 42, + Partial: false, + Files: ["AF1", "Fail", "AF2"]) + }, + [], + testDir.FullName, + RecordState, + eventHandlerMock.Object, + CancellationToken.None)).Should().Throw(); + + recordedState["A"].InstalledFiles.Should().BeEquivalentTo(["Fail", "AF2"]); + } + + [Fact] + public void Apply_InstallsMods() + { + modInstaller.Apply( + new Dictionary(), + [ + PackageInstalling("A", 42, [ + "AF" + ]) + ], + testDir.FullName, + RecordState, + eventHandlerMock.Object, + CancellationToken.None); + + recordedState.Should().BeEquivalentTo(new Dictionary + { + ["A"] = new(42, ["AF"], IInstallation.State.Installed) + }); + + backupStrategyMock.Verify(_ => _.PerformBackup(TestPath("AF"))); + backupStrategyMock.VerifyNoOtherCalls(); + + eventHandlerMock.Verify(_ => _.UninstallNoMods()); + eventHandlerMock.Verify(_ => _.InstallStart()); + eventHandlerMock.Verify(_ => _.InstallCurrent("A")); + eventHandlerMock.Verify(_ => _.PostProcessingNotRequired()); + eventHandlerMock.Verify(_ => _.InstallEnd()); + eventHandlerMock.Verify(_ => _.ProgressUpdate(It.IsAny())); + eventHandlerMock.VerifyNoOtherCalls(); + } + + [Fact] + public void Apply_InstallStopsIfBackupFails() + { + backupStrategyMock.Setup(_ => _.PerformBackup(TestPath("Fail"))).Throws(); + + modInstaller.Invoking(_ => _.Apply( + new Dictionary(), + [ + PackageInstalling("A", 42, [ + "AF1", "Fail", "AF2" + ]) + ], + testDir.FullName, + RecordState, + eventHandlerMock.Object, + CancellationToken.None)).Should().Throw(); + + recordedState["A"].InstalledFiles.Should().BeEquivalentTo(["AF1"]); + } + + [Fact] + public void Apply_UpdatesMods() + { + var endState = new Dictionary(); + modInstaller.Apply( + new Dictionary + { + ["A"] = new(Time: null, FsHash: 1, Partial: false, Files: [ + "AF", + "AF1", + ]) + }, + [ + PackageInstalling("A", 2, [ + "AF", + "AF2" + ]) + ], + testDir.FullName, + RecordState, + eventHandlerMock.Object, + CancellationToken.None); + + recordedState.Should().BeEquivalentTo(new Dictionary + { + ["A"] = new(2, ["AF", "AF2"], IInstallation.State.Installed) + }); + } + + #region Utility methods + + private ModPackage PackageInstalling(string name, int? fsHash, IReadOnlyCollection files) + { + var unusedPath = $@"Some\Unused\Path\{name}"; + var unusedEnabled = Random.Shared.NextDouble() < 0.5; + var package = new ModPackage(name, unusedPath, unusedEnabled, fsHash); + var installer = new StaticFilesInstaller(name, fsHash, new SubdirectoryTempDir(testDir.FullName), files); + installationFactoryMock.Setup(_ => _.ModInstaller(package)).Returns(installer); + return package; + } + + private class StaticFilesInstaller : BaseInstaller + { + private static readonly object NoContext = new(); + private readonly IReadOnlyCollection files; + + internal StaticFilesInstaller(string packageName, int? packageFsHash, ITempDir tempDir, IReadOnlyCollection files) : + base(packageName, packageFsHash, tempDir, Config()) + { + this.files = files; + } + + protected override void InstalAllFiles(InstallBody body) + { + foreach (var file in files) + { + body(file, NoContext); + } + } + + protected override void InstallFile(RootedPath destinationPath, object context) + { + // Do not install any file for real + } + + // Install everything from the root directory + + private static readonly string DirAtRoot = "X"; + + private static BaseInstaller.IConfig Config() + { + var mock = new Mock(); + mock.Setup(_ => _.DirsAtRoot).Returns([DirAtRoot]); + return mock.Object; + } + + protected override IEnumerable RelativeDirectoryPaths => [DirAtRoot]; + } + + private void RecordState(IInstallation state) + { + recordedState[state.PackageName] = new InstallationResult(state); + } + + #endregion +} diff --git a/tests/Core.Tests/Mods/ModRepositoryIntegrationTest.cs b/tests/Core.Tests/Mods/ModRepositoryIntegrationTest.cs index e48c950..851569d 100644 --- a/tests/Core.Tests/Mods/ModRepositoryIntegrationTest.cs +++ b/tests/Core.Tests/Mods/ModRepositoryIntegrationTest.cs @@ -1,4 +1,5 @@ using Core.Mods; +using Core.Tests.Base; using FluentAssertions; namespace Core.Tests.Mods; diff --git a/tests/Core.Tests/Utils/DictionaryExtensionsTest.cs b/tests/Core.Tests/Utils/DictionaryExtensionsTest.cs new file mode 100644 index 0000000..effcf0e --- /dev/null +++ b/tests/Core.Tests/Utils/DictionaryExtensionsTest.cs @@ -0,0 +1,33 @@ +using Core.Utils; +using FluentAssertions; + +namespace Core.Tests.Utils; + +[UnitTest] +public class DictionaryExtensionsTest +{ + private const int Key = 3; + + [Fact] + public void Upsert_AddsIfNotExisting() + { + var dict = new Dictionary(); + + dict.Upsert(Key, _ => throw new Exception("Should not have been called"), () => 42); + + dict[Key].Should().Be(42); + } + + [Fact] + public void Upsert_UpdatesIfExisting() + { + var dict = new Dictionary() + { + [Key] = 2 + }; + + dict.Upsert(Key, _ => _ + 40, () => throw new Exception("Should not have been called")); + + dict[Key].Should().Be(42); + } +} diff --git a/tests/Core.Tests/Utils/StringExtensionsTest.cs b/tests/Core.Tests/Utils/StringExtensionsTest.cs index c1752a9..0d12920 100644 --- a/tests/Core.Tests/Utils/StringExtensionsTest.cs +++ b/tests/Core.Tests/Utils/StringExtensionsTest.cs @@ -4,7 +4,7 @@ namespace Core.Tests.Utils; [UnitTest] -public class PostProcessorTest +public class StringExtensionsTest { [Fact] public void NormalizeWhitespaces_ReplacesWithSpaces()