Aetheria/tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs
Meta Vens 3f5eb6b5c3 Merge origin/master into ghostlight-verse/verse-grammar
Takes master's catalog pointer (GameData/Aetheria.cc) and its TestCatalog.Registry fixture path;
VerseGrammar is the global scratch catalogs author (TestCatalogGlobal stays deleted: the shipped
registry now has a real global, so a test-only second one would have to be authored beside it).
StatResolverTests, TestCatalogTests and AetheriaStoresTests author VerseGrammar.

Does not pass the suite: the catalog is master's and holds no grammar until the verse-grammar apply commit follows.

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

228 lines
11 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;
// 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 VerseGrammar { Revision = 1 });
var faction = new Faction { Name = "Adrasteia", ShortName = "ADR", PrimaryColor = new float3(1, .5f, .25f) };
faction.Allegiance[cache.Upsert(faction)] = 1;
cache.Upsert(faction);
var lance = cache.Upsert(new WeaponItemData { Name = "Lance" });
cache.Upsert(new FactionProductData { Name = "Lance", Design = new CultRecordRef<CraftedItemData>(lance.Key), 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<FactionProductData>().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");
// A catalog file that exists but lacks the global: the read-only open reaches the global check.
File.Copy(Catalog, empty);
using (var seed = AetheriaStores.Open(empty, catalogWritable: true))
Assert.True(seed.Commit(batch => batch.Remove(seed.RefOf(seed.GetGlobal<VerseGrammar>()).Key)));
var error = Assert.Throws<InvalidOperationException>(() => AetheriaStores.Open(empty));
Assert.Contains("aetheria.verse_grammar", 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<VerseGrammar>();
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.verse_grammar", 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<VerseGrammar>()).Key)));
var error = Assert.Throws<InvalidOperationException>(() => AetheriaStores.Open(Catalog, Run, Player));
Assert.Contains("aetheria.verse_grammar", error.Message);
}
// --- F7 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): "there are at least four [write bypasses]."
// --- tools/AetherDb/Program.cs's Dangling, ShieldMigrate and BrownoutMigrate commands repair an existing
// --- record in place through `cache.Commit(batch => batch.Upsert(document.GetType(), document, key))` --
// --- CultCache's own batch API, keyed explicitly to preserve identity -- because CultRecordRefs.Upsert's own
// --- cache.UpsertAsync(document) cannot take a caller-supplied key. That is real, not an excuse to skip
// --- validation: all three now call CultRecordRefs.Validate(document) directly, immediately before their own
// --- commit. This pins that the extracted method actually refuses what Upsert would have, proving the tools'
// --- direct call is wired to the same rule rather than a hollow copy. ---
[Fact]
public void ValidateRefusesAnEquippableItemWithAZeroSpanHeatRangeTheSameWayUpsertWould()
{
var badDesign = new GearData
{
Name = "BadDangling", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10,
MinimumTemperature = 100, MaximumTemperature = 100, OptimalTemperature = 100, PlateauWidth = 0
};
var error = Assert.Throws<InvalidOperationException>(() => CultRecordRefs.Validate(badDesign));
Assert.Contains("BadDangling", error.Message);
}
// Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19), superseding F6: this
// direct-call path used to also catch F6's static rule (a power request stat may not carry a PowerSupply
// term at all -- StatValidation.ValidateNoPowerSupplyOnRequest, since deleted). PowerRequest now reads a
// request field through EquippedItem.EvaluateNominalPower, which pins that stat's own PowerSupplyFactor to 1
// regardless of its Terms, so the term can no longer make the request depend on its own answer -- a repair
// tool landing a curved ThrusterData.EnergyUsage is no longer any more dangerous than R-heat's own check,
// which this still runs (see the sibling test above).
[Fact]
public void ValidateAcceptsAPowerRequestCarryingAPowerSupplyTermTheSameWayUpsertWould()
{
var curvedEnergy = new PerformanceStat { Min = 1, Max = 1, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 1 } } };
var goodDesign = new GearData
{
Name = "CurvedMigratedThruster", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10,
MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400,
Behaviors = { new ThrusterData { EnergyUsage = curvedEnergy } }
};
var record = Record.Exception(() => CultRecordRefs.Validate(goodDesign));
Assert.Null(record);
}
// A document type Validate has no opinion about (neither EquippableItemData nor ConsumableItemData) must be
// a pure no-op, not a crash -- the three tools' change lists carry `object`, and not every migration touches
// an equippable design (e.g. a future repair over FactionProductData).
[Fact]
public void ValidateIsANoOpForADocumentTypeItDoesNotGovern()
{
var record = Record.Exception(() => CultRecordRefs.Validate(new Faction { Name = "Unrelated" }));
Assert.Null(record);
}
// A shipped player with no catalog must not boot into an empty game: a read-only open of a missing path throws
// naming the path, and does not create the file. A writable open still seeds an empty catalog.
[Fact]
public void AStoreRefusesAMissingCatalog()
{
var missing = Path.Combine(_root, "absent", "Aetheria.cc");
// FileNotFoundException, not the catalog-global check: that one also throws on a missing file whenever a global is registered.
var error = Assert.Throws<FileNotFoundException>(() => AetheriaStores.Open(missing));
Assert.Contains(missing, error.Message);
Assert.False(File.Exists(missing));
Assert.False(Directory.Exists(Path.GetDirectoryName(missing)));
using (var seeded = AetheriaStores.Open(missing, catalogWritable: true))
Assert.Empty(seeded.GetAll<Faction>());
}
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();
}
}