diff --git a/tests/Aetheria.Shared.Tests/CatalogRoleTests.cs b/tests/Aetheria.Shared.Tests/CatalogRoleTests.cs index 665602de3..bb83a23cd 100644 --- a/tests/Aetheria.Shared.Tests/CatalogRoleTests.cs +++ b/tests/Aetheria.Shared.Tests/CatalogRoleTests.cs @@ -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 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().ToArray(); var broken = new List(); - foreach (var design in cache.GetAll() - .Where(i => i is WeaponItemData || (i is GearData && QualityGearKinds.Contains(i.HardpointType))) - .OrderBy(i => i.Name, StringComparer.Ordinal)) + foreach (var design in cache.GetAll().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()).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()).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().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(), 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()); 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 diff --git a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs index 708f863bb..ea6ce903d 100644 --- a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs +++ b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs @@ -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().Single(gear => gear.Name == "Large Drive").Behaviors.OfType().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. diff --git a/tests/Aetheria.Shared.Tests/RolesBackfillCommandTests.cs b/tests/Aetheria.Shared.Tests/RolesBackfillCommandTests.cs new file mode 100644 index 000000000..923dedbb1 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/RolesBackfillCommandTests.cs @@ -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 _shipped = new Dictionary(); + + 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() != null)); + using (var cache = new CultCache(registry)) + { + cache.AddBackingStore(new SingleFileMessagePackBackingStore(CatalogPath), AetheriaStores.CatalogTypes); + var designs = cache.GetAll().ToArray(); + var products = cache.GetAll().ToArray(); + var changed = new List(); + 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(); + changed.Add(design); + foreach (var product in products.Where(p => p.Design.Key.Equals(cache.RefOf(design).Key))) + { + product.Roles = new List(); + 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()).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()).Select(r => r.Name).OrderBy(r => r, StringComparer.Ordinal)); + return string.Join("\n", new[] { roles }.Concat(stats).Concat(sellers)); + } + + private Dictionary Read() + { + var db = AetherDb.Open(root: _root); + try + { + var products = db.Cache.GetAll().ToArray(); + return db.Cache.GetAll().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().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(() => Program.RolesBackfill(apply: true, root: _root)); + Assert.Equal(before, File.ReadAllBytes(CatalogPath)); + } +} diff --git a/tests/Aetheria.Shared.Tests/ShippedWeaponOperabilityTests.cs b/tests/Aetheria.Shared.Tests/ShippedWeaponOperabilityTests.cs index 537fbc3a9..8ac543451 100644 --- a/tests/Aetheria.Shared.Tests/ShippedWeaponOperabilityTests.cs +++ b/tests/Aetheria.Shared.Tests/ShippedWeaponOperabilityTests.cs @@ -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().OfType().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)