Opening the process cache's catalog writable in the editor was the wrong cut. It made catalog read-only only by convention, so a stray catalog write in play succeeded and a later flush could land play-mutated instances in Aetheria.cc. - The process cache opens the catalog read-only in every build again, so the missing-global check runs for it as it did beforedb83cd29. The writable empty-seed exemption fromab4ced0fstays for AetherDb and fixtures. - Loadouts.Commit(catalogPath, loadout, replace) is the only preset writer. It opens its own short-lived cache over the catalog, writable, commits the one preset conditionally on its key and disposes it. No other cache or instance is touched. The running game sees the preset only after the catalog reloads. CultLib at a0813c6 allows this: a read-only single-file store takes no lock and holds no handle, and the writer's commit takes the exclusive sidecar <path>.lock and replaces the file atomically. - Capture refuses, writing nothing, when Aetheria.cc is missing (it never creates the catalog), when a preset of that name exists under another key (CultCache does not reject duplicate names on write), or when the key holds a preset and replace is not given. - The console refuses a command with any character outside letters, digits, spaces and hyphens instead of stripping it, so "Mk.II" can no longer become "MkII". capturepreset refuses a second argument other than replace, so an unquoted capturepreset My Ship refuses instead of saving "My". - The capture log line says the session sees the preset after a reload, and that a Studio session opened earlier overwrites captured presets on save. - Tests: capture while the process cache holds the catalog read-only leaves it unchanged; a missing catalog refuses and creates nothing; a same name under a Studio-minted key refuses, with or without replace; a game-shaped read-only open missing a global throws. The existing preset tests now commit through the catalog path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
160 lines
6.6 KiB
C#
160 lines
6.6 KiB
C#
using System;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Security.Cryptography;
|
|
using CultMath;
|
|
using GameCult.Caching;
|
|
using GameCult.Caching.MessagePack;
|
|
using MessagePack;
|
|
using Xunit;
|
|
|
|
// A [CultGlobal] routed to the catalog (PersonalityAttribute is a catalog home). The game has none yet, so this is
|
|
// the only way to prove a read-only catalog refuses to open without one.
|
|
[CultDocument("aetheria.tests.catalogglobal", "1"), CultGlobal, MessagePackObject]
|
|
public class TestCatalogGlobal : PersonalityAttribute { }
|
|
|
|
// Each test gets its own temp directory holding a catalog built through Open(catalogWritable: true).
|
|
public sealed class AetheriaStoresTests : IDisposable
|
|
{
|
|
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-stores-" + Guid.NewGuid().ToString("N"));
|
|
private string Catalog => Path.Combine(_root, "Aetheria.cc");
|
|
private string Run => Path.Combine(_root, "run.cc");
|
|
private string Player => Path.Combine(_root, "player.cc");
|
|
|
|
public AetheriaStoresTests()
|
|
{
|
|
Directory.CreateDirectory(_root);
|
|
using var cache = AetheriaStores.Open(Catalog, catalogWritable: true);
|
|
cache.Upsert(new TestCatalogGlobal { Name = "Temperament" });
|
|
var faction = new Faction { Name = "Adrasteia", ShortName = "ADR", PrimaryColor = new float3(1, .5f, .25f) };
|
|
faction.Allegiance[cache.Upsert(faction)] = 1;
|
|
cache.Upsert(faction);
|
|
cache.Upsert(new WeaponItemData { Name = "Lance", Manufacturer = cache.RefOf(faction) });
|
|
cache.Upsert(new InputLayout { Rows = new InputLayoutRow[] { new InputLayoutRowSpacer { Height = 1 } } });
|
|
cache.FlushAsync().Wait();
|
|
}
|
|
|
|
public void Dispose() => Directory.Delete(_root, true);
|
|
|
|
[Fact]
|
|
public void OpenThenFlushLeavesEveryFileByteIdentical()
|
|
{
|
|
using (var cache = AetheriaStores.Open(Catalog, Run, Player))
|
|
{
|
|
var zone = cache.Upsert(new SavedZone { Name = "Entrance", Position = new float2(1, 2), Contents = new ZonePack() });
|
|
cache.Upsert(new SavedGame { Zones = new[] { zone }, Factions = new[] { cache.RefOf(cache.GetAll<Faction>().Single()) } });
|
|
cache.Upsert(new PlayerSettings());
|
|
cache.FlushAsync().Wait();
|
|
}
|
|
|
|
var before = new[] { Catalog, Run, Player }.Select(Hash).ToArray();
|
|
using (var cache = AetheriaStores.Open(Catalog, Run, Player))
|
|
{
|
|
Assert.NotNull(cache.GetGlobal<SavedGame>());
|
|
Assert.NotNull(cache.GetGlobal<PlayerSettings>());
|
|
cache.FlushAsync().Wait();
|
|
}
|
|
|
|
Assert.Equal(before, new[] { Catalog, Run, Player }.Select(Hash).ToArray());
|
|
}
|
|
|
|
[Fact]
|
|
public void WeaponWrittenThroughGearHandleReloadsAsWeapon()
|
|
{
|
|
CultRecordHandle<GearData> handle;
|
|
using (var cache = AetheriaStores.Open(Catalog, catalogWritable: true))
|
|
{
|
|
handle = cache.UpsertAsync<GearData>(new WeaponItemData { Name = "Pike" }).Result;
|
|
cache.FlushAsync().Wait();
|
|
}
|
|
|
|
using (var cache = AetheriaStores.Open(Catalog))
|
|
Assert.Equal("Pike", Assert.IsType<WeaponItemData>(cache.Get(handle.Key)).Name);
|
|
}
|
|
|
|
[Fact]
|
|
public void CatalogRefusesWrites()
|
|
{
|
|
var before = Hash(Catalog);
|
|
using (var cache = AetheriaStores.Open(Catalog))
|
|
{
|
|
Assert.Throws<InvalidOperationException>(() => cache.Upsert(new Faction { Name = "Usurper" }));
|
|
cache.FlushAsync().Wait();
|
|
}
|
|
|
|
Assert.Equal(before, Hash(Catalog));
|
|
}
|
|
|
|
[Fact]
|
|
public void SavedZoneNeverLandsInCatalog()
|
|
{
|
|
using (var cache = AetheriaStores.Open(Catalog, catalogWritable: true))
|
|
Assert.Throws<InvalidOperationException>(() => cache.Upsert(new SavedZone { Name = "Stray" }));
|
|
|
|
using (var cache = AetheriaStores.Open(Catalog, Run, catalogWritable: true))
|
|
{
|
|
cache.Upsert(new SavedZone { Name = "Home" });
|
|
cache.FlushAsync().Wait();
|
|
}
|
|
|
|
Assert.DoesNotContain(SchemaNames(Catalog), name => name == "aetheria.savedzone");
|
|
Assert.Contains("aetheria.savedzone", SchemaNames(Run));
|
|
}
|
|
|
|
[Fact]
|
|
public void FactionIsASingletonInstance()
|
|
{
|
|
using var cache = AetheriaStores.Open(Catalog);
|
|
var faction = cache.GetAll<Faction>().Single();
|
|
Assert.Same(faction, cache.Get(cache.GetAll<WeaponItemData>().Single().Manufacturer));
|
|
Assert.Same(faction, cache.Get(faction.Allegiance.Keys.Single()));
|
|
Assert.Same(faction, cache.GetByName<Faction>("Adrasteia"));
|
|
}
|
|
|
|
[Fact]
|
|
public void MissingCatalogGlobalIsLoud()
|
|
{
|
|
var empty = Path.Combine(_root, "empty.cc");
|
|
using (AetheriaStores.Open(empty, catalogWritable: true)) { }
|
|
var error = Assert.Throws<InvalidOperationException>(() => AetheriaStores.Open(empty));
|
|
Assert.Contains("aetheria.tests.catalogglobal", error.Message);
|
|
using (AetheriaStores.Open(Catalog)) { }
|
|
}
|
|
|
|
// Writability exempts only an empty seed catalog: a populated catalog missing a global is loud even when writable.
|
|
[Fact]
|
|
public void WritablePopulatedCatalogMissingGlobalIsLoud()
|
|
{
|
|
using (var cache = AetheriaStores.Open(Catalog, catalogWritable: true))
|
|
{
|
|
var global = cache.GetGlobal<TestCatalogGlobal>();
|
|
var key = cache.RefOf(global).Key;
|
|
Assert.True(cache.Commit(batch => batch.Remove(key)));
|
|
}
|
|
|
|
var error = Assert.Throws<InvalidOperationException>(() => AetheriaStores.Open(Catalog, catalogWritable: true));
|
|
Assert.Contains("aetheria.tests.catalogglobal", error.Message);
|
|
}
|
|
|
|
// The game opens the catalog read-only with its run and player stores, in the editor as in a build: a populated
|
|
// catalog missing a global refuses to open.
|
|
[Fact]
|
|
public void GameShapedOpenMissingGlobalIsLoud()
|
|
{
|
|
using (var cache = AetheriaStores.Open(Catalog, catalogWritable: true))
|
|
Assert.True(cache.Commit(batch => batch.Remove(cache.RefOf(cache.GetGlobal<TestCatalogGlobal>()).Key)));
|
|
|
|
var error = Assert.Throws<InvalidOperationException>(() => AetheriaStores.Open(Catalog, Run, Player));
|
|
Assert.Contains("aetheria.tests.catalogglobal", error.Message);
|
|
}
|
|
|
|
private static string Hash(string path) => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path)));
|
|
|
|
private static string[] SchemaNames(string path)
|
|
{
|
|
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path));
|
|
return snapshot.Records
|
|
.Select(record => snapshot.SchemaCatalog.Single(entry => entry.SchemaId == record.SchemaId).SchemaName)
|
|
.ToArray();
|
|
}
|
|
}
|