diff --git a/src/Core/API/BaseEventLogger.cs b/src/Core/API/BaseEventLogger.cs index 491670f..e0458a8 100644 --- a/src/Core/API/BaseEventLogger.cs +++ b/src/Core/API/BaseEventLogger.cs @@ -43,9 +43,12 @@ public void UninstallStart() => LogMessage($"Uninstalling mods:"); public void UninstallCurrent(string packageName) => LogMessage($"- {packageName}"); - public void UninstallSkipModified(string filePath) => - LogMessage($" Skipping modified file {filePath}"); public void UninstallEnd() { } + + public void BackupSkipped(RootedPath path) => + LogMessage($" Skipping backup of {path.Full}"); + public void RestoreSkipped(RootedPath path) => + LogMessage($" Skipping restore of {path.Full}"); } diff --git a/src/Core/API/Init.cs b/src/Core/API/Init.cs index 3572cbb..eea398c 100644 --- a/src/Core/API/Init.cs +++ b/src/Core/API/Init.cs @@ -21,18 +21,18 @@ public static IModManager CreateModManager(Config config) var statePersistence = new JsonFileStatePersistence(modsDir); var modRepository = new FileSystemRepository(modsDir); var safeFileDelete = new WindowsRecyclingBin(); - var packagesUpdater = CreateModPackagesUpdater(config.ModInstall, game, tempDir); + var packagesUpdater = CreatePackagesUpdater(config.ModInstall, game, tempDir); return new ModManager(game, modRepository, packagesUpdater, statePersistence, safeFileDelete, tempDir); } - internal static ModPackagesUpdater CreateModPackagesUpdater( + internal static IPackagesUpdater CreatePackagesUpdater( ModInstallConfig installerConfig, IGame game, ITempDir tempDir) { - var backupStrategy = new SuffixBackupStrategy(); - var backupStrategyProvider = new SkipUpdatedBackupStrategy.Provider(backupStrategy); - return new ModPackagesUpdater( + var backupStrategyProvider = new SkipUpdatedBackupStrategy.Provider( + new SuffixBackupStrategy.Provider()); + return new ModPackagesUpdater( new FileSystemInstallerFactory(), backupStrategyProvider, TimeProvider.System, game, tempDir, installerConfig); } diff --git a/src/Core/API/ModManager.cs b/src/Core/API/ModManager.cs index d422471..c156054 100644 --- a/src/Core/API/ModManager.cs +++ b/src/Core/API/ModManager.cs @@ -16,9 +16,9 @@ internal class ModManager : IModManager private readonly ISafeFileDelete safeFileDelete; private readonly ITempDir tempDir; - private readonly ModPackagesUpdater packagesUpdater; + private readonly IPackagesUpdater packagesUpdater; - internal ModManager(IGame game, IPackageRepository packageRepository, ModPackagesUpdater packagesUpdater, IStatePersistence statePersistence, ISafeFileDelete safeFileDelete, ITempDir tempDir) + internal ModManager(IGame game, IPackageRepository packageRepository, IPackagesUpdater packagesUpdater, IStatePersistence statePersistence, ISafeFileDelete safeFileDelete, ITempDir tempDir) { this.game = game; this.packageRepository = packageRepository; @@ -47,7 +47,7 @@ public List FetchState() var availableModPackages = enabledModPackages.Merge(disabledModPackages); var isModInstalled = DependencyResolver - .CollectValues(installedMods, s => s.Dependencies ?? Array.Empty(), s => s?.Partial ?? true) + .CollectValues(installedMods, s => s.Dependencies.Concat(s.ShadowedBy).ToArray(), s => s?.Partial ?? true) .SelectValues, bool?>(partials => partials.Any(p => p) ? null : true); var modsOutOfDate = installedMods.SelectValues((packageName, modInstallationState) => @@ -158,7 +158,7 @@ private void UpdateMods(IEnumerable packages, IEventHandler eventHandle nextState => statePersistence.WriteState(new SavedState( Install: new InstallationState( - Time: nextState.Values.Max(s => s.Time), + Time: null, Mods: nextState ) )), diff --git a/src/Core/Mods/Installation/ModPackagesUpdater.cs b/src/Core/Mods/Installation/ModPackagesUpdater.cs index a0cf7da..989f1a8 100644 --- a/src/Core/Mods/Installation/ModPackagesUpdater.cs +++ b/src/Core/Mods/Installation/ModPackagesUpdater.cs @@ -8,28 +8,16 @@ namespace Core.Mods.Installation; -public class ModPackagesUpdater : PackagesUpdater +public class ModPackagesUpdater : PackagesUpdater + where TEventHandler : ModPackagesUpdater.IEventHandler { - #region TODO Move to a better place when not called all over the place - - internal const string BootfilesPrefix = "__bootfiles"; - - internal static bool IsBootFiles(string packageName) => - packageName.StartsWith(BootfilesPrefix); - - #endregion - - public interface IEventHandler : PackagesUpdater.IEventHandler, BootfilesInstaller.IEventHandler - { - } - private readonly IGame game; private readonly ITempDir tempDir; private readonly ModInstaller.IConfig config; public ModPackagesUpdater( IInstallerFactory installerFactory, - IBackupStrategyProvider backupStrategyProvider, + IBackupStrategyProvider backupStrategyProvider, TimeProvider timeProvider, IGame game, ITempDir tempDir, @@ -45,23 +33,39 @@ protected override void Apply( IReadOnlyDictionary currentState, IReadOnlyCollection installers, string installDir, - Action afterInstall, - IEventHandler eventHandler, + Action updatePackageState, + TEventHandler eventHandler, CancellationToken cancellationToken) { - var (bootfiles, notBootfiles) = installers.Partition(p => IsBootFiles(p.PackageName)); + var (bootfiles, notBootfiles) = installers.Partition(p => ModPackagesUpdater.IsBootFiles(p.PackageName)); var bootfilesInstaller = CreateBootfilesInstaller(bootfiles, eventHandler); var allInstallers = notBootfiles .Select(i => new ModInstaller(i, bootfilesInstaller.PackageName, game, tempDir, config)) .Append(bootfilesInstaller).ToImmutableArray(); - base.Apply(currentState, allInstallers, installDir, afterInstall, eventHandler, cancellationToken); + base.Apply(currentState, allInstallers, installDir, updatePackageState, eventHandler, cancellationToken); } - private IInstaller CreateBootfilesInstaller(IEnumerable bootfilesPackageInstallers, IEventHandler eventHandler) + private IInstaller CreateBootfilesInstaller(IEnumerable bootfilesPackageInstallers, ModPackagesUpdater.IEventHandler eventHandler) { var installer = bootfilesPackageInstallers.FirstOrDefault(); return new BootfilesInstaller(installer, game, tempDir, eventHandler, config); } } + +public static class ModPackagesUpdater +{ + #region TODO Move to a better place when not called all over the place + + internal const string BootfilesPrefix = "__bootfiles"; + + internal static bool IsBootFiles(string packageName) => + packageName.StartsWith(BootfilesPrefix); + + #endregion + + public interface IEventHandler : PackagesUpdater.IEventHandler, BootfilesInstaller.IEventHandler + { + } +} diff --git a/src/Core/Packages/Installation/Backup/IBackupEventHandler.cs b/src/Core/Packages/Installation/Backup/IBackupEventHandler.cs new file mode 100644 index 0000000..acdb75f --- /dev/null +++ b/src/Core/Packages/Installation/Backup/IBackupEventHandler.cs @@ -0,0 +1,9 @@ +using Core.Utils; + +namespace Core.Packages.Installation.Backup; + +public interface IBackupEventHandler +{ + void BackupSkipped(RootedPath path); + void RestoreSkipped(RootedPath path); +} diff --git a/src/Core/Packages/Installation/Backup/IBackupStrategy.cs b/src/Core/Packages/Installation/Backup/IBackupStrategy.cs index d6b0c36..63d68dc 100644 --- a/src/Core/Packages/Installation/Backup/IBackupStrategy.cs +++ b/src/Core/Packages/Installation/Backup/IBackupStrategy.cs @@ -4,8 +4,27 @@ namespace Core.Packages.Installation.Backup; public interface IBackupStrategy { + /// + /// Performs backup of a file. + /// + /// File to back up. public void PerformBackup(RootedPath path); - public bool RestoreBackup(RootedPath path); + + /// + /// Restores and deletes a previously performed backup. + /// + /// File to restore. + public void RestoreBackup(RootedPath path); + + /// + /// Removes an existing backup without restoring it. + /// + /// public void DeleteBackup(RootedPath path); + + /// + /// Optional post-install steps to track files overwritten by game updates. + /// + /// public void AfterInstall(RootedPath path); } diff --git a/src/Core/Packages/Installation/Backup/IBackupStrategyProvider.cs b/src/Core/Packages/Installation/Backup/IBackupStrategyProvider.cs index c9dfd12..dd8fe70 100644 --- a/src/Core/Packages/Installation/Backup/IBackupStrategyProvider.cs +++ b/src/Core/Packages/Installation/Backup/IBackupStrategyProvider.cs @@ -1,6 +1,6 @@ namespace Core.Packages.Installation.Backup; -public interface IBackupStrategyProvider +public interface IBackupStrategyProvider { - IBackupStrategy BackupStrategy(TState? state); + IBackupStrategy BackupStrategy(TState? state, TEventHandler? eventHandler); } diff --git a/src/Core/Packages/Installation/Backup/MoveFileBackupStrategy.cs b/src/Core/Packages/Installation/Backup/MoveFileBackupStrategy.cs index 1c9d951..a624a53 100644 --- a/src/Core/Packages/Installation/Backup/MoveFileBackupStrategy.cs +++ b/src/Core/Packages/Installation/Backup/MoveFileBackupStrategy.cs @@ -13,16 +13,18 @@ public interface IBackupFileNaming private readonly IFileSystem fs; private readonly IBackupFileNaming backupFileNaming; + private readonly IBackupEventHandler? eventHandler; - public MoveFileBackupStrategy(IBackupFileNaming backupFileNaming) : - this(new FileSystem(), backupFileNaming) + public MoveFileBackupStrategy(IBackupFileNaming backupFileNaming, IBackupEventHandler? eventHandler) : + this(new FileSystem(), backupFileNaming, eventHandler) { } - public MoveFileBackupStrategy(IFileSystem fs, IBackupFileNaming backupFileNaming) + internal MoveFileBackupStrategy(IFileSystem fs, IBackupFileNaming backupFileNaming, IBackupEventHandler? eventHandler) { this.fs = fs; this.backupFileNaming = backupFileNaming; + this.eventHandler = eventHandler; } public virtual void PerformBackup(RootedPath path) @@ -37,17 +39,18 @@ public virtual void PerformBackup(RootedPath path) } var backupFilePath = backupFileNaming.ToBackup(path.Full); + if (fs.File.Exists(backupFilePath)) { fs.File.Delete(path.Full); + eventHandler?.BackupSkipped(path); + return; } - else - { - fs.File.Move(path.Full, backupFilePath); - } + + fs.File.Move(path.Full, backupFilePath); } - public bool RestoreBackup(RootedPath path) + public void RestoreBackup(RootedPath path) { if (fs.File.Exists(path.Full)) { @@ -58,8 +61,6 @@ public bool RestoreBackup(RootedPath path) { fs.File.Move(backupFilePath, path.Full); } - - return true; } public void DeleteBackup(RootedPath path) diff --git a/src/Core/Packages/Installation/Backup/SkipUpdatedBackupStrategy.cs b/src/Core/Packages/Installation/Backup/SkipUpdatedBackupStrategy.cs index f1b770a..c863861 100644 --- a/src/Core/Packages/Installation/Backup/SkipUpdatedBackupStrategy.cs +++ b/src/Core/Packages/Installation/Backup/SkipUpdatedBackupStrategy.cs @@ -8,38 +8,45 @@ namespace Core.Packages.Installation.Backup; /// internal class SkipUpdatedBackupStrategy : IBackupStrategy { - internal class Provider : IBackupStrategyProvider + internal class Provider : IBackupStrategyProvider + where TEventHandler : IBackupEventHandler { - private readonly IBackupStrategy baseStrategy; + private readonly IBackupStrategyProvider baseProvider; - public Provider(IBackupStrategy baseStrategy) + public Provider(IBackupStrategyProvider baseProvider) { - this.baseStrategy = baseStrategy; + this.baseProvider = baseProvider; } - public IBackupStrategy BackupStrategy(PackageInstallationState? state) => - new SkipUpdatedBackupStrategy(baseStrategy, state?.Time); + public IBackupStrategy BackupStrategy(PackageInstallationState? state, TEventHandler? eventHandler) { + var baseStrategy = baseProvider.BackupStrategy(state, eventHandler); + return new SkipUpdatedBackupStrategy(baseStrategy, state?.Time, eventHandler); + } } private readonly IFileSystem fs; private readonly IBackupStrategy inner; private readonly DateTime? backupTimeUtc; + private readonly IBackupEventHandler? eventHandler; private SkipUpdatedBackupStrategy( IBackupStrategy backupStrategy, - DateTime? backupTimeUtc) : - this(new FileSystem(), backupStrategy, backupTimeUtc) + DateTime? backupTimeUtc, + IBackupEventHandler? eventHandler) : + this(new FileSystem(), backupStrategy, backupTimeUtc, eventHandler) { } internal SkipUpdatedBackupStrategy( IFileSystem fs, IBackupStrategy backupStrategy, - DateTime? backupTimeUtc) + DateTime? backupTimeUtc, + IBackupEventHandler? eventHandler) { this.fs = fs; inner = backupStrategy; this.backupTimeUtc = backupTimeUtc; + this.eventHandler = eventHandler; } public void DeleteBackup(RootedPath path) => @@ -48,15 +55,15 @@ public void DeleteBackup(RootedPath path) => public void PerformBackup(RootedPath path) => inner.PerformBackup(path); - public bool RestoreBackup(RootedPath path) + public void RestoreBackup(RootedPath path) { if (FileWasOverwritten(path)) { inner.DeleteBackup(path); - return false; + eventHandler?.RestoreSkipped(path); + return; } - - return inner.RestoreBackup(path); + inner.RestoreBackup(path); } private bool FileWasOverwritten(RootedPath path) => diff --git a/src/Core/Packages/Installation/Backup/SuffixBackupStrategy.cs b/src/Core/Packages/Installation/Backup/SuffixBackupStrategy.cs index d4b90d7..f5b9b99 100644 --- a/src/Core/Packages/Installation/Backup/SuffixBackupStrategy.cs +++ b/src/Core/Packages/Installation/Backup/SuffixBackupStrategy.cs @@ -2,6 +2,13 @@ public class SuffixBackupStrategy : MoveFileBackupStrategy { + internal class Provider : IBackupStrategyProvider + where TEventHandler : IBackupEventHandler + { + public IBackupStrategy BackupStrategy(TState? _, TEventHandler? eventHandler) => + new SuffixBackupStrategy(eventHandler); + } + private class BackupFileNaming : IBackupFileNaming { private const string BackupSuffix = ".orig"; @@ -10,7 +17,8 @@ private class BackupFileNaming : IBackupFileNaming public bool IsBackup(string fullPath) => fullPath.EndsWith(BackupSuffix); } - public SuffixBackupStrategy() : base(new BackupFileNaming()) + public SuffixBackupStrategy(IBackupEventHandler? eventHandler) : + base(new BackupFileNaming(), eventHandler) { } } diff --git a/src/Core/Packages/Installation/IPackagesUpdater.cs b/src/Core/Packages/Installation/IPackagesUpdater.cs index e1dc962..c80825c 100644 --- a/src/Core/Packages/Installation/IPackagesUpdater.cs +++ b/src/Core/Packages/Installation/IPackagesUpdater.cs @@ -1,14 +1,14 @@ -using Core.Packages.Installation.Installers; +using Core.Packages.Repository; namespace Core.Packages.Installation; public interface IPackagesUpdater { void Apply( - IReadOnlyDictionary currentState, - IReadOnlyCollection installers, + IReadOnlyDictionary previousState, + IEnumerable packages, string installDir, - Action afterInstall, + Action> afterInstall, TEventHandler eventHandler, CancellationToken cancellationToken); } diff --git a/src/Core/Packages/Installation/Installers/BaseInstaller.cs b/src/Core/Packages/Installation/Installers/BaseInstaller.cs index dcde282..6d764b5 100644 --- a/src/Core/Packages/Installation/Installers/BaseInstaller.cs +++ b/src/Core/Packages/Installation/Installers/BaseInstaller.cs @@ -1,4 +1,5 @@ -using Core.Packages.Installation.Backup; +using System.Runtime.CompilerServices; +using Core.Packages.Installation.Backup; using Core.Utils; namespace Core.Packages.Installation.Installers; @@ -12,8 +13,7 @@ internal abstract class BaseInstaller : IInstaller public string PackageName { get; } public int? PackageFsHash { get; } - // A package cannot currently specify dependencies. - public IReadOnlyCollection PackageDependencies => Array.Empty(); + public IReadOnlyCollection PackageDependencies { get; } public IInstallation.State Installed { get; private set; } public IReadOnlyCollection InstalledFiles => installedFiles; @@ -21,9 +21,16 @@ internal abstract class BaseInstaller : IInstaller private readonly List installedFiles = new(); protected BaseInstaller(string packageName, int? packageFsHash) + : this(packageName, packageFsHash, Array.Empty()) + { + } + + // A package cannot currently specify dependencies. + protected BaseInstaller(string packageName, int? packageFsHash, IReadOnlyCollection packageDependencies) { PackageName = packageName; PackageFsHash = packageFsHash; + PackageDependencies = packageDependencies; } public void Install(IInstaller.Destination destination, IBackupStrategy backupStrategy, ProcessingCallbacks callbacks) diff --git a/src/Core/Packages/Installation/Installers/ProcessingCallbacks.cs b/src/Core/Packages/Installation/Installers/ProcessingCallbacks.cs index e286acd..48686ac 100644 --- a/src/Core/Packages/Installation/Installers/ProcessingCallbacks.cs +++ b/src/Core/Packages/Installation/Installers/ProcessingCallbacks.cs @@ -1,98 +1,77 @@ namespace Core.Packages.Installation.Installers; -public struct ProcessingCallbacks +public readonly struct ProcessingCallbacks { + public ProcessingCallbacks() + { + } + private static readonly Predicate EmptyPredicate = _ => true; private static readonly Action EmptyAction = _ => { }; - private Predicate? accept; /// /// Decide if an entry should be processed. /// - public Predicate Accept - { - get => accept ?? EmptyPredicate; - set => accept = value; - } + public Predicate Accept { get; init; } = EmptyPredicate; - private Action? before; /// /// Called before processing an entry. /// - public Action Before - { - get => before ?? EmptyAction; - set => before = value; - } + public Action Before { get; init; } = EmptyAction; - private Action? after; /// /// Called after processing an entry. /// - public Action After - { - get => after ?? EmptyAction; - set => after = value; - } + public Action After { get; init; } = EmptyAction; - private Action? notAccepted; /// /// Called if not processing an entry. /// - public Action NotAccepted - { - get => notAccepted ?? EmptyAction; - set => notAccepted = value; - } + public Action NotAccepted { get; init; } = EmptyAction; public ProcessingCallbacks AndAccept(Predicate additional) => - new() + this with { - accept = Combine(accept, additional), - before = before, - after = after, - notAccepted = notAccepted + Accept = Combine(Accept, additional), }; public ProcessingCallbacks AndBefore(Action additional) => - new() + this with { - accept = accept, - before = Combine(before, additional), - after = after, - notAccepted = notAccepted + Before = Combine(Before, additional) }; public ProcessingCallbacks AndAfter(Action additional) => - new() + this with { - accept = accept, - before = before, - after = Combine(after, additional), - notAccepted = notAccepted + After = Combine(After, additional) }; public ProcessingCallbacks AndNotAccepted(Action additional) => - new() - { - accept = accept, - before = before, - after = after, - notAccepted = Combine(notAccepted, additional) - }; + this with + { + NotAccepted = Combine(NotAccepted, additional) + }; public ProcessingCallbacks AndFinally(Action additional) => - new() - { - accept = accept, - before = before, - after = Combine(after, additional), - notAccepted = Combine(notAccepted, additional) - }; + this with + { + After = Combine(After, additional), + NotAccepted = Combine(NotAccepted, additional) + }; + + public ProcessingCallbacks And(ProcessingCallbacks additional) => + new() + { + Accept = Combine(Accept, additional.Accept), + Before = Combine(Before, additional.Before), + After = Combine(After, additional.After), + NotAccepted = Combine(NotAccepted, additional.NotAccepted) + }; - private static Predicate? Combine(Predicate? p1, Predicate p2) => - p1 is null ? p2 : key => p1(key) && p2(key); + private static Predicate Combine(Predicate p1, Predicate p2) => + p1 == EmptyPredicate ? p2 : p2 == EmptyPredicate ? p1 : key => p1(key) && p2(key); - private static Action? Combine(Action? a1, Action a2) => - a1 is null ? a2 : key => { a1(key); a2(key); }; + private static Action Combine(Action a1, Action a2) => + a1 == EmptyAction ? a2 : a2 == EmptyAction ? a1 : key => { a1(key); a2(key); }; } diff --git a/src/Core/Packages/Installation/PackageInstallationState.cs b/src/Core/Packages/Installation/PackageInstallationState.cs index cf86a8d..d0fe1d6 100644 --- a/src/Core/Packages/Installation/PackageInstallationState.cs +++ b/src/Core/Packages/Installation/PackageInstallationState.cs @@ -1,14 +1,15 @@ -namespace Core.Packages.Installation; +using Newtonsoft.Json; + +namespace Core.Packages.Installation; public record PackageInstallationState( - // TODO: nullable for backward compatibility - DateTime? Time, + 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, - // TODO: needed for backward compatibility - IReadOnlyCollection? Dependencies, - IReadOnlyCollection Files + IReadOnlyCollection Dependencies, + IReadOnlyCollection Files, + IReadOnlyCollection ShadowedBy ); diff --git a/src/Core/Packages/Installation/PackagesUpdater.cs b/src/Core/Packages/Installation/PackagesUpdater.cs index 7d41431..ad0bf23 100644 --- a/src/Core/Packages/Installation/PackagesUpdater.cs +++ b/src/Core/Packages/Installation/PackagesUpdater.cs @@ -6,16 +6,16 @@ namespace Core.Packages.Installation; -public class PackagesUpdater +public class PackagesUpdater : IPackagesUpdater where TEventHandler : PackagesUpdater.IEventHandler { private readonly IInstallerFactory installerFactory; - private readonly IBackupStrategyProvider backupStrategyProvider; + private readonly IBackupStrategyProvider backupStrategyProvider; private readonly TimeProvider timeProvider; public PackagesUpdater( IInstallerFactory installerFactory, - IBackupStrategyProvider backupStrategyProvider, + IBackupStrategyProvider backupStrategyProvider, TimeProvider timeProvider) { this.installerFactory = installerFactory; @@ -28,7 +28,8 @@ public void Apply( IEnumerable packages, string installDir, Action> afterInstall, - TEventHandler eventHandler, CancellationToken cancellationToken) + TEventHandler eventHandler, + CancellationToken cancellationToken) { var installers = packages.Select(installerFactory.PackageInstaller).ToImmutableArray(); @@ -63,62 +64,61 @@ protected virtual void Apply( IReadOnlyDictionary currentState, IReadOnlyCollection installers, string installDir, - Action afterInstall, + Action updatePackageState, TEventHandler eventHandler, CancellationToken cancellationToken) { - UninstallPackages(currentState, installDir, afterInstall, eventHandler, cancellationToken); - InstallPackages(installers, installDir, afterInstall, eventHandler, cancellationToken); + UninstallPackages(currentState, installers, installDir, updatePackageState, eventHandler, cancellationToken); + InstallPackages(currentState, installers, installDir, updatePackageState, eventHandler, cancellationToken); } private void UninstallPackages( IReadOnlyDictionary currentState, + IReadOnlyCollection installers, string installDir, - Action afterUninstall, + Action updatePackageState, TEventHandler eventHandler, CancellationToken cancellationToken) { if (currentState.Any()) { eventHandler.UninstallStart(); - foreach (var (packageName, packagePackageInstallationState) in currentState) + foreach (var (packageName, packageInstallationState) in currentState) { if (cancellationToken.IsCancellationRequested) { break; } eventHandler.UninstallCurrent(packageName); - var backupStrategy = backupStrategyProvider.BackupStrategy(packagePackageInstallationState); - var filesLeft = packagePackageInstallationState.Files.ToHashSet(StringComparer.OrdinalIgnoreCase); + var backupStrategy = backupStrategyProvider.BackupStrategy(packageInstallationState, eventHandler); + var filesLeft = packageInstallationState.Files.ToHashSet(StringComparer.OrdinalIgnoreCase); + var error = false; try { - foreach (var relativePath in packagePackageInstallationState.Files) + foreach (var relativePath in packageInstallationState.Files) { var gamePath = new RootedPath(installDir, relativePath); - if (!backupStrategy.RestoreBackup(gamePath)) - { - eventHandler.UninstallSkipModified(gamePath.Relative); - } + backupStrategy.RestoreBackup(gamePath); filesLeft.Remove(gamePath.Relative); } - DeleteEmptyDirectories(installDir, packagePackageInstallationState.Files); + DeleteEmptyDirectories(installDir, packageInstallationState.Files); + } + catch + { + error = true; + throw; } finally { - if (filesLeft.Count == 0) - { - afterUninstall(packageName, null); - } - else - { - afterUninstall(packageName, packagePackageInstallationState with - { - // // Once partially uninstalled, it will stay that way unless fully uninstalled - Partial = packagePackageInstallationState.Partial || - filesLeft.Count != packagePackageInstallationState.Files.Count, - Files = filesLeft - }); - } + updatePackageState(packageName, + filesLeft.Count == 0 ? + null : + packageInstallationState with + { + Partial = error, + Files = filesLeft + } + ); } } eventHandler.UninstallEnd(); @@ -157,26 +157,27 @@ private static List AncestorsUpTo(string root, string path) } private void InstallPackages( + IReadOnlyDictionary currentState, IReadOnlyCollection installers, - string destinationDir, - Action afterInstall, + string installDir, + Action updatePackageState, TEventHandler eventHandler, CancellationToken cancellationToken) { - var allInstalledFiles = new Dictionary(StringComparer.OrdinalIgnoreCase); + // Increase by one for uninstall step + var progress = new PercentOfTotal(installers.Count + 1); - // Increase by one in case bootfiles are needed and another one to show that something is happening - var progress = new PercentOfTotal(installers.Count + 2); - if (installers.Any()) + var allInstalledFiles = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (installers.Count > 0) { eventHandler.InstallStart(); - eventHandler.ProgressUpdate(progress.IncrementDone()); foreach (var installer in installers.TakeWhile(_ => !cancellationToken.IsCancellationRequested)) { + eventHandler.ProgressUpdate(progress.IncrementDone()); eventHandler.InstallCurrent(installer.PackageName); - var backupStrategy = backupStrategyProvider.BackupStrategy(null); - var automaticDependencies = new HashSet(); + var backupStrategy = backupStrategyProvider.BackupStrategy(state: null, eventHandler); + var shadowedBy = new HashSet(); var installCallbacks = new ProcessingCallbacks { Accept = gamePath => @@ -188,7 +189,7 @@ private void InstallPackages( } if (overridingPackageName != installer.PackageName) { - automaticDependencies.Add(overridingPackageName); + shadowedBy.Add(overridingPackageName); } return false; }, @@ -196,31 +197,29 @@ private void InstallPackages( }; try { - installer.Install(InstallTo(destinationDir), backupStrategy, installCallbacks); + installer.Install(InstallTo(installDir), backupStrategy, installCallbacks); } finally { var packageInstalledFiles = installer.InstalledFiles - .Where(rp => rp.Root == destinationDir) + .Where(rp => rp.Root == installDir) .Select(rp => rp.Relative) .ToImmutableList(); - automaticDependencies.UnionWith(installer.PackageDependencies); - afterInstall(installer.PackageName, - packageInstalledFiles.Count == 0 + updatePackageState(installer.PackageName, + packageInstalledFiles.IsEmpty ? null : new PackageInstallationState( Time: timeProvider.GetUtcNow().DateTime, FsHash: installer.PackageFsHash, Partial: installer.Installed == IInstallation.State.PartiallyInstalled, - Dependencies: automaticDependencies, + Dependencies: installer.PackageDependencies, + ShadowedBy: shadowedBy, Files: packageInstalledFiles )); } - eventHandler.ProgressUpdate(progress.IncrementDone()); } eventHandler.InstallEnd(); - eventHandler.ProgressUpdate(progress.IncrementDone()); } else { @@ -235,7 +234,7 @@ private static IInstaller.Destination InstallTo(string destDir) => public static class PackagesUpdater { - public interface IEventHandler : IProgress + public interface IEventHandler : IProgress, IBackupEventHandler { void InstallNoPackages(); void InstallStart(); @@ -245,7 +244,6 @@ public interface IEventHandler : IProgress void UninstallNoPackages(); void UninstallStart(); void UninstallCurrent(string packageName); - void UninstallSkipModified(string filePath); void UninstallEnd(); } diff --git a/src/Core/State/JsonFileStatePersistence.cs b/src/Core/State/JsonFileStatePersistence.cs index f8f74ba..e3323b1 100644 --- a/src/Core/State/JsonFileStatePersistence.cs +++ b/src/Core/State/JsonFileStatePersistence.cs @@ -1,4 +1,6 @@ -using Core.Packages.Installation; +using System.Diagnostics.CodeAnalysis; +using System.IO.Abstractions; +using Core.Packages.Installation; using Core.Utils; using Newtonsoft.Json; @@ -6,11 +8,12 @@ namespace Core.State; internal class JsonFileStatePersistence : IStatePersistence { - private const string StateFileName = "state.json"; - private const string OldStateFileName = "installed.json"; + private const string StateV2FileName = "state.json"; + private const string StateV1FileName = "installed.json"; - private readonly string stateFile; - private readonly string oldStateFile; + private readonly IFileSystem fs; + private readonly string stateV2FilePath; + private readonly string stateV1FilePath; private static readonly JsonSerializerSettings JsonSerializerSettings = new() { @@ -18,44 +21,57 @@ internal class JsonFileStatePersistence : IStatePersistence DefaultValueHandling = DefaultValueHandling.Ignore, }; - public JsonFileStatePersistence(string modsDir) + public JsonFileStatePersistence(string modsPath) : + this(new FileSystem(), Path.Combine(modsPath, StateV2FileName), Path.Combine(modsPath, StateV1FileName)) { - stateFile = Path.Combine(modsDir, StateFileName); - oldStateFile = Path.Combine(modsDir, OldStateFileName); } + internal JsonFileStatePersistence(IFileSystem fs, string stateV2FilePath, string stateV1FilePath) + { + this.fs = fs; + this.stateV2FilePath = stateV2FilePath; + this.stateV1FilePath = stateV1FilePath; + } + + // TODO: this can be made better with some work + // The beauty of JSON libraries setting non-null fields to null + [SuppressMessage("ReSharper", "NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract")] public SavedState ReadState() { - // Always favour new state if present - if (File.Exists(stateFile)) + if (fs.File.Exists(stateV2FilePath)) { - var contents = File.ReadAllText(stateFile); + var contents = fs.File.ReadAllText(stateV2FilePath); var state = JsonConvert.DeserializeObject(contents); - // Fill mod install time if not present (for migration) + var installTime = state.Install.Time ?? fs.File.GetLastWriteTimeUtc(stateV2FilePath); return state with { Install = state.Install with { - Mods = state.Install.Mods.SelectValues(_ => _ with { Time = _.Time ?? state.Install.Time }) + Mods = state.Install.Mods.SelectValues(pis => pis with + { + Time = pis.Time == default ? installTime : pis.Time, + Dependencies = pis.Dependencies ?? Array.Empty(), + Files = pis.Files ?? Array.Empty(), + ShadowedBy = pis.ShadowedBy ?? Array.Empty() + }) } }; } - // Fallback to old state when new state is not present - if (File.Exists(oldStateFile)) + if (fs.File.Exists(stateV1FilePath)) { - var contents = File.ReadAllText(oldStateFile); - var oldState = JsonConvert.DeserializeObject>>(contents); - var installTime = File.GetLastWriteTimeUtc(oldStateFile); + var contents = fs.File.ReadAllText(stateV1FilePath); + var state = JsonConvert.DeserializeObject>>(contents); + var installTime = fs.File.GetLastWriteTimeUtc(stateV1FilePath); return new SavedState( - Install: new( - Time: installTime, - Mods: oldState.AsEnumerable().ToDictionary( + Install: new InstallationState( + Time: null, + Mods: state.AsEnumerable().ToDictionary( kv => kv.Key, kv => new PackageInstallationState( Time: installTime, FsHash: null, Partial: false, Dependencies: Array.Empty(), - Files: kv.Value) + Files: kv.Value, ShadowedBy: Array.Empty()) ) ) ); @@ -66,10 +82,9 @@ public SavedState ReadState() public void WriteState(SavedState state) { - // Remove old state if upgrading from a previous version - File.Delete(oldStateFile); + // Remove state v1 on write if upgrading from a previous version + fs.File.Delete(stateV1FilePath); - File.WriteAllText(stateFile, JsonConvert.SerializeObject(state, JsonSerializerSettings)); + fs.File.WriteAllText(stateV2FilePath, JsonConvert.SerializeObject(state, JsonSerializerSettings)); } } - diff --git a/src/Core/Utils/RootedPath.cs b/src/Core/Utils/RootedPath.cs index 7ac7660..533c4ff 100644 --- a/src/Core/Utils/RootedPath.cs +++ b/src/Core/Utils/RootedPath.cs @@ -6,7 +6,7 @@ public record RootedPath public string Relative { get; } public string Full { get; } - public RootedPath(string rootPath, string relativePath) + public RootedPath(string rootPath, string relativePath = "") { Root = rootPath; Relative = relativePath; diff --git a/tests/Core.Tests/API/ModManagerIntegrationTest.cs b/tests/Core.Tests/API/ModManagerIntegrationTest.cs index 966caab..198eed5 100644 --- a/tests/Core.Tests/API/ModManagerIntegrationTest.cs +++ b/tests/Core.Tests/API/ModManagerIntegrationTest.cs @@ -1,4 +1,3 @@ -using System.IO.Compression; using Core.API; using Core.Games; using Core.IO; @@ -31,8 +30,7 @@ public class ModManagerIntegrationTest : AbstractFilesystemTest private static readonly string DrivelineRelativePath = Path.Combine(BootfilesInstaller.DrivelineRelativeDir, PostProcessor.DrivelineFileName); - // 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 DateTime PastDate = DateTime.Today.AddDays(-1); private static readonly TimeSpan TimeTolerance = TimeSpan.FromMilliseconds(100); @@ -61,12 +59,12 @@ public ModManagerIntegrationTest() DirsAtRoot = [DirAtRoot], ExcludedFromInstall = [$"**\\{FileExcludedFromInstall}"] }; - var modPackagesUpdater = Init.CreateModPackagesUpdater(modInstallConfig, gameMock.Object, tempDir); + var packagesUpdater = Init.CreatePackagesUpdater(modInstallConfig, gameMock.Object, tempDir); modManager = new ModManager( gameMock.Object, modRepositoryMock.Object, - modPackagesUpdater, + packagesUpdater, persistedState, safeFileDeleteMock.Object, tempDir); @@ -82,9 +80,7 @@ public void FetchState_AlwaysReturnsModsInstalledOrNot() persistedState.InitModInstallationState(new Dictionary { ["I"] = new( - Time: null, FsHash: null, Partial: false, - Dependencies: [], - Files: []), + Time: PastDate, FsHash: null, Partial: false, Dependencies: [],Files: [], ShadowedBy: []), }); modRepositoryMock.Setup(m => m.ListEnabled()).Returns( [ @@ -109,17 +105,20 @@ public void FetchState_MergesInstalledAndAvailable() persistedState.InitModInstallationState(new Dictionary { ["A"] = new( - Time: null, FsHash: 999, Partial: false, + Time: PastDate, FsHash: 999, Partial: false, Dependencies: [], - Files: []), + Files: [], + ShadowedBy: []), ["B"] = new( - Time: null, FsHash: null, Partial: false, + Time: PastDate, FsHash: null, Partial: false, Dependencies: [], - Files: []), + Files: [], + ShadowedBy: []), ["C"] = new( - Time: null, FsHash: 103, Partial: true, + Time: PastDate, FsHash: 103, Partial: true, Dependencies: [], - Files: []) + Files: [], + ShadowedBy: []) }); modRepositoryMock.Setup(m => m.ListEnabled()).Returns( [ @@ -145,17 +144,52 @@ public void FetchState_PropagatesPartialOrMissingInstallationToDependants() persistedState.InitModInstallationState(new Dictionary { ["A"] = new( - Time: null, FsHash: null, Partial: false, - Dependencies: ["AD"], - Files: []), - ["AD"] = new( - Time: null, FsHash: null, Partial: true, + Time: PastDate, FsHash: null, Partial: false, + Dependencies: ["Partial"], + Files: [], + ShadowedBy: []), + ["B"] = new( + Time: PastDate, FsHash: null, Partial: false, + Dependencies: ["NotInstalled"], + Files: [], + ShadowedBy: []), + ["Partial"] = new( + Time: PastDate, FsHash: null, Partial: true, + Dependencies: [], + Files: [], + ShadowedBy: []), + }); + modRepositoryMock.Setup(m => m.ListEnabled()).Returns([]); + modRepositoryMock.Setup(m => m.ListDisabled()).Returns([]); + + modManager.FetchState().Should().BeEquivalentTo( + [ + new ModState("A", null, IsInstalled: null, IsEnabled: false, IsOutOfDate: false), + new ModState("B", null, IsInstalled: null, IsEnabled: false, IsOutOfDate: false), + new ModState("Partial", null, IsInstalled: null, IsEnabled: false, IsOutOfDate: false), + ]); + } + + [Fact] + public void FetchState_PropagatesPartialOrMissingInstallationToShadowed() + { + persistedState.InitModInstallationState(new Dictionary + { + ["A"] = new( + Time: PastDate, FsHash: null, Partial: false, Dependencies: [], - Files: []), + Files: [], + ShadowedBy: ["Partial"]), ["B"] = new( - Time: null, FsHash: null, Partial: false, - Dependencies: ["BD"], - Files: []), + Time: PastDate, FsHash: null, Partial: false, + Dependencies: [], + Files: [], + ShadowedBy: ["NotInstalled"]), + ["Partial"] = new( + Time: PastDate, FsHash: null, Partial: true, + Dependencies: [], + Files: [], + ShadowedBy: []), }); modRepositoryMock.Setup(m => m.ListEnabled()).Returns([]); modRepositoryMock.Setup(m => m.ListDisabled()).Returns([]); @@ -163,8 +197,8 @@ public void FetchState_PropagatesPartialOrMissingInstallationToDependants() modManager.FetchState().Should().BeEquivalentTo( [ new ModState("A", null, IsInstalled: null, IsEnabled: false, IsOutOfDate: false), - new ModState("AD", null, IsInstalled: null, IsEnabled: false, IsOutOfDate: false), new ModState("B", null, IsInstalled: null, IsEnabled: false, IsOutOfDate: false), + new ModState("Partial", null, IsInstalled: null, IsEnabled: false, IsOutOfDate: false), ]); } @@ -174,17 +208,20 @@ public void FetchState_RemovesUnavailableBootfiles() persistedState.InitModInstallationState(new Dictionary { [$"{ModPackagesUpdater.BootfilesPrefix}_IU"] = new( - Time: null, FsHash: null, Partial: false, + Time: PastDate, FsHash: null, Partial: false, Dependencies: [], - Files: []), + Files: [], + ShadowedBy: []), [$"{ModPackagesUpdater.BootfilesPrefix}_IE"] = new( - Time: null, FsHash: null, Partial: false, + Time: PastDate, FsHash: null, Partial: false, Dependencies: [], - Files: []), + Files: [], + ShadowedBy: []), [$"{ModPackagesUpdater.BootfilesPrefix}_ID"] = new( - Time: null, FsHash: null, Partial: false, + Time: PastDate, FsHash: null, Partial: false, Dependencies: [], - Files: []) + Files: [], + ShadowedBy: []) }); modRepositoryMock.Setup(m => m.ListEnabled()).Returns( [ @@ -223,20 +260,22 @@ public void Uninstall_DeletesCreatedFilesAndDirectories() persistedState.InitModInstallationState(new Dictionary { ["A"] = new( - Time: null, FsHash: null, Partial: false, + Time: PastDate, FsHash: null, Partial: false, Dependencies: [], Files: [ Path.Combine("X", "ModAFile"), Path.Combine("Y", "ModAFile") - ]), + ], + ShadowedBy: []), ["B"] = new( - Time: null, FsHash: null, Partial: false, + Time: PastDate, FsHash: null, Partial: false, Dependencies: [], Files: [ Path.Combine("X", "ModBFile") - ]) + ], + ShadowedBy: []) }); CreateFile(GamePath("Y", "ExistingFile")); @@ -262,7 +301,8 @@ public void Uninstall_SkipsFilesCreatedAfterInstallation() "ModFile", "RecreatedFile", "AlreadyDeletedFile" - ]) + ], + ShadowedBy: []) }); CreateFile(GamePath("ModFile")).CreationTime = installationDateTime; CreateFile(GamePath("RecreatedFile")); @@ -287,7 +327,8 @@ public void Uninstall_StopsAfterAnyError() Files: [ "ModAFile" - ]), + ], + ShadowedBy: []), ["B"] = new( Time: installationDateTime.ToUniversalTime(), FsHash: null, Partial: false, Dependencies: [], @@ -295,14 +336,16 @@ public void Uninstall_StopsAfterAnyError() [ "ModBFile1", "ModBFile2" - ]), + ], + ShadowedBy: []), ["C"] = new( Time: installationDateTime.ToUniversalTime(), FsHash: null, Partial: false, Dependencies: [], Files: [ "ModCFile" - ]) + ], + ShadowedBy: []) }); CreateFile(GamePath("ModAFile")); @@ -315,7 +358,7 @@ public void Uninstall_StopsAfterAnyError() persistedState.Should().Be(new SavedState( Install: new InstallationState( - Time: installationDateTime.ToUniversalTime(), + Time: null, Mods: new Dictionary { ["B"] = new( @@ -324,14 +367,16 @@ public void Uninstall_StopsAfterAnyError() Files: [ "ModBFile2" - ]), + ], + ShadowedBy: []), ["C"] = new( Time: installationDateTime.ToUniversalTime(), FsHash: null, Partial: false, Dependencies: [], Files: [ "ModCFile" - ]) + ], + ShadowedBy: []) } ))); } @@ -340,15 +385,18 @@ public void Uninstall_StopsAfterAnyError() [Fact] public void Uninstall_RestoresBackups() { + // It must be after files are created + var installationDateTime = DateTime.Now.AddMinutes(1); persistedState.InitModInstallationState(new Dictionary { [""] = new( - Time: null, FsHash: null, Partial: false, + Time: installationDateTime, FsHash: null, Partial: false, Dependencies: [], Files: [ "ModFile" - ]) + ], + ShadowedBy: []) }); CreateFile(GamePath("ModFile"), "Mod"); @@ -373,7 +421,8 @@ public void Uninstall_SkipsRestoreIfModFileOverwritten() Files: [ "ModFile" - ]) + ], + ShadowedBy: []) }); CreateFile(GamePath("ModFile"), "Overwritten"); @@ -428,7 +477,8 @@ public void Install_InstallsContentFromRootDirectories() Path.Combine(DirAtRoot, "A"), Path.Combine(DirAtRoot, "B"), "C" - ]), + ], + ShadowedBy: []), }); } @@ -454,7 +504,8 @@ public void InstallmSkipsBlacklistedFiles() Files: [ Path.Combine(DirAtRoot, "B") - ]), + ], + ShadowedBy: []), }); } @@ -493,17 +544,19 @@ public void Install_GivesPriorityToFilesLaterInTheModList() persistedState.Should().HaveInstalled(new Dictionary { ["Package100"] = new(Time: DateTime.UtcNow, FsHash: 100, Partial: false, - Dependencies: ["Package200"], + Dependencies: [], Files: [ Path.Combine(DirAtRoot, "B") - ]), + ], + ShadowedBy: ["Package200"]), ["Package200"] = new(Time: DateTime.UtcNow, FsHash: 200, Partial: false, Dependencies: [], Files: [ Path.Combine(DirAtRoot, "a") - ]), + ], + ShadowedBy: []), }); } @@ -526,7 +579,8 @@ public void Install_DuplicatesAreCaseInsensitive() Files: [ Path.Combine(DirAtRoot, "A") - ]), + ], + ShadowedBy: []), }); } @@ -557,7 +611,7 @@ public void Install_StopsAfterAnyError() File.Exists(GamePath(DirAtRoot, "A").Full).Should().BeFalse(); persistedState.Should().Be(new SavedState( Install: new InstallationState( - Time: DateTime.UtcNow, + Time: null, Mods: new Dictionary { ["Package200"] = new( @@ -567,14 +621,16 @@ public void Install_StopsAfterAnyError() [ Path.Combine(DirAtRoot, "B1"), Path.Combine(DirAtRoot, "B2") // We don't know when it failed - ]), + ], + ShadowedBy: []), ["Package300"] = new( Time: DateTime.UtcNow, FsHash: 300, Partial: false, Dependencies: [], Files: [ Path.Combine(DirAtRoot, "C") - ]), + ], + ShadowedBy: []), } ))); } @@ -772,10 +828,11 @@ private class InMemoryStatePersistence : IStatePersistence // Avoids bootfiles checks on uninstall private static readonly SavedState SkipBootfilesCheck = new( Install: new( - Time: ValueNotUsed, + Time: PastDate, Mods: new Dictionary { - ["INIT"] = new(Time: null, FsHash: null, Partial: false, Dependencies: [], Files: []), + ["INIT"] = new(Time: PastDate, FsHash: null, Partial: false, Dependencies: [], Files: [], + ShadowedBy: []), } )); @@ -784,7 +841,7 @@ private class InMemoryStatePersistence : IStatePersistence public void InitModInstallationState(Dictionary modInstallationState) => initState = new SavedState( - Install: new InstallationState(Time: ValueNotUsed, Mods: modInstallationState)); + Install: new InstallationState(Time: null, Mods: modInstallationState)); public SavedState ReadState() => savedState ?? initState; @@ -796,7 +853,7 @@ internal PackageInstallationState For(string packageName) { var state = savedState?.Install.Mods[packageName]; state.Should().NotBeNull(); - return state!; + return state; } } @@ -831,7 +888,7 @@ internal void HaveInstalled(IReadOnlyDictionary(mod.Key, - mod.Value with { Time = actualTime }); + mod.Value with { Time = (DateTime)actualTime }); }); actualMods.Should().BeEquivalentTo(expectedMods); } diff --git a/tests/Core.Tests/Packages/Installation/Backup/MoveFileBackupStrategyTest.cs b/tests/Core.Tests/Packages/Installation/Backup/MoveFileBackupStrategyTest.cs index b58c27e..b784706 100644 --- a/tests/Core.Tests/Packages/Installation/Backup/MoveFileBackupStrategyTest.cs +++ b/tests/Core.Tests/Packages/Installation/Backup/MoveFileBackupStrategyTest.cs @@ -14,6 +14,7 @@ public class MoveFileBackupStrategyTest private static readonly RootedPath OriginalPath = new("", OriginalFile); private readonly Mock backupFileNamingMock = new(); + private readonly Mock eventHandlerMock = new(); private MoveFileBackupStrategy.IBackupFileNaming BackupFileNaming => backupFileNamingMock.Object; private string BackupFile => BackupFileNaming.ToBackup(OriginalFile); @@ -30,12 +31,14 @@ public void PerformBackup_MovesOriginalToBackup() { { OriginalFile, OriginalContents }, }); - var mfbs = new MoveFileBackupStrategy(fs, BackupFileNaming); + var mfbs = new MoveFileBackupStrategy(fs, BackupFileNaming, eventHandlerMock.Object); mfbs.PerformBackup(OriginalPath); fs.FileExists(OriginalFile).Should().BeFalse(); fs.File.ReadAllText(BackupFile).Should().Be(OriginalContents); + + eventHandlerMock.VerifyNoOtherCalls(); } [Fact] @@ -43,23 +46,27 @@ public void PerformBackup_ErrorsIfNameIsBackupName() { var fs = new MockFileSystem(); backupFileNamingMock.Setup(_ => _.IsBackup(OriginalFile)).Returns(true); - var mfbs = new MoveFileBackupStrategy(fs, BackupFileNaming); + var mfbs = new MoveFileBackupStrategy(fs, BackupFileNaming, eventHandlerMock.Object); mfbs.Invoking(_ => _.PerformBackup(OriginalPath)) .Should().Throw(); fs.FileExists(BackupFile).Should().BeFalse(); + + eventHandlerMock.VerifyNoOtherCalls(); } [Fact] public void PerformBackup_SkipsBackupIfFileNotPresent() { var fs = new MockFileSystem(); - var mfbs = new MoveFileBackupStrategy(fs, BackupFileNaming); + var mfbs = new MoveFileBackupStrategy(fs, BackupFileNaming, eventHandlerMock.Object); mfbs.PerformBackup(OriginalPath); fs.FileExists(BackupFile).Should().BeFalse(); + + eventHandlerMock.VerifyNoOtherCalls(); } [Fact] @@ -71,12 +78,15 @@ public void PerformBackup_KeepsExistingBackup() { OriginalFile, OriginalContents }, { BackupFile, oldBackupContents }, }); - var mfbs = new MoveFileBackupStrategy(fs, BackupFileNaming); + var mfbs = new MoveFileBackupStrategy(fs, BackupFileNaming, eventHandlerMock.Object); mfbs.PerformBackup(OriginalPath); fs.FileExists(OriginalFile).Should().BeFalse(); fs.File.ReadAllText(BackupFile).Should().Be(oldBackupContents); + + eventHandlerMock.Verify(m => m.BackupSkipped(OriginalPath)); + eventHandlerMock.VerifyNoOtherCalls(); } [Fact] @@ -86,12 +96,14 @@ public void RestoreBackup_MovesBackupToOriginal() { { BackupFile, OriginalContents}, }); - var mfbs = new MoveFileBackupStrategy(fs, BackupFileNaming); + var mfbs = new MoveFileBackupStrategy(fs, BackupFileNaming, eventHandlerMock.Object); mfbs.RestoreBackup(OriginalPath); fs.File.ReadAllText(OriginalFile).Should().Be(OriginalContents); fs.FileExists(BackupFile).Should().BeFalse(); + + eventHandlerMock.VerifyNoOtherCalls(); } [Fact] @@ -102,12 +114,14 @@ public void RestoreBackup_OverwritesOriginalFile() { OriginalFile, "other contents" }, { BackupFile, OriginalContents}, }); - var mfbs = new MoveFileBackupStrategy(fs, BackupFileNaming); + var mfbs = new MoveFileBackupStrategy(fs, BackupFileNaming, eventHandlerMock.Object); mfbs.RestoreBackup(OriginalPath); fs.File.ReadAllText(OriginalFile).Should().Be(OriginalContents); fs.FileExists(BackupFile).Should().BeFalse(); + + eventHandlerMock.VerifyNoOtherCalls(); } [Fact] @@ -117,12 +131,14 @@ public void RestoreBackup_WhenNoOriginalFile() { { BackupFile, OriginalContents}, }); - var mfbs = new MoveFileBackupStrategy(fs, BackupFileNaming); + var mfbs = new MoveFileBackupStrategy(fs, BackupFileNaming, eventHandlerMock.Object); mfbs.RestoreBackup(OriginalPath); fs.File.ReadAllText(OriginalFile).Should().Be(OriginalContents); fs.FileExists(BackupFile).Should().BeFalse(); + + eventHandlerMock.VerifyNoOtherCalls(); } [Fact] @@ -132,11 +148,13 @@ public void RestoreBackup_DeletesOriginalFileIfNoBackup() { { OriginalFile, "other contents" }, }); - var mfbs = new MoveFileBackupStrategy(fs, BackupFileNaming); + var mfbs = new MoveFileBackupStrategy(fs, BackupFileNaming, eventHandlerMock.Object); mfbs.RestoreBackup(OriginalPath); fs.FileExists(OriginalFile).Should().BeFalse(); + + eventHandlerMock.VerifyNoOtherCalls(); } [Fact] @@ -146,7 +164,7 @@ public void DeleteBackup_RemovesBackupIfItExists() { { BackupFile, OriginalContents}, }); - var mfbs = new MoveFileBackupStrategy(fs, BackupFileNaming); + var mfbs = new MoveFileBackupStrategy(fs, BackupFileNaming, eventHandlerMock.Object); mfbs.DeleteBackup(OriginalPath); @@ -154,5 +172,7 @@ public void DeleteBackup_RemovesBackupIfItExists() fs.FileExists(BackupFile).Should().BeFalse(); mfbs.DeleteBackup(OriginalPath); // Check that it does not error + + eventHandlerMock.VerifyNoOtherCalls(); } } diff --git a/tests/Core.Tests/Packages/Installation/Backup/SkipUpdatedBackupStrategyTest.cs b/tests/Core.Tests/Packages/Installation/Backup/SkipUpdatedBackupStrategyTest.cs index 0fa04c8..df71329 100644 --- a/tests/Core.Tests/Packages/Installation/Backup/SkipUpdatedBackupStrategyTest.cs +++ b/tests/Core.Tests/Packages/Installation/Backup/SkipUpdatedBackupStrategyTest.cs @@ -8,57 +8,61 @@ namespace Core.Tests.Packages.Installation.Backup; [IntegrationTest] public class SkipUpdatedBackupStrategyTest { - private readonly RootedPath OriginalFile = new("root", "original"); + private readonly RootedPath originalFile = new("root", "original"); - private readonly Mock innerStategyMock; - - public SkipUpdatedBackupStrategyTest() - { - innerStategyMock = new(); - } + private readonly Mock innerStrategyMock = new(); + private readonly Mock eventHandlerMock = new(); [Fact] public void PerformBackup_ProxiesCallToInnerStategy() { var fs = new MockFileSystem(new Dictionary()); - var subs = new SkipUpdatedBackupStrategy(fs, innerStategyMock.Object, null); + var subs = new SkipUpdatedBackupStrategy(fs, innerStrategyMock.Object, null, eventHandlerMock.Object); - subs.PerformBackup(OriginalFile); + subs.PerformBackup(originalFile); - innerStategyMock.Verify(_ => _.PerformBackup(OriginalFile)); + innerStrategyMock.Verify(m => m.PerformBackup(originalFile)); + + eventHandlerMock.VerifyNoOtherCalls(); } [Fact] public void DeleteBackup_ProxiesCallToInnerStategy() { var fs = new MockFileSystem(new Dictionary()); - var subs = new SkipUpdatedBackupStrategy(fs, innerStategyMock.Object, null); + var subs = new SkipUpdatedBackupStrategy(fs, innerStrategyMock.Object, null, eventHandlerMock.Object); + + subs.DeleteBackup(originalFile); - subs.DeleteBackup(OriginalFile); + innerStrategyMock.Verify(m => m.DeleteBackup(originalFile)); - innerStategyMock.Verify(_ => _.DeleteBackup(OriginalFile)); + eventHandlerMock.VerifyNoOtherCalls(); } [Fact] public void RestoreBackup_ProxiesCallToInnerStategyIfNoBackupTime() { var fs = new MockFileSystem(new Dictionary()); - var subs = new SkipUpdatedBackupStrategy(fs, innerStategyMock.Object, null); + var subs = new SkipUpdatedBackupStrategy(fs, innerStrategyMock.Object, null, eventHandlerMock.Object); - subs.RestoreBackup(OriginalFile); + subs.RestoreBackup(originalFile); - innerStategyMock.Verify(_ => _.RestoreBackup(OriginalFile)); + innerStrategyMock.Verify(m => m.RestoreBackup(originalFile)); + + eventHandlerMock.VerifyNoOtherCalls(); } [Fact] public void RestoreBackup_ProxiesCallToInnerStategyIfNoOriginalFile() { var fs = new MockFileSystem(new Dictionary()); - var subs = new SkipUpdatedBackupStrategy(fs, innerStategyMock.Object, DateTime.UtcNow); + var subs = new SkipUpdatedBackupStrategy(fs, innerStrategyMock.Object, DateTime.UtcNow, eventHandlerMock.Object); + + subs.RestoreBackup(originalFile); - subs.RestoreBackup(OriginalFile); + innerStrategyMock.Verify(m => m.RestoreBackup(originalFile)); - innerStategyMock.Verify(_ => _.RestoreBackup(OriginalFile)); + eventHandlerMock.VerifyNoOtherCalls(); } [Fact] @@ -68,42 +72,49 @@ public void RestoreBackup_DeletesBackupIfOverwritten() var backupTime = fileCreationTime.Subtract(TimeSpan.FromSeconds(1)); var fs = new MockFileSystem(new Dictionary { - { OriginalFile.Full, new MockFileData("") { CreationTime = fileCreationTime } }, + { originalFile.Full, new MockFileData("") { CreationTime = fileCreationTime } }, }); - var subs = new SkipUpdatedBackupStrategy(fs, innerStategyMock.Object, backupTime); + var subs = new SkipUpdatedBackupStrategy(fs, innerStrategyMock.Object, backupTime, eventHandlerMock.Object); + + subs.RestoreBackup(originalFile); - subs.RestoreBackup(OriginalFile); + innerStrategyMock.Verify(m => m.DeleteBackup(originalFile)); - innerStategyMock.Verify(_ => _.DeleteBackup(OriginalFile)); + eventHandlerMock.Verify(m => m.RestoreSkipped(originalFile)); + eventHandlerMock.VerifyNoOtherCalls(); } [Fact] - public void RestoreBackup_ProxiesCallToInnerStategyIfNotOverwritten() + public void RestoreBackup_ProxiesCallToInnerStrategyIfNotOverwritten() { var backupTime = DateTime.UtcNow; var fs = new MockFileSystem(new Dictionary { - { OriginalFile.Full, new MockFileData("") { CreationTime = backupTime } }, + { originalFile.Full, new MockFileData("") { CreationTime = backupTime } }, }); - var subs = new SkipUpdatedBackupStrategy(fs, innerStategyMock.Object, backupTime); + var subs = new SkipUpdatedBackupStrategy(fs, innerStrategyMock.Object, backupTime, eventHandlerMock.Object); - subs.RestoreBackup(OriginalFile); + subs.RestoreBackup(originalFile); - innerStategyMock.Verify(_ => _.RestoreBackup(OriginalFile)); + innerStrategyMock.Verify(m => m.RestoreBackup(originalFile)); + + eventHandlerMock.VerifyNoOtherCalls(); } [Fact] - public void AfterInstall_EnduresDateInThePast() + public void AfterInstall_EnsuresDateInThePast() { var futureDate = DateTime.UtcNow.AddDays(1); var fs = new MockFileSystem(new Dictionary { - { OriginalFile.Full, new MockFileData("") { CreationTime = futureDate } }, + { originalFile.Full, new MockFileData("") { CreationTime = futureDate } }, }); - var subs = new SkipUpdatedBackupStrategy(fs, innerStategyMock.Object, null); + var subs = new SkipUpdatedBackupStrategy(fs, innerStrategyMock.Object, null, eventHandlerMock.Object); + + subs.AfterInstall(originalFile); - subs.AfterInstall(OriginalFile); + fs.File.GetCreationTimeUtc(originalFile.Full).Should().BeOnOrBefore(DateTime.UtcNow); - fs.File.GetCreationTimeUtc(OriginalFile.Full).Should().BeOnOrBefore(DateTime.UtcNow); + eventHandlerMock.VerifyNoOtherCalls(); } } diff --git a/tests/Core.Tests/Packages/Installation/Installers/ProcessingCallbacksTest.cs b/tests/Core.Tests/Packages/Installation/Installers/ProcessingCallbacksTest.cs index 698aac0..79915cd 100644 --- a/tests/Core.Tests/Packages/Installation/Installers/ProcessingCallbacksTest.cs +++ b/tests/Core.Tests/Packages/Installation/Installers/ProcessingCallbacksTest.cs @@ -74,4 +74,36 @@ public void After_ExecutesAllActionsInChain() ma1.Verify(a => a.Invoke(SomeValue), Times.Once); ma2.Verify(a => a.Invoke(SomeValue), Times.Once); } + + [Fact] + public void BuilderMethodsKeepOtherCallbacks() + { + var mpAccept = new Mock>(); + var maBefore = new Mock>(); + var maAfter = new Mock>(); + var maNotAccepted = new Mock>(); + var maFinally = new Mock>(); + + var callbacks = new ProcessingCallbacks() + .AndAccept(mpAccept.Object) + .AndBefore(maBefore.Object) + .AndAfter(maAfter.Object) + .AndNotAccepted(maNotAccepted.Object) + .AndFinally(maFinally.Object) + .AndAccept(new Mock>().Object); + + callbacks.Accept(SomeValue); + mpAccept.Verify(a => a.Invoke(SomeValue), Times.Once); + + callbacks.Before(SomeValue); + maBefore.Verify(a => a.Invoke(SomeValue), Times.Once); + + callbacks.After(SomeValue); + maAfter.Verify(a => a.Invoke(SomeValue), Times.Once); + maFinally.Verify(a => a.Invoke(SomeValue), Times.Once); + + callbacks.NotAccepted(SomeValue); + maNotAccepted.Verify(a => a.Invoke(SomeValue), Times.Once); + maFinally.Verify(a => a.Invoke(SomeValue), Times.Exactly(2)); + } } diff --git a/tests/Core.Tests/Packages/Installation/PackagesUpdaterIntegrationTest.cs b/tests/Core.Tests/Packages/Installation/PackagesUpdaterIntegrationTest.cs deleted file mode 100644 index 830b415..0000000 --- a/tests/Core.Tests/Packages/Installation/PackagesUpdaterIntegrationTest.cs +++ /dev/null @@ -1,235 +0,0 @@ -using Core.Packages.Installation; -using Core.Packages.Installation.Backup; -using Core.Packages.Installation.Installers; -using Core.Packages.Repository; -using Core.Tests.Base; -using Core.Utils; -using FluentAssertions; -using FluentAssertions.Extensions; -using Microsoft.Extensions.Time.Testing; - -namespace Core.Tests.Packages.Installation; - -public class PackagesUpdaterIntegrationTest : AbstractFilesystemTest -{ - #region Initialisation - - private class TestException : Exception {} - - private readonly Mock backupStrategyMock = new(); - private readonly Mock eventHandlerMock = new(); - private readonly DateTime fakeUtcInstallationDate = DateTime.Today.AddDays(10).ToUniversalTime(); - private readonly TimeSpan fakeLocalTimeOffset = TimeSpan.FromHours(3); - private IReadOnlyDictionary? recordedState; - - private class InstallerForPackage : IInstallerFactory - { - private readonly IReadOnlyCollection installers; - - internal InstallerForPackage(IReadOnlyCollection installers) - { - this.installers = installers; - } - - public IInstaller PackageInstaller(Package package) => - installers.First(installer => installer.PackageName == package.Name); - } - - #endregion - - [Fact] - public void Apply_NoMods() - { - Apply( - new Dictionary(), - [] - ); - - recordedState.Should().BeEmpty(); - } - - [Fact] - public void Apply_UninstallsMods() - { - Apply( - new Dictionary{ - ["A"] = new( - Time: null, - FsHash: 42, - Partial: false, - Dependencies: [], - Files: ["AF"]) - }, - [] - ); - - recordedState.Should().BeEmpty(); - - backupStrategyMock.Verify(_ => _.RestoreBackup(TestPath("AF"))); - backupStrategyMock.VerifyNoOtherCalls(); - - eventHandlerMock.Verify(_ => _.UninstallStart()); - eventHandlerMock.Verify(_ => _.UninstallCurrent("A")); - eventHandlerMock.Verify(_ => _.UninstallSkipModified("AF")); - eventHandlerMock.Verify(_ => _.UninstallEnd()); - eventHandlerMock.Verify(_ => _.InstallNoPackages()); - eventHandlerMock.Verify(_ => _.ProgressUpdate(It.IsAny())); - eventHandlerMock.VerifyNoOtherCalls(); - } - - [Fact] - public void Apply_UninstallStopsIfBackupFails() - { - backupStrategyMock.Setup(_ => _.RestoreBackup(TestPath("Fail"))).Throws(); - - this.Invoking(_ => _.Apply( - new Dictionary - { - ["A"] = new( - Time: null, - FsHash: 42, - Partial: false, - Dependencies: [], - Files: ["AF1", "Fail", "AF2"]) - }, - [] - )).Should().Throw(); - - recordedState["A"]?.Files.Should().BeEquivalentTo(["Fail", "AF2"]); - } - - [Fact] - public void Apply_InstallsMods() - { - Apply( - new Dictionary(), - [ - InstallerOf("A", 42, [ - "AF" - ]) - ] - ); - - recordedState.Should().BeEquivalentTo(new Dictionary - { - ["A"] = new(fakeUtcInstallationDate, 42, false, [], ["AF"]) - }); - - backupStrategyMock.Verify(_ => _.PerformBackup(TestPath("AF"))); - backupStrategyMock.Verify(_ => _.AfterInstall(TestPath("AF"))); - backupStrategyMock.VerifyNoOtherCalls(); - - eventHandlerMock.Verify(_ => _.UninstallNoPackages()); - eventHandlerMock.Verify(_ => _.InstallStart()); - eventHandlerMock.Verify(_ => _.InstallCurrent("A")); - eventHandlerMock.Verify(_ => _.InstallEnd()); - eventHandlerMock.Verify(_ => _.ProgressUpdate(It.IsAny())); - eventHandlerMock.VerifyNoOtherCalls(); - } - - [Fact] - public void Apply_InstallStopsIfBackupFails() - { - backupStrategyMock.Setup(_ => _.PerformBackup(TestPath("Fail"))).Throws(); - - this.Invoking(_ => _.Apply( - new Dictionary(), - [ - InstallerOf("A", 42, [ - "AF1", "Fail", "AF2" - ]) - ] - )).Should().Throw(); - - recordedState["A"]?.Files.Should().BeEquivalentTo([ - "AF1", - "Fail" // We don't know where it failed, so we add it - ]); - } - - [Fact] - public void Apply_UpdatesMods() - { - Apply( - new Dictionary - { - ["A"] = new(Time: null, FsHash: 1, Partial: false, Dependencies: [], Files: [ - "AF", - "AF1", - ]) - }, - [ - InstallerOf("A", 2, [ - "AF", - "AF2" - ]) - ] - ); - - recordedState.Should().BeEquivalentTo(new Dictionary - { - ["A"] = new(fakeUtcInstallationDate, 2, false, [], ["AF", "AF2"]) - }); - } - - #region Utility methods - - private void Apply( - IReadOnlyDictionary oldState, - IReadOnlyCollection installers) - { - var packages = installers.Select(i => new Package(i.PackageName, "", true, null)); - var backupStrategyProviderMock = new Mock>(); - backupStrategyProviderMock.Setup(m => m.BackupStrategy(It.IsAny())) - .Returns(backupStrategyMock.Object); - var packagesUpdater = new PackagesUpdater( - new InstallerForPackage(installers), - backupStrategyProviderMock.Object, - new FakeTimeProvider(fakeUtcInstallationDate.WithOffset(fakeLocalTimeOffset))); - packagesUpdater.Apply( - oldState, - packages, - TestDir.FullName, - newState => recordedState = newState, - eventHandlerMock.Object, - CancellationToken.None); - } - - private IInstaller InstallerOf(string name, int? fsHash, IReadOnlyCollection files) - { - return new StaticFilesInstaller(name, fsHash, files); - } - - private class StaticFilesInstaller : BaseInstaller - { - private static readonly object NoContext = new(); - private readonly IReadOnlyCollection files; - - internal StaticFilesInstaller(string packageName, int? packageFsHash, IReadOnlyCollection files) : - base(packageName, packageFsHash) - { - 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"; - - public override IEnumerable RelativeDirectoryPaths => [DirAtRoot]; - } - - #endregion -} diff --git a/tests/Core.Tests/Packages/Installation/PackagesUpdaterTest.cs b/tests/Core.Tests/Packages/Installation/PackagesUpdaterTest.cs new file mode 100644 index 0000000..a5ba607 --- /dev/null +++ b/tests/Core.Tests/Packages/Installation/PackagesUpdaterTest.cs @@ -0,0 +1,442 @@ +using Core.Packages.Installation; +using Core.Packages.Installation.Backup; +using Core.Packages.Installation.Installers; +using Core.Packages.Repository; +using Core.Utils; +using FluentAssertions; +using FluentAssertions.Extensions; +using Microsoft.Extensions.Time.Testing; + +namespace Core.Tests.Packages.Installation; + +public class PackagesUpdaterTest +{ + #region Initialisation + + private class TestException : Exception; + + private readonly Mock backupStrategyMock = new(); + private readonly Mock eventHandlerMock = new(); + private readonly DateTime fakeUtcInstallationDate = DateTime.Today.AddDays(10).ToUniversalTime(); + private readonly TimeSpan fakeLocalTimeOffset = TimeSpan.FromHours(3); + private IReadOnlyDictionary? recordedState; + private readonly string destinationDir = Path.GetRandomFileName(); + + // 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; + + #endregion + + [Fact] + public void Apply_NoPackages() + { + var progress = new List(); + eventHandlerMock.Setup(m => m.ProgressUpdate(It.IsAny())) + .Callback(p => progress.Add(p.Percent)); + + Apply( + new Dictionary(), + [] + ); + + recordedState.Should().BeEmpty(); + + progress.Should().Equal(1.0); + } + + [Fact] + public void Apply_TracksProgress() + { + var progress = new List(); + eventHandlerMock.Setup(m => m.ProgressUpdate(It.IsAny())) + .Callback(p => progress.Add(p.Percent)); + + Apply( + new Dictionary + { + ["U1"] = new(Time: ValueNotUsed, FsHash: null, Partial: false, Dependencies: [], Files: [], ShadowedBy: []), + ["U2"] = new(Time: ValueNotUsed, FsHash: null, Partial: false, Dependencies: [], Files: [], ShadowedBy: []) + }, // 25% + [ + InstallerOf("I1", fsHash: null, []), // 50% + InstallerOf("I2", fsHash: null, []), // 75% + InstallerOf("I3", fsHash: null, []), // 100% + ] + ); + + recordedState.Should().BeEmpty(); + + progress.Should().Equal(0.25, 0.5, 0.75, 1.0); + } + + [Fact] + public void Apply_InstallsSelectedPackages() + { + Apply( + new Dictionary(), + [ + InstallerOf("A", fsHash: 42, files: [ + "AF" + ]) + ] + ); + + recordedState.Should().BeEquivalentTo(new Dictionary + { + ["A"] = new(Time: fakeUtcInstallationDate, FsHash: 42, Partial: false, Dependencies: [], Files: [ + "AF" + ], ShadowedBy: []) + }); + + backupStrategyMock.Verify(m => m.PerformBackup(DestinationPath("AF"))); + backupStrategyMock.Verify(m => m.AfterInstall(DestinationPath("AF"))); + backupStrategyMock.VerifyNoOtherCalls(); + + eventHandlerMock.Verify(m => m.UninstallNoPackages()); + eventHandlerMock.Verify(m => m.InstallStart()); + eventHandlerMock.Verify(m => m.InstallCurrent("A")); + eventHandlerMock.Verify(m => m.InstallEnd()); + eventHandlerMock.Verify(m => m.ProgressUpdate(It.IsAny())); + eventHandlerMock.VerifyNoOtherCalls(); + } + + [Fact] + public void Apply_UninstallsUnselectedPackages() + { + Apply( + new Dictionary{ + ["A"] = new( + Time: ValueNotUsed, + FsHash: 42, + Partial: false, + Dependencies: [], + Files: ["AF"], + ShadowedBy: []) + }, + [] + ); + + recordedState.Should().BeEmpty(); + + backupStrategyMock.Verify(m => m.RestoreBackup(DestinationPath("AF"))); + backupStrategyMock.VerifyNoOtherCalls(); + + eventHandlerMock.Verify(m => m.UninstallStart()); + eventHandlerMock.Verify(m => m.UninstallCurrent("A")); + eventHandlerMock.Verify(m => m.UninstallEnd()); + eventHandlerMock.Verify(m => m.InstallNoPackages()); + eventHandlerMock.Verify(m => m.ProgressUpdate(It.IsAny())); + eventHandlerMock.VerifyNoOtherCalls(); + } + + [Fact] + public void Apply_UpdatesChangedPackages() + { + Apply( + new Dictionary + { + ["A"] = new(Time: ValueNotUsed, FsHash: 1, Partial: false, Dependencies: [], Files: [ + "AF", + "AF1", + ], ShadowedBy: []) + }, + [ + InstallerOf("A", fsHash: 2, [ + "AF", + "AF2" + ]) + ] + ); + + recordedState.Should().BeEquivalentTo(new Dictionary + { + ["A"] = new(Time: fakeUtcInstallationDate, FsHash: 2, Partial: false, Dependencies: [], Files: [ + "AF", + "AF2" + ], ShadowedBy: []) + }); + + backupStrategyMock.Verify(m => m.RestoreBackup(DestinationPath("AF1"))); + backupStrategyMock.Verify(m => m.PerformBackup(DestinationPath("AF2"))); + } + + [Fact] + public void Apply_PreservesPackageDependencies() + { + Apply( + new Dictionary(), + [ + InstallerOf("A", fsHash: 42, files: [ + "AF" + ], dependencies: ["X"]) + ] + ); + + recordedState.Should().BeEquivalentTo(new Dictionary + { + ["A"] = new(Time: fakeUtcInstallationDate, FsHash: 42, Partial: false, Dependencies: ["X"], Files: [ + "AF" + ], ShadowedBy: []) + }); + } + + [Fact] + public void Apply_FirstInstalledFilesTakePrecedence() + { + Apply( + new Dictionary(), + [ + InstallerOf("A", fsHash: 1, files: [ + "AF1", "AF2" + ]), + InstallerOf("B", fsHash: 2, files: [ + "BF" + ]), + InstallerOf("C", fsHash: 3, files: [ + "AF1", "BF", "CF" + ]) + ] + ); + + recordedState.Should().BeEquivalentTo(new Dictionary + { + ["A"] = new(Time: fakeUtcInstallationDate, FsHash: 1, Partial: false, Dependencies: [], Files: [ + "AF1", "AF2" + ], ShadowedBy: []), + ["B"] = new(Time: fakeUtcInstallationDate, FsHash: 2, Partial: false, Dependencies: [], Files: [ + "BF" + ], ShadowedBy: []), + ["C"] = new(Time: fakeUtcInstallationDate, FsHash: 3, Partial: false, Dependencies: [], Files: [ + "CF" + ], ShadowedBy: ["A", "B"]) + }); + } + + [Fact] + public void Apply_RestoresFilesPreviouslyShadowedByUninstalledPackage() + { + Apply( + new Dictionary + { + ["A"] = new(Time: ValueNotUsed, FsHash: 1, Partial: false, Dependencies: [], Files: [ + "AF1", + ], ShadowedBy: []), + ["B"] = new(Time: ValueNotUsed, FsHash: 2, Partial: false, Dependencies: [], Files: [ + "SF", // SF in A was shadowed by B + "BF1", + ], ShadowedBy: []) + }, + [ + InstallerOf("A", fsHash: 1, [ + "SF", + "AF1" + ]) + ] + ); + + recordedState.Should().BeEquivalentTo(new Dictionary + { + ["A"] = new(Time: fakeUtcInstallationDate, FsHash: 1, Partial: false, Dependencies: [], Files: [ + "SF", + "AF1" + ], ShadowedBy: []) + }); + } + + [Fact] + public void Apply_InstallStopsIfBackupFails() + { + backupStrategyMock.Setup(m => m.PerformBackup(DestinationPath("Fail"))).Throws(); + + this.Invoking(m => m.Apply( + new Dictionary(), + [ + InstallerOf("A", fsHash: 42, files: [ + "AF1", "Fail", "AF2" + ]) + ] + )).Should().Throw(); + + recordedState.Should().BeEquivalentTo(new Dictionary + { + ["A"] = new(Time: fakeUtcInstallationDate, FsHash: 42, Partial: true, Dependencies: [], Files: [ + "AF1", + "Fail" // We don't know where it failed, so we add it + ], ShadowedBy: []) + }); + } + + [Fact] + public void Apply_UninstallStopsIfBackupFails() + { + backupStrategyMock.Setup(m => m.RestoreBackup(DestinationPath("Fail"))).Throws(); + + this.Invoking(m => m.Apply( + new Dictionary + { + ["A"] = new(Time: ValueNotUsed, FsHash: 42, Partial: false, Dependencies: [], Files: [ + "AF1", + "Fail", + "AF2" + ], ShadowedBy: []) + }, + [] + )).Should().Throw(); + + recordedState.Should().BeEquivalentTo(new Dictionary + { + ["A"] = new(Time: ValueNotUsed, FsHash: 42, Partial: true, Dependencies: [], Files: [ + "Fail", // We don't know where it failed, so we leave it + "AF2" + ], ShadowedBy: []) + }); + } + + + [Fact] + public void Apply_UninstallFailuresResultsInPartialInstallation() + { + backupStrategyMock.Setup(m => m.RestoreBackup(DestinationPath("Fail"))).Throws(); + + this.Invoking(m => m.Apply( + new Dictionary + { + ["A"] = new(Time: ValueNotUsed, FsHash: null, Partial: false, Dependencies: [], Files: [ + "Fail" + ], ShadowedBy: []) + }, + [] + )).Should().Throw(); + + recordedState.Should().BeEquivalentTo(new Dictionary + { + ["A"] = new(Time: ValueNotUsed, FsHash: null, Partial: true, Dependencies: [], Files: [ + "Fail" + ], ShadowedBy: []) + }); + } + + [Fact] + public void Apply_PartialPackagesStayPartial() + { + backupStrategyMock.Setup(m => m.RestoreBackup(DestinationPath("Fail"))).Throws(); + + this.Invoking(m => m.Apply( + new Dictionary + { + ["A"] = new(Time: ValueNotUsed, FsHash: null, Partial: true, Dependencies: [], Files: [ + "Fail" + ], ShadowedBy: []) + }, + [] + )).Should().Throw(); + + recordedState.Should().BeEquivalentTo(new Dictionary + { + ["A"] = new(Time: ValueNotUsed, FsHash: null, Partial: true, Dependencies: [], Files: [ + "Fail" + ], ShadowedBy: []) + }); + } + + + [Fact] + public void Apply_UninstallRemovesEmptyDirectories() + { + var subDir = Path.Combine("D1", "D2"); + Directory.CreateDirectory(DestinationPath(subDir).Full); + + Apply( + new Dictionary + { + ["A"] = new(Time: ValueNotUsed, FsHash: null, Partial: true, Dependencies: [], Files: [ + Path.Combine(subDir, "F1") + ], ShadowedBy: []) + }, + [] + ); + + recordedState.Should().BeEmpty(); + + Directory.Exists(DestinationPath("D1").Full).Should().BeFalse(); + } + + #region Utility methods + + protected RootedPath DestinationPath(string relativePath) => new(destinationDir, relativePath); + + private void Apply( + IReadOnlyDictionary oldState, + IReadOnlyCollection installers) + { + var packages = installers.Select(i => new Package(i.PackageName, "", true, null)); + var backupStrategyProviderMock = new Mock>(); + backupStrategyProviderMock.Setup(m => m.BackupStrategy(It.IsAny(), eventHandlerMock.Object)) + .Returns(backupStrategyMock.Object); + var packagesUpdater = new PackagesUpdater( + new InstallerForPackage(installers), + backupStrategyProviderMock.Object, + new FakeTimeProvider(fakeUtcInstallationDate.WithOffset(fakeLocalTimeOffset))); + packagesUpdater.Apply( + oldState, + packages, + destinationDir, + newState => recordedState = newState, + eventHandlerMock.Object, + CancellationToken.None); + } + + private class InstallerForPackage : IInstallerFactory + { + private readonly IReadOnlyCollection installers; + + internal InstallerForPackage(IReadOnlyCollection installers) + { + this.installers = installers; + } + + public IInstaller PackageInstaller(Package package) => + installers.First(installer => installer.PackageName == package.Name); + } + + private static IInstaller InstallerOf(string name, int? fsHash, IReadOnlyCollection files) => + InstallerOf(name, fsHash, files, Array.Empty()); + + private static IInstaller InstallerOf(string name, int? fsHash, + IReadOnlyCollection files, IReadOnlyCollection dependencies) => + new StaticFilesInstaller(name, fsHash, files, dependencies); + + private class StaticFilesInstaller : BaseInstaller + { + private static readonly object NoContext = new(); + private readonly IReadOnlyCollection files; + + internal StaticFilesInstaller(string packageName, int? packageFsHash, IReadOnlyCollection files, + IReadOnlyCollection packageDependencies) : + base(packageName, packageFsHash, packageDependencies) + { + 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"; + + public override IEnumerable RelativeDirectoryPaths => [DirAtRoot]; + } + + #endregion +} diff --git a/tests/Core.Tests/State/JsonFileStatePersistenceTest.cs b/tests/Core.Tests/State/JsonFileStatePersistenceTest.cs new file mode 100644 index 0000000..35da253 --- /dev/null +++ b/tests/Core.Tests/State/JsonFileStatePersistenceTest.cs @@ -0,0 +1,159 @@ +using System.IO.Abstractions.TestingHelpers; +using Core.State; +using FluentAssertions; + +namespace Core.Tests.State; + +[IntegrationTest] +public class JsonFileStatePersistenceTest +{ + private const string StateV2File = "v2"; + private const string StateV1File = "v1"; + + [Fact] + public void ReadState_EmptyStateWhenNoStateFileExists() + { + var fs = new MockFileSystem(); + var sp = new JsonFileStatePersistence(fs, StateV2File, StateV1File); + + sp.ReadState().Should().Be(SavedState.Empty()); + } + + [Fact] + public void ReadState_V2DefaultValues() + { + var fileWriteTime = DateTime.Today.AddDays(-1); + var fs = new MockFileSystem(new Dictionary + { + { StateV2File, new MockFileData( + """ + { + "Install": { + "Mods": { + "M": { + } + } + } + } + """) { LastWriteTime = fileWriteTime } + } + }); + var sp = new JsonFileStatePersistence(fs, fs.Path.GetFullPath(StateV2File), "NotUsed"); + + var state = sp.ReadState(); + state.Install.Time.Should().BeNull(); + state.Install.Mods.Keys.Should().Contain("M"); + + var mod = state.Install.Mods["M"]; + mod.Time.Should().Be(fileWriteTime); + mod.FsHash.Should().BeNull(); + mod.Partial.Should().BeFalse(); + mod.Dependencies.Should().BeEmpty(); + mod.Files.Should().BeEmpty(); + mod.ShadowedBy.Should().BeEmpty(); + } + + [Fact] + public void ReadState_V2ModsInstallTimeDefaultsToGlobal() + { + var fs = new MockFileSystem(new Dictionary + { + { StateV2File, + """ + { + "Install": { + "Time": "1970-01-01T00:00:00Z", + "Mods": { + "M": { + } + } + } + } + """ + } + }); + var sp = new JsonFileStatePersistence(fs, fs.Path.GetFullPath(StateV2File), "NotUsed"); + + var state = sp.ReadState(); + state.Install.Time.Should().Be(DateTime.UnixEpoch); + state.Install.Mods.Keys.Should().Contain("M"); + + var mod = state.Install.Mods["M"]; + mod.Time.Should().Be(DateTime.UnixEpoch); + } + + [Fact] + public void ReadState_V1DefaultValues() + { + var fileWriteTime = DateTime.Today.AddDays(-1); + var fs = new MockFileSystem(new Dictionary + { + { StateV1File, new MockFileData( + """ + { + "M": [] + } + """) { LastWriteTime = fileWriteTime } + } + }); + var sp = new JsonFileStatePersistence(fs, "NotUsed", fs.Path.GetFullPath(StateV1File)); + + var state = sp.ReadState(); + state.Install.Time.Should().BeNull(); + state.Install.Mods.Keys.Should().Contain("M"); + + var mod = state.Install.Mods["M"]; + mod.Time.Should().Be(fileWriteTime); + mod.FsHash.Should().BeNull(); + mod.Partial.Should().BeFalse(); + mod.Dependencies.Should().BeEmpty(); + mod.Files.Should().BeEmpty(); + mod.ShadowedBy.Should().BeEmpty(); + } + + [Fact] + public void ReadState_FavoursLatestStateFile() + { + var fs = new MockFileSystem(new Dictionary + { + { StateV2File, + """ + { + "Install": { + "Mods": { + "V2": { + } + } + } + } + """ + }, + { StateV1File, + """ + { + "V1": [] + } + """ + } + }); + var sp = new JsonFileStatePersistence(fs, fs.Path.GetFullPath(StateV2File), fs.Path.GetFullPath(StateV1File)); + + var state = sp.ReadState(); + + state.Install.Mods.Keys.Should().Contain("V2"); + } + + [Fact] + public void WriteState_DeletesPreviousStates() + { + var fs = new MockFileSystem(new Dictionary + { + { StateV1File, "NotUsed" } + }); + var sp = new JsonFileStatePersistence(fs, fs.Path.GetFullPath(StateV2File), fs.Path.GetFullPath(StateV1File)); + + sp.WriteState(SavedState.Empty()); + + fs.AllFiles.Should().BeEquivalentTo(fs.Path.GetFullPath(StateV2File)); + } +}