diff --git a/.gitattributes b/.gitattributes index e5c5394e6..08d566094 100644 --- a/.gitattributes +++ b/.gitattributes @@ -54,6 +54,8 @@ *.sbsar filter=lfs diff=lfs merge=lfs -text *.msgpack filter=lfs diff=lfs merge=lfs -text GameData/*.cc filter=lfs diff=lfs merge=lfs -text +GameData/Mods/**/*.cc filter=lfs diff=lfs merge=lfs -text +GameData/Mods/**/*.glb filter=lfs diff=lfs merge=lfs -text *.a filter=lfs diff=lfs merge=lfs -text *AkSoundEngine filter=lfs diff=lfs merge=lfs -text *AkWaapiClient filter=lfs diff=lfs merge=lfs -text diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index 755dd91bb..2ee5de580 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -18,7 +18,7 @@ using float3 = CultMath.float3; // RunStart owns what a new run starts with, and Zone.Admit is the one admission into a zone (docs/scenarios-cut.md, // 2.2 and R). Every test stages into a real arena: the entrance zone of a prelude galaxy at a fixed seed, built from a -// scratch copy of the shipped catalog and the authored settings (Assets/Resources/Settings.asset). +// scratch copy of the catalog the game boots (TestCatalog) and the authored settings (Assets/Resources/Settings.asset). public sealed partial class RunStartTests : IDisposable { private const uint GalaxySeed = 1; @@ -38,7 +38,7 @@ public sealed partial class RunStartTests : IDisposable var repo = FindRepoRoot(); Directory.CreateDirectory(_root); var catalog = Path.Combine(_root, "Aetheria.cc"); - File.Copy(Path.Combine(repo, "GameData", "Aetheria.cc"), catalog); + File.Copy(TestCatalog.Repo, catalog); // A registry scoped to the shipped assembly's own [CultDocument] types, as RestoredHullsTests composes it, so // this test assembly's own documents never reach the real catalog's validation. @@ -72,7 +72,7 @@ public sealed partial class RunStartTests : IDisposable .Where(t => t is { IsAbstract: false, IsInterface: false }) .Where(t => t.GetCustomAttribute() != null)); - private static string FindRepoRoot() + internal static string FindRepoRoot() { for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir != null; dir = dir.Parent) if (File.Exists(Path.Combine(dir.FullName, "Aetheria.Shared", "Aetheria.Shared.csproj"))) diff --git a/tests/Aetheria.Shared.Tests/TestCatalog.cs b/tests/Aetheria.Shared.Tests/TestCatalog.cs new file mode 100644 index 000000000..1c4e1c289 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/TestCatalog.cs @@ -0,0 +1,33 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.IO; +using System.Linq; + +// The catalog the game boots, for tests: ShipModCatalog.ResolveCatalog over GameData/Mods, the call ActionGameManager +// makes. The game skips a broken package so a player still boots; a test never does, so an exclusion throws here and +// a broken first-party package fails the suite instead of vanishing from it. +internal static class TestCatalog +{ + private static readonly Lazy Shared = new Lazy(() => + { + var repo = RunStartTests.FindRepoRoot(); + var work = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "aetheria-testcatalog-" + Guid.NewGuid().ToString("N"))).FullName; + AppDomain.CurrentDomain.ProcessExit += (_, _) => { try { Directory.Delete(work, true); } catch (IOException) { } }; + return Resolve(Path.Combine(repo, "GameData", "Aetheria.cc"), Path.Combine(repo, "GameData", "Mods"), work); + }); + + // The repo's catalog with its first-party packages composed in, resolved once per test process. + public static string Repo => Shared.Value; + + public static string Resolve(string shippedCatalog, string modsRoot, string workDir) + { + var (catalog, excluded) = ShipModCatalog.ResolveCatalog(shippedCatalog, Path.Combine(workDir, "derived.cc"), modsRoot); + if (excluded.Length > 0) + throw new InvalidOperationException("First-party packages were excluded from the catalog: " + + string.Join("; ", excluded.Select(entry => entry.Package + ": " + entry.Reason))); + return catalog; + } +} diff --git a/tests/Aetheria.Shared.Tests/TestCatalogTests.cs b/tests/Aetheria.Shared.Tests/TestCatalogTests.cs new file mode 100644 index 000000000..4d95c4aec --- /dev/null +++ b/tests/Aetheria.Shared.Tests/TestCatalogTests.cs @@ -0,0 +1,64 @@ +using System; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using GameCult.Caching; +using Xunit; + +// TestCatalog.Resolve: the catalog every RunStartTests partial stages from is the one the game boots. +public sealed class TestCatalogTests : IDisposable +{ + private readonly TempDirectory _directory = new TempDirectory(); + private string Shipped => Path.Combine(_directory.Path, "Aetheria.cc"); + private string Mods => Path.Combine(_directory.Path, "Mods"); + private string Work => Path.Combine(_directory.Path, "Work"); + + public TestCatalogTests() + { + Directory.CreateDirectory(Mods); + using var cache = AetheriaStores.Open(Shipped, catalogWritable: true); + // The test assembly registers TestCatalogGlobal (AetheriaStoresTests), so any populated catalog must hold one. + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.Upsert(new HullData { Name = "Wasp", Shape = ShipAuthoringTests.Fixture().Hull.Shape }); + cache.FlushAsync().Wait(); + } + + public void Dispose() => _directory.Dispose(); + + [Fact] + public void NoModsMeansTheShippedCatalog() + { + var before = File.ReadAllBytes(Shipped); + + Assert.Equal(Shipped, TestCatalog.Resolve(Shipped, Path.Combine(_directory.Path, "absent"), Work)); + Assert.Equal(Shipped, TestCatalog.Resolve(Shipped, Mods, Work)); + + Assert.Equal(SHA256.HashData(before), SHA256.HashData(File.ReadAllBytes(Shipped))); + } + + [Fact] + public void APackageReachesTheFixture() + { + ShipFixture.WritePackage(Mods, "mod.skiff"); + + var catalog = TestCatalog.Resolve(Shipped, Mods, Work); + + Assert.NotEqual(Shipped, catalog); + using var cache = AetheriaStores.Open(catalog); + Assert.Equal("Skiff", cache.Get(ShipModCatalog.HullKey("mod.skiff")).Name); + Assert.Contains("Wasp", cache.GetAll().Select(hull => hull.Name)); + } + + [Fact] + public void ABrokenPackageFailsLoudly() + { + ShipFixture.WritePackage(Mods, "mod.good", hullName: "Good"); + ShipFixture.WritePackage(Mods, "mod.broken", hullName: "Broken"); + File.Delete(Path.Combine(Mods, "mod.broken", "skiff.glb")); + + var error = Assert.Throws(() => TestCatalog.Resolve(Shipped, Mods, Work)); + + Assert.Contains("mod.broken", error.Message); + Assert.DoesNotContain("mod.good", error.Message); + } +}