Tests: CB-R1 enforced whole, backfill replay on a scratch catalog, envelope and operability read the catalog

ShippedCatalogRolesAreLive checks every design of every kind: each non-flat stat reads one declared role,
declared roles are read, sellers author each once, role-mapped designs are not all flat (the tool answers
which, via Program.HasRoleMap). RolesBackfillIsAuthored pins ratios and roles, with the Autocannon
Penetration, Mine Range and LRMM72 LockAngle asserts. RolesBackfillCommandTests resets the backfilled designs
on a scratch catalog, applies, and expects the shipped catalog back, then replays as a no-op.
EveryShippedProductHasAMaker. TheEnvelopeComesFromTheGear bounds each drive by the catalog's Large Drive
Thrust range; ShippedWeaponOperabilityTests reads the catalog's DefaultEntitySettings.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-08 23:46:28 +02:00
commit 2e1c2c439b
4 changed files with 219 additions and 42 deletions

View file

@ -4,44 +4,47 @@ using System.Linq;
using Xunit;
// Aetheria release, cut roles-backfill, rule CB-R1: a crafted design declares roles iff a stat varies with quality,
// every declared role is read by a non-flat stat, and every seller authors one ProductRole per role. Both tests read
// every declared role is read by a non-flat stat, and every seller authors one ProductRole per role. These tests read
// the shipped catalog through the real reader.
public sealed class CatalogRoleTests
{
// Every weapon and every gear of a kind that carries per-part quality stats (the kinds roles-migrate keeps a role
// map for). Cargo bays, docking bays, control modules, tools and hulls carry no quality-reading stat.
private static readonly HardpointType[] QualityGearKinds =
{ HardpointType.Radiator, HardpointType.Reactor, HardpointType.Sensors, HardpointType.Thruster };
private static IEnumerable<PerformanceStat> StatsOf(EquippableItemData design) =>
internal static IEnumerable<(string Name, PerformanceStat Stat)> StatsOf(EquippableItemData design) =>
design.Behaviors.Where(b => b != null)
.SelectMany(b => b.GetType().GetFields().Where(f => f.FieldType == typeof(PerformanceStat)).Select(f => (PerformanceStat)f.GetValue(b)))
.Where(s => s?.Terms != null);
.SelectMany(b => b.GetType().GetFields().Where(f => f.FieldType == typeof(PerformanceStat))
.Select(f => ($"{b.GetType().Name}.{f.Name}", (PerformanceStat)f.GetValue(b))))
.Where(s => s.Item2?.Terms != null);
private static PerformanceStat Stat(EquippableItemData design, string field) =>
Assert.Single(design.Behaviors.SelectMany(b => b.GetType().GetFields().Where(f => f.Name == field && f.FieldType == typeof(PerformanceStat)).Select(f => (PerformanceStat)f.GetValue(b))));
private static string[] NamedQualityRoles(PerformanceStat stat) =>
internal static string[] NamedQualityRoles(PerformanceStat stat) =>
stat.Terms.Where(t => t.Source == StatSource.Quality && !string.IsNullOrEmpty(t.Role)).Select(t => t.Role).ToArray();
// The whole of CB-R1 over every design in the catalog: each non-flat stat reads exactly one declared role, every
// declared role is read, and every seller authors each role once. Weapons, and designs of a kind the tool keeps a role
// map for, must also carry a non-flat stat (a design with no stat at all has nothing for quality to vary).
[Fact]
public void ShippedCatalogRolesAreLive()
{
var cache = RestoredHullsTests.OpenCatalog();
using var cache = RestoredHullsTests.OpenCatalog();
var products = cache.GetAll<FactionProductData>().ToArray();
var broken = new List<string>();
foreach (var design in cache.GetAll<EquippableItemData>()
.Where(i => i is WeaponItemData || (i is GearData && QualityGearKinds.Contains(i.HardpointType)))
.OrderBy(i => i.Name, StringComparer.Ordinal))
foreach (var design in cache.GetAll<EquippableItemData>().OrderBy(i => i.Name, StringComparer.Ordinal))
{
var stats = StatsOf(design).ToArray();
if (stats.Length == 0) continue; // Tractor Beam: a thruster kind with no stat at all, so nothing for quality to vary
var varying = stats.Where(s => s.Min != s.Max).ToArray();
var read = varying.SelectMany(NamedQualityRoles).Distinct().OrderBy(r => r, StringComparer.Ordinal).ToArray();
var varying = stats.Where(s => s.Stat.Min != s.Stat.Max).ToArray();
var declared = (design.Roles ?? new List<ItemRole>()).Select(r => r.Name).OrderBy(r => r, StringComparer.Ordinal).ToArray();
if (varying.Length == 0) broken.Add($"{design.Name}: every stat is flat");
foreach (var (name, stat) in varying)
{
var named = NamedQualityRoles(stat);
if (named.Length != 1 || !declared.Contains(named[0]))
broken.Add($"{design.Name}: non-flat {name} reads [{string.Join(", ", named)}], not one of its declared roles [{string.Join(", ", declared)}]");
}
var read = varying.SelectMany(s => NamedQualityRoles(s.Stat)).Distinct().OrderBy(r => r, StringComparer.Ordinal).ToArray();
if (!read.SequenceEqual(declared))
broken.Add($"{design.Name}: declares [{string.Join(", ", declared)}] but non-flat stats read [{string.Join(", ", read)}]");
if (varying.Length == 0 && stats.Length > 0 && (design is WeaponItemData || Program.HasRoleMap(design)))
broken.Add($"{design.Name}: every stat is flat");
foreach (var product in products.Where(p => p.Design.Key.Equals(cache.RefOf(design).Key)))
{
var authored = (product.Roles ?? new List<ProductRole>()).Select(r => r.Role).OrderBy(r => r, StringComparer.Ordinal).ToArray();
@ -49,45 +52,62 @@ public sealed class CatalogRoleTests
broken.Add($"{design.Name}: product \"{product.Name}\" authors [{string.Join(", ", authored)}], not each of [{string.Join(", ", declared)}] once");
}
}
Assert.True(broken.Count == 0, string.Join("\n", broken));
Assert.True(broken.Count == 0, string.Join("
", broken));
}
// value, then the stat's expected Min and Max: a benefit rises with quality (0.8x to 1.2x), a cost falls (1.2x to 0.8x).
private static void AssertRange(EquippableItemData design, string field, float value, bool cost, string role)
// A product with no maker would title as its design and take no maker profile for its roles.
[Fact]
public void EveryShippedProductHasAMaker()
{
using var cache = RestoredHullsTests.OpenCatalog();
var makerless = cache.GetAll<FactionProductData>().Where(p => cache.Get(p.Manufacturer) == null).Select(p => p.Name).OrderBy(n => n, StringComparer.Ordinal).ToArray();
Assert.True(makerless.Length == 0, "products with no maker: " + string.Join(", ", makerless));
}
// A benefit rises with quality across 0.8x to 1.2x of its authored value (Max/Min 1.5), a cost falls across 1.2x to 0.8x
// (Max/Min 2/3). The ratio and the role are pinned, not the value, so an operator retune of a design stays green.
private static void AssertRange(EquippableItemData design, string field, bool cost, string role)
{
var stat = Stat(design, field);
Assert.Equal(value * (cost ? 1.2f : .8f), stat.Min, 3);
Assert.Equal(value * (cost ? .8f : 1.2f), stat.Max, 3);
Assert.Contains(role, NamedQualityRoles(stat));
Assert.Equal(cost ? 2f / 3f : 1.5f, stat.Max / stat.Min, 3);
Assert.Equal(new[] { role }, NamedQualityRoles(stat));
}
// Stats that already varied before the backfill keep their range; only their unnamed Quality term gains the role.
private static void AssertNamed(EquippableItemData design, string field, string role) =>
Assert.Equal(new[] { role }, NamedQualityRoles(Stat(design, field)));
[Fact]
public void RolesBackfillIsAuthored()
{
var cache = RestoredHullsTests.OpenCatalog();
using var cache = RestoredHullsTests.OpenCatalog();
EquippableItemData Design(string name) => Assert.Single(cache.GetAll<EquippableItemData>(), i => i.Name == name);
string[] Roles(EquippableItemData d) => d.Roles.Select(r => r.Name).OrderBy(r => r, StringComparer.Ordinal).ToArray();
var autocannon = Design("Autocannon");
Assert.Equal(new[] { "barrel", "feed mechanism" }, Roles(autocannon));
AssertRange(autocannon, "Damage", 3f, false, "barrel");
AssertRange(autocannon, "Range", 1000f, false, "barrel");
AssertRange(autocannon, "Damage", false, "barrel");
AssertRange(autocannon, "Range", false, "barrel");
AssertRange(autocannon, "Penetration", false, "barrel");
var lrm = Design("LRMM72");
Assert.Equal(new[] { "guidance system", "thruster", "warhead" }, Roles(lrm));
AssertRange(lrm, "Damage", 256f, false, "warhead");
AssertRange(lrm, "Cooldown", 10f, true, "guidance system");
AssertRange(lrm, "Damage", false, "warhead");
AssertRange(lrm, "Cooldown", true, "guidance system");
AssertRange(lrm, "LockAngle", false, "guidance system");
var srm = Design("SRMM72");
Assert.Equal(new[] { "guidance system", "thruster", "warhead" }, Roles(srm));
AssertRange(srm, "Damage", 128f, false, "warhead");
AssertRange(srm, "Cooldown", 5f, true, "guidance system");
AssertRange(srm, "Damage", false, "warhead");
AssertRange(srm, "Cooldown", true, "guidance system");
var mine = Design("Mine Launcher");
Assert.Equal(new[] { "dispenser", "warhead" }, Roles(mine));
AssertRange(mine, "Damage", 50f, false, "warhead");
AssertRange(mine, "Cooldown", 2f, true, "dispenser");
AssertRange(mine, "Spread", 5f, true, "dispenser");
AssertRange(mine, "Damage", false, "warhead");
AssertRange(mine, "Range", false, "dispenser");
AssertRange(mine, "Cooldown", true, "dispenser");
AssertRange(mine, "Spread", true, "dispenser");
var layer = Assert.Single(mine.Behaviors.OfType<MineLayerData>());
Assert.Equal(2f, layer.ArmingDelay);
Assert.Equal(2f, layer.FuseDelay);
@ -95,12 +115,19 @@ public sealed class CatalogRoleTests
var drive = Design("Large Drive");
Assert.Equal(new[] { "injector", "nozzle" }, Roles(drive));
AssertRange(drive, "Thrust", 250000f, false, "nozzle");
AssertRange(drive, "Visibility", 100f, true, "nozzle");
AssertRange(drive, "Heat", 2500f, true, "injector");
AssertRange(drive, "Thrust", false, "nozzle");
AssertRange(drive, "Visibility", true, "nozzle");
AssertRange(drive, "Heat", true, "injector");
var smallDrive = Design("Small Drive");
AssertRange(smallDrive, "Thrust", 75000f, false, "nozzle");
AssertRange(Design("Small Drive"), "Thrust", false, "nozzle");
// Designs whose non-flat stats named no role are pointed at the role their kind's map gives them.
AssertNamed(Design("DeathCluster"), "Count", "feed mechanism");
AssertNamed(Design("Flak Gun"), "Count", "feed mechanism");
AssertNamed(Design("GT 3K"), "MinRange", "guidance system");
AssertNamed(Design("plight"), "DamageSpread", "focusing array");
AssertNamed(Design("MoveOnPro"), "Modifier", "core");
AssertNamed(Design("MoveOnPro Station Reactor"), "Modifier", "core");
// Each seller's means come from its maker's profile; the Pirates are middling and inconsistent.
var profiles = new Dictionary<string, (float Mean, float Dev)>

View file

@ -316,7 +316,11 @@ public sealed partial class RunStartTests
var envelope = longinus.Envelope;
var mass = longinus.Mass;
var forward = largeDrives.Sum(t => t.Thrust) / mass; // each drive's own thrust: a Large Drive's roll moves it between 0.8x and 1.2x
// The shipped Large Drive's Thrust is a range its lot's roll moves it through, so each drive pushes within the catalog's
// range and the envelope forward is what the two push together.
var catalogThrust = _cache.GetAll<GearData>().Single(gear => gear.Name == "Large Drive").Behaviors.OfType<ThrusterData>().Single().Thrust;
Assert.All(largeDrives, t => Assert.InRange(t.Thrust, catalogThrust.Min * .99f, catalogThrust.Max * 1.01f));
var forward = largeDrives.Sum(t => t.Thrust) / mass;
Assert.InRange(envelope.Forward, forward * .99f, forward * 1.01f);
Assert.Equal(0f, envelope.Reverse);
// One Talaria pushes each way, and each has its own lot's thrust: the sides are the two flanks' accelerations.

View file

@ -0,0 +1,146 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using Xunit;
// AetherDb's roles-backfill command (aetheria-release, cut roles-backfill) run on a scratch copy of the shipped catalog,
// with the designs it backfills put back to the state they shipped in before it: flat stats at the middle of their range,
// no roles, no seller quality, and the unnamed Quality terms of the stats that already varied. The shipped catalog is the
// oracle, since the command produced it.
public sealed class RolesBackfillCommandTests : IDisposable
{
private static readonly string[] Flattened = { "Autocannon", "LRMM72", "SRMM72", "Mine Launcher", "Large Drive", "Small Drive" };
// Designs that shipped with non-flat stats whose Quality term named no role; the field is the stat to unname.
private static readonly (string Design, string Field)[] Unnamed =
{
("DeathCluster", "Count"), ("Flak Gun", "Count"), ("GT 3K", "MinRange"), ("plight", "DamageSpread"),
("MoveOnPro", "Modifier"), ("MoveOnPro Station Reactor", "Modifier"),
};
private readonly string _root = Path.Combine(Path.GetTempPath(), "roles-backfill-" + Guid.NewGuid().ToString("N"));
private string CatalogPath => Path.Combine(_root, "GameData", "Aetheria.cc");
private readonly Dictionary<string, string> _shipped = new Dictionary<string, string>();
public RolesBackfillCommandTests()
{
Directory.CreateDirectory(Path.Combine(_root, "GameData"));
File.Copy(Path.Combine(AetherDb.FindRoot(), "GameData", "Aetheria.cc"), CatalogPath);
// As PiratesFactionCommandTests: edit through a registry scoped to the shipped assembly, then give the catalog the
// record AetherDb's process-wide registry demands in this test host.
var registry = CultDocumentRegistry.ForTypes(typeof(ItemData).Assembly.GetTypes()
.Where(t => t is { IsAbstract: false, IsInterface: false })
.Where(t => t.GetCustomAttribute<CultDocumentAttribute>() != null));
using (var cache = new CultCache(registry))
{
cache.AddBackingStore(new SingleFileMessagePackBackingStore(CatalogPath), AetheriaStores.CatalogTypes);
var designs = cache.GetAll<EquippableItemData>().ToArray();
var products = cache.GetAll<FactionProductData>().ToArray();
var changed = new List<object>();
foreach (var design in designs.Where(d => Flattened.Contains(d.Name)))
{
_shipped[design.Name] = Describe(cache, design, products);
foreach (var (_, stat) in CatalogRoleTests.StatsOf(design))
{
if (stat.Min != stat.Max) stat.Min = stat.Max = (stat.Min + stat.Max) / 2f;
stat.Terms.RemoveAll(t => t.Source == StatSource.Quality);
}
design.Roles = new List<ItemRole>();
changed.Add(design);
foreach (var product in products.Where(p => p.Design.Key.Equals(cache.RefOf(design).Key)))
{
product.Roles = new List<ProductRole>();
changed.Add(product);
}
}
foreach (var (name, field) in Unnamed)
{
var design = designs.Single(d => d.Name == name);
_shipped[name] = Describe(cache, design, products);
foreach (var term in Stat(design, field).Terms.Where(t => t.Source == StatSource.Quality)) term.Role = null;
changed.Add(design);
}
cache.Commit(batch =>
{
foreach (var document in changed) batch.Upsert(document.GetType(), document, cache.RefOf(document).Key);
});
cache.FlushAsync().Wait();
}
using (var cache = new CultCache())
{
cache.AddBackingStore(new SingleFileMessagePackBackingStore(CatalogPath), AetheriaStores.CatalogTypes);
cache.Upsert(new TestCatalogGlobal { Name = "Temperament" });
cache.FlushAsync().Wait();
}
}
public void Dispose() => Directory.Delete(_root, true);
private static PerformanceStat Stat(EquippableItemData design, string field) =>
Assert.Single(CatalogRoleTests.StatsOf(design), s => s.Name.EndsWith("." + field)).Stat;
// Everything the command decides about one design: its roles, each stat's range and role, and its sellers' role quality.
private static string Describe(CultCache cache, EquippableItemData design, FactionProductData[] products)
{
static string F(float v) => float.IsInfinity(v) ? v.ToString() : ((double) v).ToString("G5");
var stats = CatalogRoleTests.StatsOf(design).Select(s =>
$"{s.Name} {F(s.Stat.Min)}..{F(s.Stat.Max)} {string.Join("+", CatalogRoleTests.NamedQualityRoles(s.Stat))}");
var sellers = products.Where(p => p.Design.Key.Equals(cache.RefOf(design).Key)).OrderBy(p => p.Name, StringComparer.Ordinal)
.Select(p => $"{p.Name}: " + string.Join(", ", (p.Roles ?? new List<ProductRole>()).OrderBy(r => r.Role, StringComparer.Ordinal)
.Select(r => $"{r.Role} {F(r.Mean)}/{F(r.StandardDeviation)}")));
var roles = "roles " + string.Join(", ", (design.Roles ?? new List<ItemRole>()).Select(r => r.Name).OrderBy(r => r, StringComparer.Ordinal));
return string.Join("\n", new[] { roles }.Concat(stats).Concat(sellers));
}
private Dictionary<string, string> Read()
{
var db = AetherDb.Open(root: _root);
try
{
var products = db.Cache.GetAll<FactionProductData>().ToArray();
return db.Cache.GetAll<EquippableItemData>().Where(d => _shipped.ContainsKey(d.Name))
.ToDictionary(d => d.Name, d => Describe(db.Cache, d, products));
}
finally { db.Cache.Dispose(); }
}
[Fact]
public void A_dry_run_writes_nothing_and_apply_lands_what_the_catalog_ships_and_replays_as_a_no_op()
{
var before = File.ReadAllBytes(CatalogPath);
var start = Read();
Assert.All(_shipped.Keys, name => Assert.NotEqual(_shipped[name], start[name]));
Assert.Equal(0, Program.RolesBackfill(apply: false, root: _root));
Assert.Equal(before, File.ReadAllBytes(CatalogPath));
Assert.Equal(0, Program.RolesBackfill(apply: true, root: _root));
var landed = File.ReadAllBytes(CatalogPath);
Assert.NotEqual(before, landed);
var after = Read();
foreach (var name in _shipped.Keys.OrderBy(n => n, StringComparer.Ordinal))
Assert.Equal(_shipped[name], after[name]);
// A second apply finds every stat ranged and named and every seller authored, and writes nothing.
Assert.Equal(0, Program.RolesBackfill(apply: true, root: _root));
Assert.Equal(landed, File.ReadAllBytes(CatalogPath));
}
[Fact]
public void A_design_the_catalog_does_not_hold_refuses_and_lands_nothing()
{
var db = AetherDb.Open(catalogWritable: true, root: _root);
var autocannon = db.Cache.GetAll<WeaponItemData>().Single(weapon => weapon.Name == "Autocannon");
autocannon.Name = "Autocannon (renamed)";
db.Cache.Commit(batch => batch.Upsert(typeof(WeaponItemData), autocannon, db.Cache.RefOf(autocannon).Key));
db.Cache.Dispose();
var before = File.ReadAllBytes(CatalogPath);
Assert.Throws<InvalidOperationException>(() => Program.RolesBackfill(apply: true, root: _root));
Assert.Equal(before, File.ReadAllBytes(CatalogPath));
}
}

View file

@ -2,7 +2,7 @@ using System.Linq;
using Xunit;
// A weapon is thermally online only while its performance exceeds the shutdown threshold (Entity.UpdatePerformance
// compares against EntitySettings.ShutdownPerformance), and wear runs at full rate below it. Hull cells
// compares against the catalog settings' DefaultEntitySettings.ShutdownPerformance, which production clones), and wear runs at full rate below it. Hull cells
// start at Entity.StartingCellTemperature, so every shipped weapon must perform there, or a ship fitted with it
// can never fire it. The catalog comes from the shipped GameData/Aetheria.cc and the temperature from the
// constant Entity builds its cells with, so a unit slip in authoring (celsius for kelvin) fails here.
@ -15,7 +15,7 @@ public sealed class ShippedWeaponOperabilityTests
var weapons = cache.GetAll<GearData>().OfType<WeaponItemData>().ToArray();
Assert.NotEmpty(weapons);
var threshold = new EntitySettings().ShutdownPerformance;
var threshold = RestoredHullsTests.Settings().DefaultEntitySettings.ShutdownPerformance;
Assert.True(threshold > 0f);
var dead = weapons.Where(weapon => weapon.Performance(Entity.StartingCellTemperature) <= threshold)