Aetheria/tests/Aetheria.Shared.Tests/CatalogRoleTests.cs
Meta Vens 651f5cdc4f Tests: CB-R1 counts every Quality term on a non-flat stat and rejects a role named on a flat one
A non-flat stat must carry exactly one Quality term, named, declared, with a positive exponent; a flat stat may not name a role. The check is one function with its own synthetic-stat test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 01:59:26 +02:00

185 lines
11 KiB
C#

using System;
using System.Collections.Generic;
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. These tests read
// the shipped catalog through the real reader.
public sealed class CatalogRoleTests
{
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 => ($"{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))));
internal static string[] NamedQualityRoles(PerformanceStat stat) =>
stat.Terms.Where(t => t.Source == StatSource.Quality && !string.IsNullOrEmpty(t.Role)).Select(t => t.Role).ToArray();
// CB-R1 for one stat. A non-flat stat carries exactly one Quality term, it names a declared role, and its exponent is
// positive (a role that cannot move the stat is not read). A flat stat carries no Quality term that names a role.
// Returns the fault, or null.
internal static string StatFault(PerformanceStat stat, string[] declared)
{
var quality = stat.Terms.Where(t => t.Source == StatSource.Quality).ToArray();
if (stat.Min == stat.Max)
return quality.Any(t => !string.IsNullOrEmpty(t.Role)) ? $"is flat but a Quality term names [{string.Join(", ", NamedQualityRoles(stat))}]" : null;
if (quality.Length != 1)
return $"is non-flat with {quality.Length} Quality terms, not one";
if (string.IsNullOrEmpty(quality[0].Role) || !declared.Contains(quality[0].Role))
return $"is non-flat and reads [{quality[0].Role}], not one of its declared roles [{string.Join(", ", declared)}]";
return quality[0].Exponent > 0f ? null : $"is non-flat but its role \"{quality[0].Role}\" has exponent {quality[0].Exponent}";
}
// 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()
{
using var cache = RestoredHullsTests.OpenCatalog();
var products = cache.GetAll<FactionProductData>().ToArray();
var broken = new List<string>();
foreach (var design in cache.GetAll<EquippableItemData>().OrderBy(i => i.Name, StringComparer.Ordinal))
{
var stats = StatsOf(design).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();
foreach (var (name, stat) in stats)
{
var fault = StatFault(stat, declared);
if (fault != null) broken.Add($"{design.Name}: {name} {fault}");
}
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();
if (!authored.SequenceEqual(declared))
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));
}
private static PerformanceStat Ranged(params StatTerm[] terms) => new PerformanceStat { Min = 1f, Max = 2f, Terms = terms.ToList() };
private static StatTerm Quality(string role, float exponent = 1f) => new StatTerm { Source = StatSource.Quality, Role = role, Exponent = exponent };
// The stat check refuses what the shipped catalog does not hold but a later content cut could author.
[Fact]
public void TheStatCheckRefusesGenericQualityDeadRolesAndNamedFlatStats()
{
var declared = new[] { "barrel" };
Assert.Null(StatFault(Ranged(Quality("barrel")), declared));
Assert.Null(StatFault(new PerformanceStat { Min = 3f, Max = 3f, Terms = new List<StatTerm>() }, declared));
Assert.NotNull(StatFault(Ranged(Quality("barrel"), Quality(null)), declared));
Assert.NotNull(StatFault(Ranged(Quality("barrel", 0f)), declared));
Assert.NotNull(StatFault(Ranged(Quality(null)), declared));
Assert.NotNull(StatFault(Ranged(Quality("bogus")), declared));
Assert.NotNull(StatFault(new PerformanceStat { Min = 3f, Max = 3f, Terms = new List<StatTerm> { Quality("bogus") } }, declared));
}
// 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(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()
{
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", 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", 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", false, "warhead");
AssertRange(srm, "Cooldown", true, "guidance system");
var mine = Design("Mine Launcher");
Assert.Equal(new[] { "dispenser", "warhead" }, Roles(mine));
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);
Assert.Equal(30f, layer.Lifetime);
var drive = Design("Large Drive");
Assert.Equal(new[] { "injector", "nozzle" }, Roles(drive));
AssertRange(drive, "Thrust", false, "nozzle");
AssertRange(drive, "Visibility", true, "nozzle");
AssertRange(drive, "Heat", true, "injector");
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)>
{
["Zhestokost"] = (.50f, .08f), ["R&D"] = (.60f, .15f), ["DME"] = (.55f, .20f), ["Lightsail"] = (.55f, .10f), ["Pirates"] = (.45f, .22f),
};
foreach (var design in new[] { autocannon, lrm, srm, mine, drive })
{
var sellers = cache.GetAll<FactionProductData>().Where(p => p.Design.Key.Equals(cache.RefOf(design).Key)).ToArray();
Assert.NotEmpty(sellers);
foreach (var product in sellers)
{
var maker = cache.Get(product.Manufacturer);
var (mean, dev) = profiles[maker.ShortName];
Assert.Equal(Roles(design), product.Roles.Select(r => r.Role).OrderBy(r => r, StringComparer.Ordinal).ToArray());
Assert.All(product.Roles, r =>
{
Assert.Equal(mean, r.Mean, 3);
Assert.Equal(dev, r.StandardDeviation, 3);
});
}
}
Assert.Contains(cache.GetAll<FactionProductData>(), p => p.Design.Key.Equals(cache.RefOf(autocannon).Key) && cache.Get(p.Manufacturer).ShortName == "Pirates");
}
}