Aetheria/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs
Meta Vens fe38f28865 Close the gaps a mutation pass found in the new ship tests (S-Soul F1-F3)
A pass of 137 hand-written mutations over ShipAuthoring.cs, ShipModCatalog.cs
and ship_cc.py, run against the tests from the last three commits, left six
surviving or unrunnable. Each is resolved:

- Validator: the hull Width/Height < 1 guards and the opacity IsInfinity
  guard could not change any outcome (an empty grid has no occupied cell;
  +Infinity exceeds 1 and -Infinity is below 0). Deleted.
- Two accepted shapes had no test: a hardpoint footprint with empty cells
  beside occupied ones, and an empty ParentId meaning no parent. Added.
- The GLB header length was pinned only from above. Added the shorter case.
- Compose's working-directory cleanup was invisible because the assertion
  listed files, not directory entries. It now lists both.
- ReadPackage runs the catalog's item validation on the hull (the derived
  catalog must reopen); that call now has a test.
- Python: a single non-ship record, a non-array payload long enough to fill
  every slot, and a hardpoint-only change to the layout revision each had a
  surviving mutation. Tests added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 02:14:10 +02:00

330 lines
16 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using GameCult.Caching;
using Xunit;
// Compose, ReadPackage and ReadNodeIds: the derived catalog a game would open, and the GLB node ids its anchors bind to.
public sealed class ShipModCatalogTests : IDisposable
{
private readonly TempDirectory _directory = new TempDirectory();
private string Shipped => Path.Combine(_directory.Path, "Aetheria.cc");
private string Derived => Path.Combine(_directory.Path, "Derived", "Aetheria.modded.cc");
private string Mods => Path.Combine(_directory.Path, "Mods");
private static readonly string[] FixtureNodes = { "map", "collider", "shield", "tractor", "thruster-port" };
public ShipModCatalogTests()
{
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.Upsert(new GearData { Name = "Widget" });
cache.FlushAsync().Wait();
}
public void Dispose() => _directory.Dispose();
[Fact]
public void ComposeDerivesACatalogAndLeavesTheShippedOneUntouched()
{
WritePackage("mod.skiff");
Directory.CreateDirectory(Path.Combine(Mods, "notes"));
var shippedBefore = Hash(Shipped);
Assert.Equal(1, ShipModCatalog.Compose(Shipped, Derived, Mods));
Assert.Equal(shippedBefore, Hash(Shipped));
using var cache = AetheriaStores.Open(Derived);
Assert.Contains("Wasp", cache.GetAll<HullData>().Select(hull => hull.Name));
Assert.Contains("Widget", cache.GetAll<GearData>().Select(gear => gear.Name));
var records = cache.AllStoredDocuments.ToDictionary(record => record.Key.Value, record => record.Document);
Assert.Equal("Skiff", Assert.IsType<HullData>(records[ShipModCatalog.HullKey("mod.skiff").Value]).Name);
Assert.Equal("mod.skiff", Assert.IsType<ShipAuthoring>(records[ShipModCatalog.AuthoringKey("mod.skiff").Value]).Id);
AssertOnlyTheDerivedFile();
}
[Fact]
public void ComposeCountsEveryPackageAndReplacesAnExistingDerivedCatalog()
{
WritePackage("mod.skiff");
Assert.Equal(1, ShipModCatalog.Compose(Shipped, Derived, Mods));
WritePackage("mod.barge", hullName: "Barge");
Assert.Equal(2, ShipModCatalog.Compose(Shipped, Derived, Mods));
using var cache = AetheriaStores.Open(Derived);
Assert.Equal(new[] { "Barge", "Skiff", "Wasp" }, cache.GetAll<HullData>().Select(hull => hull.Name).OrderBy(name => name));
AssertOnlyTheDerivedFile();
}
[Fact]
public void AFailedComposeLeavesThePreviousDerivedFileAndNoTemporaryFile()
{
WritePackage("mod.skiff");
ShipModCatalog.Compose(Shipped, Derived, Mods);
var before = File.ReadAllBytes(Derived);
WritePackage("mod.barge", hullName: "Wasp");
Assert.Contains("collides with an existing catalog item", Assert.Throws<InvalidOperationException>(() =>
ShipModCatalog.Compose(Shipped, Derived, Mods)).Message);
Assert.Equal(before, File.ReadAllBytes(Derived));
AssertOnlyTheDerivedFile();
Directory.Delete(Path.Combine(Mods, "mod.barge"), true);
WritePackage("mod.barge", hullName: "Barge", nodes: FixtureNodes.Where(node => node != "shield").ToArray());
Assert.Contains("model has no node with aetheria.id=shield", Assert.Throws<InvalidOperationException>(() =>
ShipModCatalog.Compose(Shipped, Derived, Mods)).Message);
Assert.Equal(before, File.ReadAllBytes(Derived));
}
[Theory]
[InlineData("mod-hull:mod.skiff", false)]
[InlineData("mod-ship:mod.skiff", true)]
public void ComposeRefusesAModKeyThatAlreadyExistsInTheCatalog(string key, bool authoring)
{
WritePackage("mod.skiff");
using (var cache = AetheriaStores.Open(Shipped, catalogWritable: true))
{
if (authoring)
{
var stranger = ShipAuthoringTests.Fixture();
stranger.Id = "stranger";
cache.UpsertAsync(typeof(ShipAuthoring), stranger, new CultRecordKey(key)).GetAwaiter().GetResult();
}
else
cache.UpsertAsync(typeof(HullData), new HullData { Name = "Stranger", Shape = ShipAuthoringTests.Fixture().Hull.Shape },
new CultRecordKey(key)).GetAwaiter().GetResult();
cache.FlushAsync().Wait();
}
Assert.Contains("a mod key collides", Assert.Throws<InvalidOperationException>(() =>
ShipModCatalog.Compose(Shipped, Derived, Mods)).Message);
Assert.False(File.Exists(Derived));
}
[Theory]
[InlineData(typeof(HullData))]
[InlineData(typeof(GearData))]
public void ComposeRefusesAHullNamedLikeAShippedItem(Type shippedType)
{
using (var cache = AetheriaStores.Open(Shipped, catalogWritable: true))
{
if (shippedType == typeof(HullData))
cache.Upsert(new HullData { Name = "Skiff", Shape = ShipAuthoringTests.Fixture().Hull.Shape });
else
cache.Upsert(new GearData { Name = "Skiff" });
cache.FlushAsync().Wait();
}
WritePackage("mod.skiff");
Assert.Contains("hull name 'Skiff' collides with an existing catalog item", Assert.Throws<InvalidOperationException>(() =>
ShipModCatalog.Compose(Shipped, Derived, Mods)).Message);
Assert.False(File.Exists(Derived));
}
[Fact]
public void ComposeRefusesTwoModsThatShareAHullName()
{
WritePackage("mod.a", hullName: "Skiff");
WritePackage("mod.b", hullName: "Skiff");
Assert.Contains("mod.b: hull name 'Skiff' collides", Assert.Throws<InvalidOperationException>(() =>
ShipModCatalog.Compose(Shipped, Derived, Mods)).Message);
Assert.False(File.Exists(Derived));
}
[Fact]
public void ComposeRefusesAShipIdThatAShippedAuthoringRecordAlreadyUses()
{
using (var cache = AetheriaStores.Open(Shipped, catalogWritable: true))
{
cache.UpsertAsync(typeof(ShipAuthoring), ShipAuthoringTests.Fixture(), new CultRecordKey("shipped:skiff")).GetAwaiter().GetResult();
cache.FlushAsync().Wait();
}
WritePackage("mod.skiff", hullName: "Different");
Assert.Contains("ship ID collides with an existing catalog ship authoring record", Assert.Throws<InvalidOperationException>(() =>
ShipModCatalog.Compose(Shipped, Derived, Mods)).Message);
Assert.False(File.Exists(Derived));
}
[Fact]
public void ComposeRefusesUnsafeInputsAndOutputs()
{
WritePackage("mod.skiff");
Assert.Contains("Shipped catalog is missing", Assert.Throws<FileNotFoundException>(() =>
ShipModCatalog.Compose(Path.Combine(_directory.Path, "absent.cc"), Derived, Mods)).Message);
Assert.Contains("Mod root is missing", Assert.Throws<DirectoryNotFoundException>(() =>
ShipModCatalog.Compose(Shipped, Derived, Path.Combine(_directory.Path, "NoMods"))).Message);
var before = Hash(Shipped);
Assert.Contains("cannot replace the shipped catalog", Assert.Throws<InvalidOperationException>(() =>
ShipModCatalog.Compose(Shipped, Shipped, Mods)).Message);
Assert.Contains("cannot replace the shipped catalog", Assert.Throws<InvalidOperationException>(() =>
ShipModCatalog.Compose(Shipped, Shipped.ToUpperInvariant().Replace(_directory.Path.ToUpperInvariant(), _directory.Path), Mods)).Message);
Assert.Contains("cannot replace a mod source or asset", Assert.Throws<InvalidOperationException>(() =>
ShipModCatalog.Compose(Shipped, Path.Combine(Mods, "mod.skiff", "out.cc"), Mods)).Message);
Assert.Equal(before, Hash(Shipped));
Assert.False(File.Exists(Path.Combine(Mods, "mod.skiff", "out.cc")));
}
[Fact]
public void ReadPackageBindsTheShipToItsDirectoryAndGlb()
{
WritePackage("mod.skiff");
var package = ShipModCatalog.ReadPackage(Path.Combine(Mods, "mod.skiff", "ship.cc"));
Assert.Equal("mod.skiff", package.Ship.Id);
Assert.Equal(Path.GetFullPath(Path.Combine(Mods, "mod.skiff", "skiff.glb")), package.ModelPath);
Assert.Equal(new Dictionary<string, uint> { ["map"] = 0, ["collider"] = 1, ["shield"] = 2, ["tractor"] = 3, ["thruster-port"] = 4 }, package.NodeIndices);
}
[Fact]
public void ReadPackageRefusesADirectoryNameThatIsNotTheShipId()
{
WritePackage("mod.skiff");
Directory.Move(Path.Combine(Mods, "mod.skiff"), Path.Combine(Mods, "renamed"));
Assert.Contains("ship ID must match its package directory name", Assert.Throws<InvalidOperationException>(() =>
ShipModCatalog.ReadPackage(Path.Combine(Mods, "renamed", "ship.cc"))).Message);
}
[Fact]
public void ReadPackageRefusesAMissingOrWronglyNamedModel()
{
WritePackage("mod.skiff");
File.Delete(Path.Combine(Mods, "mod.skiff", "skiff.glb"));
Assert.Contains("must name an existing GLB inside its package", Assert.Throws<InvalidOperationException>(() =>
ShipModCatalog.ReadPackage(Path.Combine(Mods, "mod.skiff", "ship.cc"))).Message);
Directory.Delete(Path.Combine(Mods, "mod.skiff"), true);
WritePackage("mod.skiff", modelAsset: "skiff.bin");
Assert.Contains("must name an existing GLB inside its package", Assert.Throws<InvalidOperationException>(() =>
ShipModCatalog.ReadPackage(Path.Combine(Mods, "mod.skiff", "ship.cc"))).Message);
}
[Fact]
public void ReadPackageAppliesTheCatalogsItemValidationToTheHull()
{
WritePackage("mod.skiff", tweak: ship => ship.Hull.MinimumTemperature = ship.Hull.MaximumTemperature);
Assert.Contains("zero-span range", Assert.Throws<InvalidOperationException>(() =>
ShipModCatalog.ReadPackage(Path.Combine(Mods, "mod.skiff", "ship.cc"))).Message);
}
[Fact]
public void ReadPackageRefusesAnAnchorWhoseNodeIsNotInTheModel()
{
WritePackage("mod.skiff", nodes: FixtureNodes.Where(node => node != "thruster-port").ToArray());
Assert.Contains("model has no node with aetheria.id=thruster-port for anchor thruster.port", Assert.Throws<InvalidOperationException>(() =>
ShipModCatalog.ReadPackage(Path.Combine(Mods, "mod.skiff", "ship.cc"))).Message);
}
[Fact]
public void NodeIdsMapToTheirGlbNodeIndexSkippingNodesWithoutOne()
{
var path = Write("nodes.glb", Glb(@"{""asset"":{""version"":""2.0""},""nodes"":[
{""name"":""plain""},{""extras"":{""aetheria.id"":""a""}},{""extras"":{}},{""extras"":{""aetheria.id"":""""}},{""extras"":{""aetheria.id"":""b""}}]}"));
Assert.Equal(new Dictionary<string, uint> { ["a"] = 1, ["b"] = 4 }, ShipModCatalog.ReadNodeIds(path));
}
[Fact]
public void AGlbWithoutNodesHasNoIds() =>
Assert.Empty(ShipModCatalog.ReadNodeIds(Write("empty.glb", Glb(@"{""asset"":{""version"":""2.0""}}"))));
[Fact]
public void ADuplicateNodeIdIsRefused() =>
Assert.Contains("duplicate GLB node aetheria.id=a", Assert.Throws<InvalidOperationException>(() =>
ShipModCatalog.ReadNodeIds(Write("dup.glb", Glb(@"{""nodes"":[{""extras"":{""aetheria.id"":""a""}},{""extras"":{""aetheria.id"":""a""}}]}")))).Message);
[Fact]
public void AMalformedGlbHeaderIsRefused()
{
var good = Glb(@"{""nodes"":[]}");
void Refuse(string name, byte[] bytes, string message) =>
Assert.Contains(message, Assert.Throws<InvalidOperationException>(() => ShipModCatalog.ReadNodeIds(Write(name, bytes))).Message);
Refuse("short.glb", With(good.Take(19).ToArray(), 8, 19), "invalid GLB 2 header");
Refuse("magic.glb", With(good, 0, 0x46546C68), "invalid GLB 2 header");
Refuse("version.glb", With(good, 4, 1), "invalid GLB 2 header");
Refuse("longer.glb", With(good, 8, (uint)good.Length + 1), "invalid GLB 2 header");
Refuse("shorter.glb", With(good, 8, (uint)good.Length - 4), "invalid GLB 2 header");
Refuse("chunktype.glb", With(good, 16, 0x004E4942), "missing GLB JSON chunk");
Refuse("chunklength.glb", With(good, 12, (uint)good.Length), "missing GLB JSON chunk");
}
[Fact]
public void AGlbWhoseJsonChunkFillsTheFileIsAccepted()
{
var json = Encoding.UTF8.GetBytes(@"{""nodes"":[{""extras"":{""aetheria.id"":""a""}}]} ");
var bytes = new byte[20 + json.Length];
BitConverter.GetBytes(0x46546C67u).CopyTo(bytes, 0);
BitConverter.GetBytes(2u).CopyTo(bytes, 4);
BitConverter.GetBytes((uint)bytes.Length).CopyTo(bytes, 8);
BitConverter.GetBytes((uint)json.Length).CopyTo(bytes, 12);
BitConverter.GetBytes(0x4E4F534Au).CopyTo(bytes, 16);
json.CopyTo(bytes, 20);
Assert.Equal(new Dictionary<string, uint> { ["a"] = 0 }, ShipModCatalog.ReadNodeIds(Write("full.glb", bytes)));
}
private void AssertOnlyTheDerivedFile()
{
var names = Directory.GetFileSystemEntries(Path.GetDirectoryName(Derived)).Select(entry => Path.GetFileName(entry)).ToArray();
Assert.True(names.Length == 1 && names[0] == "Aetheria.modded.cc", string.Join(" | ", names));
}
private static byte[] With(byte[] bytes, int offset, uint value)
{
var copy = (byte[])bytes.Clone();
BitConverter.GetBytes(value).CopyTo(copy, offset);
return copy;
}
private static byte[] Glb(string json)
{
var body = Encoding.UTF8.GetBytes(json);
var padded = new byte[(body.Length + 3) / 4 * 4];
Array.Fill(padded, (byte)' ');
body.CopyTo(padded, 0);
var bytes = new byte[20 + padded.Length + 8];
BitConverter.GetBytes(0x46546C67u).CopyTo(bytes, 0);
BitConverter.GetBytes(2u).CopyTo(bytes, 4);
BitConverter.GetBytes((uint)bytes.Length).CopyTo(bytes, 8);
BitConverter.GetBytes((uint)padded.Length).CopyTo(bytes, 12);
BitConverter.GetBytes(0x4E4F534Au).CopyTo(bytes, 16);
padded.CopyTo(bytes, 20);
BitConverter.GetBytes(0u).CopyTo(bytes, 20 + padded.Length);
BitConverter.GetBytes(0x004E4942u).CopyTo(bytes, 24 + padded.Length);
return bytes;
}
private string Write(string name, byte[] bytes)
{
var path = Path.Combine(_directory.Path, name);
File.WriteAllBytes(path, bytes);
return path;
}
private void WritePackage(string id, string hullName = "Skiff", string[] nodes = null, string modelAsset = "skiff.glb",
Action<ShipAuthoring> tweak = null)
{
var ship = ShipAuthoringTests.Fixture();
tweak?.Invoke(ship);
ship.Id = id;
ship.Hull.Name = hullName;
ship.ModelAsset = modelAsset;
var directory = Path.Combine(Mods, id);
Directory.CreateDirectory(directory);
using (var cache = ShipAuthoringStore.Open(Path.Combine(directory, "ship.cc"), writable: true))
{
cache.Upsert(ship);
cache.FlushAsync().Wait();
}
var json = @"{""asset"":{""version"":""2.0""},""nodes"":[" +
string.Join(",", (nodes ?? FixtureNodes).Select(node => $@"{{""extras"":{{""aetheria.id"":""{node}""}}}}")) + "]}";
File.WriteAllBytes(Path.Combine(directory, modelAsset), Glb(json));
}
private static string Hash(string path) => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path)));
}