From 13170987ae0a2572d2a7f653e63f4620f12646cf Mon Sep 17 00:00:00 2001 From: Paolo Ambrosio Date: Sun, 18 Jan 2026 09:10:19 +0000 Subject: [PATCH 1/6] Refactored PackagesUpdater tests --- .../Installation/PackagesUpdaterTest.cs | 309 +++++++++--------- 1 file changed, 148 insertions(+), 161 deletions(-) diff --git a/tests/Core.Tests/Packages/Installation/PackagesUpdaterTest.cs b/tests/Core.Tests/Packages/Installation/PackagesUpdaterTest.cs index a5ba607..d15f25c 100644 --- a/tests/Core.Tests/Packages/Installation/PackagesUpdaterTest.cs +++ b/tests/Core.Tests/Packages/Installation/PackagesUpdaterTest.cs @@ -1,3 +1,4 @@ +using System.Collections.ObjectModel; using Core.Packages.Installation; using Core.Packages.Installation.Backup; using Core.Packages.Installation.Installers; @@ -19,7 +20,7 @@ private class TestException : Exception; 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 IReadOnlyDictionary? installationState; private readonly string destinationDir = Path.GetRandomFileName(); // Randomness ensures that at least some test runs will fail if it's used @@ -34,12 +35,9 @@ public void Apply_NoPackages() eventHandlerMock.Setup(m => m.ProgressUpdate(It.IsAny())) .Callback(p => progress.Add(p.Percent)); - Apply( - new Dictionary(), - [] - ); + Apply([]); - recordedState.Should().BeEmpty(); + installationState.Should().BeEmpty(); progress.Should().Equal(1.0); } @@ -51,20 +49,22 @@ public void Apply_TracksProgress() 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% - ] - ); + installationState = new Dictionary + { + ["U1"] = + new(Time: ValueNotUsed, FsHash: null, Partial: false, Dependencies: [], Files: [], ShadowedBy: []), + ["U2"] = new(Time: ValueNotUsed, FsHash: null, Partial: false, Dependencies: [], Files: [], + ShadowedBy: []) + }; + + Apply([ + // Uninstall 25% + InstallerOf("I1", fsHash: null, []), // 50% + InstallerOf("I2", fsHash: null, []), // 75% + InstallerOf("I3", fsHash: null, []), // 100% + ]); - recordedState.Should().BeEmpty(); + installationState.Should().BeEmpty(); progress.Should().Equal(0.25, 0.5, 0.75, 1.0); } @@ -72,16 +72,13 @@ public void Apply_TracksProgress() [Fact] public void Apply_InstallsSelectedPackages() { - Apply( - new Dictionary(), - [ - InstallerOf("A", fsHash: 42, files: [ - "AF" - ]) - ] - ); + Apply([ + InstallerOf("A", fsHash: 42, files: [ + "AF" + ]) + ]); - recordedState.Should().BeEquivalentTo(new Dictionary + installationState.Should().BeEquivalentTo(new Dictionary { ["A"] = new(Time: fakeUtcInstallationDate, FsHash: 42, Partial: false, Dependencies: [], Files: [ "AF" @@ -103,20 +100,20 @@ public void Apply_InstallsSelectedPackages() [Fact] public void Apply_UninstallsUnselectedPackages() { - Apply( - new Dictionary{ - ["A"] = new( - Time: ValueNotUsed, - FsHash: 42, - Partial: false, - Dependencies: [], - Files: ["AF"], - ShadowedBy: []) - }, - [] - ); - - recordedState.Should().BeEmpty(); + installationState = new Dictionary + { + ["A"] = new( + Time: ValueNotUsed, + FsHash: 42, + Partial: false, + Dependencies: [], + Files: ["AF"], + ShadowedBy: []) + }; + + Apply([]); + + installationState.Should().BeEmpty(); backupStrategyMock.Verify(m => m.RestoreBackup(DestinationPath("AF"))); backupStrategyMock.VerifyNoOtherCalls(); @@ -132,23 +129,23 @@ public void Apply_UninstallsUnselectedPackages() [Fact] public void Apply_UpdatesChangedPackages() { - Apply( - new Dictionary - { - ["A"] = new(Time: ValueNotUsed, FsHash: 1, Partial: false, Dependencies: [], Files: [ - "AF", - "AF1", - ], ShadowedBy: []) - }, + installationState = new Dictionary + { + ["A"] = new(Time: ValueNotUsed, FsHash: 1, Partial: false, Dependencies: [], Files: [ - InstallerOf("A", fsHash: 2, [ - "AF", - "AF2" - ]) - ] - ); - - recordedState.Should().BeEquivalentTo(new Dictionary + "AF", + "AF1", + ], ShadowedBy: []) + }; + + Apply([ + InstallerOf("A", fsHash: 2, [ + "AF", + "AF2" + ]) + ]); + + installationState.Should().BeEquivalentTo(new Dictionary { ["A"] = new(Time: fakeUtcInstallationDate, FsHash: 2, Partial: false, Dependencies: [], Files: [ "AF", @@ -163,16 +160,13 @@ public void Apply_UpdatesChangedPackages() [Fact] public void Apply_PreservesPackageDependencies() { - Apply( - new Dictionary(), - [ - InstallerOf("A", fsHash: 42, files: [ - "AF" - ], dependencies: ["X"]) - ] - ); + Apply([ + InstallerOf("A", fsHash: 42, files: [ + "AF" + ], dependencies: ["X"]) + ]); - recordedState.Should().BeEquivalentTo(new Dictionary + installationState.Should().BeEquivalentTo(new Dictionary { ["A"] = new(Time: fakeUtcInstallationDate, FsHash: 42, Partial: false, Dependencies: ["X"], Files: [ "AF" @@ -183,22 +177,19 @@ public void Apply_PreservesPackageDependencies() [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 + Apply([ + InstallerOf("A", fsHash: 1, files: [ + "AF1", "AF2" + ]), + InstallerOf("B", fsHash: 2, files: [ + "BF" + ]), + InstallerOf("C", fsHash: 3, files: [ + "AF1", "BF", "CF" + ]) + ]); + + installationState.Should().BeEquivalentTo(new Dictionary { ["A"] = new(Time: fakeUtcInstallationDate, FsHash: 1, Partial: false, Dependencies: [], Files: [ "AF1", "AF2" @@ -215,26 +206,27 @@ public void Apply_FirstInstalledFilesTakePrecedence() [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: []) - }, + installationState = new Dictionary + { + ["A"] = new(Time: ValueNotUsed, FsHash: 1, Partial: false, Dependencies: [], Files: [ - InstallerOf("A", fsHash: 1, [ - "SF", - "AF1" - ]) - ] - ); - - recordedState.Should().BeEquivalentTo(new Dictionary + "AF1", + ], ShadowedBy: []), + ["B"] = new(Time: ValueNotUsed, FsHash: 2, Partial: false, Dependencies: [], Files: + [ + "SF", // SF in A was shadowed by B + "BF1", + ], ShadowedBy: []) + }; + + Apply([ + InstallerOf("A", fsHash: 1, [ + "SF", + "AF1" + ]) + ]); + + installationState.Should().BeEquivalentTo(new Dictionary { ["A"] = new(Time: fakeUtcInstallationDate, FsHash: 1, Partial: false, Dependencies: [], Files: [ "SF", @@ -248,16 +240,13 @@ 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(); + this.Invoking(m => m.Apply([ + InstallerOf("A", fsHash: 42, files: [ + "AF1", "Fail", "AF2" + ]) + ])).Should().Throw(); - recordedState.Should().BeEquivalentTo(new Dictionary + installationState.Should().BeEquivalentTo(new Dictionary { ["A"] = new(Time: fakeUtcInstallationDate, FsHash: 42, Partial: true, Dependencies: [], Files: [ "AF1", @@ -271,19 +260,19 @@ 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 + installationState = new Dictionary + { + ["A"] = new(Time: ValueNotUsed, FsHash: 42, Partial: false, Dependencies: [], Files: + [ + "AF1", + "Fail", + "AF2" + ], ShadowedBy: []) + }; + + this.Invoking(m => m.Apply([])).Should().Throw(); + + installationState.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 @@ -298,17 +287,17 @@ 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 + installationState = new Dictionary + { + ["A"] = new(Time: ValueNotUsed, FsHash: null, Partial: false, Dependencies: [], Files: + [ + "Fail" + ], ShadowedBy: []) + }; + + this.Invoking(m => m.Apply([])).Should().Throw(); + + installationState.Should().BeEquivalentTo(new Dictionary { ["A"] = new(Time: ValueNotUsed, FsHash: null, Partial: true, Dependencies: [], Files: [ "Fail" @@ -321,17 +310,17 @@ 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 + installationState = new Dictionary + { + ["A"] = new(Time: ValueNotUsed, FsHash: null, Partial: true, Dependencies: [], Files: + [ + "Fail" + ], ShadowedBy: []) + }; + + this.Invoking(m => m.Apply([])).Should().Throw(); + + installationState.Should().BeEquivalentTo(new Dictionary { ["A"] = new(Time: ValueNotUsed, FsHash: null, Partial: true, Dependencies: [], Files: [ "Fail" @@ -346,17 +335,17 @@ 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: []) - }, - [] - ); + installationState = new Dictionary + { + ["A"] = new(Time: ValueNotUsed, FsHash: null, Partial: true, Dependencies: [], Files: + [ + Path.Combine(subDir, "F1") + ], ShadowedBy: []) + }; + + Apply([]); - recordedState.Should().BeEmpty(); + installationState.Should().BeEmpty(); Directory.Exists(DestinationPath("D1").Full).Should().BeFalse(); } @@ -365,9 +354,7 @@ public void Apply_UninstallRemovesEmptyDirectories() protected RootedPath DestinationPath(string relativePath) => new(destinationDir, relativePath); - private void Apply( - IReadOnlyDictionary oldState, - IReadOnlyCollection installers) + private void Apply(IInstaller[] installers) { var packages = installers.Select(i => new Package(i.PackageName, "", true, null)); var backupStrategyProviderMock = new Mock>(); @@ -378,10 +365,10 @@ private void Apply( backupStrategyProviderMock.Object, new FakeTimeProvider(fakeUtcInstallationDate.WithOffset(fakeLocalTimeOffset))); packagesUpdater.Apply( - oldState, + installationState ?? ReadOnlyDictionary.Empty, packages, destinationDir, - newState => recordedState = newState, + newState => installationState = newState, eventHandlerMock.Object, CancellationToken.None); } From 3bf0aa9417fae7d5ed98a0331e86a6c01f33c3c7 Mon Sep 17 00:00:00 2001 From: Paolo Ambrosio Date: Fri, 6 Feb 2026 07:33:06 +0000 Subject: [PATCH 2/6] ModPackagesUpdater refactoring and tests - Extacted the logic for checking if a package is bootfiles into a dedicated interface `IBootfilesNameChecker` - Extracted the logic for creating mod installers into a dedicated interface `IModInstallerFactory` - Refactored `ModPackagesUpdater` to use these new interfaces, improving separation of concerns and testability --- src/Core/API/IEventHandler.cs | 5 +- src/Core/API/Init.cs | 23 +- src/Core/API/ModManager.cs | 15 +- .../Installers/BootfilesInstaller.cs | 4 +- .../Installers/IBootfilesNameChecker.cs | 6 + .../Installers/IModInstallerFactory.cs | 9 + .../Installers/ModInstallerFactory.cs | 32 +++ .../Mods/Installation/ModPackagesUpdater.cs | 41 +--- .../API/ModManagerIntegrationTest.cs | 35 ++- .../ContainedDirsRootFinderTest.cs | 2 +- .../Installers}/PostProcessorTest.cs | 2 +- .../Installation/ModPackagesUpdaterTest.cs | 97 ++++++++ .../Installation/PackagesUpdaterTest.cs | 230 ++++++++++++------ 13 files changed, 355 insertions(+), 146 deletions(-) create mode 100644 src/Core/Mods/Installation/Installers/IBootfilesNameChecker.cs create mode 100644 src/Core/Mods/Installation/Installers/IModInstallerFactory.cs create mode 100644 src/Core/Mods/Installation/Installers/ModInstallerFactory.cs rename tests/Core.Tests/Mods/{ => Installation/Installers}/ContainedDirsRootFinderTest.cs (97%) rename tests/Core.Tests/Mods/{ => Installation/Installers}/PostProcessorTest.cs (96%) create mode 100644 tests/Core.Tests/Mods/Installation/ModPackagesUpdaterTest.cs diff --git a/src/Core/API/IEventHandler.cs b/src/Core/API/IEventHandler.cs index b902746..ba36a1c 100644 --- a/src/Core/API/IEventHandler.cs +++ b/src/Core/API/IEventHandler.cs @@ -1,7 +1,8 @@ -using Core.Mods.Installation; +using Core.Mods.Installation.Installers; +using Core.Packages.Installation; namespace Core.API; -public interface IEventHandler : ModPackagesUpdater.IEventHandler +public interface IEventHandler : PackagesUpdater.IEventHandler, BootfilesInstaller.IEventHandler { } diff --git a/src/Core/API/Init.cs b/src/Core/API/Init.cs index eea398c..bc5b6d5 100644 --- a/src/Core/API/Init.cs +++ b/src/Core/API/Init.cs @@ -1,6 +1,7 @@ using Core.Games; using Core.IO; using Core.Mods.Installation; +using Core.Mods.Installation.Installers; using Core.Packages.Installation; using Core.Packages.Installation.Backup; using Core.Packages.Repository; @@ -17,23 +18,27 @@ public static IModManager CreateModManager(Config config) { var game = new Game(config.Game); var modsDir = Path.Combine(game.InstallationDirectory, ModsDirName); - var tempDir = new SubdirectoryTempDir(modsDir); - var statePersistence = new JsonFileStatePersistence(modsDir); var modRepository = new FileSystemRepository(modsDir); + var statePersistence = new JsonFileStatePersistence(modsDir); var safeFileDelete = new WindowsRecyclingBin(); - var packagesUpdater = CreatePackagesUpdater(config.ModInstall, game, tempDir); - return new ModManager(game, modRepository, packagesUpdater, statePersistence, safeFileDelete, tempDir); + var tempDir = new SubdirectoryTempDir(modsDir); + return CreateModManager(game, modRepository, statePersistence, safeFileDelete, tempDir, config.ModInstall); } - internal static IPackagesUpdater CreatePackagesUpdater( - ModInstallConfig installerConfig, + public static IModManager CreateModManager( IGame game, - ITempDir tempDir) + IPackageRepository modRepository, + IStatePersistence statePersistence, + ISafeFileDelete safeFileDelete, + ITempDir tempDir, + ModInstallConfig modInstallConfig) { var backupStrategyProvider = new SkipUpdatedBackupStrategy.Provider( new SuffixBackupStrategy.Provider()); - return new ModPackagesUpdater( + var modInstallerFactory = new ModInstallerFactory(game, tempDir, modInstallConfig); + var modPackagesUpdater = new ModPackagesUpdater( new FileSystemInstallerFactory(), backupStrategyProvider, - TimeProvider.System, game, tempDir, installerConfig); + TimeProvider.System, modInstallerFactory); + return new ModManager(game, modRepository, modInstallerFactory, modPackagesUpdater, statePersistence, safeFileDelete, tempDir); } } diff --git a/src/Core/API/ModManager.cs b/src/Core/API/ModManager.cs index c156054..b9de729 100644 --- a/src/Core/API/ModManager.cs +++ b/src/Core/API/ModManager.cs @@ -1,6 +1,6 @@ using Core.Games; using Core.IO; -using Core.Mods.Installation; +using Core.Mods.Installation.Installers; using Core.Packages.Installation; using Core.Packages.Repository; using Core.State; @@ -12,16 +12,25 @@ internal class ModManager : IModManager { private readonly IGame game; private readonly IPackageRepository packageRepository; + private readonly IBootfilesNameChecker bootfilesNameChecker; private readonly IStatePersistence statePersistence; private readonly ISafeFileDelete safeFileDelete; private readonly ITempDir tempDir; private readonly IPackagesUpdater packagesUpdater; - internal ModManager(IGame game, IPackageRepository packageRepository, IPackagesUpdater packagesUpdater, IStatePersistence statePersistence, ISafeFileDelete safeFileDelete, ITempDir tempDir) + internal ModManager( + IGame game, + IPackageRepository packageRepository, + IBootfilesNameChecker bootfilesNameChecker, + IPackagesUpdater packagesUpdater, + IStatePersistence statePersistence, + ISafeFileDelete safeFileDelete, + ITempDir tempDir) { this.game = game; this.packageRepository = packageRepository; + this.bootfilesNameChecker = bootfilesNameChecker; this.statePersistence = statePersistence; this.safeFileDelete = safeFileDelete; this.tempDir = tempDir; @@ -56,7 +65,7 @@ public List FetchState() return IsOutOfDate(modPackage, modInstallationState); }); - var allPackageNames = installedMods.Keys.Where(packageName => !ModPackagesUpdater.IsBootFiles(packageName)) + var allPackageNames = installedMods.Keys.Where(packageName => !bootfilesNameChecker.IsBootFiles(packageName)) .Concat(enabledModPackages.Keys) .Concat(disabledModPackages.Keys) .Distinct(); diff --git a/src/Core/Mods/Installation/Installers/BootfilesInstaller.cs b/src/Core/Mods/Installation/Installers/BootfilesInstaller.cs index e8fa918..3717ac4 100644 --- a/src/Core/Mods/Installation/Installers/BootfilesInstaller.cs +++ b/src/Core/Mods/Installation/Installers/BootfilesInstaller.cs @@ -18,7 +18,7 @@ public interface IEventHandler void PostProcessingEnd(); } - private const string GeneratedBootfilesPackageName = $"{ModPackagesUpdater.BootfilesPrefix}_generated"; + private const string GeneratedBootfilesPackageName = $"{ModInstallerFactory.BootfilesPrefix}_generated"; internal const string VehicleListRelativeDir = "vehicles"; internal static readonly string TrackListRelativeDir = Path.Combine("tracks", "_data"); @@ -26,7 +26,7 @@ public interface IEventHandler private readonly IEventHandler eventHandler; - public BootfilesInstaller(IInstaller? bootfilesPackageInstaller, IGame game, ITempDir tempDir, IEventHandler eventHandler, IConfig config) : + public BootfilesInstaller(IInstaller? bootfilesPackageInstaller, IGame game, ITempDir tempDir, IEventHandler eventHandler, IConfig config) : base(PackageOrGenerated(bootfilesPackageInstaller, game, tempDir), game, tempDir, config) { this.eventHandler = eventHandler; diff --git a/src/Core/Mods/Installation/Installers/IBootfilesNameChecker.cs b/src/Core/Mods/Installation/Installers/IBootfilesNameChecker.cs new file mode 100644 index 0000000..bfdd8e6 --- /dev/null +++ b/src/Core/Mods/Installation/Installers/IBootfilesNameChecker.cs @@ -0,0 +1,6 @@ +namespace Core.Mods.Installation.Installers; + +public interface IBootfilesNameChecker +{ + public bool IsBootFiles(string packageName); +} diff --git a/src/Core/Mods/Installation/Installers/IModInstallerFactory.cs b/src/Core/Mods/Installation/Installers/IModInstallerFactory.cs new file mode 100644 index 0000000..991c8f0 --- /dev/null +++ b/src/Core/Mods/Installation/Installers/IModInstallerFactory.cs @@ -0,0 +1,9 @@ +using Core.Packages.Installation.Installers; + +namespace Core.Mods.Installation.Installers; + +public interface IModInstallerFactory : IBootfilesNameChecker +{ + public IInstaller ModInstaller(IInstaller packageInstaller, IInstaller bootfilesInstaller); + public IInstaller BootfilesInstaller(IInstaller? bootfilesPackageInstaller, TEventHandler eventHandler); +} diff --git a/src/Core/Mods/Installation/Installers/ModInstallerFactory.cs b/src/Core/Mods/Installation/Installers/ModInstallerFactory.cs new file mode 100644 index 0000000..7b4ec7d --- /dev/null +++ b/src/Core/Mods/Installation/Installers/ModInstallerFactory.cs @@ -0,0 +1,32 @@ +using Core.Games; +using Core.Packages.Installation.Installers; +using Core.Utils; + +namespace Core.Mods.Installation.Installers; + +public class ModInstallerFactory : IModInstallerFactory +{ + internal const string BootfilesPrefix = "__bootfiles"; + + private readonly IGame game; + private readonly ITempDir tempDir; + private readonly ModInstaller.IConfig config; + + public ModInstallerFactory(IGame game, + ITempDir tempDir, + ModInstaller.IConfig config) + { + this.game = game; + this.tempDir = tempDir; + this.config = config; + } + + public IInstaller ModInstaller(IInstaller packageInstaller, IInstaller bootfilesInstaller) => + new ModInstaller(packageInstaller, bootfilesInstaller.PackageName, game, tempDir, config); + + public IInstaller BootfilesInstaller(IInstaller? bootfilesPackageInstaller, BootfilesInstaller.IEventHandler eventHandler) => + new BootfilesInstaller(bootfilesPackageInstaller, game, tempDir, eventHandler, config); + + public bool IsBootFiles(string packageName) => + packageName.StartsWith(BootfilesPrefix); +} diff --git a/src/Core/Mods/Installation/ModPackagesUpdater.cs b/src/Core/Mods/Installation/ModPackagesUpdater.cs index 989f1a8..2e94733 100644 --- a/src/Core/Mods/Installation/ModPackagesUpdater.cs +++ b/src/Core/Mods/Installation/ModPackagesUpdater.cs @@ -1,5 +1,4 @@ using System.Collections.Immutable; -using Core.Games; using Core.Mods.Installation.Installers; using Core.Packages.Installation; using Core.Packages.Installation.Backup; @@ -9,24 +8,18 @@ namespace Core.Mods.Installation; public class ModPackagesUpdater : PackagesUpdater - where TEventHandler : ModPackagesUpdater.IEventHandler + where TEventHandler : PackagesUpdater.IEventHandler { - private readonly IGame game; - private readonly ITempDir tempDir; - private readonly ModInstaller.IConfig config; + private readonly IModInstallerFactory modInstallerFactory; public ModPackagesUpdater( IInstallerFactory installerFactory, IBackupStrategyProvider backupStrategyProvider, TimeProvider timeProvider, - IGame game, - ITempDir tempDir, - ModInstaller.IConfig config) : + IModInstallerFactory modInstallerFactory) : base(installerFactory, backupStrategyProvider, timeProvider) { - this.game = game; - this.tempDir = tempDir; - this.config = config; + this.modInstallerFactory = modInstallerFactory; } protected override void Apply( @@ -37,35 +30,19 @@ protected override void Apply( TEventHandler eventHandler, CancellationToken cancellationToken) { - var (bootfiles, notBootfiles) = installers.Partition(p => ModPackagesUpdater.IsBootFiles(p.PackageName)); - + var (bootfiles, notBootfiles) = installers.Partition(p => modInstallerFactory.IsBootFiles(p.PackageName)); var bootfilesInstaller = CreateBootfilesInstaller(bootfiles, eventHandler); + var allInstallers = notBootfiles - .Select(i => new ModInstaller(i, bootfilesInstaller.PackageName, game, tempDir, config)) + .Select(i => modInstallerFactory.ModInstaller(i, bootfilesInstaller)) .Append(bootfilesInstaller).ToImmutableArray(); base.Apply(currentState, allInstallers, installDir, updatePackageState, eventHandler, cancellationToken); } - private IInstaller CreateBootfilesInstaller(IEnumerable bootfilesPackageInstallers, ModPackagesUpdater.IEventHandler eventHandler) + private IInstaller CreateBootfilesInstaller(IEnumerable bootfilesPackageInstallers, TEventHandler 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 - { + return modInstallerFactory.BootfilesInstaller(installer, eventHandler); } } diff --git a/tests/Core.Tests/API/ModManagerIntegrationTest.cs b/tests/Core.Tests/API/ModManagerIntegrationTest.cs index 198eed5..984bda2 100644 --- a/tests/Core.Tests/API/ModManagerIntegrationTest.cs +++ b/tests/Core.Tests/API/ModManagerIntegrationTest.cs @@ -1,7 +1,6 @@ using Core.API; using Core.Games; using Core.IO; -using Core.Mods.Installation; using Core.Mods.Installation.Installers; using Core.Packages.Installation; using Core.Packages.Installation.Installers; @@ -44,7 +43,7 @@ public class ModManagerIntegrationTest : AbstractFilesystemTest private readonly InMemoryStatePersistence persistedState; - private readonly ModManager modManager; + private readonly IModManager modManager; public ModManagerIntegrationTest() { @@ -59,15 +58,13 @@ public ModManagerIntegrationTest() DirsAtRoot = [DirAtRoot], ExcludedFromInstall = [$"**\\{FileExcludedFromInstall}"] }; - var packagesUpdater = Init.CreatePackagesUpdater(modInstallConfig, gameMock.Object, tempDir); - - modManager = new ModManager( + modManager = Init.CreateModManager( gameMock.Object, modRepositoryMock.Object, - packagesUpdater, persistedState, safeFileDeleteMock.Object, - tempDir); + tempDir, + modInstallConfig); gameMock.Setup(m => m.InstallationDirectory).Returns(gameDir.FullName); } @@ -207,17 +204,17 @@ public void FetchState_RemovesUnavailableBootfiles() { persistedState.InitModInstallationState(new Dictionary { - [$"{ModPackagesUpdater.BootfilesPrefix}_IU"] = new( + [$"{ModInstallerFactory.BootfilesPrefix}_IU"] = new( Time: PastDate, FsHash: null, Partial: false, Dependencies: [], Files: [], ShadowedBy: []), - [$"{ModPackagesUpdater.BootfilesPrefix}_IE"] = new( + [$"{ModInstallerFactory.BootfilesPrefix}_IE"] = new( Time: PastDate, FsHash: null, Partial: false, Dependencies: [], Files: [], ShadowedBy: []), - [$"{ModPackagesUpdater.BootfilesPrefix}_ID"] = new( + [$"{ModInstallerFactory.BootfilesPrefix}_ID"] = new( Time: PastDate, FsHash: null, Partial: false, Dependencies: [], Files: [], @@ -225,21 +222,21 @@ public void FetchState_RemovesUnavailableBootfiles() }); modRepositoryMock.Setup(m => m.ListEnabled()).Returns( [ - new Package(Name: $"{ModPackagesUpdater.BootfilesPrefix}_IE", FullPath: "ie/path", Enabled: true, FsHash: null), - new Package(Name: $"{ModPackagesUpdater.BootfilesPrefix}_UE", FullPath: "ue/path", Enabled: true, FsHash: null) + new Package(Name: $"{ModInstallerFactory.BootfilesPrefix}_IE", FullPath: "ie/path", Enabled: true, FsHash: null), + new Package(Name: $"{ModInstallerFactory.BootfilesPrefix}_UE", FullPath: "ue/path", Enabled: true, FsHash: null) ]); modRepositoryMock.Setup(m => m.ListDisabled()).Returns( [ - new Package(Name: $"{ModPackagesUpdater.BootfilesPrefix}_ID", FullPath: "id/path", Enabled: false, FsHash: null), - new Package(Name: $"{ModPackagesUpdater.BootfilesPrefix}_UD", FullPath: "ud/path", Enabled: false, FsHash: null) + new Package(Name: $"{ModInstallerFactory.BootfilesPrefix}_ID", FullPath: "id/path", Enabled: false, FsHash: null), + new Package(Name: $"{ModInstallerFactory.BootfilesPrefix}_UD", FullPath: "ud/path", Enabled: false, FsHash: null) ]); modManager.FetchState().Should().BeEquivalentTo( [ - new ModState($"{ModPackagesUpdater.BootfilesPrefix}_IE", "ie/path", IsInstalled: true, IsEnabled: true, IsOutOfDate: true), - new ModState($"{ModPackagesUpdater.BootfilesPrefix}_UE", "ue/path", IsInstalled: false, IsEnabled: true, IsOutOfDate: false), - new ModState($"{ModPackagesUpdater.BootfilesPrefix}_ID", "id/path", IsInstalled: true, IsEnabled: false, IsOutOfDate: true), - new ModState($"{ModPackagesUpdater.BootfilesPrefix}_UD", "ud/path", IsInstalled: false, IsEnabled: false, IsOutOfDate: false), + new ModState($"{ModInstallerFactory.BootfilesPrefix}_IE", "ie/path", IsInstalled: true, IsEnabled: true, IsOutOfDate: true), + new ModState($"{ModInstallerFactory.BootfilesPrefix}_UE", "ue/path", IsInstalled: false, IsEnabled: true, IsOutOfDate: false), + new ModState($"{ModInstallerFactory.BootfilesPrefix}_ID", "id/path", IsInstalled: true, IsEnabled: false, IsOutOfDate: true), + new ModState($"{ModInstallerFactory.BootfilesPrefix}_UD", "ud/path", IsInstalled: false, IsEnabled: false, IsOutOfDate: false), ]); } @@ -782,7 +779,7 @@ private Package CreateModArchive(int fsHash, IEnumerable relativePaths, CreateModPackage("Package", fsHash, relativePaths, callback); private Package CreateCustomBootfiles(int fsHash) => - CreateModPackage(ModPackagesUpdater.BootfilesPrefix, fsHash, [ + CreateModPackage(ModInstallerFactory.BootfilesPrefix, fsHash, [ Path.Combine(DirAtRoot, "OrTheyWontBeInstalled"), VehicleListRelativePath, TrackListRelativePath, diff --git a/tests/Core.Tests/Mods/ContainedDirsRootFinderTest.cs b/tests/Core.Tests/Mods/Installation/Installers/ContainedDirsRootFinderTest.cs similarity index 97% rename from tests/Core.Tests/Mods/ContainedDirsRootFinderTest.cs rename to tests/Core.Tests/Mods/Installation/Installers/ContainedDirsRootFinderTest.cs index 2900399..932c6aa 100644 --- a/tests/Core.Tests/Mods/ContainedDirsRootFinderTest.cs +++ b/tests/Core.Tests/Mods/Installation/Installers/ContainedDirsRootFinderTest.cs @@ -1,7 +1,7 @@ using Core.Mods.Installation.Installers; using FluentAssertions; -namespace Core.Tests.Mods; +namespace Core.Tests.Mods.Installation.Installers; [UnitTest] public class ContainedDirsRootFinderTest diff --git a/tests/Core.Tests/Mods/PostProcessorTest.cs b/tests/Core.Tests/Mods/Installation/Installers/PostProcessorTest.cs similarity index 96% rename from tests/Core.Tests/Mods/PostProcessorTest.cs rename to tests/Core.Tests/Mods/Installation/Installers/PostProcessorTest.cs index 260e1ed..977e7b3 100644 --- a/tests/Core.Tests/Mods/PostProcessorTest.cs +++ b/tests/Core.Tests/Mods/Installation/Installers/PostProcessorTest.cs @@ -1,7 +1,7 @@ using Core.Mods.Installation.Installers; using FluentAssertions; -namespace Core.Tests.Mods; +namespace Core.Tests.Mods.Installation.Installers; [UnitTest] public class PostProcessorTest diff --git a/tests/Core.Tests/Mods/Installation/ModPackagesUpdaterTest.cs b/tests/Core.Tests/Mods/Installation/ModPackagesUpdaterTest.cs new file mode 100644 index 0000000..a2b291a --- /dev/null +++ b/tests/Core.Tests/Mods/Installation/ModPackagesUpdaterTest.cs @@ -0,0 +1,97 @@ +using Core.Mods.Installation; +using Core.Mods.Installation.Installers; +using Core.Packages.Installation; +using Core.Packages.Installation.Backup; +using Core.Packages.Installation.Installers; +using Core.Tests.Packages.Installation; +using Core.Utils; +using FluentAssertions; + +namespace Core.Tests.Mods.Installation; + +public class ModPackagesUpdaterTest : PackagesUpdaterTestBase +{ + #region Initialisation + + // 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 string GeneratedBootfilesName = "__generated"; + private static readonly string BootfilesPackageName = "__package"; + + private class WrappedInstaller(IInstaller inner) : IInstaller + { + public string PackageName => $"({inner.PackageName})"; + public int? PackageFsHash => inner.PackageFsHash; + public IReadOnlyCollection PackageDependencies => inner.PackageDependencies; + public IReadOnlyCollection InstalledFiles => inner.InstalledFiles; + public IInstallation.State Installed => inner.Installed; + public void Install(IInstaller.Destination destination, IBackupStrategy backupStrategy, + ProcessingCallbacks callbacks) => inner.Install(destination, backupStrategy, callbacks); + public IEnumerable RelativeDirectoryPaths => inner.RelativeDirectoryPaths; + } + + private class TestModInstallerFactory : IModInstallerFactory + { + public IInstaller ModInstaller(IInstaller packageInstaller, IInstaller bootfilesInstaller) => + new WrappedInstaller(packageInstaller); + + public IInstaller BootfilesInstaller(IInstaller? bootfilesPackageInstaller, PackagesUpdater.IEventHandler eventHandler) => + bootfilesPackageInstaller ?? InstallerOf(GeneratedBootfilesName, fsHash: null, []); + + public bool IsBootFiles(string packageName) => + packageName == BootfilesPackageName || packageName == GeneratedBootfilesName; + } + + protected override IPackagesUpdater NewPackagesUpdater( + IInstallerFactory installerFactory, + IBackupStrategyProvider backupStrategyProvider, + TimeProvider timeProvider) { + return new ModPackagesUpdater( + installerFactory, backupStrategyProvider, timeProvider, new TestModInstallerFactory()); + } + + #endregion + + [Fact] + public void Apply_AlwaysInstallsBootfilesPackage() + { + var packages = new List(); + var progress = new List(); + EventHandlerMock.Setup(m => m.InstallCurrent(It.IsAny())).Callback(packages.Add); + EventHandlerMock.Setup(m => m.ProgressUpdate(It.IsAny())) + .Callback(p => progress.Add(p.Percent)); + + Apply([ + // Uninstall 25% + InstallerOf("I1", fsHash: null, []), // 50% + InstallerOf("I2", fsHash: null, []), // 75% + InstallerOf(BootfilesPackageName, fsHash: null, []), // 100% + ]); + + InstallationState.Should().BeEmpty(); + + packages.Should().Equal("(I1)", "(I2)", BootfilesPackageName); + progress.Should().Equal(0.25, 0.5, 0.75, 1.0); + } + + [Fact] + public void Apply_AlwaysInstallsGeneratedBootfiles() + { + var packages = new List(); + var progress = new List(); + EventHandlerMock.Setup(m => m.InstallCurrent(It.IsAny())).Callback(packages.Add); + EventHandlerMock.Setup(m => m.ProgressUpdate(It.IsAny())) + .Callback(p => progress.Add(p.Percent)); + + Apply([ + // Uninstall 50% + // Generated bootfiles 100% + ]); + + InstallationState.Should().BeEmpty(); + + packages.Should().Equal(GeneratedBootfilesName); + progress.Should().Equal(0.5, 1.0); + } +} diff --git a/tests/Core.Tests/Packages/Installation/PackagesUpdaterTest.cs b/tests/Core.Tests/Packages/Installation/PackagesUpdaterTest.cs index d15f25c..07c5fe3 100644 --- a/tests/Core.Tests/Packages/Installation/PackagesUpdaterTest.cs +++ b/tests/Core.Tests/Packages/Installation/PackagesUpdaterTest.cs @@ -10,34 +10,33 @@ namespace Core.Tests.Packages.Installation; -public class PackagesUpdaterTest +public class PackagesUpdaterTest : PackagesUpdaterTestBase { #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? installationState; - 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; + protected override IPackagesUpdater NewPackagesUpdater( + IInstallerFactory installerFactory, + IBackupStrategyProvider backupStrategyProvider, + TimeProvider timeProvider) => + new PackagesUpdater(installerFactory, backupStrategyProvider, timeProvider); + #endregion [Fact] public void Apply_NoPackages() { var progress = new List(); - eventHandlerMock.Setup(m => m.ProgressUpdate(It.IsAny())) + EventHandlerMock.Setup(m => m.ProgressUpdate(It.IsAny())) .Callback(p => progress.Add(p.Percent)); Apply([]); - installationState.Should().BeEmpty(); + InstallationState.Should().BeEmpty(); progress.Should().Equal(1.0); } @@ -46,10 +45,10 @@ public void Apply_NoPackages() public void Apply_TracksProgress() { var progress = new List(); - eventHandlerMock.Setup(m => m.ProgressUpdate(It.IsAny())) + EventHandlerMock.Setup(m => m.ProgressUpdate(It.IsAny())) .Callback(p => progress.Add(p.Percent)); - installationState = new Dictionary + InstallationState = new Dictionary { ["U1"] = new(Time: ValueNotUsed, FsHash: null, Partial: false, Dependencies: [], Files: [], ShadowedBy: []), @@ -64,7 +63,7 @@ public void Apply_TracksProgress() InstallerOf("I3", fsHash: null, []), // 100% ]); - installationState.Should().BeEmpty(); + InstallationState.Should().BeEmpty(); progress.Should().Equal(0.25, 0.5, 0.75, 1.0); } @@ -78,29 +77,29 @@ public void Apply_InstallsSelectedPackages() ]) ]); - installationState.Should().BeEquivalentTo(new Dictionary + InstallationState.Should().BeEquivalentTo(new Dictionary { - ["A"] = new(Time: fakeUtcInstallationDate, FsHash: 42, Partial: false, Dependencies: [], Files: [ + ["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(); + 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(); + 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() { - installationState = new Dictionary + InstallationState = new Dictionary { ["A"] = new( Time: ValueNotUsed, @@ -113,23 +112,23 @@ public void Apply_UninstallsUnselectedPackages() Apply([]); - installationState.Should().BeEmpty(); + InstallationState.Should().BeEmpty(); - backupStrategyMock.Verify(m => m.RestoreBackup(DestinationPath("AF"))); - backupStrategyMock.VerifyNoOtherCalls(); + 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(); + 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() { - installationState = new Dictionary + InstallationState = new Dictionary { ["A"] = new(Time: ValueNotUsed, FsHash: 1, Partial: false, Dependencies: [], Files: [ @@ -145,16 +144,16 @@ public void Apply_UpdatesChangedPackages() ]) ]); - installationState.Should().BeEquivalentTo(new Dictionary + InstallationState.Should().BeEquivalentTo(new Dictionary { - ["A"] = new(Time: fakeUtcInstallationDate, FsHash: 2, Partial: false, Dependencies: [], Files: [ + ["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"))); + BackupStrategyMock.Verify(m => m.RestoreBackup(DestinationPath("AF1"))); + BackupStrategyMock.Verify(m => m.PerformBackup(DestinationPath("AF2"))); } [Fact] @@ -166,9 +165,9 @@ public void Apply_PreservesPackageDependencies() ], dependencies: ["X"]) ]); - installationState.Should().BeEquivalentTo(new Dictionary + InstallationState.Should().BeEquivalentTo(new Dictionary { - ["A"] = new(Time: fakeUtcInstallationDate, FsHash: 42, Partial: false, Dependencies: ["X"], Files: [ + ["A"] = new(Time: FakeUtcInstallationDate, FsHash: 42, Partial: false, Dependencies: ["X"], Files: [ "AF" ], ShadowedBy: []) }); @@ -189,15 +188,15 @@ public void Apply_FirstInstalledFilesTakePrecedence() ]) ]); - installationState.Should().BeEquivalentTo(new Dictionary + InstallationState.Should().BeEquivalentTo(new Dictionary { - ["A"] = new(Time: fakeUtcInstallationDate, FsHash: 1, Partial: false, Dependencies: [], Files: [ + ["A"] = new(Time: FakeUtcInstallationDate, FsHash: 1, Partial: false, Dependencies: [], Files: [ "AF1", "AF2" ], ShadowedBy: []), - ["B"] = new(Time: fakeUtcInstallationDate, FsHash: 2, Partial: false, Dependencies: [], Files: [ + ["B"] = new(Time: FakeUtcInstallationDate, FsHash: 2, Partial: false, Dependencies: [], Files: [ "BF" ], ShadowedBy: []), - ["C"] = new(Time: fakeUtcInstallationDate, FsHash: 3, Partial: false, Dependencies: [], Files: [ + ["C"] = new(Time: FakeUtcInstallationDate, FsHash: 3, Partial: false, Dependencies: [], Files: [ "CF" ], ShadowedBy: ["A", "B"]) }); @@ -206,7 +205,7 @@ public void Apply_FirstInstalledFilesTakePrecedence() [Fact] public void Apply_RestoresFilesPreviouslyShadowedByUninstalledPackage() { - installationState = new Dictionary + InstallationState = new Dictionary { ["A"] = new(Time: ValueNotUsed, FsHash: 1, Partial: false, Dependencies: [], Files: [ @@ -226,9 +225,9 @@ public void Apply_RestoresFilesPreviouslyShadowedByUninstalledPackage() ]) ]); - installationState.Should().BeEquivalentTo(new Dictionary + InstallationState.Should().BeEquivalentTo(new Dictionary { - ["A"] = new(Time: fakeUtcInstallationDate, FsHash: 1, Partial: false, Dependencies: [], Files: [ + ["A"] = new(Time: FakeUtcInstallationDate, FsHash: 1, Partial: false, Dependencies: [], Files: [ "SF", "AF1" ], ShadowedBy: []) @@ -238,7 +237,7 @@ public void Apply_RestoresFilesPreviouslyShadowedByUninstalledPackage() [Fact] public void Apply_InstallStopsIfBackupFails() { - backupStrategyMock.Setup(m => m.PerformBackup(DestinationPath("Fail"))).Throws(); + BackupStrategyMock.Setup(m => m.PerformBackup(DestinationPath("Fail"))).Throws(); this.Invoking(m => m.Apply([ InstallerOf("A", fsHash: 42, files: [ @@ -246,9 +245,9 @@ public void Apply_InstallStopsIfBackupFails() ]) ])).Should().Throw(); - installationState.Should().BeEquivalentTo(new Dictionary + InstallationState.Should().BeEquivalentTo(new Dictionary { - ["A"] = new(Time: fakeUtcInstallationDate, FsHash: 42, Partial: true, Dependencies: [], Files: [ + ["A"] = new(Time: FakeUtcInstallationDate, FsHash: 42, Partial: true, Dependencies: [], Files: [ "AF1", "Fail" // We don't know where it failed, so we add it ], ShadowedBy: []) @@ -258,9 +257,9 @@ public void Apply_InstallStopsIfBackupFails() [Fact] public void Apply_UninstallStopsIfBackupFails() { - backupStrategyMock.Setup(m => m.RestoreBackup(DestinationPath("Fail"))).Throws(); + BackupStrategyMock.Setup(m => m.RestoreBackup(DestinationPath("Fail"))).Throws(); - installationState = new Dictionary + InstallationState = new Dictionary { ["A"] = new(Time: ValueNotUsed, FsHash: 42, Partial: false, Dependencies: [], Files: [ @@ -272,7 +271,7 @@ public void Apply_UninstallStopsIfBackupFails() this.Invoking(m => m.Apply([])).Should().Throw(); - installationState.Should().BeEquivalentTo(new Dictionary + InstallationState.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 @@ -285,9 +284,9 @@ public void Apply_UninstallStopsIfBackupFails() [Fact] public void Apply_UninstallFailuresResultsInPartialInstallation() { - backupStrategyMock.Setup(m => m.RestoreBackup(DestinationPath("Fail"))).Throws(); + BackupStrategyMock.Setup(m => m.RestoreBackup(DestinationPath("Fail"))).Throws(); - installationState = new Dictionary + InstallationState = new Dictionary { ["A"] = new(Time: ValueNotUsed, FsHash: null, Partial: false, Dependencies: [], Files: [ @@ -297,7 +296,7 @@ public void Apply_UninstallFailuresResultsInPartialInstallation() this.Invoking(m => m.Apply([])).Should().Throw(); - installationState.Should().BeEquivalentTo(new Dictionary + InstallationState.Should().BeEquivalentTo(new Dictionary { ["A"] = new(Time: ValueNotUsed, FsHash: null, Partial: true, Dependencies: [], Files: [ "Fail" @@ -308,9 +307,9 @@ public void Apply_UninstallFailuresResultsInPartialInstallation() [Fact] public void Apply_PartialPackagesStayPartial() { - backupStrategyMock.Setup(m => m.RestoreBackup(DestinationPath("Fail"))).Throws(); + BackupStrategyMock.Setup(m => m.RestoreBackup(DestinationPath("Fail"))).Throws(); - installationState = new Dictionary + InstallationState = new Dictionary { ["A"] = new(Time: ValueNotUsed, FsHash: null, Partial: true, Dependencies: [], Files: [ @@ -320,7 +319,7 @@ public void Apply_PartialPackagesStayPartial() this.Invoking(m => m.Apply([])).Should().Throw(); - installationState.Should().BeEquivalentTo(new Dictionary + InstallationState.Should().BeEquivalentTo(new Dictionary { ["A"] = new(Time: ValueNotUsed, FsHash: null, Partial: true, Dependencies: [], Files: [ "Fail" @@ -335,7 +334,7 @@ public void Apply_UninstallRemovesEmptyDirectories() var subDir = Path.Combine("D1", "D2"); Directory.CreateDirectory(DestinationPath(subDir).Full); - installationState = new Dictionary + InstallationState = new Dictionary { ["A"] = new(Time: ValueNotUsed, FsHash: null, Partial: true, Dependencies: [], Files: [ @@ -345,31 +344,110 @@ public void Apply_UninstallRemovesEmptyDirectories() Apply([]); - installationState.Should().BeEmpty(); + InstallationState.Should().BeEmpty(); Directory.Exists(DestinationPath("D1").Full).Should().BeFalse(); } - #region Utility methods + [Fact] + public void Apply_HandlesPriorityInversion() + { + InstallationState = new Dictionary + { + ["A"] = new(Time: ValueNotUsed, FsHash: 0, Partial: false, Dependencies: [], Files: + [ + "AF", + "Shared" + ], ShadowedBy: []), + ["B"] = new(Time: ValueNotUsed, FsHash: 0, Partial: false, Dependencies: [], Files: + [ + "BF" + ], ShadowedBy: ["A"]) + }; + + Apply([ + InstallerOf("B", fsHash: 0, [ + "BF", + "Shared" + ]), + InstallerOf("A", fsHash: 0, [ + "AF", + "Shared" + ]) + ]); + + InstallationState.Should().BeEquivalentTo(new Dictionary + { + ["A"] = new(Time: FakeUtcInstallationDate, FsHash: 0, Partial: false, Dependencies: [], Files: [ + "AF", + + ], ShadowedBy: ["B"]), + ["B"] = new(Time: FakeUtcInstallationDate, FsHash: 0, Partial: false, Dependencies: [], Files: [ + "BF", + "Shared" + ], ShadowedBy: []) + }); + } + + [Fact] + public void Apply_HandlesFileDeletionOnUpgrade() + { + InstallationState = new Dictionary + { + ["A"] = new(Time: ValueNotUsed, FsHash: 0, Partial: false, Dependencies: [], Files: + [ + "A1", + "A2" + ], ShadowedBy: []), + }; + + Apply([ + InstallerOf("A", fsHash: 1, [ + "A1" + ]) + ]); + + InstallationState.Should().BeEquivalentTo(new Dictionary + { + ["A"] = new(Time: FakeUtcInstallationDate, FsHash: 1, Partial: false, Dependencies: [], Files: [ + "A1", + ], ShadowedBy: []), + }); + } +} + +public abstract class PackagesUpdaterTestBase where TEventHandler : class +{ + protected readonly Mock BackupStrategyMock = new(); + protected readonly Mock EventHandlerMock = new(); + protected readonly DateTime FakeUtcInstallationDate = DateTime.Today.AddDays(10).ToUniversalTime(); + private readonly TimeSpan fakeLocalTimeOffset = TimeSpan.FromHours(3); + protected IReadOnlyDictionary? InstallationState; + private readonly string destinationDir = Path.GetRandomFileName(); protected RootedPath DestinationPath(string relativePath) => new(destinationDir, relativePath); - private void Apply(IInstaller[] installers) + protected abstract IPackagesUpdater NewPackagesUpdater( + IInstallerFactory installerFactory, + IBackupStrategyProvider backupStrategyProvider, + TimeProvider timeProvider); + + protected void Apply(IInstaller[] 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( + var backupStrategyProviderMock = new Mock>(); + backupStrategyProviderMock.Setup(m => m.BackupStrategy(It.IsAny(), It.IsAny())) + .Returns(BackupStrategyMock.Object); + var packagesUpdater = NewPackagesUpdater( new InstallerForPackage(installers), backupStrategyProviderMock.Object, - new FakeTimeProvider(fakeUtcInstallationDate.WithOffset(fakeLocalTimeOffset))); + new FakeTimeProvider(FakeUtcInstallationDate.WithOffset(fakeLocalTimeOffset))); packagesUpdater.Apply( - installationState ?? ReadOnlyDictionary.Empty, + InstallationState ?? ReadOnlyDictionary.Empty, packages, destinationDir, - newState => installationState = newState, - eventHandlerMock.Object, + newState => InstallationState = newState, + EventHandlerMock.Object, CancellationToken.None); } @@ -386,10 +464,10 @@ public IInstaller PackageInstaller(Package package) => installers.First(installer => installer.PackageName == package.Name); } - private static IInstaller InstallerOf(string name, int? fsHash, IReadOnlyCollection files) => + protected static IInstaller InstallerOf(string name, int? fsHash, IReadOnlyCollection files) => InstallerOf(name, fsHash, files, Array.Empty()); - private static IInstaller InstallerOf(string name, int? fsHash, + protected static IInstaller InstallerOf(string name, int? fsHash, IReadOnlyCollection files, IReadOnlyCollection dependencies) => new StaticFilesInstaller(name, fsHash, files, dependencies); @@ -420,10 +498,8 @@ protected override void InstallFile(RootedPath destinationPath, object context) // Install everything from the root directory - private static readonly string DirAtRoot = "X"; + private const string DirAtRoot = "X"; public override IEnumerable RelativeDirectoryPaths => [DirAtRoot]; } - - #endregion } From 13b1d70200e437cb002cc6662d37b90625a8e16d Mon Sep 17 00:00:00 2001 From: Paolo Ambrosio Date: Fri, 6 Mar 2026 21:49:37 +0000 Subject: [PATCH 3/6] ModInstaller refactoring and tests --- src/Core/API/Config.cs | 1 + .../Installers/BaseModInstaller.cs | 120 ++++++++++- .../Installers/BootfilesInstaller.cs | 52 +++-- .../Installers/GeneratedBootfilesInstaller.cs | 6 +- .../Installation/Installers/ModInstaller.cs | 76 +++++-- .../Installers/ModInstallerFactory.cs | 7 +- .../Installation/Installers/PostProcessor.cs | 134 ------------- src/Core/Packages/IPackageInfo.cs | 7 + .../Packages/Installation/IInstallation.cs | 4 +- .../Installation/Installers/BaseInstaller.cs | 14 +- ...erIntegrationTest.cs => ModManagerTest.cs} | 34 ++-- .../Core.Tests/Base/AbstractFilesystemTest.cs | 1 - ...ocessorTest.cs => BaseModInstallerTest.cs} | 10 +- .../Installers/ModInstallerTest.cs | 186 ++++++++++++++++++ .../Installation/ModPackagesUpdaterTest.cs | 19 +- .../Installers/StaticFilesInstaller.cs | 52 +++++ .../Installation/PackagesUpdaterTest.cs | 48 +---- ...ionTest.cs => FileSystemRepositoryTest.cs} | 5 +- 18 files changed, 510 insertions(+), 266 deletions(-) delete mode 100644 src/Core/Mods/Installation/Installers/PostProcessor.cs create mode 100644 src/Core/Packages/IPackageInfo.cs rename tests/Core.Tests/API/{ModManagerIntegrationTest.cs => ModManagerTest.cs} (96%) rename tests/Core.Tests/Mods/Installation/Installers/{PostProcessorTest.cs => BaseModInstallerTest.cs} (83%) create mode 100644 tests/Core.Tests/Mods/Installation/Installers/ModInstallerTest.cs create mode 100644 tests/Core.Tests/Packages/Installation/Installers/StaticFilesInstaller.cs rename tests/Core.Tests/Packages/Repository/{FileSystemRepositoryIntegrationTest.cs => FileSystemRepositoryTest.cs} (96%) diff --git a/src/Core/API/Config.cs b/src/Core/API/Config.cs index 98fd668..9a4d075 100644 --- a/src/Core/API/Config.cs +++ b/src/Core/API/Config.cs @@ -39,6 +39,7 @@ public class ModInstallConfig : ModInstaller.IConfig { public IEnumerable DirsAtRoot { get; set; } = Array.Empty(); public IEnumerable ExcludedFromInstall { get; set; } = Array.Empty(); + public string GameSupportedModDirectory { get; set; } = Path.Combine("UserData", "Mods"); public IEnumerable ExcludedFromConfig { get; set; } = Array.Empty(); public bool GenerateModDetails { get; set; } = true; } diff --git a/src/Core/Mods/Installation/Installers/BaseModInstaller.cs b/src/Core/Mods/Installation/Installers/BaseModInstaller.cs index 74453dd..8433f75 100644 --- a/src/Core/Mods/Installation/Installers/BaseModInstaller.cs +++ b/src/Core/Mods/Installation/Installers/BaseModInstaller.cs @@ -1,5 +1,5 @@ using System.Collections.Immutable; -using Core.Games; +using System.IO.Abstractions; using Core.Packages.Installation; using Core.Packages.Installation.Backup; using Core.Packages.Installation.Installers; @@ -21,11 +21,18 @@ IEnumerable ExcludedFromInstall { get; } + + string GameSupportedModDirectory { get; } } + internal const string VehicleListFileName = "vehiclelist.lst"; + internal const string TrackListFileName = "tracklist.lst"; + internal const string DrivelineFileName = "driveline.rg"; + + protected readonly IFileSystem FileSystem; protected readonly IInstaller Inner; - protected readonly IGame Game; - protected readonly DirectoryInfo StagingDir; + protected readonly string StagingFullPath; + protected readonly string GameSupportedModDirectory; private readonly Lazy rootPaths; private readonly Matcher filesToInstallMatcher; @@ -34,11 +41,12 @@ IEnumerable ExcludedFromInstall private readonly List localInstalledFiles = new(); - protected BaseModInstaller(IInstaller inner, IGame game, ITempDir tempDir, IConfig config) + protected BaseModInstaller(IFileSystem fileSystem, IInstaller inner, string tempDir, IConfig config) { + FileSystem = fileSystem; Inner = inner; - Game = game; - StagingDir = new DirectoryInfo(Path.Combine(tempDir.BasePath, inner.PackageName)); + StagingFullPath = Path.GetFullPath(Path.Combine(tempDir, inner.PackageName)); + GameSupportedModDirectory = config.GameSupportedModDirectory; var rootFinder = new ContainedDirsRootFinder(config.DirsAtRoot); rootPaths = new Lazy( () => rootFinder.FromDirectoryList(Inner.RelativeDirectoryPaths)); @@ -92,7 +100,7 @@ private IInstaller.Destination ConfigToStagingDir(IInstaller.Destination destina { var relativePathFromRoot = rootPaths.Value.GetPathFromRoot(pathInPackage); return relativePathFromRoot is null - ? new RootedPath(StagingDir.FullName, pathInPackage) + ? new RootedPath(StagingFullPath, pathInPackage) // If part of a game root, return the destination relative to that root : destination(relativePathFromRoot); }; @@ -121,8 +129,102 @@ private Action IgnoreForStagedFiles(Action action) => rp }; private bool RootIsNotStagingDir(RootedPath rp) => - rp.Root != StagingDir.FullName; + rp.Root != StagingFullPath; private bool RootIsStagingDir(RootedPath rp) => - rp.Root == StagingDir.FullName; + rp.Root == StagingFullPath; + + protected RootedPath? AppendCrdFileEntries(IEnumerable crdFileEntries) => + AppendEntryList(VehicleListDir.SubPath(VehicleListFileName), crdFileEntries); + + protected abstract RootedPath VehicleListDir { get; } + + public RootedPath? AppendTrdFileEntries(IEnumerable trdFileEntries) => + AppendEntryList(TrackListDir.SubPath(TrackListFileName), trdFileEntries); + + protected abstract RootedPath TrackListDir { get; } + + public RootedPath? AppendDrivelineRecords(IEnumerable recordBlocks) + { + var recordsTextBlock = DrivelineBlock(recordBlocks); + if (recordsTextBlock.Length == 0) + { + return null; + } + + var driveLineFilePath = DrivelineDir.SubPath(DrivelineFileName); + CreateParentDirectory(driveLineFilePath); + var newContents = DrivelineFileContents(driveLineFilePath, WrapConfigBlock(recordsTextBlock)); + FileSystem.File.WriteAllText(driveLineFilePath.Full, newContents); // TODO THIS DOES NOT EXIST!!!!! + return driveLineFilePath; + } + + protected abstract RootedPath DrivelineDir { get; } + + private static string DrivelineBlock(IEnumerable recordBlocks) + { + var dedupedRecordBlocks = DedupeRecordBlocks(recordBlocks); + return string.Join($"{Environment.NewLine}{Environment.NewLine}", dedupedRecordBlocks); + } + + internal static IEnumerable DedupeRecordBlocks(IEnumerable recordBlocks) + { + var seen = new HashSet(); + var deduped = new List(); + foreach (var rb in recordBlocks.Reverse()) + { + var key = rb.Split(Environment.NewLine, 2).First().NormalizeWhitespaces(); + if (seen.Contains(key)) + { + continue; + } + seen.Add(key); + deduped.Add(rb); + } + return deduped.Reverse(); + } + + private string DrivelineFileContents(RootedPath driveLineFilePath, string recordsTextBlock) + { + if (!FileSystem.File.Exists(driveLineFilePath.Full)) + { + return recordsTextBlock; + } + + var contents = FileSystem.File.ReadAllText(driveLineFilePath.Full); + var endIndex = contents.LastIndexOf("END", StringComparison.Ordinal); + if (endIndex < 0) + { + throw new Exception("Could not find insertion point in driveline file"); + } + return contents.Insert(endIndex, recordsTextBlock); + } + + private RootedPath? AppendEntryList( + RootedPath filePath, + IEnumerable entries) + { + var entriesBlock = string.Join(Environment.NewLine, entries); + if (entriesBlock.Length == 0) + { + return null; + } + + CreateParentDirectory(filePath); + var f = FileSystem.File.AppendText(filePath.Full); + f.Write(WrapConfigBlock(entriesBlock)); + f.Close(); + return filePath; + } + + private void CreateParentDirectory(RootedPath filePath) + { + var dirPath = Path.GetDirectoryName(filePath.Full); + if (dirPath is not null) + { + FileSystem.Directory.CreateDirectory(dirPath); + } + } + + protected virtual string WrapConfigBlock(string configBlock) => configBlock; } diff --git a/src/Core/Mods/Installation/Installers/BootfilesInstaller.cs b/src/Core/Mods/Installation/Installers/BootfilesInstaller.cs index 3717ac4..0c1fd85 100644 --- a/src/Core/Mods/Installation/Installers/BootfilesInstaller.cs +++ b/src/Core/Mods/Installation/Installers/BootfilesInstaller.cs @@ -1,5 +1,5 @@ using System.Collections.Immutable; -using Core.Games; +using System.IO.Abstractions; using Core.Packages.Installation.Installers; using Core.Utils; @@ -24,16 +24,27 @@ public interface IEventHandler internal static readonly string TrackListRelativeDir = Path.Combine("tracks", "_data"); internal static readonly string DrivelineRelativeDir = Path.Combine(VehicleListRelativeDir, "physics", "driveline"); + private readonly RootedPath gameInstallationPath; private readonly IEventHandler eventHandler; - public BootfilesInstaller(IInstaller? bootfilesPackageInstaller, IGame game, ITempDir tempDir, IEventHandler eventHandler, IConfig config) : - base(PackageOrGenerated(bootfilesPackageInstaller, game, tempDir), game, tempDir, config) + public BootfilesInstaller(IInstaller? bootfilesPackageInstaller, string tempDir, IConfig config, + string gameInstallationDir, IEventHandler eventHandler) : + this(new FileSystem(), bootfilesPackageInstaller, tempDir, config, gameInstallationDir, eventHandler) { + } + + public BootfilesInstaller(IFileSystem fileSystem, IInstaller? bootfilesPackageInstaller, + string tempDir, IConfig config, string gameInstallationDir, IEventHandler eventHandler) : + base(fileSystem, PackageOrGenerated(bootfilesPackageInstaller, gameInstallationDir, tempDir), tempDir, config) + { + gameInstallationPath = new RootedPath(gameInstallationDir); this.eventHandler = eventHandler; } - private static IInstaller PackageOrGenerated(IInstaller? bootfilesPackageInstaller, IGame game, ITempDir tempDir) => - bootfilesPackageInstaller ?? new GeneratedBootfilesInstaller(GeneratedBootfilesPackageName, game, tempDir); + private static IInstaller PackageOrGenerated(IInstaller? bootfilesPackageInstaller, + string gameInstallationDirectory, string tempDir) => + bootfilesPackageInstaller ?? new GeneratedBootfilesInstaller(GeneratedBootfilesPackageName, + gameInstallationDirectory, tempDir); // Bootfiles cannot have dependencies. public override IReadOnlyCollection PackageDependencies => Array.Empty(); @@ -48,14 +59,11 @@ protected override void Install(Action innerInstall) eventHandler.ExtractingBootfiles(packageNameIfNotGenerated); innerInstall(); eventHandler.PostProcessingVehicles(); - PostProcessor.AppendCrdFileEntries(new RootedPath(Game.InstallationDirectory, VehicleListRelativeDir), - modConfigs.SelectMany(c => c.CrdFileEntries), WrapInComments); + AppendCrdFileEntries(modConfigs.SelectMany(c => c.CrdFileEntries)); eventHandler.PostProcessingTracks(); - PostProcessor.AppendTrdFileEntries(new RootedPath(Game.InstallationDirectory, TrackListRelativeDir), - modConfigs.SelectMany(c => c.TrdFileEntries), WrapInComments); + AppendTrdFileEntries(modConfigs.SelectMany(c => c.TrdFileEntries)); eventHandler.PostProcessingDrivelines(); - PostProcessor.AppendDrivelineRecords(new RootedPath(Game.InstallationDirectory, DrivelineRelativeDir), - modConfigs.SelectMany(c => c.DrivelineRecords), WrapInComments); + AppendDrivelineRecords(modConfigs.SelectMany(c => c.DrivelineRecords)); eventHandler.PostProcessingEnd(); } else @@ -64,15 +72,19 @@ protected override void Install(Action innerInstall) } } - private static string WrapInComments(string content) - { - return $"{Environment.NewLine}### BEGIN AMS2CM{Environment.NewLine}{content}{Environment.NewLine}### END AMS2CM{Environment.NewLine}"; - } + protected override RootedPath VehicleListDir => gameInstallationPath.SubPath(VehicleListRelativeDir); + + protected override RootedPath TrackListDir => gameInstallationPath.SubPath(TrackListRelativeDir); + + protected override RootedPath DrivelineDir => gameInstallationPath.SubPath(DrivelineRelativeDir); + + protected override string WrapConfigBlock(string configBlock) => + $"{Environment.NewLine}### BEGIN AMS2CM{Environment.NewLine}{configBlock}{Environment.NewLine}### END AMS2CM{Environment.NewLine}"; private IReadOnlyList CollectModConfigs() { - var modsGamePath = Path.Combine(Game.InstallationDirectory, PostProcessor.GameSupportedModDirectory); - var directoryInfo = new DirectoryInfo(modsGamePath); + var modsGamePath = gameInstallationPath.SubPath(GameSupportedModDirectory); + var directoryInfo = new DirectoryInfo(modsGamePath.Full); if (!directoryInfo.Exists) return Array.Empty(); return directoryInfo.GetDirectories("*").Select(modDir => @@ -80,9 +92,9 @@ private IReadOnlyList CollectModConfigs() ConfigEntries.Empty : new ConfigEntries ( - FileLinesOrEmpty(modDir, PostProcessor.VehicleListFileName), - FileLinesOrEmpty(modDir, PostProcessor.TrackListFileName), - FileLinesOrEmpty(modDir, PostProcessor.DrivelineFileName) + FileLinesOrEmpty(modDir, VehicleListFileName), + FileLinesOrEmpty(modDir, TrackListFileName), + FileLinesOrEmpty(modDir, DrivelineFileName) ) ).ToImmutableList(); } diff --git a/src/Core/Mods/Installation/Installers/GeneratedBootfilesInstaller.cs b/src/Core/Mods/Installation/Installers/GeneratedBootfilesInstaller.cs index 222dedf..e676367 100644 --- a/src/Core/Mods/Installation/Installers/GeneratedBootfilesInstaller.cs +++ b/src/Core/Mods/Installation/Installers/GeneratedBootfilesInstaller.cs @@ -17,11 +17,11 @@ internal class GeneratedBootfilesInstaller : BaseDirectoryInstaller private readonly string BmtFilesWildcard = Path.Combine("vehicles", "_data", "effects", "backfire", "*.bmt"); - public GeneratedBootfilesInstaller(string packageName, IGame game, ITempDir tempDir) : + public GeneratedBootfilesInstaller(string packageName, string gameInstallationDirectory, string tempDir) : base(packageName, null) { - pakPath = Path.Combine(game.InstallationDirectory, PakfilesDirectory); - var extractionPath = Path.Combine(tempDir.BasePath, Guid.NewGuid().ToString()); + pakPath = Path.Combine(gameInstallationDirectory, PakfilesDirectory); + var extractionPath = Path.Combine(tempDir, Guid.NewGuid().ToString()); Source = Directory.CreateDirectory(extractionPath); } diff --git a/src/Core/Mods/Installation/Installers/ModInstaller.cs b/src/Core/Mods/Installation/Installers/ModInstaller.cs index f6e085c..f1c555e 100644 --- a/src/Core/Mods/Installation/Installers/ModInstaller.cs +++ b/src/Core/Mods/Installation/Installers/ModInstaller.cs @@ -1,5 +1,5 @@ using System.Collections.Immutable; -using Core.Games; +using System.IO.Abstractions; using Core.Packages.Installation.Installers; using Core.Utils; using Microsoft.Extensions.FileSystemGlobbing; @@ -30,12 +30,30 @@ bool GenerateModDetails private IReadOnlyCollection bootfilesDependency = Array.Empty(); private readonly string bootfilesPackageName; - internal ModInstaller(IInstaller inner, string bootfilesPackageName, IGame game, ITempDir tempDir, IConfig config) : - base(inner, game, tempDir, config) + private RootedPath modConfigPath; + private string modName; + + internal ModInstaller(IInstaller inner, string tempDir, IConfig config, + string gameInstallationDir, string bootfilesPackageName) : + this(new FileSystem(), inner, tempDir, config, gameInstallationDir, bootfilesPackageName) + { + } + + internal ModInstaller(IFileSystem fileSystem, IInstaller inner, string tempDir, IConfig config, + string gameInstallationDir, string bootfilesPackageName) : + base(fileSystem, inner, tempDir, config) { this.bootfilesPackageName = bootfilesPackageName; filesToConfigureMatcher = Matchers.ExcludingPatterns(config.ExcludedFromConfig); generateModDetails = config.GenerateModDetails; + + var normalisedName = string.Concat( + Path.GetFileNameWithoutExtension(inner.PackageName) + .Where(char.IsAsciiLetterOrDigit)); + var hexFsHash = (inner.PackageFsHash ?? 0).ToString("x"); + modName = $"{normalisedName}_{hexFsHash}"; + + modConfigPath = new RootedPath(gameInstallationDir, Path.Combine(GameSupportedModDirectory, modName)); } public override IReadOnlyCollection PackageDependencies => @@ -48,10 +66,16 @@ protected override void Install(Action innerInstall) GenerateModConfig(); } + protected override RootedPath VehicleListDir => modConfigPath; + + protected override RootedPath TrackListDir => modConfigPath; + + protected override RootedPath DrivelineDir => modConfigPath; + private void GenerateModConfig() { var gameSupportedMod = FileEntriesToConfigure() - .Any(p => p.StartsWith(PostProcessor.GameSupportedModDirectory)); + .Any(p => p.StartsWith(GameSupportedModDirectory)); var modConfig = gameSupportedMod ? ConfigEntries.Empty : new ConfigEntries(CrdFileEntries(), TrdFileEntries(), FindDrivelineRecords()); @@ -63,22 +87,12 @@ private void WriteModConfigFiles(ConfigEntries modConfig) if (modConfig.None()) return; - var normalisedName = string.Concat( - Path.GetFileNameWithoutExtension(Inner.PackageName) - .Where(char.IsAsciiLetterOrDigit)); - var hexFsHash = (PackageFsHash ?? 0).ToString("x"); - var modConfigDirPath = new RootedPath( - Game.InstallationDirectory, - Path.Combine(PostProcessor.GameSupportedModDirectory, $"{normalisedName}_{hexFsHash}")); - - // TODO this can fail - Directory.CreateDirectory(modConfigDirPath.Full); - AddToInstalledFiles(PostProcessor.AppendCrdFileEntries(modConfigDirPath, modConfig.CrdFileEntries)); - AddToInstalledFiles(PostProcessor.AppendTrdFileEntries(modConfigDirPath, modConfig.TrdFileEntries)); - AddToInstalledFiles(PostProcessor.AppendDrivelineRecords(modConfigDirPath, modConfig.DrivelineRecords)); + AddToInstalledFiles(AppendCrdFileEntries(modConfig.CrdFileEntries)); + AddToInstalledFiles(AppendTrdFileEntries(modConfig.TrdFileEntries)); + AddToInstalledFiles(AppendDrivelineRecords(modConfig.DrivelineRecords)); if (generateModDetails && !modConfig.TrdFileEntries.Any()) { - AddToInstalledFiles(PostProcessor.GenerateModDetails(modConfigDirPath, Inner)); + AddToInstalledFiles(GenerateModDetails()); } else { bootfilesDependency = new[] { bootfilesPackageName }; @@ -104,16 +118,16 @@ private IEnumerable FileEntriesToConfigure() => private List FindDrivelineRecords() { var recordBlocks = new List(); - if (!StagingDir.Exists) + if (!FileSystem.Directory.Exists(StagingFullPath)) { return recordBlocks; } - foreach (var configFile in StagingDir.EnumerateFiles()) + foreach (var configFile in FileSystem.Directory.EnumerateFiles(StagingFullPath)) { var recordIndent = -1; var recordLines = new List(); - foreach (var line in File.ReadAllLines(configFile.FullName)) + foreach (var line in FileSystem.File.ReadAllLines(configFile)) { // Read each line until we find one with RECORD if (recordIndent < 0) @@ -149,4 +163,24 @@ private List FindDrivelineRecords() return recordBlocks; } + public RootedPath GenerateModDetails() + { + var contents = @$" + + + + + + + + + + + + +"; + var filePath = modConfigPath.SubPath($"{modName}.xml"); + FileSystem.File.WriteAllText(filePath.Full, contents); + return filePath; + } } diff --git a/src/Core/Mods/Installation/Installers/ModInstallerFactory.cs b/src/Core/Mods/Installation/Installers/ModInstallerFactory.cs index 7b4ec7d..b5bd529 100644 --- a/src/Core/Mods/Installation/Installers/ModInstallerFactory.cs +++ b/src/Core/Mods/Installation/Installers/ModInstallerFactory.cs @@ -22,10 +22,11 @@ public ModInstallerFactory(IGame game, } public IInstaller ModInstaller(IInstaller packageInstaller, IInstaller bootfilesInstaller) => - new ModInstaller(packageInstaller, bootfilesInstaller.PackageName, game, tempDir, config); + new ModInstaller(packageInstaller, tempDir.BasePath, config, game.InstallationDirectory, bootfilesInstaller.PackageName); - public IInstaller BootfilesInstaller(IInstaller? bootfilesPackageInstaller, BootfilesInstaller.IEventHandler eventHandler) => - new BootfilesInstaller(bootfilesPackageInstaller, game, tempDir, eventHandler, config); + public IInstaller BootfilesInstaller(IInstaller? bootfilesPackageInstaller, + BootfilesInstaller.IEventHandler eventHandler) => + new BootfilesInstaller(bootfilesPackageInstaller, tempDir.BasePath, config, game.InstallationDirectory, eventHandler); public bool IsBootFiles(string packageName) => packageName.StartsWith(BootfilesPrefix); diff --git a/src/Core/Mods/Installation/Installers/PostProcessor.cs b/src/Core/Mods/Installation/Installers/PostProcessor.cs deleted file mode 100644 index 9a22fe9..0000000 --- a/src/Core/Mods/Installation/Installers/PostProcessor.cs +++ /dev/null @@ -1,134 +0,0 @@ -using Core.Packages.Installation; -using Core.Utils; - -namespace Core.Mods.Installation.Installers; - -internal static class PostProcessor -{ - internal static readonly string GameSupportedModDirectory = Path.Combine("UserData", "Mods"); - - internal const string VehicleListFileName = "vehiclelist.lst"; - internal const string TrackListFileName = "tracklist.lst"; - internal const string DrivelineFileName = "driveline.rg"; - - public static RootedPath? AppendCrdFileEntries( - RootedPath destDirPath, IEnumerable crdFileEntries) => - AppendCrdFileEntries(destDirPath, crdFileEntries, IdentityProcessor); - - public static RootedPath? AppendCrdFileEntries( - RootedPath destDirPath, - IEnumerable crdFileEntries, - Func blockProcessor) => - AppendEntryList(destDirPath.SubPath(VehicleListFileName), crdFileEntries, blockProcessor); - - public static RootedPath? AppendTrdFileEntries( - RootedPath destDirPath, IEnumerable trdFileEntries) => - AppendTrdFileEntries(destDirPath, trdFileEntries, IdentityProcessor); - - public static RootedPath? AppendTrdFileEntries( - RootedPath destDirPath, - IEnumerable trdFileEntries, - Func blockProcessor) => - AppendEntryList(destDirPath.SubPath(TrackListFileName), trdFileEntries, blockProcessor); - - private static RootedPath? AppendEntryList( - RootedPath filePath, - IEnumerable entries, - Func blockProcessor) - { - var entriesBlock = string.Join(Environment.NewLine, entries); - if (entriesBlock.Length == 0) - { - return null; - } - - var f = File.AppendText(filePath.Full); - f.Write(blockProcessor(entriesBlock)); - f.Close(); - return filePath; - } - - public static RootedPath? AppendDrivelineRecords( - RootedPath destDirPath, IEnumerable recordBlocks) => - AppendDrivelineRecords(destDirPath, recordBlocks, IdentityProcessor); - - public static RootedPath? AppendDrivelineRecords( - RootedPath destDirPath, - IEnumerable recordBlocks, - Func blockProcessor) - { - var recordsTextBlock = DrivelineBlock(recordBlocks); - if (recordsTextBlock.Length == 0) - { - return null; - } - - var driveLineFilePath = destDirPath.SubPath(DrivelineFileName); - var newContents = DrivelineFileContents(driveLineFilePath, blockProcessor(recordsTextBlock)); - File.WriteAllText(driveLineFilePath.Full, newContents); - return driveLineFilePath; - } - - private static string DrivelineBlock(IEnumerable recordBlocks) - { - var dedupedRecordBlocks = DedupeRecordBlocks(recordBlocks); - return string.Join($"{Environment.NewLine}{Environment.NewLine}", dedupedRecordBlocks); - } - - internal static IEnumerable DedupeRecordBlocks(IEnumerable recordBlocks) - { - var seen = new HashSet(); - var deduped = new List(); - foreach (var rb in recordBlocks.Reverse()) - { - var key = rb.Split(Environment.NewLine, 2).First().NormalizeWhitespaces(); - if (seen.Contains(key)) - { - continue; - } - seen.Add(key); - deduped.Add(rb); - } - return deduped.Reverse(); - } - - private static string DrivelineFileContents(RootedPath driveLineFilePath, string recordsTextBlock) - { - if (!File.Exists(driveLineFilePath.Full)) - { - return recordsTextBlock; - } - - var contents = File.ReadAllText(driveLineFilePath.Full); - var endIndex = contents.LastIndexOf("END", StringComparison.Ordinal); - if (endIndex < 0) - { - throw new Exception("Could not find insertion point in driveline file"); - } - return contents.Insert(endIndex, recordsTextBlock); - } - - private static string IdentityProcessor(string block) => block; - - public static RootedPath GenerateModDetails(RootedPath destDirPath, IInstallation installation) - { - var modName = Path.GetFileName(destDirPath.Full); - var filePath = destDirPath.SubPath($"{modName}.xml"); - var contents = @$" - - - - - - - - - - - - -"; - File.WriteAllText(filePath.Full, contents); - return filePath; - } -} diff --git a/src/Core/Packages/IPackageInfo.cs b/src/Core/Packages/IPackageInfo.cs new file mode 100644 index 0000000..030e34f --- /dev/null +++ b/src/Core/Packages/IPackageInfo.cs @@ -0,0 +1,7 @@ +namespace Core.Packages; + +public interface IPackageInfo +{ + string PackageName { get; } + int? PackageFsHash { get; } +} diff --git a/src/Core/Packages/Installation/IInstallation.cs b/src/Core/Packages/Installation/IInstallation.cs index 99c7c60..c93e5e4 100644 --- a/src/Core/Packages/Installation/IInstallation.cs +++ b/src/Core/Packages/Installation/IInstallation.cs @@ -2,10 +2,8 @@ namespace Core.Packages.Installation; -public interface IInstallation +public interface IInstallation : IPackageInfo { - string PackageName { get; } - int? PackageFsHash { get; } IReadOnlyCollection PackageDependencies { get; } IReadOnlyCollection InstalledFiles { get; } diff --git a/src/Core/Packages/Installation/Installers/BaseInstaller.cs b/src/Core/Packages/Installation/Installers/BaseInstaller.cs index 6d764b5..06232d7 100644 --- a/src/Core/Packages/Installation/Installers/BaseInstaller.cs +++ b/src/Core/Packages/Installation/Installers/BaseInstaller.cs @@ -1,4 +1,4 @@ -using System.Runtime.CompilerServices; +using System.IO.Abstractions; using Core.Packages.Installation.Backup; using Core.Utils; @@ -18,6 +18,8 @@ internal abstract class BaseInstaller : IInstaller public IInstallation.State Installed { get; private set; } public IReadOnlyCollection InstalledFiles => installedFiles; + protected readonly IFileSystem FileSystem; + private readonly List installedFiles = new(); protected BaseInstaller(string packageName, int? packageFsHash) @@ -25,9 +27,15 @@ protected BaseInstaller(string packageName, int? packageFsHash) { } + protected BaseInstaller(string packageName, int? packageFsHash, IReadOnlyCollection packageDependencies) : + this(new FileSystem(), packageName, packageFsHash, packageDependencies) + { + } + // A package cannot currently specify dependencies. - protected BaseInstaller(string packageName, int? packageFsHash, IReadOnlyCollection packageDependencies) + protected BaseInstaller(IFileSystem fs, string packageName, int? packageFsHash, IReadOnlyCollection packageDependencies) { + FileSystem = fs; PackageName = packageName; PackageFsHash = packageFsHash; PackageDependencies = packageDependencies; @@ -53,7 +61,7 @@ public void Install(IInstaller.Destination destination, IBackupStrategy backupSt backupStrategy.PerformBackup(destPath); if (!removeFile) { - Directory.GetParent(destPath.Full)?.Create(); + FileSystem.Directory.GetParent(destPath.Full)?.Create(); InstallFile(destPath, context); } } diff --git a/tests/Core.Tests/API/ModManagerIntegrationTest.cs b/tests/Core.Tests/API/ModManagerTest.cs similarity index 96% rename from tests/Core.Tests/API/ModManagerIntegrationTest.cs rename to tests/Core.Tests/API/ModManagerTest.cs index 984bda2..b7fda43 100644 --- a/tests/Core.Tests/API/ModManagerIntegrationTest.cs +++ b/tests/Core.Tests/API/ModManagerTest.cs @@ -13,21 +13,23 @@ namespace Core.Tests.API; -public class ModManagerIntegrationTest : AbstractFilesystemTest +[IntegrationTest] +public class ModManagerTest : AbstractFilesystemTest { #region Initialisation private const string DirAtRoot = "DirAtRoot"; private const string FileExcludedFromInstall = "Excluded"; + private static readonly string GameSupportedModDirectory = Path.Combine("Mod", "Directory"); private static readonly string VehicleListRelativePath = - Path.Combine(BootfilesInstaller.VehicleListRelativeDir, PostProcessor.VehicleListFileName); + Path.Combine(BootfilesInstaller.VehicleListRelativeDir, BaseModInstaller.VehicleListFileName); private static readonly string TrackListRelativePath = - Path.Combine(BootfilesInstaller.TrackListRelativeDir, PostProcessor.TrackListFileName); + Path.Combine(BootfilesInstaller.TrackListRelativeDir, BaseModInstaller.TrackListFileName); private static readonly string DrivelineRelativePath = - Path.Combine(BootfilesInstaller.DrivelineRelativeDir, PostProcessor.DrivelineFileName); + Path.Combine(BootfilesInstaller.DrivelineRelativeDir, BaseModInstaller.DrivelineFileName); private static readonly DateTime PastDate = DateTime.Today.AddDays(-1); @@ -45,7 +47,7 @@ public class ModManagerIntegrationTest : AbstractFilesystemTest private readonly IModManager modManager; - public ModManagerIntegrationTest() + public ModManagerTest() { gameDir = TestDir.CreateSubdirectory("Game"); modsDir = TestDir.CreateSubdirectory("Packages"); @@ -55,7 +57,9 @@ public ModManagerIntegrationTest() persistedState = new InMemoryStatePersistence(); var modInstallConfig = new ModInstallConfig { - DirsAtRoot = [DirAtRoot], ExcludedFromInstall = [$"**\\{FileExcludedFromInstall}"] + DirsAtRoot = [DirAtRoot], + ExcludedFromInstall = [$"**\\{FileExcludedFromInstall}"], + GameSupportedModDirectory = GameSupportedModDirectory }; modManager = Init.CreateModManager( @@ -674,7 +678,7 @@ public void Install_GameSupportedModsNeverRequireBootfiles() CreateModArchive(100, [ Path.Combine(DirAtRoot, "Vehicle.crd"), Path.Combine(DirAtRoot, "Track.trd"), // Tracks do not currently work in game - Path.Combine(PostProcessor.GameSupportedModDirectory, "Anything") + Path.Combine(GameSupportedModDirectory, "Anything") ]), CreateCustomBootfiles(900), ]); @@ -704,11 +708,11 @@ public void Install_OldVehicleModsDoNotRequireBootfiles() persistedState.For("Package100").Dependencies.Should().BeEmpty(); var generatedConfigDir = $"Package100_{100:x}"; - File.ReadAllText(GamePath(PostProcessor.GameSupportedModDirectory, generatedConfigDir, - PostProcessor.VehicleListFileName).Full).Should().Contain("Vehicle.crd"); - File.ReadAllText(GamePath(PostProcessor.GameSupportedModDirectory, generatedConfigDir, - PostProcessor.DrivelineFileName).Full).Should().Contain(drivelineRecord); - File.Exists(GamePath(PostProcessor.GameSupportedModDirectory, generatedConfigDir, $"{generatedConfigDir}.xml") + File.ReadAllText(GamePath(GameSupportedModDirectory, generatedConfigDir, + BaseModInstaller.VehicleListFileName).Full).Should().Contain("Vehicle.crd"); + File.ReadAllText(GamePath(GameSupportedModDirectory, generatedConfigDir, + BaseModInstaller.DrivelineFileName).Full).Should().Contain(drivelineRecord); + File.Exists(GamePath(GameSupportedModDirectory, generatedConfigDir, $"{generatedConfigDir}.xml") .Full).Should().BeTrue(); } @@ -730,9 +734,9 @@ public void Install_OldTrackModsAlwaysRequireBootfiles() persistedState.For("Package100").Dependencies.Should().Contain("__bootfiles900"); var generatedConfigDir = $"Package100_{100:x}"; - File.ReadAllText(GamePath(PostProcessor.GameSupportedModDirectory, generatedConfigDir, - PostProcessor.TrackListFileName).Full).Should().Contain("Track.trd"); - File.Exists(GamePath(PostProcessor.GameSupportedModDirectory, generatedConfigDir, $"{generatedConfigDir}.xml") + File.ReadAllText(GamePath(GameSupportedModDirectory, generatedConfigDir, + BaseModInstaller.TrackListFileName).Full).Should().Contain("Track.trd"); + File.Exists(GamePath(GameSupportedModDirectory, generatedConfigDir, $"{generatedConfigDir}.xml") .Full).Should().BeFalse(); File.ReadAllText(GamePath(TrackListRelativePath).Full).Should().Contain("Track.trd"); diff --git a/tests/Core.Tests/Base/AbstractFilesystemTest.cs b/tests/Core.Tests/Base/AbstractFilesystemTest.cs index e2baaaf..4dbf700 100644 --- a/tests/Core.Tests/Base/AbstractFilesystemTest.cs +++ b/tests/Core.Tests/Base/AbstractFilesystemTest.cs @@ -2,7 +2,6 @@ namespace Core.Tests.Base; -[IntegrationTest] public abstract class AbstractFilesystemTest : IDisposable { protected readonly DirectoryInfo TestDir; diff --git a/tests/Core.Tests/Mods/Installation/Installers/PostProcessorTest.cs b/tests/Core.Tests/Mods/Installation/Installers/BaseModInstallerTest.cs similarity index 83% rename from tests/Core.Tests/Mods/Installation/Installers/PostProcessorTest.cs rename to tests/Core.Tests/Mods/Installation/Installers/BaseModInstallerTest.cs index 977e7b3..b1327ea 100644 --- a/tests/Core.Tests/Mods/Installation/Installers/PostProcessorTest.cs +++ b/tests/Core.Tests/Mods/Installation/Installers/BaseModInstallerTest.cs @@ -4,12 +4,12 @@ namespace Core.Tests.Mods.Installation.Installers; [UnitTest] -public class PostProcessorTest +public class BaseModInstallerTest { [Fact] public void DedupeRecordBlocks_ConsidersOnlyFirstLine() { - PostProcessor.DedupeRecordBlocks([ + BaseModInstaller.DedupeRecordBlocks([ @"RECORDGROUP foo first", @"RECORDGROUP foo @@ -23,7 +23,7 @@ public void DedupeRecordBlocks_ConsidersOnlyFirstLine() [Fact] public void DedupeRecordBlocks_IgnoresRedundantWhitespaces() { - PostProcessor.DedupeRecordBlocks([ + BaseModInstaller.DedupeRecordBlocks([ " RECORD foo\vbar ", "RECORD foo\tbar" ]).Should().BeEquivalentTo([ @@ -34,7 +34,7 @@ public void DedupeRecordBlocks_IgnoresRedundantWhitespaces() [Fact] public void DedupeRecordBlocks_WorksForEmptyLines() { - PostProcessor.DedupeRecordBlocks([ + BaseModInstaller.DedupeRecordBlocks([ "", "", ]).Should().BeEquivalentTo([ @@ -45,7 +45,7 @@ public void DedupeRecordBlocks_WorksForEmptyLines() [Fact] public void DedupeRecordBlocks_AssumesCommentsAlreadyRemoved() { - PostProcessor.DedupeRecordBlocks([ + BaseModInstaller.DedupeRecordBlocks([ @"RECORD foo bar # first", @"RECORD foo bar # last" ]).Should().BeEquivalentTo([ diff --git a/tests/Core.Tests/Mods/Installation/Installers/ModInstallerTest.cs b/tests/Core.Tests/Mods/Installation/Installers/ModInstallerTest.cs new file mode 100644 index 0000000..7db3e77 --- /dev/null +++ b/tests/Core.Tests/Mods/Installation/Installers/ModInstallerTest.cs @@ -0,0 +1,186 @@ +using System.IO.Abstractions.TestingHelpers; +using Core.Mods.Installation.Installers; +using Core.Packages.Installation.Backup; +using Core.Packages.Installation.Installers; +using Core.Tests.Packages.Installation.Installers; +using Core.Utils; +using FluentAssertions; + +namespace Core.Tests.Mods.Installation.Installers; + +[UnitTest] +public class ModInstallerTest +{ + private const string GameSupportedModDirectory = "ModDirectory"; + private const string GameDirAtRoot = "DirAtRoot"; + private const string BootfilesPackageName = "BootFilesPackage"; + + #region Setup + + private readonly MockFileSystem fs = new(); + private readonly Mock configMock = new(); + private readonly Mock backupStrategyMock = new(); + private readonly string destDir; + private readonly string tempDir; + + public ModInstallerTest() + { + configMock.Setup(c => c.DirsAtRoot).Returns([GameDirAtRoot, GameSupportedModDirectory]); + configMock.Setup(c => c.GameSupportedModDirectory).Returns(GameSupportedModDirectory); + configMock.Setup(c => c.GenerateModDetails).Returns(true); + + destDir = fs.Directory.CreateDirectory("Dest").FullName; + tempDir = fs.Directory.CreateDirectory("Temp").FullName; + } + + #endregion + + [Fact] + public void AutomaticModConfigurationSkippedWhenNothingToConfigure() + { + string[] packageFiles = + [ + Path.Combine(GameDirAtRoot, "NotRequiringConfiguration"), + ]; + + var modInstaller = InstallWithModInstaller(InstallerOf("A", null, packageFiles)); + + fs.AllFiles.Should().BeEquivalentTo( + packageFiles.Select(f => Path.Combine(destDir, f)) + ); + + modInstaller.InstalledFiles.Should().BeEquivalentTo( + packageFiles.Select(f => new RootedPath(destDir, f)) + ); + + modInstaller.PackageDependencies.Should().BeEmpty(); + } + + [Fact] + public void AutomaticModConfigurationSkippedWhenConfiguredByPackage() + { + string[] packageFiles = + [ + Path.Combine(GameDirAtRoot, "File.crd"), + // This disables configuration + Path.Combine(GameSupportedModDirectory, "Anything") + ]; + + var modInstaller = InstallWithModInstaller(InstallerOf("A", null, packageFiles)); + + fs.AllFiles.Should().BeEquivalentTo( + packageFiles.Select(f => Path.Combine(destDir, f)) + ); + + modInstaller.InstalledFiles.Should().BeEquivalentTo( + packageFiles.Select(f => new RootedPath(destDir, f)) + ); + + modInstaller.PackageDependencies.Should().BeEmpty(); + } + + [Fact] + public void AutomaticModConfigurationForVehicles() + { + var crdFile = Path.Combine(GameDirAtRoot, "File.crd"); + var drivelineRecord = $"RECORD Something{Environment.NewLine}"; + + var packageFiles = new Dictionary + { + [Path.Combine("SubDir", crdFile)] = "Anything", + ["FileAtRoot.txt"] = drivelineRecord + }; + + var modInstaller = InstallWithModInstaller(InstallerOf("A", 0xbadcafe, packageFiles)); + + string[] expectedFiles = { + crdFile, + Path.Combine(GameSupportedModDirectory, "A_badcafe", BaseModInstaller.VehicleListFileName), + Path.Combine(GameSupportedModDirectory, "A_badcafe", BaseModInstaller.DrivelineFileName), + Path.Combine(GameSupportedModDirectory, "A_badcafe", "A_badcafe.xml") + }; + + modInstaller.InstalledFiles.Should().BeEquivalentTo( + expectedFiles.Select(f => new RootedPath(destDir, f)) + ); + + modInstaller.PackageDependencies.Should().BeEmpty(); + + fs.AllFiles.Should().BeEquivalentTo( + expectedFiles.Select(f => Path.Combine(destDir, f)) + ); + fs.GetFile(Path.Combine(destDir, GameSupportedModDirectory, "A_badcafe", BaseModInstaller.VehicleListFileName)) + .TextContents.Should().Be(crdFile); + fs.GetFile(Path.Combine(destDir, GameSupportedModDirectory, "A_badcafe", BaseModInstaller.DrivelineFileName)) + .TextContents.Should().Be(drivelineRecord.Trim()); + } + + [Fact] + public void AutomaticModConfigurationCanBeDisabled() + { + configMock.Setup(c => c.GenerateModDetails).Returns(false); + + string[] packageFiles = + [ + Path.Combine(GameDirAtRoot, "File.crd") + ]; + + var modInstaller = InstallWithModInstaller(InstallerOf("A", null, packageFiles)); + + var expectedFiles = packageFiles.Concat([ + Path.Combine(GameSupportedModDirectory, "A_0", "vehiclelist.lst") + // No mod xml + ]).ToHashSet(); + + fs.AllFiles.Should().BeEquivalentTo( + expectedFiles.Select(f => Path.Combine(destDir, f)) + ); + + modInstaller.InstalledFiles.Should().BeEquivalentTo( + expectedFiles.Select(f => new RootedPath(destDir, f)) + ); + + modInstaller.PackageDependencies.Should().ContainSingle(BootfilesPackageName); + } + + [Fact] + public void AutomaticModConfigurationNotPossibleForTracks() + { + string[] packageFiles = + [ + Path.Combine(GameDirAtRoot, "File.trd") + ]; + + var modInstaller = InstallWithModInstaller(InstallerOf("Bee Cee", null, packageFiles)); + + var expectedFiles = packageFiles.Concat([ + Path.Combine(GameSupportedModDirectory, "BeeCee_0", "tracklist.lst") + // No mod xml + ]).ToHashSet(); + + fs.AllFiles.Should().BeEquivalentTo( + expectedFiles.Select(f => Path.Combine(destDir, f)) + ); + + modInstaller.InstalledFiles.Should().BeEquivalentTo( + expectedFiles.Select(f => new RootedPath(destDir, f)) + ); + + modInstaller.PackageDependencies.Should().ContainSingle(BootfilesPackageName); + } + + private ModInstaller InstallWithModInstaller(IInstaller inner) + { + var modInstaller = new ModInstaller(fs, inner, tempDir, configMock.Object, destDir, BootfilesPackageName); + modInstaller.Install(packagePath => new RootedPath(destDir, packagePath), + backupStrategyMock.Object, new ProcessingCallbacks()); + fs.Directory.Delete(tempDir, recursive: true); + return modInstaller; + } + + private IInstaller InstallerOf(string name, int? fsHash, IReadOnlyCollection files) => + InstallerOf(name, fsHash, files.ToDictionary(f => f, _ => Convert.ToString(fsHash) ?? string.Empty)); + + private IInstaller InstallerOf(string name, int? fsHash, IReadOnlyDictionary fileContents) => + new StaticFilesInstaller(fs, name, fsHash, fileContents, Array.Empty()); +} diff --git a/tests/Core.Tests/Mods/Installation/ModPackagesUpdaterTest.cs b/tests/Core.Tests/Mods/Installation/ModPackagesUpdaterTest.cs index a2b291a..1f1d781 100644 --- a/tests/Core.Tests/Mods/Installation/ModPackagesUpdaterTest.cs +++ b/tests/Core.Tests/Mods/Installation/ModPackagesUpdaterTest.cs @@ -1,21 +1,21 @@ +using System.Collections.ObjectModel; using Core.Mods.Installation; using Core.Mods.Installation.Installers; using Core.Packages.Installation; using Core.Packages.Installation.Backup; using Core.Packages.Installation.Installers; using Core.Tests.Packages.Installation; +using Core.Tests.Packages.Installation.Installers; using Core.Utils; using FluentAssertions; namespace Core.Tests.Mods.Installation; +[IntegrationTest] public class ModPackagesUpdaterTest : PackagesUpdaterTestBase { #region Initialisation - // 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 string GeneratedBootfilesName = "__generated"; private static readonly string BootfilesPackageName = "__package"; @@ -37,7 +37,7 @@ public IInstaller ModInstaller(IInstaller packageInstaller, IInstaller bootfiles new WrappedInstaller(packageInstaller); public IInstaller BootfilesInstaller(IInstaller? bootfilesPackageInstaller, PackagesUpdater.IEventHandler eventHandler) => - bootfilesPackageInstaller ?? InstallerOf(GeneratedBootfilesName, fsHash: null, []); + bootfilesPackageInstaller ?? InstallerOf(GeneratedBootfilesName); public bool IsBootFiles(string packageName) => packageName == BootfilesPackageName || packageName == GeneratedBootfilesName; @@ -63,10 +63,10 @@ public void Apply_AlwaysInstallsBootfilesPackage() .Callback(p => progress.Add(p.Percent)); Apply([ - // Uninstall 25% - InstallerOf("I1", fsHash: null, []), // 50% - InstallerOf("I2", fsHash: null, []), // 75% - InstallerOf(BootfilesPackageName, fsHash: null, []), // 100% + // Uninstall 25% + InstallerOf("I1"), // 50% + InstallerOf("I2"), // 75% + InstallerOf(BootfilesPackageName), // 100% ]); InstallationState.Should().BeEmpty(); @@ -94,4 +94,7 @@ public void Apply_AlwaysInstallsGeneratedBootfiles() packages.Should().Equal(GeneratedBootfilesName); progress.Should().Equal(0.5, 1.0); } + + internal static IInstaller InstallerOf(string name) => + new StaticFilesInstaller(name, null, ReadOnlyDictionary.Empty, []); } diff --git a/tests/Core.Tests/Packages/Installation/Installers/StaticFilesInstaller.cs b/tests/Core.Tests/Packages/Installation/Installers/StaticFilesInstaller.cs new file mode 100644 index 0000000..6fdb5c9 --- /dev/null +++ b/tests/Core.Tests/Packages/Installation/Installers/StaticFilesInstaller.cs @@ -0,0 +1,52 @@ +using System.Collections.Immutable; +using System.IO.Abstractions; +using Core.Packages.Installation.Installers; +using Core.Utils; + +namespace Core.Tests.Packages.Installation.Installers; + +internal class StaticFilesInstaller : BaseInstaller +{ + private readonly IReadOnlyDictionary files; + private readonly bool createFiles; + + internal StaticFilesInstaller(string packageName, int? packageFsHash, IReadOnlyDictionary files, + IReadOnlyCollection packageDependencies) : + base(packageName, packageFsHash, packageDependencies) + { + createFiles = false; + this.files = files; + } + + internal StaticFilesInstaller(IFileSystem fs, string packageName, int? packageFsHash, IReadOnlyDictionary files, + IReadOnlyCollection packageDependencies) : + base(fs, packageName, packageFsHash, packageDependencies) + { + createFiles = true; + this.files = files; + } + + protected override void InstalAllFiles(InstallBody body) + { + foreach (var file in files) + { + body(file.Key, file.Value); + } + } + + protected override void InstallFile(RootedPath destinationPath, string fileContents) + { + if (!createFiles) + return; + + var parent = Path.GetDirectoryName(destinationPath.Full); + if (parent != null) + { + FileSystem.Directory.CreateDirectory(parent); + } + FileSystem.File.WriteAllText(destinationPath.Full, fileContents); + } + + public override IEnumerable RelativeDirectoryPaths => + files.Keys.SelectNotNull(Path.GetDirectoryName).ToImmutableHashSet(); +} diff --git a/tests/Core.Tests/Packages/Installation/PackagesUpdaterTest.cs b/tests/Core.Tests/Packages/Installation/PackagesUpdaterTest.cs index 07c5fe3..62fbd32 100644 --- a/tests/Core.Tests/Packages/Installation/PackagesUpdaterTest.cs +++ b/tests/Core.Tests/Packages/Installation/PackagesUpdaterTest.cs @@ -3,6 +3,7 @@ using Core.Packages.Installation.Backup; using Core.Packages.Installation.Installers; using Core.Packages.Repository; +using Core.Tests.Packages.Installation.Installers; using Core.Utils; using FluentAssertions; using FluentAssertions.Extensions; @@ -10,6 +11,7 @@ namespace Core.Tests.Packages.Installation; +[IntegrationTest] public class PackagesUpdaterTest : PackagesUpdaterTestBase { #region Initialisation @@ -414,6 +416,13 @@ public void Apply_HandlesFileDeletionOnUpgrade() ], ShadowedBy: []), }); } + + 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.ToDictionary(f => f, _ => ""), dependencies); } public abstract class PackagesUpdaterTestBase where TEventHandler : class @@ -463,43 +472,4 @@ internal InstallerForPackage(IReadOnlyCollection installers) public IInstaller PackageInstaller(Package package) => installers.First(installer => installer.PackageName == package.Name); } - - protected static IInstaller InstallerOf(string name, int? fsHash, IReadOnlyCollection files) => - InstallerOf(name, fsHash, files, Array.Empty()); - - protected 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 const string DirAtRoot = "X"; - - public override IEnumerable RelativeDirectoryPaths => [DirAtRoot]; - } } diff --git a/tests/Core.Tests/Packages/Repository/FileSystemRepositoryIntegrationTest.cs b/tests/Core.Tests/Packages/Repository/FileSystemRepositoryTest.cs similarity index 96% rename from tests/Core.Tests/Packages/Repository/FileSystemRepositoryIntegrationTest.cs rename to tests/Core.Tests/Packages/Repository/FileSystemRepositoryTest.cs index fd47cd0..db1ee81 100644 --- a/tests/Core.Tests/Packages/Repository/FileSystemRepositoryIntegrationTest.cs +++ b/tests/Core.Tests/Packages/Repository/FileSystemRepositoryTest.cs @@ -4,13 +4,14 @@ namespace Core.Tests.Packages.Repository; -public class FileSystemRepositoryIntegrationTest : AbstractFilesystemTest +[IntegrationTest] +public class FileSystemRepositoryTest : AbstractFilesystemTest { private const int NotChecked = 42; private readonly FileSystemRepository fileSystemRepository; - public FileSystemRepositoryIntegrationTest() : base() + public FileSystemRepositoryTest() : base() { fileSystemRepository = new(TestDir.FullName); } From 04983b9f538222535adf3fc93a87617075805949 Mon Sep 17 00:00:00 2001 From: Paolo Ambrosio Date: Mon, 9 Mar 2026 08:30:43 +0000 Subject: [PATCH 4/6] Bootfiles naming from config --- src/Core/API/Config.cs | 4 ++- src/Core/API/Init.cs | 8 +++-- src/Core/API/ModManager.cs | 9 ++--- src/Core/Mods/IBootfilesNaming.cs | 8 +++++ .../Installers/BootfilesInstaller.cs | 22 ++++++------ .../Installers/IBootfilesNameChecker.cs | 6 ---- .../Installers/IModInstallerFactory.cs | 2 +- .../Installers/ModInstallerFactory.cs | 11 +++--- .../Mods/Installation/ModPackagesUpdater.cs | 5 ++- src/Core/Mods/PrefixBootfilesNaming.cs | 25 ++++++++++++++ tests/Core.Tests/API/ModManagerTest.cs | 34 ++++++++++--------- .../Installation/ModPackagesUpdaterTest.cs | 11 +++--- 12 files changed, 92 insertions(+), 53 deletions(-) create mode 100644 src/Core/Mods/IBootfilesNaming.cs delete mode 100644 src/Core/Mods/Installation/Installers/IBootfilesNameChecker.cs create mode 100644 src/Core/Mods/PrefixBootfilesNaming.cs diff --git a/src/Core/API/Config.cs b/src/Core/API/Config.cs index 9a4d075..07ace99 100644 --- a/src/Core/API/Config.cs +++ b/src/Core/API/Config.cs @@ -1,4 +1,5 @@ using Core.Games; +using Core.Mods; using Core.Mods.Installation.Installers; using Core.SoftwareUpdates; using Microsoft.Extensions.Configuration; @@ -35,8 +36,9 @@ public class GameConfig : Game.IConfig public string ProcessName { get; set; } = "Undefined"; } -public class ModInstallConfig : ModInstaller.IConfig +public class ModInstallConfig : PrefixBootfilesNaming.IConfig, ModInstaller.IConfig { + public string BootfilesPrefix { get; set; } = "__bootfiles"; public IEnumerable DirsAtRoot { get; set; } = Array.Empty(); public IEnumerable ExcludedFromInstall { get; set; } = Array.Empty(); public string GameSupportedModDirectory { get; set; } = Path.Combine("UserData", "Mods"); diff --git a/src/Core/API/Init.cs b/src/Core/API/Init.cs index bc5b6d5..deb5cd2 100644 --- a/src/Core/API/Init.cs +++ b/src/Core/API/Init.cs @@ -1,5 +1,6 @@ using Core.Games; using Core.IO; +using Core.Mods; using Core.Mods.Installation; using Core.Mods.Installation.Installers; using Core.Packages.Installation; @@ -35,10 +36,11 @@ public static IModManager CreateModManager( { var backupStrategyProvider = new SkipUpdatedBackupStrategy.Provider( new SuffixBackupStrategy.Provider()); - var modInstallerFactory = new ModInstallerFactory(game, tempDir, modInstallConfig); + var bootfilesNaming = new PrefixBootfilesNaming(modInstallConfig); + var modInstallerFactory = new ModInstallerFactory(game, tempDir, bootfilesNaming, modInstallConfig); var modPackagesUpdater = new ModPackagesUpdater( new FileSystemInstallerFactory(), backupStrategyProvider, - TimeProvider.System, modInstallerFactory); - return new ModManager(game, modRepository, modInstallerFactory, modPackagesUpdater, statePersistence, safeFileDelete, tempDir); + TimeProvider.System, bootfilesNaming, modInstallerFactory); + return new ModManager(game, modRepository, bootfilesNaming, modPackagesUpdater, statePersistence, safeFileDelete, tempDir); } } diff --git a/src/Core/API/ModManager.cs b/src/Core/API/ModManager.cs index b9de729..d2b9e4d 100644 --- a/src/Core/API/ModManager.cs +++ b/src/Core/API/ModManager.cs @@ -1,5 +1,6 @@ using Core.Games; using Core.IO; +using Core.Mods; using Core.Mods.Installation.Installers; using Core.Packages.Installation; using Core.Packages.Repository; @@ -12,7 +13,7 @@ internal class ModManager : IModManager { private readonly IGame game; private readonly IPackageRepository packageRepository; - private readonly IBootfilesNameChecker bootfilesNameChecker; + private readonly IBootfilesNaming bootfilesNaming; private readonly IStatePersistence statePersistence; private readonly ISafeFileDelete safeFileDelete; private readonly ITempDir tempDir; @@ -22,7 +23,7 @@ internal class ModManager : IModManager internal ModManager( IGame game, IPackageRepository packageRepository, - IBootfilesNameChecker bootfilesNameChecker, + IBootfilesNaming bootfilesNaming, IPackagesUpdater packagesUpdater, IStatePersistence statePersistence, ISafeFileDelete safeFileDelete, @@ -30,7 +31,7 @@ internal ModManager( { this.game = game; this.packageRepository = packageRepository; - this.bootfilesNameChecker = bootfilesNameChecker; + this.bootfilesNaming = bootfilesNaming; this.statePersistence = statePersistence; this.safeFileDelete = safeFileDelete; this.tempDir = tempDir; @@ -65,7 +66,7 @@ public List FetchState() return IsOutOfDate(modPackage, modInstallationState); }); - var allPackageNames = installedMods.Keys.Where(packageName => !bootfilesNameChecker.IsBootFiles(packageName)) + var allPackageNames = installedMods.Keys.Where(packageName => !bootfilesNaming.IsBootfiles(packageName)) .Concat(enabledModPackages.Keys) .Concat(disabledModPackages.Keys) .Distinct(); diff --git a/src/Core/Mods/IBootfilesNaming.cs b/src/Core/Mods/IBootfilesNaming.cs new file mode 100644 index 0000000..0425f4f --- /dev/null +++ b/src/Core/Mods/IBootfilesNaming.cs @@ -0,0 +1,8 @@ +namespace Core.Mods; + +public interface IBootfilesNaming +{ + public bool IsBootfiles(string packageName); + public string GeneratedBootfilesName { get; } + public bool IsGeneratedBootfiles(string packageName); +} diff --git a/src/Core/Mods/Installation/Installers/BootfilesInstaller.cs b/src/Core/Mods/Installation/Installers/BootfilesInstaller.cs index 0c1fd85..7739121 100644 --- a/src/Core/Mods/Installation/Installers/BootfilesInstaller.cs +++ b/src/Core/Mods/Installation/Installers/BootfilesInstaller.cs @@ -18,32 +18,34 @@ public interface IEventHandler void PostProcessingEnd(); } - private const string GeneratedBootfilesPackageName = $"{ModInstallerFactory.BootfilesPrefix}_generated"; - internal const string VehicleListRelativeDir = "vehicles"; internal static readonly string TrackListRelativeDir = Path.Combine("tracks", "_data"); internal static readonly string DrivelineRelativeDir = Path.Combine(VehicleListRelativeDir, "physics", "driveline"); private readonly RootedPath gameInstallationPath; + private readonly IBootfilesNaming bootfilesNaming; private readonly IEventHandler eventHandler; public BootfilesInstaller(IInstaller? bootfilesPackageInstaller, string tempDir, IConfig config, - string gameInstallationDir, IEventHandler eventHandler) : - this(new FileSystem(), bootfilesPackageInstaller, tempDir, config, gameInstallationDir, eventHandler) + string gameInstallationDir, IBootfilesNaming bootfilesNaming, IEventHandler eventHandler) : + this(new FileSystem(), bootfilesPackageInstaller, tempDir, config, + gameInstallationDir, bootfilesNaming, eventHandler) { } - public BootfilesInstaller(IFileSystem fileSystem, IInstaller? bootfilesPackageInstaller, - string tempDir, IConfig config, string gameInstallationDir, IEventHandler eventHandler) : - base(fileSystem, PackageOrGenerated(bootfilesPackageInstaller, gameInstallationDir, tempDir), tempDir, config) + public BootfilesInstaller(IFileSystem fileSystem, IInstaller? bootfilesPackageInstaller, string tempDir, + IConfig config, string gameInstallationDir, IBootfilesNaming bootfilesNaming, IEventHandler eventHandler) : + base(fileSystem, PackageOrGenerated(bootfilesPackageInstaller, gameInstallationDir, tempDir, bootfilesNaming), + tempDir, config) { gameInstallationPath = new RootedPath(gameInstallationDir); + this.bootfilesNaming = bootfilesNaming; this.eventHandler = eventHandler; } private static IInstaller PackageOrGenerated(IInstaller? bootfilesPackageInstaller, - string gameInstallationDirectory, string tempDir) => - bootfilesPackageInstaller ?? new GeneratedBootfilesInstaller(GeneratedBootfilesPackageName, + string gameInstallationDirectory, string tempDir, IBootfilesNaming bootfilesNaming) => + bootfilesPackageInstaller ?? new GeneratedBootfilesInstaller(bootfilesNaming.GeneratedBootfilesName, gameInstallationDirectory, tempDir); // Bootfiles cannot have dependencies. @@ -55,7 +57,7 @@ protected override void Install(Action innerInstall) if (modConfigs.Any(c => c.Any())) { eventHandler.PostProcessingStart(); - var packageNameIfNotGenerated = PackageName != GeneratedBootfilesPackageName ? PackageName : null; + var packageNameIfNotGenerated = bootfilesNaming.IsGeneratedBootfiles(PackageName) ? PackageName : null; eventHandler.ExtractingBootfiles(packageNameIfNotGenerated); innerInstall(); eventHandler.PostProcessingVehicles(); diff --git a/src/Core/Mods/Installation/Installers/IBootfilesNameChecker.cs b/src/Core/Mods/Installation/Installers/IBootfilesNameChecker.cs deleted file mode 100644 index bfdd8e6..0000000 --- a/src/Core/Mods/Installation/Installers/IBootfilesNameChecker.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Core.Mods.Installation.Installers; - -public interface IBootfilesNameChecker -{ - public bool IsBootFiles(string packageName); -} diff --git a/src/Core/Mods/Installation/Installers/IModInstallerFactory.cs b/src/Core/Mods/Installation/Installers/IModInstallerFactory.cs index 991c8f0..847bbf9 100644 --- a/src/Core/Mods/Installation/Installers/IModInstallerFactory.cs +++ b/src/Core/Mods/Installation/Installers/IModInstallerFactory.cs @@ -2,7 +2,7 @@ namespace Core.Mods.Installation.Installers; -public interface IModInstallerFactory : IBootfilesNameChecker +public interface IModInstallerFactory { public IInstaller ModInstaller(IInstaller packageInstaller, IInstaller bootfilesInstaller); public IInstaller BootfilesInstaller(IInstaller? bootfilesPackageInstaller, TEventHandler eventHandler); diff --git a/src/Core/Mods/Installation/Installers/ModInstallerFactory.cs b/src/Core/Mods/Installation/Installers/ModInstallerFactory.cs index b5bd529..bb18af4 100644 --- a/src/Core/Mods/Installation/Installers/ModInstallerFactory.cs +++ b/src/Core/Mods/Installation/Installers/ModInstallerFactory.cs @@ -6,18 +6,19 @@ namespace Core.Mods.Installation.Installers; public class ModInstallerFactory : IModInstallerFactory { - internal const string BootfilesPrefix = "__bootfiles"; - private readonly IGame game; private readonly ITempDir tempDir; + private readonly IBootfilesNaming bootfilesNaming; private readonly ModInstaller.IConfig config; public ModInstallerFactory(IGame game, ITempDir tempDir, + IBootfilesNaming bootfilesNaming, ModInstaller.IConfig config) { this.game = game; this.tempDir = tempDir; + this.bootfilesNaming = bootfilesNaming; this.config = config; } @@ -26,8 +27,6 @@ public IInstaller ModInstaller(IInstaller packageInstaller, IInstaller bootfiles public IInstaller BootfilesInstaller(IInstaller? bootfilesPackageInstaller, BootfilesInstaller.IEventHandler eventHandler) => - new BootfilesInstaller(bootfilesPackageInstaller, tempDir.BasePath, config, game.InstallationDirectory, eventHandler); - - public bool IsBootFiles(string packageName) => - packageName.StartsWith(BootfilesPrefix); + new BootfilesInstaller(bootfilesPackageInstaller, tempDir.BasePath, config, + game.InstallationDirectory, bootfilesNaming, eventHandler); } diff --git a/src/Core/Mods/Installation/ModPackagesUpdater.cs b/src/Core/Mods/Installation/ModPackagesUpdater.cs index 2e94733..6ac05f8 100644 --- a/src/Core/Mods/Installation/ModPackagesUpdater.cs +++ b/src/Core/Mods/Installation/ModPackagesUpdater.cs @@ -10,15 +10,18 @@ namespace Core.Mods.Installation; public class ModPackagesUpdater : PackagesUpdater where TEventHandler : PackagesUpdater.IEventHandler { + private readonly IBootfilesNaming bootfilesNaming; private readonly IModInstallerFactory modInstallerFactory; public ModPackagesUpdater( IInstallerFactory installerFactory, IBackupStrategyProvider backupStrategyProvider, TimeProvider timeProvider, + IBootfilesNaming bootfilesNaming, IModInstallerFactory modInstallerFactory) : base(installerFactory, backupStrategyProvider, timeProvider) { + this.bootfilesNaming = bootfilesNaming; this.modInstallerFactory = modInstallerFactory; } @@ -30,7 +33,7 @@ protected override void Apply( TEventHandler eventHandler, CancellationToken cancellationToken) { - var (bootfiles, notBootfiles) = installers.Partition(p => modInstallerFactory.IsBootFiles(p.PackageName)); + var (bootfiles, notBootfiles) = installers.Partition(p => bootfilesNaming.IsBootfiles(p.PackageName)); var bootfilesInstaller = CreateBootfilesInstaller(bootfiles, eventHandler); var allInstallers = notBootfiles diff --git a/src/Core/Mods/PrefixBootfilesNaming.cs b/src/Core/Mods/PrefixBootfilesNaming.cs new file mode 100644 index 0000000..0b3e052 --- /dev/null +++ b/src/Core/Mods/PrefixBootfilesNaming.cs @@ -0,0 +1,25 @@ +namespace Core.Mods; + +public class PrefixBootfilesNaming : IBootfilesNaming +{ + public interface IConfig + { + string BootfilesPrefix { get; } + } + + private readonly string bootfilesPrefix; + + public PrefixBootfilesNaming(IConfig config) + { + bootfilesPrefix = config.BootfilesPrefix; + GeneratedBootfilesName = $"{bootfilesPrefix}_generated"; + } + + public bool IsBootfiles(string packageName) => + packageName.StartsWith(bootfilesPrefix); + + public string GeneratedBootfilesName { get; } + + public bool IsGeneratedBootfiles(string packageName) => + packageName == GeneratedBootfilesName; +} diff --git a/tests/Core.Tests/API/ModManagerTest.cs b/tests/Core.Tests/API/ModManagerTest.cs index b7fda43..df0635f 100644 --- a/tests/Core.Tests/API/ModManagerTest.cs +++ b/tests/Core.Tests/API/ModManagerTest.cs @@ -18,6 +18,7 @@ public class ModManagerTest : AbstractFilesystemTest { #region Initialisation + private const string BootfilesPrefix = "BP"; private const string DirAtRoot = "DirAtRoot"; private const string FileExcludedFromInstall = "Excluded"; private static readonly string GameSupportedModDirectory = Path.Combine("Mod", "Directory"); @@ -57,6 +58,7 @@ public ModManagerTest() persistedState = new InMemoryStatePersistence(); var modInstallConfig = new ModInstallConfig { + BootfilesPrefix = BootfilesPrefix, DirsAtRoot = [DirAtRoot], ExcludedFromInstall = [$"**\\{FileExcludedFromInstall}"], GameSupportedModDirectory = GameSupportedModDirectory @@ -208,17 +210,17 @@ public void FetchState_RemovesUnavailableBootfiles() { persistedState.InitModInstallationState(new Dictionary { - [$"{ModInstallerFactory.BootfilesPrefix}_IU"] = new( + [$"{BootfilesPrefix}_IU"] = new( Time: PastDate, FsHash: null, Partial: false, Dependencies: [], Files: [], ShadowedBy: []), - [$"{ModInstallerFactory.BootfilesPrefix}_IE"] = new( + [$"{BootfilesPrefix}_IE"] = new( Time: PastDate, FsHash: null, Partial: false, Dependencies: [], Files: [], ShadowedBy: []), - [$"{ModInstallerFactory.BootfilesPrefix}_ID"] = new( + [$"{BootfilesPrefix}_ID"] = new( Time: PastDate, FsHash: null, Partial: false, Dependencies: [], Files: [], @@ -226,21 +228,21 @@ public void FetchState_RemovesUnavailableBootfiles() }); modRepositoryMock.Setup(m => m.ListEnabled()).Returns( [ - new Package(Name: $"{ModInstallerFactory.BootfilesPrefix}_IE", FullPath: "ie/path", Enabled: true, FsHash: null), - new Package(Name: $"{ModInstallerFactory.BootfilesPrefix}_UE", FullPath: "ue/path", Enabled: true, FsHash: null) + new Package(Name: $"{BootfilesPrefix}_IE", FullPath: "ie/path", Enabled: true, FsHash: null), + new Package(Name: $"{BootfilesPrefix}_UE", FullPath: "ue/path", Enabled: true, FsHash: null) ]); modRepositoryMock.Setup(m => m.ListDisabled()).Returns( [ - new Package(Name: $"{ModInstallerFactory.BootfilesPrefix}_ID", FullPath: "id/path", Enabled: false, FsHash: null), - new Package(Name: $"{ModInstallerFactory.BootfilesPrefix}_UD", FullPath: "ud/path", Enabled: false, FsHash: null) + new Package(Name: $"{BootfilesPrefix}_ID", FullPath: "id/path", Enabled: false, FsHash: null), + new Package(Name: $"{BootfilesPrefix}_UD", FullPath: "ud/path", Enabled: false, FsHash: null) ]); modManager.FetchState().Should().BeEquivalentTo( [ - new ModState($"{ModInstallerFactory.BootfilesPrefix}_IE", "ie/path", IsInstalled: true, IsEnabled: true, IsOutOfDate: true), - new ModState($"{ModInstallerFactory.BootfilesPrefix}_UE", "ue/path", IsInstalled: false, IsEnabled: true, IsOutOfDate: false), - new ModState($"{ModInstallerFactory.BootfilesPrefix}_ID", "id/path", IsInstalled: true, IsEnabled: false, IsOutOfDate: true), - new ModState($"{ModInstallerFactory.BootfilesPrefix}_UD", "ud/path", IsInstalled: false, IsEnabled: false, IsOutOfDate: false), + new ModState($"{BootfilesPrefix}_IE", "ie/path", IsInstalled: true, IsEnabled: true, IsOutOfDate: true), + new ModState($"{BootfilesPrefix}_UE", "ue/path", IsInstalled: false, IsEnabled: true, IsOutOfDate: false), + new ModState($"{BootfilesPrefix}_ID", "id/path", IsInstalled: true, IsEnabled: false, IsOutOfDate: true), + new ModState($"{BootfilesPrefix}_UD", "ud/path", IsInstalled: false, IsEnabled: false, IsOutOfDate: false), ]); } @@ -730,8 +732,8 @@ public void Install_OldTrackModsAlwaysRequireBootfiles() modManager.InstallEnabledMods(eventHandlerMock.Object); - persistedState.Should().HaveInstalled(["Package100", "__bootfiles900"]); - persistedState.For("Package100").Dependencies.Should().Contain("__bootfiles900"); + persistedState.Should().HaveInstalled(["Package100", $"{BootfilesPrefix}900"]); + persistedState.For("Package100").Dependencies.Should().Contain($"{BootfilesPrefix}900"); var generatedConfigDir = $"Package100_{100:x}"; File.ReadAllText(GamePath(GameSupportedModDirectory, generatedConfigDir, @@ -757,7 +759,7 @@ public void Install_ExtractsBootfilesFromGameByDefault() // //modManager.InstallEnabledMods() // - //persistedState.Should().HaveInstalled(["Package100", "__bootfiles"]); + //persistedState.Should().HaveInstalled(["Package100", $"{BootfilesPrefix}_generated"]); } [Fact] @@ -771,7 +773,7 @@ public void Install_ChoosesLastOfMultipleCustomBootfiles() modManager.InstallEnabledMods(eventHandlerMock.Object); - persistedState.Should().HaveInstalled(["Package100", "__bootfiles901"]); + persistedState.Should().HaveInstalled(["Package100", $"{BootfilesPrefix}901"]); } #region Utility methods @@ -783,7 +785,7 @@ private Package CreateModArchive(int fsHash, IEnumerable relativePaths, CreateModPackage("Package", fsHash, relativePaths, callback); private Package CreateCustomBootfiles(int fsHash) => - CreateModPackage(ModInstallerFactory.BootfilesPrefix, fsHash, [ + CreateModPackage(BootfilesPrefix, fsHash, [ Path.Combine(DirAtRoot, "OrTheyWontBeInstalled"), VehicleListRelativePath, TrackListRelativePath, diff --git a/tests/Core.Tests/Mods/Installation/ModPackagesUpdaterTest.cs b/tests/Core.Tests/Mods/Installation/ModPackagesUpdaterTest.cs index 1f1d781..4c9abbe 100644 --- a/tests/Core.Tests/Mods/Installation/ModPackagesUpdaterTest.cs +++ b/tests/Core.Tests/Mods/Installation/ModPackagesUpdaterTest.cs @@ -1,4 +1,5 @@ using System.Collections.ObjectModel; +using Core.Mods; using Core.Mods.Installation; using Core.Mods.Installation.Installers; using Core.Packages.Installation; @@ -38,17 +39,17 @@ public IInstaller ModInstaller(IInstaller packageInstaller, IInstaller bootfiles public IInstaller BootfilesInstaller(IInstaller? bootfilesPackageInstaller, PackagesUpdater.IEventHandler eventHandler) => bootfilesPackageInstaller ?? InstallerOf(GeneratedBootfilesName); - - public bool IsBootFiles(string packageName) => - packageName == BootfilesPackageName || packageName == GeneratedBootfilesName; } protected override IPackagesUpdater NewPackagesUpdater( IInstallerFactory installerFactory, IBackupStrategyProvider backupStrategyProvider, - TimeProvider timeProvider) { + TimeProvider timeProvider) + { + var bootfilesNamingMock = new Mock(); + bootfilesNamingMock.Setup(m => m.IsBootfiles(BootfilesPackageName)).Returns(true); return new ModPackagesUpdater( - installerFactory, backupStrategyProvider, timeProvider, new TestModInstallerFactory()); + installerFactory, backupStrategyProvider, timeProvider, bootfilesNamingMock.Object, new TestModInstallerFactory()); } #endregion From f99b64ca592548721bbc7b89825b17572746a30f Mon Sep 17 00:00:00 2001 From: Paolo Ambrosio Date: Fri, 13 Mar 2026 07:48:49 +0000 Subject: [PATCH 5/6] BootfilesInstaller refactoring and test - Transformed many constants into default config - Added test for BootfilesInstaller - Minor refactor to make it more readable - Post processing event for each section only if not empty --- src/Core/API/Config.cs | 31 ++- src/Core/API/Init.cs | 2 +- .../Installers/BaseModInstaller.cs | 32 ++- .../Installers/BootfilesInstaller.cs | 88 ++++---- .../Installation/Installers/ConfigEntries.cs | 15 +- .../Installation/Installers/ModInstaller.cs | 15 +- .../Installers/ModInstallerFactory.cs | 7 +- src/Shared/Config.yaml | 16 -- tests/Core.Tests/API/ModManagerTest.cs | 16 +- .../Installers/BootfilesInstallerTest.cs | 191 ++++++++++++++++++ .../Installers/ModInstallerTest.cs | 20 +- 11 files changed, 327 insertions(+), 106 deletions(-) create mode 100644 tests/Core.Tests/Mods/Installation/Installers/BootfilesInstallerTest.cs diff --git a/src/Core/API/Config.cs b/src/Core/API/Config.cs index 07ace99..4152d72 100644 --- a/src/Core/API/Config.cs +++ b/src/Core/API/Config.cs @@ -1,4 +1,4 @@ -using Core.Games; +using Core.Games; using Core.Mods; using Core.Mods.Installation.Installers; using Core.SoftwareUpdates; @@ -36,12 +36,31 @@ public class GameConfig : Game.IConfig public string ProcessName { get; set; } = "Undefined"; } -public class ModInstallConfig : PrefixBootfilesNaming.IConfig, ModInstaller.IConfig +public class ModInstallConfig : ModInstaller.IConfig, BootfilesInstaller.IConfig, PrefixBootfilesNaming.IConfig { - public string BootfilesPrefix { get; set; } = "__bootfiles"; - public IEnumerable DirsAtRoot { get; set; } = Array.Empty(); - public IEnumerable ExcludedFromInstall { get; set; } = Array.Empty(); - public string GameSupportedModDirectory { get; set; } = Path.Combine("UserData", "Mods"); + public IEnumerable DirsAtRoot { get; set; } = new[] + { + "cameras", "characters", "effects", "gui", "pakfiles", "render", + "text", "tracks", "userdata", "upgrade", "vehicles" + }; + public IEnumerable ExcludedFromInstall { get; set; } = new[] + { + @"**\*.orig", + @"**\*.dll", + @"**\*.exe" + }; + public string GameSupportedModDir { get; set; } = Path.Combine("UserData", "Mods"); + public IEnumerable ExcludedFromConfig { get; set; } = Array.Empty(); public bool GenerateModDetails { get; set; } = true; + + public string BootfilesVehicleListDir { get; set; } = "vehicles"; + public string BootfilesTrackListDir { get; set; } = Path.Combine("tracks", "_data"); + public string BootfilesDrivelineDir { get; set; } = Path.Combine("vehicles", "physics", "driveline"); + + public string VehicleListFileName { get; set; } = "vehiclelist.lst"; + public string TrackListFileName { get; set; } = "tracklist.lst"; + public string DrivelineFileName { get; set; } = "driveline.rg"; + + public string BootfilesPrefix { get; set; } = "__bootfiles"; } diff --git a/src/Core/API/Init.cs b/src/Core/API/Init.cs index deb5cd2..42dfebc 100644 --- a/src/Core/API/Init.cs +++ b/src/Core/API/Init.cs @@ -37,7 +37,7 @@ public static IModManager CreateModManager( var backupStrategyProvider = new SkipUpdatedBackupStrategy.Provider( new SuffixBackupStrategy.Provider()); var bootfilesNaming = new PrefixBootfilesNaming(modInstallConfig); - var modInstallerFactory = new ModInstallerFactory(game, tempDir, bootfilesNaming, modInstallConfig); + var modInstallerFactory = new ModInstallerFactory(game, tempDir, bootfilesNaming, modInstallConfig); var modPackagesUpdater = new ModPackagesUpdater( new FileSystemInstallerFactory(), backupStrategyProvider, TimeProvider.System, bootfilesNaming, modInstallerFactory); diff --git a/src/Core/Mods/Installation/Installers/BaseModInstaller.cs b/src/Core/Mods/Installation/Installers/BaseModInstaller.cs index 8433f75..22c4d52 100644 --- a/src/Core/Mods/Installation/Installers/BaseModInstaller.cs +++ b/src/Core/Mods/Installation/Installers/BaseModInstaller.cs @@ -1,4 +1,4 @@ -using System.Collections.Immutable; +using System.Collections.Immutable; using System.IO.Abstractions; using Core.Packages.Installation; using Core.Packages.Installation.Backup; @@ -12,27 +12,22 @@ public abstract class BaseModInstaller : IInstaller { public interface IConfig { - IEnumerable DirsAtRoot - { - get; - } - - IEnumerable ExcludedFromInstall - { - get; - } - - string GameSupportedModDirectory { get; } + IEnumerable DirsAtRoot { get; } + IEnumerable ExcludedFromInstall { get; } + string GameSupportedModDir { get; } + string VehicleListFileName { get; } + string TrackListFileName { get; } + string DrivelineFileName { get; } } - internal const string VehicleListFileName = "vehiclelist.lst"; - internal const string TrackListFileName = "tracklist.lst"; - internal const string DrivelineFileName = "driveline.rg"; + protected readonly string VehicleListFileName; + protected readonly string TrackListFileName; + protected readonly string DrivelineFileName; protected readonly IFileSystem FileSystem; protected readonly IInstaller Inner; protected readonly string StagingFullPath; - protected readonly string GameSupportedModDirectory; + protected readonly string GameSupportedModRelativeDir; private readonly Lazy rootPaths; private readonly Matcher filesToInstallMatcher; @@ -46,7 +41,10 @@ protected BaseModInstaller(IFileSystem fileSystem, IInstaller inner, string temp FileSystem = fileSystem; Inner = inner; StagingFullPath = Path.GetFullPath(Path.Combine(tempDir, inner.PackageName)); - GameSupportedModDirectory = config.GameSupportedModDirectory; + GameSupportedModRelativeDir = config.GameSupportedModDir; + VehicleListFileName = config.VehicleListFileName; + TrackListFileName = config.TrackListFileName; + DrivelineFileName = config.DrivelineFileName; var rootFinder = new ContainedDirsRootFinder(config.DirsAtRoot); rootPaths = new Lazy( () => rootFinder.FromDirectoryList(Inner.RelativeDirectoryPaths)); diff --git a/src/Core/Mods/Installation/Installers/BootfilesInstaller.cs b/src/Core/Mods/Installation/Installers/BootfilesInstaller.cs index 7739121..a57dbe2 100644 --- a/src/Core/Mods/Installation/Installers/BootfilesInstaller.cs +++ b/src/Core/Mods/Installation/Installers/BootfilesInstaller.cs @@ -1,5 +1,4 @@ -using System.Collections.Immutable; -using System.IO.Abstractions; +using System.IO.Abstractions; using Core.Packages.Installation.Installers; using Core.Utils; @@ -7,6 +6,13 @@ namespace Core.Mods.Installation.Installers; public class BootfilesInstaller : BaseModInstaller { + public new interface IConfig : BaseModInstaller.IConfig + { + string BootfilesVehicleListDir { get; } + string BootfilesTrackListDir { get; } + string BootfilesDrivelineDir { get; } + } + public interface IEventHandler { void PostProcessingNotRequired(); @@ -18,10 +24,6 @@ public interface IEventHandler void PostProcessingEnd(); } - internal const string VehicleListRelativeDir = "vehicles"; - internal static readonly string TrackListRelativeDir = Path.Combine("tracks", "_data"); - internal static readonly string DrivelineRelativeDir = Path.Combine(VehicleListRelativeDir, "physics", "driveline"); - private readonly RootedPath gameInstallationPath; private readonly IBootfilesNaming bootfilesNaming; private readonly IEventHandler eventHandler; @@ -39,6 +41,9 @@ public BootfilesInstaller(IFileSystem fileSystem, IInstaller? bootfilesPackageIn tempDir, config) { gameInstallationPath = new RootedPath(gameInstallationDir); + VehicleListDir = gameInstallationPath.SubPath(config.BootfilesVehicleListDir); + TrackListDir = gameInstallationPath.SubPath(config.BootfilesTrackListDir); + DrivelineDir = gameInstallationPath.SubPath(config.BootfilesDrivelineDir); this.bootfilesNaming = bootfilesNaming; this.eventHandler = eventHandler; } @@ -53,57 +58,66 @@ private static IInstaller PackageOrGenerated(IInstaller? bootfilesPackageInstall protected override void Install(Action innerInstall) { - var modConfigs = CollectModConfigs(); - if (modConfigs.Any(c => c.Any())) + var modConfigs = CollectModConfig(); + if (modConfigs.None()) + { + eventHandler.PostProcessingNotRequired(); + return; + } + + eventHandler.PostProcessingStart(); + var packageNameIfNotGenerated = bootfilesNaming.IsGeneratedBootfiles(PackageName) ? PackageName : null; + eventHandler.ExtractingBootfiles(packageNameIfNotGenerated); + innerInstall(); + if (modConfigs.CrdFileEntries.Count > 0) { - eventHandler.PostProcessingStart(); - var packageNameIfNotGenerated = bootfilesNaming.IsGeneratedBootfiles(PackageName) ? PackageName : null; - eventHandler.ExtractingBootfiles(packageNameIfNotGenerated); - innerInstall(); eventHandler.PostProcessingVehicles(); - AppendCrdFileEntries(modConfigs.SelectMany(c => c.CrdFileEntries)); + AppendCrdFileEntries(modConfigs.CrdFileEntries); + } + if (modConfigs.TrdFileEntries.Count > 0) + { eventHandler.PostProcessingTracks(); - AppendTrdFileEntries(modConfigs.SelectMany(c => c.TrdFileEntries)); - eventHandler.PostProcessingDrivelines(); - AppendDrivelineRecords(modConfigs.SelectMany(c => c.DrivelineRecords)); - eventHandler.PostProcessingEnd(); + AppendTrdFileEntries(modConfigs.TrdFileEntries); } - else + if (modConfigs.DrivelineRecords.Count > 0) { - eventHandler.PostProcessingNotRequired(); + eventHandler.PostProcessingDrivelines(); + AppendDrivelineRecords(modConfigs.DrivelineRecords); } + eventHandler.PostProcessingEnd(); } - protected override RootedPath VehicleListDir => gameInstallationPath.SubPath(VehicleListRelativeDir); + protected override RootedPath VehicleListDir { get; } - protected override RootedPath TrackListDir => gameInstallationPath.SubPath(TrackListRelativeDir); + protected override RootedPath TrackListDir { get; } - protected override RootedPath DrivelineDir => gameInstallationPath.SubPath(DrivelineRelativeDir); + protected override RootedPath DrivelineDir { get; } protected override string WrapConfigBlock(string configBlock) => $"{Environment.NewLine}### BEGIN AMS2CM{Environment.NewLine}{configBlock}{Environment.NewLine}### END AMS2CM{Environment.NewLine}"; - private IReadOnlyList CollectModConfigs() + private ConfigEntries CollectModConfig() { - var modsGamePath = gameInstallationPath.SubPath(GameSupportedModDirectory); - var directoryInfo = new DirectoryInfo(modsGamePath.Full); + var modsGamePath = gameInstallationPath.SubPath(GameSupportedModRelativeDir); + var directoryInfo = FileSystem.DirectoryInfo.New(modsGamePath.Full); if (!directoryInfo.Exists) - return Array.Empty(); + return ConfigEntries.Empty; + return directoryInfo.GetDirectories("*").Select(modDir => - modDir.EnumerateFiles($"{modDir.Name}.xml").Any() ? - ConfigEntries.Empty : - new ConfigEntries - ( - FileLinesOrEmpty(modDir, VehicleListFileName), - FileLinesOrEmpty(modDir, TrackListFileName), - FileLinesOrEmpty(modDir, DrivelineFileName) - ) - ).ToImmutableList(); + modDir.EnumerateFiles($"{modDir.Name}.xml").Any() + ? ConfigEntries.Empty + : new ConfigEntries + ( + FileLinesOrEmpty(modDir, VehicleListFileName), + FileLinesOrEmpty(modDir, TrackListFileName), + FileLinesOrEmpty(modDir, DrivelineFileName) + ) + ).Aggregate(ConfigEntries.Empty, ConfigEntries.Combine); } - private static string[] FileLinesOrEmpty(DirectoryInfo parent, string fileName) + private string[] FileLinesOrEmpty(IDirectoryInfo parent, string fileName) { var filePath = Path.Combine(parent.FullName, fileName); - return File.Exists(filePath) ? File.ReadAllLines(filePath) : Array.Empty(); + return FileSystem.File.Exists(filePath) ? FileSystem.File.ReadAllLines(filePath) : Array.Empty(); } } diff --git a/src/Core/Mods/Installation/Installers/ConfigEntries.cs b/src/Core/Mods/Installation/Installers/ConfigEntries.cs index 1f5a60c..5d1d907 100644 --- a/src/Core/Mods/Installation/Installers/ConfigEntries.cs +++ b/src/Core/Mods/Installation/Installers/ConfigEntries.cs @@ -1,4 +1,6 @@ -namespace Core.Mods.Installation.Installers; +using System.Collections.Immutable; + +namespace Core.Mods.Installation.Installers; public record ConfigEntries( IReadOnlyCollection CrdFileEntries, @@ -12,4 +14,15 @@ IReadOnlyCollection DrivelineRecords public bool Any() => CrdFileEntries.Any() || TrdFileEntries.Any() || DrivelineRecords.Any(); public bool None() => !Any(); + + public ConfigEntries Combine(ConfigEntries other) => + Combine(this, other); + + public static ConfigEntries Combine(ConfigEntries first, ConfigEntries second) => + new ( + first.CrdFileEntries.Concat(second.CrdFileEntries).ToImmutableList(), + first.TrdFileEntries.Concat(second.TrdFileEntries).ToImmutableList(), + first.DrivelineRecords.Concat(second.DrivelineRecords).ToImmutableList() + ); + }; diff --git a/src/Core/Mods/Installation/Installers/ModInstaller.cs b/src/Core/Mods/Installation/Installers/ModInstaller.cs index f1c555e..cd8021b 100644 --- a/src/Core/Mods/Installation/Installers/ModInstaller.cs +++ b/src/Core/Mods/Installation/Installers/ModInstaller.cs @@ -13,15 +13,8 @@ public class ModInstaller : BaseModInstaller { public new interface IConfig : BaseModInstaller.IConfig { - IEnumerable ExcludedFromConfig - { - get; - } - - bool GenerateModDetails - { - get; - } + IEnumerable ExcludedFromConfig { get; } + bool GenerateModDetails { get; } } private readonly Matcher filesToConfigureMatcher; @@ -53,7 +46,7 @@ internal ModInstaller(IFileSystem fileSystem, IInstaller inner, string tempDir, var hexFsHash = (inner.PackageFsHash ?? 0).ToString("x"); modName = $"{normalisedName}_{hexFsHash}"; - modConfigPath = new RootedPath(gameInstallationDir, Path.Combine(GameSupportedModDirectory, modName)); + modConfigPath = new RootedPath(gameInstallationDir, Path.Combine(GameSupportedModRelativeDir, modName)); } public override IReadOnlyCollection PackageDependencies => @@ -75,7 +68,7 @@ protected override void Install(Action innerInstall) private void GenerateModConfig() { var gameSupportedMod = FileEntriesToConfigure() - .Any(p => p.StartsWith(GameSupportedModDirectory)); + .Any(p => p.StartsWith(GameSupportedModRelativeDir)); var modConfig = gameSupportedMod ? ConfigEntries.Empty : new ConfigEntries(CrdFileEntries(), TrdFileEntries(), FindDrivelineRecords()); diff --git a/src/Core/Mods/Installation/Installers/ModInstallerFactory.cs b/src/Core/Mods/Installation/Installers/ModInstallerFactory.cs index bb18af4..e70480f 100644 --- a/src/Core/Mods/Installation/Installers/ModInstallerFactory.cs +++ b/src/Core/Mods/Installation/Installers/ModInstallerFactory.cs @@ -4,17 +4,18 @@ namespace Core.Mods.Installation.Installers; -public class ModInstallerFactory : IModInstallerFactory +public class ModInstallerFactory : IModInstallerFactory + where TConfig : ModInstaller.IConfig, BootfilesInstaller.IConfig { private readonly IGame game; private readonly ITempDir tempDir; private readonly IBootfilesNaming bootfilesNaming; - private readonly ModInstaller.IConfig config; + private readonly TConfig config; public ModInstallerFactory(IGame game, ITempDir tempDir, IBootfilesNaming bootfilesNaming, - ModInstaller.IConfig config) + TConfig config) { this.game = game; this.tempDir = tempDir; diff --git a/src/Shared/Config.yaml b/src/Shared/Config.yaml index 53e7823..d9bfc9d 100644 --- a/src/Shared/Config.yaml +++ b/src/Shared/Config.yaml @@ -3,22 +3,6 @@ Game: Path: steamapps\common\Automobilista 2 ProcessName: AMS2AVX ModInstall: - DirsAtRoot: - - cameras - - characters - - effects - - gui - - pakfiles - - render - - text - - tracks - - userdata - - upgrade - - vehicles - ExcludedFromInstall: - - '**\*.orig' - - '**\*.dll' - - '**\*.exe' ExcludedFromConfig: # IndyCar 2023 1.0 Fix - '**\IR-18_2023_Dale_Coyne_hr.crd' diff --git a/tests/Core.Tests/API/ModManagerTest.cs b/tests/Core.Tests/API/ModManagerTest.cs index df0635f..54fd3d7 100644 --- a/tests/Core.Tests/API/ModManagerTest.cs +++ b/tests/Core.Tests/API/ModManagerTest.cs @@ -23,14 +23,16 @@ public class ModManagerTest : AbstractFilesystemTest private const string FileExcludedFromInstall = "Excluded"; private static readonly string GameSupportedModDirectory = Path.Combine("Mod", "Directory"); + private static readonly ModInstallConfig DefaultModInstallConfig = new(); + private static readonly string VehicleListRelativePath = - Path.Combine(BootfilesInstaller.VehicleListRelativeDir, BaseModInstaller.VehicleListFileName); + Path.Combine(DefaultModInstallConfig.BootfilesVehicleListDir, DefaultModInstallConfig.VehicleListFileName); private static readonly string TrackListRelativePath = - Path.Combine(BootfilesInstaller.TrackListRelativeDir, BaseModInstaller.TrackListFileName); + Path.Combine(DefaultModInstallConfig.BootfilesTrackListDir, DefaultModInstallConfig.TrackListFileName); private static readonly string DrivelineRelativePath = - Path.Combine(BootfilesInstaller.DrivelineRelativeDir, BaseModInstaller.DrivelineFileName); + Path.Combine(DefaultModInstallConfig.BootfilesDrivelineDir, DefaultModInstallConfig.DrivelineFileName); private static readonly DateTime PastDate = DateTime.Today.AddDays(-1); @@ -61,7 +63,7 @@ public ModManagerTest() BootfilesPrefix = BootfilesPrefix, DirsAtRoot = [DirAtRoot], ExcludedFromInstall = [$"**\\{FileExcludedFromInstall}"], - GameSupportedModDirectory = GameSupportedModDirectory + GameSupportedModDir = GameSupportedModDirectory }; modManager = Init.CreateModManager( @@ -711,9 +713,9 @@ public void Install_OldVehicleModsDoNotRequireBootfiles() var generatedConfigDir = $"Package100_{100:x}"; File.ReadAllText(GamePath(GameSupportedModDirectory, generatedConfigDir, - BaseModInstaller.VehicleListFileName).Full).Should().Contain("Vehicle.crd"); + DefaultModInstallConfig.VehicleListFileName).Full).Should().Contain("Vehicle.crd"); File.ReadAllText(GamePath(GameSupportedModDirectory, generatedConfigDir, - BaseModInstaller.DrivelineFileName).Full).Should().Contain(drivelineRecord); + DefaultModInstallConfig.DrivelineFileName).Full).Should().Contain(drivelineRecord); File.Exists(GamePath(GameSupportedModDirectory, generatedConfigDir, $"{generatedConfigDir}.xml") .Full).Should().BeTrue(); } @@ -737,7 +739,7 @@ public void Install_OldTrackModsAlwaysRequireBootfiles() var generatedConfigDir = $"Package100_{100:x}"; File.ReadAllText(GamePath(GameSupportedModDirectory, generatedConfigDir, - BaseModInstaller.TrackListFileName).Full).Should().Contain("Track.trd"); + DefaultModInstallConfig.TrackListFileName).Full).Should().Contain("Track.trd"); File.Exists(GamePath(GameSupportedModDirectory, generatedConfigDir, $"{generatedConfigDir}.xml") .Full).Should().BeFalse(); diff --git a/tests/Core.Tests/Mods/Installation/Installers/BootfilesInstallerTest.cs b/tests/Core.Tests/Mods/Installation/Installers/BootfilesInstallerTest.cs new file mode 100644 index 0000000..fd869d5 --- /dev/null +++ b/tests/Core.Tests/Mods/Installation/Installers/BootfilesInstallerTest.cs @@ -0,0 +1,191 @@ +using System.IO.Abstractions.TestingHelpers; +using Core.Mods; +using Core.Mods.Installation.Installers; +using Core.Packages.Installation.Backup; +using Core.Packages.Installation.Installers; +using Core.Tests.Packages.Installation.Installers; +using Core.Utils; +using FluentAssertions; + +namespace Core.Tests.Mods.Installation.Installers; + +[UnitTest] +public class BootfilesInstallerTest +{ + private const string BootfilesPackageName = "notused"; + private static readonly string FileInBootfilesPackage = Path.Combine(GameDirAtRoot, "Something"); + + private const string GameDirAtRoot = "DirAtRoot"; + private const string GameSupportedModDirectory = "ModDirectory"; + private const string BootfilesVehicleListDir = "BootfilesVehicleListDir"; + private const string VehicleListFileName = "VehicleList"; + private const string BootfilesTrackListDir = "BootfilesTrackListDir"; + private const string TrackListFileName = "TrackList"; + private const string BootfilesDrivelineDir = "BootfilesDrivelineDir"; + private const string DrivelineFileName = "Driveline"; + + #region Setup + + private readonly MockFileSystem fs = new(); + private readonly Mock configMock = new(); + private readonly Mock bootfilesNamingMock = new(); + private readonly Mock eventHandlerMock = new(); + private readonly Mock backupStrategyMock = new(); + private readonly string destDir; + private readonly string tempDir; + + public BootfilesInstallerTest() + { + configMock.Setup(c => c.DirsAtRoot).Returns([GameDirAtRoot, GameSupportedModDirectory]); + configMock.Setup(c => c.GameSupportedModDir).Returns(GameSupportedModDirectory); + configMock.Setup(c => c.BootfilesVehicleListDir).Returns(BootfilesVehicleListDir); + configMock.Setup(c => c.VehicleListFileName).Returns(VehicleListFileName); + configMock.Setup(c => c.BootfilesTrackListDir).Returns(BootfilesTrackListDir); + configMock.Setup(c => c.TrackListFileName).Returns(TrackListFileName); + configMock.Setup(c => c.BootfilesDrivelineDir).Returns(BootfilesDrivelineDir); + configMock.Setup(c => c.DrivelineFileName).Returns(DrivelineFileName); + + destDir = fs.Directory.CreateDirectory("Dest").FullName; + tempDir = fs.Directory.CreateDirectory("Temp").FullName; + } + + #endregion + + [Fact] + public void BootfilesAreNotInstalledWhenNoMods() + { + InstallBootfiles().InstalledFiles.Should().BeEmpty(); + + eventHandlerMock.Verify(m => m.PostProcessingNotRequired(), Times.Once); + + fs.AllFiles.Should().BeEmpty(); + } + + [Fact] + public void BootfilesAreNotInstalledForModsWithManifests() + { + var contents = MultiLineContents(); + + fs.AddEmptyFile(Path.Combine(destDir, GameSupportedModDirectory, "ModName", "ModName.xml")); + fs.AddFile(Path.Combine(destDir, GameSupportedModDirectory, "ModName", VehicleListFileName), contents); + fs.AddFile(Path.Combine(destDir, GameSupportedModDirectory, "ModName", TrackListFileName), contents); + fs.AddFile(Path.Combine(destDir, GameSupportedModDirectory, "ModName", DrivelineFileName), contents); + + InstallBootfiles().InstalledFiles.Should().BeEmpty(); + + eventHandlerMock.Verify(m => m.PostProcessingNotRequired(), Times.Once); + + fs.File.Exists(Path.Combine(destDir, BootfilesVehicleListDir, VehicleListFileName)).Should().BeFalse(); + fs.File.Exists(Path.Combine(destDir, BootfilesTrackListDir, TrackListFileName)).Should().BeFalse(); + fs.File.Exists(Path.Combine(destDir, BootfilesDrivelineDir, DrivelineFileName)).Should().BeFalse(); + } + + [Fact] + public void BootfilesAreInstalledForVehicleListWithoutModManifest() + { + var mod1Config = MultiLineContents(); + var mod2Config = MultiLineContents(); + + fs.AddFile(Path.Combine(destDir, GameSupportedModDirectory, "Mod1", VehicleListFileName), mod1Config); + fs.AddFile(Path.Combine(destDir, GameSupportedModDirectory, "Mod2", VehicleListFileName), mod2Config); + + InstallBootfiles().InstalledFiles.Should().BeEquivalentTo(new [] { + FileInBootfilesPackage, + //Path.Combine(BootfilesVehicleListDir, VehicleListFileName) + }.Select(f => new RootedPath(destDir, f))); + + TrimConfig(fs.File.ReadAllText(Path.Combine(destDir, BootfilesVehicleListDir, VehicleListFileName))) + .Should().Be(mod1Config + Environment.NewLine + mod2Config); + + eventHandlerMock.Verify(m => m.PostProcessingStart(), Times.Once); + eventHandlerMock.Verify(m => m.ExtractingBootfiles(null), Times.Once); + eventHandlerMock.Verify(m => m.PostProcessingVehicles(), Times.Once); + eventHandlerMock.Verify(m => m.PostProcessingEnd(), Times.Once); + eventHandlerMock.VerifyNoOtherCalls(); + } + + [Fact] + public void BootfilesAreInstalledForTrackListWithoutModManifest() + { + var mod1Config = MultiLineContents(); + var mod2Config = MultiLineContents(); + + fs.AddFile(Path.Combine(destDir, GameSupportedModDirectory, "Mod1", TrackListFileName), mod1Config); + fs.AddFile(Path.Combine(destDir, GameSupportedModDirectory, "Mod2", TrackListFileName), mod2Config); + + InstallBootfiles().InstalledFiles.Should().BeEquivalentTo(new [] { + FileInBootfilesPackage, + // Path.Combine(BootfilesTrackListDir, TrackListFileName) + }.Select(f => new RootedPath(destDir, f))); + + TrimConfig(fs.File.ReadAllText(Path.Combine(destDir, BootfilesTrackListDir, TrackListFileName))) + .Should().Be(mod1Config + Environment.NewLine + mod2Config); + + eventHandlerMock.Verify(m => m.PostProcessingStart(), Times.Once); + eventHandlerMock.Verify(m => m.ExtractingBootfiles(null), Times.Once); + eventHandlerMock.Verify(m => m.PostProcessingTracks(), Times.Once); + eventHandlerMock.Verify(m => m.PostProcessingEnd(), Times.Once); + eventHandlerMock.VerifyNoOtherCalls(); + } + + [Fact] + public void BootfilesAreInstalledForDrivelineWithoutModManifest() + { + var mod1Config = MultiLineContents(); + var mod2Config = MultiLineContents(); + + fs.AddFile(Path.Combine(destDir, GameSupportedModDirectory, "Mod1", DrivelineFileName), mod1Config); + fs.AddFile(Path.Combine(destDir, GameSupportedModDirectory, "Mod2", DrivelineFileName), mod2Config); + + InstallBootfiles().InstalledFiles.Should().BeEquivalentTo(new [] { + FileInBootfilesPackage, + // Path.Combine(BootfilesDrivelineDir, DrivelineFileName) + }.Select(f => new RootedPath(destDir, f))); + + TrimConfig(fs.File.ReadAllText(Path.Combine(destDir, BootfilesDrivelineDir, DrivelineFileName))) + .Should().Be(mod1Config + Environment.NewLine + mod2Config); + + eventHandlerMock.Verify(m => m.PostProcessingStart(), Times.Once); + eventHandlerMock.Verify(m => m.ExtractingBootfiles(null), Times.Once); + eventHandlerMock.Verify(m => m.PostProcessingDrivelines(), Times.Once); + eventHandlerMock.Verify(m => m.PostProcessingEnd(), Times.Once); + eventHandlerMock.VerifyNoOtherCalls(); + } + + + #region Utility + + private BootfilesInstaller InstallBootfiles() + { + var emptyPackage = new StaticFilesInstaller(fs, BootfilesPackageName, null, + new Dictionary + { + [FileInBootfilesPackage] = "" + }, Array.Empty()); + var bootfilesInstaller = new BootfilesInstaller(fs, emptyPackage, tempDir, configMock.Object, + destDir, bootfilesNamingMock.Object, eventHandlerMock.Object); + bootfilesInstaller.Install(packagePath => new RootedPath(destDir, packagePath), + backupStrategyMock.Object, new ProcessingCallbacks()); + fs.Directory.Delete(tempDir, recursive: true); + return bootfilesInstaller; + } + + private static string MultiLineContents() + { + var rnd = new Random(); + var options = Enumerable.Range(0, 128).Select(i => (char)i) + .Where(char.IsAsciiLetterOrDigit).Select(c => c.ToString()) + .Append(Environment.NewLine).ToArray(); + var length = rnd.Next(10, 100); + var randomConfig = string.Concat(Enumerable.Range(0, length).Select(_ => options[rnd.Next(options.Length)])); + return TrimConfig(randomConfig); + } + + private static string TrimConfig(string config) => + string.Join(Environment.NewLine, + config.Split(Environment.NewLine) + .Select(line => line.Split('#')[0].Trim()) + .Where(line => !string.IsNullOrEmpty(line))); + + #endregion +} diff --git a/tests/Core.Tests/Mods/Installation/Installers/ModInstallerTest.cs b/tests/Core.Tests/Mods/Installation/Installers/ModInstallerTest.cs index 7db3e77..6209a9f 100644 --- a/tests/Core.Tests/Mods/Installation/Installers/ModInstallerTest.cs +++ b/tests/Core.Tests/Mods/Installation/Installers/ModInstallerTest.cs @@ -14,6 +14,9 @@ public class ModInstallerTest private const string GameSupportedModDirectory = "ModDirectory"; private const string GameDirAtRoot = "DirAtRoot"; private const string BootfilesPackageName = "BootFilesPackage"; + private const string VehicleListFile = "vehiclelist.lst"; + private const string TrackListFile = "tracklist.lst"; + private const string DrivelineFile = "driveline.rg"; #region Setup @@ -26,8 +29,11 @@ public class ModInstallerTest public ModInstallerTest() { configMock.Setup(c => c.DirsAtRoot).Returns([GameDirAtRoot, GameSupportedModDirectory]); - configMock.Setup(c => c.GameSupportedModDirectory).Returns(GameSupportedModDirectory); + configMock.Setup(c => c.GameSupportedModDir).Returns(GameSupportedModDirectory); configMock.Setup(c => c.GenerateModDetails).Returns(true); + configMock.Setup(c => c.VehicleListFileName).Returns(VehicleListFile); + configMock.Setup(c => c.TrackListFileName).Returns(TrackListFile); + configMock.Setup(c => c.DrivelineFileName).Returns(DrivelineFile); destDir = fs.Directory.CreateDirectory("Dest").FullName; tempDir = fs.Directory.CreateDirectory("Temp").FullName; @@ -95,8 +101,8 @@ public void AutomaticModConfigurationForVehicles() string[] expectedFiles = { crdFile, - Path.Combine(GameSupportedModDirectory, "A_badcafe", BaseModInstaller.VehicleListFileName), - Path.Combine(GameSupportedModDirectory, "A_badcafe", BaseModInstaller.DrivelineFileName), + Path.Combine(GameSupportedModDirectory, "A_badcafe", VehicleListFile), + Path.Combine(GameSupportedModDirectory, "A_badcafe", DrivelineFile), Path.Combine(GameSupportedModDirectory, "A_badcafe", "A_badcafe.xml") }; @@ -109,9 +115,9 @@ public void AutomaticModConfigurationForVehicles() fs.AllFiles.Should().BeEquivalentTo( expectedFiles.Select(f => Path.Combine(destDir, f)) ); - fs.GetFile(Path.Combine(destDir, GameSupportedModDirectory, "A_badcafe", BaseModInstaller.VehicleListFileName)) + fs.GetFile(Path.Combine(destDir, GameSupportedModDirectory, "A_badcafe", VehicleListFile)) .TextContents.Should().Be(crdFile); - fs.GetFile(Path.Combine(destDir, GameSupportedModDirectory, "A_badcafe", BaseModInstaller.DrivelineFileName)) + fs.GetFile(Path.Combine(destDir, GameSupportedModDirectory, "A_badcafe", DrivelineFile)) .TextContents.Should().Be(drivelineRecord.Trim()); } @@ -128,7 +134,7 @@ public void AutomaticModConfigurationCanBeDisabled() var modInstaller = InstallWithModInstaller(InstallerOf("A", null, packageFiles)); var expectedFiles = packageFiles.Concat([ - Path.Combine(GameSupportedModDirectory, "A_0", "vehiclelist.lst") + Path.Combine(GameSupportedModDirectory, "A_0", VehicleListFile) // No mod xml ]).ToHashSet(); @@ -154,7 +160,7 @@ public void AutomaticModConfigurationNotPossibleForTracks() var modInstaller = InstallWithModInstaller(InstallerOf("Bee Cee", null, packageFiles)); var expectedFiles = packageFiles.Concat([ - Path.Combine(GameSupportedModDirectory, "BeeCee_0", "tracklist.lst") + Path.Combine(GameSupportedModDirectory, "BeeCee_0", TrackListFile) // No mod xml ]).ToHashSet(); From 9909eff6ed53aa70173c370c390f16472ce7336d Mon Sep 17 00:00:00 2001 From: Paolo Ambrosio Date: Mon, 16 Mar 2026 08:38:47 +0000 Subject: [PATCH 6/6] Fix configuration files not always tracked - Execute callbacks during post-processing - Test behaviour - Refactoring of callback handling --- .../Installers/BaseModInstaller.cs | 92 ++++++++++--------- .../Installers/BootfilesInstaller.cs | 15 +-- .../Installation/Installers/ModInstaller.cs | 47 ++++------ .../Packages/Installation/IInstallation.cs | 4 +- .../Installation/Installers/BaseInstaller.cs | 25 ++--- .../Installers/ProcessingCallbacks.cs | 14 +++ .../Installers/BootfilesInstallerTest.cs | 65 +++++++++---- .../Installers/ModInstallerTest.cs | 86 +++++++++-------- .../Installation/ModPackagesUpdaterTest.cs | 4 +- .../Installers/StaticFilesInstaller.cs | 4 +- 10 files changed, 205 insertions(+), 151 deletions(-) diff --git a/src/Core/Mods/Installation/Installers/BaseModInstaller.cs b/src/Core/Mods/Installation/Installers/BaseModInstaller.cs index 22c4d52..76c69c2 100644 --- a/src/Core/Mods/Installation/Installers/BaseModInstaller.cs +++ b/src/Core/Mods/Installation/Installers/BaseModInstaller.cs @@ -34,7 +34,7 @@ public interface IConfig private bool postProcessingDone; - private readonly List localInstalledFiles = new(); + private readonly HashSet localInstalledFiles = new(); protected BaseModInstaller(IFileSystem fileSystem, IInstaller inner, string tempDir, IConfig config) { @@ -56,13 +56,13 @@ protected BaseModInstaller(IFileSystem fileSystem, IInstaller inner, string temp public int? PackageFsHash => Inner.PackageFsHash; - public abstract IReadOnlyCollection PackageDependencies { get; } + public abstract IReadOnlySet PackageDependencies { get; } - public IReadOnlyCollection InstalledFiles => + public IReadOnlySet InstalledFiles => Inner.InstalledFiles .Concat(localInstalledFiles) .Where(RootIsNotStagingDir) - .ToImmutableArray(); + .ToImmutableHashSet(); public IInstallation.State Installed => Inner.Installed == IInstallation.State.Installed && !postProcessingDone @@ -72,10 +72,12 @@ protected BaseModInstaller(IFileSystem fileSystem, IInstaller inner, string temp public void Install(IInstaller.Destination destination, IBackupStrategy backupStrategy, ProcessingCallbacks callbacks) { - Install(() => Inner.Install( - ConfigToStagingDir(destination), - backupStrategy, - IgnoreForStagedFiles(callbacks.AndAccept(Whitelisted)))); + Install( + () => Inner.Install( + ConfigToStagingDir(destination), + backupStrategy, + IgnoreForStagedFiles(callbacks.AndAccept(Whitelisted))), + callbacks); postProcessingDone = true; } @@ -83,15 +85,7 @@ public void Install(IInstaller.Destination destination, IBackupStrategy backupSt public IEnumerable RelativeDirectoryPaths => Inner.RelativeDirectoryPaths.SelectNotNull(rootPaths.Value.GetPathFromRoot); - protected abstract void Install(Action innerInstall); - - protected void AddToInstalledFiles(RootedPath? installedFile) - { - if (installedFile is not null) - { - localInstalledFiles.Add(installedFile); - } - } + protected abstract void Install(Action innerInstall, ProcessingCallbacks callbacks); private IInstaller.Destination ConfigToStagingDir(IInstaller.Destination destination) => pathInPackage => @@ -132,29 +126,31 @@ private bool RootIsNotStagingDir(RootedPath rp) => private bool RootIsStagingDir(RootedPath rp) => rp.Root == StagingFullPath; - protected RootedPath? AppendCrdFileEntries(IEnumerable crdFileEntries) => - AppendEntryList(VehicleListDir.SubPath(VehicleListFileName), crdFileEntries); + protected void AppendCrdFileEntries(IEnumerable crdFileEntries, + ProcessingCallbacks callbacks) => + AppendEntryList(VehicleListDir.SubPath(VehicleListFileName), crdFileEntries, callbacks); protected abstract RootedPath VehicleListDir { get; } - public RootedPath? AppendTrdFileEntries(IEnumerable trdFileEntries) => - AppendEntryList(TrackListDir.SubPath(TrackListFileName), trdFileEntries); + protected void AppendTrdFileEntries(IEnumerable trdFileEntries, + ProcessingCallbacks callbacks) => + AppendEntryList(TrackListDir.SubPath(TrackListFileName), trdFileEntries, callbacks); protected abstract RootedPath TrackListDir { get; } - public RootedPath? AppendDrivelineRecords(IEnumerable recordBlocks) + protected void InsertDrivelineRecords(IEnumerable recordBlocks, + ProcessingCallbacks callbacks) { var recordsTextBlock = DrivelineBlock(recordBlocks); if (recordsTextBlock.Length == 0) { - return null; + return; } var driveLineFilePath = DrivelineDir.SubPath(DrivelineFileName); - CreateParentDirectory(driveLineFilePath); var newContents = DrivelineFileContents(driveLineFilePath, WrapConfigBlock(recordsTextBlock)); - FileSystem.File.WriteAllText(driveLineFilePath.Full, newContents); // TODO THIS DOES NOT EXIST!!!!! - return driveLineFilePath; + + SafeWriteAllText(driveLineFilePath, newContents, callbacks); } protected abstract RootedPath DrivelineDir { get; } @@ -198,31 +194,45 @@ private string DrivelineFileContents(RootedPath driveLineFilePath, string record return contents.Insert(endIndex, recordsTextBlock); } - private RootedPath? AppendEntryList( + private void AppendEntryList( RootedPath filePath, - IEnumerable entries) + IEnumerable entries, + ProcessingCallbacks callbacks) { var entriesBlock = string.Join(Environment.NewLine, entries); if (entriesBlock.Length == 0) { - return null; + return; } + var contents = WrapConfigBlock(entriesBlock); - CreateParentDirectory(filePath); - var f = FileSystem.File.AppendText(filePath.Full); - f.Write(WrapConfigBlock(entriesBlock)); - f.Close(); - return filePath; + SafeAppendAllText(filePath, contents, callbacks); } - private void CreateParentDirectory(RootedPath filePath) + protected virtual string WrapConfigBlock(string configBlock) => configBlock; + + protected void SafeWriteAllText(RootedPath filePath, string contents, + ProcessingCallbacks callbacks) => + SafeFileOperation(FileSystem.File.WriteAllText, filePath, contents, callbacks); + + protected void SafeAppendAllText(RootedPath filePath, string contents, + ProcessingCallbacks callbacks) => + SafeFileOperation(FileSystem.File.AppendAllText, filePath, contents, callbacks); + + private void SafeFileOperation(Action fileOperation, RootedPath filePath, + string contents, ProcessingCallbacks callbacks) { - var dirPath = Path.GetDirectoryName(filePath.Full); - if (dirPath is not null) + var fullFilePath = filePath.Full; + + (InstalledFiles.Contains(filePath) ? new ProcessingCallbacks() : callbacks).Wrap(() => { - FileSystem.Directory.CreateDirectory(dirPath); - } + var dirPath = Path.GetDirectoryName(fullFilePath); + if (dirPath is not null) + { + FileSystem.Directory.CreateDirectory(dirPath); + } + fileOperation(fullFilePath, contents); + localInstalledFiles.Add(filePath); + }, filePath); } - - protected virtual string WrapConfigBlock(string configBlock) => configBlock; } diff --git a/src/Core/Mods/Installation/Installers/BootfilesInstaller.cs b/src/Core/Mods/Installation/Installers/BootfilesInstaller.cs index a57dbe2..0745d51 100644 --- a/src/Core/Mods/Installation/Installers/BootfilesInstaller.cs +++ b/src/Core/Mods/Installation/Installers/BootfilesInstaller.cs @@ -1,4 +1,5 @@ -using System.IO.Abstractions; +using System.Collections.Immutable; +using System.IO.Abstractions; using Core.Packages.Installation.Installers; using Core.Utils; @@ -54,9 +55,9 @@ private static IInstaller PackageOrGenerated(IInstaller? bootfilesPackageInstall gameInstallationDirectory, tempDir); // Bootfiles cannot have dependencies. - public override IReadOnlyCollection PackageDependencies => Array.Empty(); + public override IReadOnlySet PackageDependencies => ImmutableHashSet.Empty; - protected override void Install(Action innerInstall) + protected override void Install(Action innerInstall, ProcessingCallbacks callbacks) { var modConfigs = CollectModConfig(); if (modConfigs.None()) @@ -68,21 +69,23 @@ protected override void Install(Action innerInstall) eventHandler.PostProcessingStart(); var packageNameIfNotGenerated = bootfilesNaming.IsGeneratedBootfiles(PackageName) ? PackageName : null; eventHandler.ExtractingBootfiles(packageNameIfNotGenerated); + innerInstall(); + if (modConfigs.CrdFileEntries.Count > 0) { eventHandler.PostProcessingVehicles(); - AppendCrdFileEntries(modConfigs.CrdFileEntries); + AppendCrdFileEntries(modConfigs.CrdFileEntries, callbacks); } if (modConfigs.TrdFileEntries.Count > 0) { eventHandler.PostProcessingTracks(); - AppendTrdFileEntries(modConfigs.TrdFileEntries); + AppendTrdFileEntries(modConfigs.TrdFileEntries, callbacks); } if (modConfigs.DrivelineRecords.Count > 0) { eventHandler.PostProcessingDrivelines(); - AppendDrivelineRecords(modConfigs.DrivelineRecords); + InsertDrivelineRecords(modConfigs.DrivelineRecords, callbacks); } eventHandler.PostProcessingEnd(); } diff --git a/src/Core/Mods/Installation/Installers/ModInstaller.cs b/src/Core/Mods/Installation/Installers/ModInstaller.cs index cd8021b..c6ba75d 100644 --- a/src/Core/Mods/Installation/Installers/ModInstaller.cs +++ b/src/Core/Mods/Installation/Installers/ModInstaller.cs @@ -49,15 +49,8 @@ internal ModInstaller(IFileSystem fileSystem, IInstaller inner, string tempDir, modConfigPath = new RootedPath(gameInstallationDir, Path.Combine(GameSupportedModRelativeDir, modName)); } - public override IReadOnlyCollection PackageDependencies => - Inner.PackageDependencies.Concat(bootfilesDependency).ToImmutableList(); - - protected override void Install(Action innerInstall) - { - innerInstall(); - - GenerateModConfig(); - } + public override IReadOnlySet PackageDependencies => + Inner.PackageDependencies.Concat(bootfilesDependency).ToImmutableHashSet(); protected override RootedPath VehicleListDir => modConfigPath; @@ -65,39 +58,40 @@ protected override void Install(Action innerInstall) protected override RootedPath DrivelineDir => modConfigPath; - private void GenerateModConfig() + protected override void Install(Action innerInstall, ProcessingCallbacks callbacks) { + innerInstall(); + var gameSupportedMod = FileEntriesToConfigure() .Any(p => p.StartsWith(GameSupportedModRelativeDir)); var modConfig = gameSupportedMod ? ConfigEntries.Empty - : new ConfigEntries(CrdFileEntries(), TrdFileEntries(), FindDrivelineRecords()); - WriteModConfigFiles(modConfig); - } + : new ConfigEntries(FindCrdFileEntries(), FindTrdFileEntries(), FindDrivelineRecords()); - private void WriteModConfigFiles(ConfigEntries modConfig) - { if (modConfig.None()) + { return; + } - AddToInstalledFiles(AppendCrdFileEntries(modConfig.CrdFileEntries)); - AddToInstalledFiles(AppendTrdFileEntries(modConfig.TrdFileEntries)); - AddToInstalledFiles(AppendDrivelineRecords(modConfig.DrivelineRecords)); + AppendCrdFileEntries(modConfig.CrdFileEntries, callbacks); + AppendTrdFileEntries(modConfig.TrdFileEntries, callbacks); + InsertDrivelineRecords(modConfig.DrivelineRecords, callbacks); if (generateModDetails && !modConfig.TrdFileEntries.Any()) { - AddToInstalledFiles(GenerateModDetails()); - } else + SafeWriteAllText(modConfigPath.SubPath($"{modName}.xml"), ModManifest, callbacks); + } + else { bootfilesDependency = new[] { bootfilesPackageName }; } } - private List CrdFileEntries() => + private List FindCrdFileEntries() => FileEntriesToConfigure() .Where(p => p.EndsWith(".crd")) .ToList(); - private List TrdFileEntries() => + private List FindTrdFileEntries() => FileEntriesToConfigure() .Where(p => p.EndsWith(".trd")) .Select(fp => $"{Path.GetDirectoryName(fp)}{Path.DirectorySeparatorChar}@{Path.GetFileName(fp)}") @@ -156,9 +150,8 @@ private List FindDrivelineRecords() return recordBlocks; } - public RootedPath GenerateModDetails() - { - var contents = @$" + public string ModManifest => + @$" @@ -172,8 +165,4 @@ public RootedPath GenerateModDetails() "; - var filePath = modConfigPath.SubPath($"{modName}.xml"); - FileSystem.File.WriteAllText(filePath.Full, contents); - return filePath; - } } diff --git a/src/Core/Packages/Installation/IInstallation.cs b/src/Core/Packages/Installation/IInstallation.cs index c93e5e4..6a51812 100644 --- a/src/Core/Packages/Installation/IInstallation.cs +++ b/src/Core/Packages/Installation/IInstallation.cs @@ -4,9 +4,9 @@ namespace Core.Packages.Installation; public interface IInstallation : IPackageInfo { - IReadOnlyCollection PackageDependencies { get; } + IReadOnlySet PackageDependencies { get; } - IReadOnlyCollection InstalledFiles { get; } + IReadOnlySet InstalledFiles { get; } State Installed { get; } enum State diff --git a/src/Core/Packages/Installation/Installers/BaseInstaller.cs b/src/Core/Packages/Installation/Installers/BaseInstaller.cs index 06232d7..8cd778a 100644 --- a/src/Core/Packages/Installation/Installers/BaseInstaller.cs +++ b/src/Core/Packages/Installation/Installers/BaseInstaller.cs @@ -1,4 +1,5 @@ -using System.IO.Abstractions; +using System.Collections.Immutable; +using System.IO.Abstractions; using Core.Packages.Installation.Backup; using Core.Utils; @@ -13,27 +14,27 @@ internal abstract class BaseInstaller : IInstaller public string PackageName { get; } public int? PackageFsHash { get; } - public IReadOnlyCollection PackageDependencies { get; } + public IReadOnlySet PackageDependencies { get; } public IInstallation.State Installed { get; private set; } - public IReadOnlyCollection InstalledFiles => installedFiles; + public IReadOnlySet InstalledFiles => installedFiles; protected readonly IFileSystem FileSystem; - private readonly List installedFiles = new(); + private readonly HashSet installedFiles = new(); protected BaseInstaller(string packageName, int? packageFsHash) - : this(packageName, packageFsHash, Array.Empty()) + : this(packageName, packageFsHash, ImmutableHashSet.Empty) { } - protected BaseInstaller(string packageName, int? packageFsHash, IReadOnlyCollection packageDependencies) : + protected BaseInstaller(string packageName, int? packageFsHash, IReadOnlySet packageDependencies) : this(new FileSystem(), packageName, packageFsHash, packageDependencies) { } // A package cannot currently specify dependencies. - protected BaseInstaller(IFileSystem fs, string packageName, int? packageFsHash, IReadOnlyCollection packageDependencies) + protected BaseInstaller(IFileSystem fs, string packageName, int? packageFsHash, IReadOnlySet packageDependencies) { FileSystem = fs; PackageName = packageName; @@ -53,9 +54,8 @@ public void Install(IInstaller.Destination destination, IBackupStrategy backupSt { var (destPath, removeFile) = NeedsRemoving(destination(pathInPackage)); - if (callbacks.Accept(destPath)) + callbacks.Wrap(() => { - callbacks.Before(destPath); try { backupStrategy.PerformBackup(destPath); @@ -70,12 +70,7 @@ public void Install(IInstaller.Destination destination, IBackupStrategy backupSt installedFiles.Add(destPath); } backupStrategy.AfterInstall(destPath); - callbacks.After(destPath); - } - else - { - callbacks.NotAccepted(destPath); - } + }, destPath); }); Installed = IInstallation.State.Installed; diff --git a/src/Core/Packages/Installation/Installers/ProcessingCallbacks.cs b/src/Core/Packages/Installation/Installers/ProcessingCallbacks.cs index 48686ac..f43b4e9 100644 --- a/src/Core/Packages/Installation/Installers/ProcessingCallbacks.cs +++ b/src/Core/Packages/Installation/Installers/ProcessingCallbacks.cs @@ -74,4 +74,18 @@ private static Predicate Combine(Predicate p1, Predicate p2) => private static Action Combine(Action a1, Action a2) => a1 == EmptyAction ? a2 : a2 == EmptyAction ? a1 : key => { a1(key); a2(key); }; + + public void Wrap(Action action, T t) + { + if (Accept(t)) + { + Before(t); + action(); + After(t); + } + else + { + NotAccepted(t); + } + } } diff --git a/tests/Core.Tests/Mods/Installation/Installers/BootfilesInstallerTest.cs b/tests/Core.Tests/Mods/Installation/Installers/BootfilesInstallerTest.cs index fd869d5..e382e8d 100644 --- a/tests/Core.Tests/Mods/Installation/Installers/BootfilesInstallerTest.cs +++ b/tests/Core.Tests/Mods/Installation/Installers/BootfilesInstallerTest.cs @@ -31,6 +31,8 @@ public class BootfilesInstallerTest private readonly Mock bootfilesNamingMock = new(); private readonly Mock eventHandlerMock = new(); private readonly Mock backupStrategyMock = new(); + private readonly Mock> callbackMock = new(); + private readonly string destDir; private readonly string tempDir; @@ -58,6 +60,8 @@ public void BootfilesAreNotInstalledWhenNoMods() eventHandlerMock.Verify(m => m.PostProcessingNotRequired(), Times.Once); + callbackMock.VerifyNoOtherCalls(); + fs.AllFiles.Should().BeEmpty(); } @@ -75,6 +79,8 @@ public void BootfilesAreNotInstalledForModsWithManifests() eventHandlerMock.Verify(m => m.PostProcessingNotRequired(), Times.Once); + callbackMock.VerifyNoOtherCalls(); + fs.File.Exists(Path.Combine(destDir, BootfilesVehicleListDir, VehicleListFileName)).Should().BeFalse(); fs.File.Exists(Path.Combine(destDir, BootfilesTrackListDir, TrackListFileName)).Should().BeFalse(); fs.File.Exists(Path.Combine(destDir, BootfilesDrivelineDir, DrivelineFileName)).Should().BeFalse(); @@ -89,19 +95,23 @@ public void BootfilesAreInstalledForVehicleListWithoutModManifest() fs.AddFile(Path.Combine(destDir, GameSupportedModDirectory, "Mod1", VehicleListFileName), mod1Config); fs.AddFile(Path.Combine(destDir, GameSupportedModDirectory, "Mod2", VehicleListFileName), mod2Config); - InstallBootfiles().InstalledFiles.Should().BeEquivalentTo(new [] { + var expected = DestRootedPaths( FileInBootfilesPackage, - //Path.Combine(BootfilesVehicleListDir, VehicleListFileName) - }.Select(f => new RootedPath(destDir, f))); + Path.Combine(BootfilesVehicleListDir, VehicleListFileName) + ); - TrimConfig(fs.File.ReadAllText(Path.Combine(destDir, BootfilesVehicleListDir, VehicleListFileName))) - .Should().Be(mod1Config + Environment.NewLine + mod2Config); + InstallBootfiles().InstalledFiles.Should().BeEquivalentTo(expected); eventHandlerMock.Verify(m => m.PostProcessingStart(), Times.Once); eventHandlerMock.Verify(m => m.ExtractingBootfiles(null), Times.Once); eventHandlerMock.Verify(m => m.PostProcessingVehicles(), Times.Once); eventHandlerMock.Verify(m => m.PostProcessingEnd(), Times.Once); eventHandlerMock.VerifyNoOtherCalls(); + + VerifyCallbackCalledWith(expected); + + TrimConfig(fs.File.ReadAllText(Path.Combine(destDir, BootfilesVehicleListDir, VehicleListFileName))) + .Should().Be(mod1Config + Environment.NewLine + mod2Config); } [Fact] @@ -113,19 +123,23 @@ public void BootfilesAreInstalledForTrackListWithoutModManifest() fs.AddFile(Path.Combine(destDir, GameSupportedModDirectory, "Mod1", TrackListFileName), mod1Config); fs.AddFile(Path.Combine(destDir, GameSupportedModDirectory, "Mod2", TrackListFileName), mod2Config); - InstallBootfiles().InstalledFiles.Should().BeEquivalentTo(new [] { + var expected = DestRootedPaths( FileInBootfilesPackage, - // Path.Combine(BootfilesTrackListDir, TrackListFileName) - }.Select(f => new RootedPath(destDir, f))); + Path.Combine(BootfilesTrackListDir, TrackListFileName) + ); - TrimConfig(fs.File.ReadAllText(Path.Combine(destDir, BootfilesTrackListDir, TrackListFileName))) - .Should().Be(mod1Config + Environment.NewLine + mod2Config); + InstallBootfiles().InstalledFiles.Should().BeEquivalentTo(expected); eventHandlerMock.Verify(m => m.PostProcessingStart(), Times.Once); eventHandlerMock.Verify(m => m.ExtractingBootfiles(null), Times.Once); eventHandlerMock.Verify(m => m.PostProcessingTracks(), Times.Once); eventHandlerMock.Verify(m => m.PostProcessingEnd(), Times.Once); eventHandlerMock.VerifyNoOtherCalls(); + + VerifyCallbackCalledWith(expected); + + TrimConfig(fs.File.ReadAllText(Path.Combine(destDir, BootfilesTrackListDir, TrackListFileName))) + .Should().Be(mod1Config + Environment.NewLine + mod2Config); } [Fact] @@ -137,19 +151,23 @@ public void BootfilesAreInstalledForDrivelineWithoutModManifest() fs.AddFile(Path.Combine(destDir, GameSupportedModDirectory, "Mod1", DrivelineFileName), mod1Config); fs.AddFile(Path.Combine(destDir, GameSupportedModDirectory, "Mod2", DrivelineFileName), mod2Config); - InstallBootfiles().InstalledFiles.Should().BeEquivalentTo(new [] { + var expected = DestRootedPaths( FileInBootfilesPackage, - // Path.Combine(BootfilesDrivelineDir, DrivelineFileName) - }.Select(f => new RootedPath(destDir, f))); + Path.Combine(BootfilesDrivelineDir, DrivelineFileName) + ); - TrimConfig(fs.File.ReadAllText(Path.Combine(destDir, BootfilesDrivelineDir, DrivelineFileName))) - .Should().Be(mod1Config + Environment.NewLine + mod2Config); + InstallBootfiles().InstalledFiles.Should().BeEquivalentTo(expected); eventHandlerMock.Verify(m => m.PostProcessingStart(), Times.Once); eventHandlerMock.Verify(m => m.ExtractingBootfiles(null), Times.Once); eventHandlerMock.Verify(m => m.PostProcessingDrivelines(), Times.Once); eventHandlerMock.Verify(m => m.PostProcessingEnd(), Times.Once); eventHandlerMock.VerifyNoOtherCalls(); + + VerifyCallbackCalledWith(expected); + + TrimConfig(fs.File.ReadAllText(Path.Combine(destDir, BootfilesDrivelineDir, DrivelineFileName))) + .Should().Be(mod1Config + Environment.NewLine + mod2Config); } @@ -165,7 +183,10 @@ private BootfilesInstaller InstallBootfiles() var bootfilesInstaller = new BootfilesInstaller(fs, emptyPackage, tempDir, configMock.Object, destDir, bootfilesNamingMock.Object, eventHandlerMock.Object); bootfilesInstaller.Install(packagePath => new RootedPath(destDir, packagePath), - backupStrategyMock.Object, new ProcessingCallbacks()); + backupStrategyMock.Object, new ProcessingCallbacks + { + Before = callbackMock.Object + }); fs.Directory.Delete(tempDir, recursive: true); return bootfilesInstaller; } @@ -187,5 +208,17 @@ private static string TrimConfig(string config) => .Select(line => line.Split('#')[0].Trim()) .Where(line => !string.IsNullOrEmpty(line))); + private IReadOnlySet DestRootedPaths(params string[] relativePaths) => + relativePaths.Select(f => new RootedPath(destDir, f)).ToHashSet(); + + private void VerifyCallbackCalledWith(IReadOnlySet relativePaths) + { + foreach (var rp in relativePaths) + { + callbackMock.Verify(a => a(rp), Times.Once); + } + callbackMock.VerifyNoOtherCalls(); + } + #endregion } diff --git a/tests/Core.Tests/Mods/Installation/Installers/ModInstallerTest.cs b/tests/Core.Tests/Mods/Installation/Installers/ModInstallerTest.cs index 6209a9f..868f82f 100644 --- a/tests/Core.Tests/Mods/Installation/Installers/ModInstallerTest.cs +++ b/tests/Core.Tests/Mods/Installation/Installers/ModInstallerTest.cs @@ -23,6 +23,8 @@ public class ModInstallerTest private readonly MockFileSystem fs = new(); private readonly Mock configMock = new(); private readonly Mock backupStrategyMock = new(); + private readonly Mock> callbackMock = new(); + private readonly string destDir; private readonly string tempDir; @@ -51,15 +53,12 @@ public void AutomaticModConfigurationSkippedWhenNothingToConfigure() var modInstaller = InstallWithModInstaller(InstallerOf("A", null, packageFiles)); - fs.AllFiles.Should().BeEquivalentTo( - packageFiles.Select(f => Path.Combine(destDir, f)) - ); + modInstaller.InstalledFiles.Should().BeEquivalentTo(ToDestRootedPath(packageFiles)); + modInstaller.PackageDependencies.Should().BeEmpty(); - modInstaller.InstalledFiles.Should().BeEquivalentTo( - packageFiles.Select(f => new RootedPath(destDir, f)) - ); + VerifyCallbackCalledWith(packageFiles); - modInstaller.PackageDependencies.Should().BeEmpty(); + fs.AllFiles.Should().BeEquivalentTo(ToDestPath(packageFiles)); } [Fact] @@ -74,15 +73,12 @@ public void AutomaticModConfigurationSkippedWhenConfiguredByPackage() var modInstaller = InstallWithModInstaller(InstallerOf("A", null, packageFiles)); - fs.AllFiles.Should().BeEquivalentTo( - packageFiles.Select(f => Path.Combine(destDir, f)) - ); + modInstaller.InstalledFiles.Should().BeEquivalentTo(ToDestRootedPath(packageFiles)); + modInstaller.PackageDependencies.Should().BeEmpty(); - modInstaller.InstalledFiles.Should().BeEquivalentTo( - packageFiles.Select(f => new RootedPath(destDir, f)) - ); + VerifyCallbackCalledWith(packageFiles); - modInstaller.PackageDependencies.Should().BeEmpty(); + fs.AllFiles.Should().BeEquivalentTo(ToDestPath(packageFiles)); } [Fact] @@ -106,15 +102,12 @@ public void AutomaticModConfigurationForVehicles() Path.Combine(GameSupportedModDirectory, "A_badcafe", "A_badcafe.xml") }; - modInstaller.InstalledFiles.Should().BeEquivalentTo( - expectedFiles.Select(f => new RootedPath(destDir, f)) - ); - + modInstaller.InstalledFiles.Should().BeEquivalentTo(ToDestRootedPath(expectedFiles)); modInstaller.PackageDependencies.Should().BeEmpty(); - fs.AllFiles.Should().BeEquivalentTo( - expectedFiles.Select(f => Path.Combine(destDir, f)) - ); + VerifyCallbackCalledWith(expectedFiles); + + fs.AllFiles.Should().BeEquivalentTo(ToDestPath(expectedFiles)); fs.GetFile(Path.Combine(destDir, GameSupportedModDirectory, "A_badcafe", VehicleListFile)) .TextContents.Should().Be(crdFile); fs.GetFile(Path.Combine(destDir, GameSupportedModDirectory, "A_badcafe", DrivelineFile)) @@ -136,17 +129,14 @@ public void AutomaticModConfigurationCanBeDisabled() var expectedFiles = packageFiles.Concat([ Path.Combine(GameSupportedModDirectory, "A_0", VehicleListFile) // No mod xml - ]).ToHashSet(); + ]).ToArray(); - fs.AllFiles.Should().BeEquivalentTo( - expectedFiles.Select(f => Path.Combine(destDir, f)) - ); + modInstaller.InstalledFiles.Should().BeEquivalentTo(ToDestRootedPath(expectedFiles)); + modInstaller.PackageDependencies.Should().ContainSingle(BootfilesPackageName); - modInstaller.InstalledFiles.Should().BeEquivalentTo( - expectedFiles.Select(f => new RootedPath(destDir, f)) - ); + VerifyCallbackCalledWith(expectedFiles); - modInstaller.PackageDependencies.Should().ContainSingle(BootfilesPackageName); + fs.AllFiles.Should().BeEquivalentTo(ToDestPath(expectedFiles)); } [Fact] @@ -162,24 +152,27 @@ public void AutomaticModConfigurationNotPossibleForTracks() var expectedFiles = packageFiles.Concat([ Path.Combine(GameSupportedModDirectory, "BeeCee_0", TrackListFile) // No mod xml - ]).ToHashSet(); + ]).ToArray(); - fs.AllFiles.Should().BeEquivalentTo( - expectedFiles.Select(f => Path.Combine(destDir, f)) - ); + modInstaller.InstalledFiles.Should().BeEquivalentTo(ToDestRootedPath(expectedFiles)); + modInstaller.PackageDependencies.Should().ContainSingle(BootfilesPackageName); - modInstaller.InstalledFiles.Should().BeEquivalentTo( - expectedFiles.Select(f => new RootedPath(destDir, f)) - ); + VerifyCallbackCalledWith(expectedFiles); - modInstaller.PackageDependencies.Should().ContainSingle(BootfilesPackageName); + fs.AllFiles.Should().BeEquivalentTo(ToDestPath(expectedFiles)); } + + #region Utility + private ModInstaller InstallWithModInstaller(IInstaller inner) { var modInstaller = new ModInstaller(fs, inner, tempDir, configMock.Object, destDir, BootfilesPackageName); modInstaller.Install(packagePath => new RootedPath(destDir, packagePath), - backupStrategyMock.Object, new ProcessingCallbacks()); + backupStrategyMock.Object, new ProcessingCallbacks + { + Before = callbackMock.Object + }); fs.Directory.Delete(tempDir, recursive: true); return modInstaller; } @@ -189,4 +182,21 @@ private IInstaller InstallerOf(string name, int? fsHash, IReadOnlyCollection fileContents) => new StaticFilesInstaller(fs, name, fsHash, fileContents, Array.Empty()); + + private IReadOnlySet ToDestPath(IReadOnlyCollection relativePaths) => + relativePaths.Select(f => Path.Combine(destDir, f)).ToHashSet(); + + private IReadOnlySet ToDestRootedPath(IReadOnlyCollection relativePaths) => + relativePaths.Select(f => new RootedPath(destDir, f)).ToHashSet(); + + private void VerifyCallbackCalledWith(IReadOnlyCollection relativePaths) + { + foreach (var rp in ToDestRootedPath(relativePaths)) + { + callbackMock.Verify(a => a(rp), Times.Once); + } + callbackMock.VerifyNoOtherCalls(); + } + + #endregion } diff --git a/tests/Core.Tests/Mods/Installation/ModPackagesUpdaterTest.cs b/tests/Core.Tests/Mods/Installation/ModPackagesUpdaterTest.cs index 4c9abbe..ff1557e 100644 --- a/tests/Core.Tests/Mods/Installation/ModPackagesUpdaterTest.cs +++ b/tests/Core.Tests/Mods/Installation/ModPackagesUpdaterTest.cs @@ -24,8 +24,8 @@ private class WrappedInstaller(IInstaller inner) : IInstaller { public string PackageName => $"({inner.PackageName})"; public int? PackageFsHash => inner.PackageFsHash; - public IReadOnlyCollection PackageDependencies => inner.PackageDependencies; - public IReadOnlyCollection InstalledFiles => inner.InstalledFiles; + public IReadOnlySet PackageDependencies => inner.PackageDependencies; + public IReadOnlySet InstalledFiles => inner.InstalledFiles; public IInstallation.State Installed => inner.Installed; public void Install(IInstaller.Destination destination, IBackupStrategy backupStrategy, ProcessingCallbacks callbacks) => inner.Install(destination, backupStrategy, callbacks); diff --git a/tests/Core.Tests/Packages/Installation/Installers/StaticFilesInstaller.cs b/tests/Core.Tests/Packages/Installation/Installers/StaticFilesInstaller.cs index 6fdb5c9..87d305e 100644 --- a/tests/Core.Tests/Packages/Installation/Installers/StaticFilesInstaller.cs +++ b/tests/Core.Tests/Packages/Installation/Installers/StaticFilesInstaller.cs @@ -12,7 +12,7 @@ internal class StaticFilesInstaller : BaseInstaller internal StaticFilesInstaller(string packageName, int? packageFsHash, IReadOnlyDictionary files, IReadOnlyCollection packageDependencies) : - base(packageName, packageFsHash, packageDependencies) + base(packageName, packageFsHash, packageDependencies.ToImmutableHashSet()) { createFiles = false; this.files = files; @@ -20,7 +20,7 @@ internal StaticFilesInstaller(string packageName, int? packageFsHash, IReadOnlyD internal StaticFilesInstaller(IFileSystem fs, string packageName, int? packageFsHash, IReadOnlyDictionary files, IReadOnlyCollection packageDependencies) : - base(fs, packageName, packageFsHash, packageDependencies) + base(fs, packageName, packageFsHash, packageDependencies.ToImmutableHashSet()) { createFiles = true; this.files = files;