Aetheria/tests/Aetheria.Shared.Tests/HeatResponseTests.cs
Meta Vens f4d5659d99 Cut 1: close S5/S6's validation gaps, pin S7's surviving mutants
StatValidation.ValidateHeatResponse now refuses NaN in any of the four heat-response
fields, a zero-span range (Minimum == Maximum -- Tractor Beam's exact bug), and a
plateau whose low or high edge pokes past its bounds, matching the R-heat ruling and
the Performance() comment that already claimed this was enforced. The writable-path
hole is closed for every path this repo owns: CultRecordRefs.Upsert (AetheriaStores.cs),
the one function every tool, test and migration script writes catalog documents
through, validates before a write reaches disk instead of waiting for the next Open().
CultCache Studio's generic editor bypasses Upsert and still has no per-document
validation hook -- that hole is CultLib's, not this repo's, and stays named in the code
rather than silently assumed closed.

EquippableItemData's four heat fields get non-degenerate defaults (0/100/50/20, the
same shape HeatResponseTests already hand-authors) because dozens of existing test
fixtures across the suite build designs that never cared about heat and defaulted into
the same zero-span trap Tractor Beam shipped with; enforcing the zero-span refusal
without this would have required touching every one of those fixtures instead of the
root cause.

Four new mutations pin S7's survivors, each with its own dedicated test rather than a
coincidentally-sensitive existing one: out-of-bounds semantics (return 1f instead of 0f
outside bounds), the plateau-clamp validation's two sides, and a Quality term ignoring
its own exponent (StatsReadTheLot's Exponent = 1 everywhere could not tell "reads the
exponent" from "always uses 1"; the new test uses Exponent = 2). Performance()'s own
runtime plateau clamp is separately confirmed dead code -- the outer bounds guard makes
it unobservable at every input, verified by an exhaustive numeric sweep -- and is
recorded as such rather than forced into a fake kill.

80 tests (9 new), 17 mutation cases in tests/mutation_tests_stats_power_cut1.py, all
green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:42:10 +02:00

265 lines
12 KiB
C#

using System;
using System.IO;
using System.Linq;
using CultMath;
using GameCult.Caching;
using Xunit;
// Cut 1 (docs/stats-and-power-cut.md), R-heat: the authored heat-response shape (minimum, maximum, optimum,
// plateau width), its load-time validation, and the wear-lever consequence (the plateau is where Wear's thermal
// term goes to zero). Each rule here has a matching mutation in tests/mutation_tests_stats_power_cut1.py.
public sealed class HeatResponseTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-heatresponse-" + Guid.NewGuid().ToString("N"));
private string Catalog => Path.Combine(_root, "Aetheria.cc");
private static readonly int2 HardpointCell = new int2(0, 0);
public HeatResponseTests() => Directory.CreateDirectory(_root);
public void Dispose() => Directory.Delete(_root, true);
private GameplaySettings Settings() => new GameplaySettings
{
DefaultEntitySettings = new EntitySettings(),
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } },
QualityPriceModifier = new ExponentialLerp()
};
// A hull plus one thermal gear design, equipped and settled at the given start temperature (two
// UpdatePerformance calls so deltaTemp reads 0 before the test drives its own change).
private EquippedItem BuildEquippedAt(CultCache cache, ItemManager items, float startTemperature, out Ship ship)
{
var maker = cache.RefOf(cache.GetByName<Faction>("Maker"));
var hullData = cache.GetByName<HullData>("Skiff");
var thermalData = cache.GetByName<GearData>("Thermal");
var hullItem = (EquippableItem) items.CreateInstance(items.CreateLot(hullData, maker, .5f));
ship = new Ship(items, null, hullItem, new EntitySettings());
var gearItem = (EquippableItem) items.CreateInstance(items.CreateLot(thermalData, maker, .5f));
Assert.True(ship.TryEquip(gearItem, HardpointCell));
var equipped = ship.Equipment.Single(e => e.Data is GearData);
foreach (var cell in equipped.InsetShape.Coordinates) ship.Temperature[cell.x, cell.y] = startTemperature;
equipped.UpdatePerformance();
equipped.UpdatePerformance(); // settle: oldTemperature now equals startTemperature, deltaTemp reads 0
return equipped;
}
private void SetTemperature(EquippedItem equipped, Ship ship, float temperature)
{
foreach (var cell in equipped.InsetShape.Coordinates) ship.Temperature[cell.x, cell.y] = temperature;
}
private CultCache OpenCatalogWithThermalGear()
{
var cache = AetheriaStores.Open(Catalog, catalogWritable: true);
cache.Upsert(new TestCatalogGlobal { Name = "Temperament" });
cache.Upsert(new Faction { Name = "Maker", ShortName = "MKR" });
var hullShape = new Shape(3, 3);
foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true;
cache.Upsert(new HullData
{
Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Price = 100,
Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = HardpointCell, Shape = new Shape() } }
});
// Plateau 40-60 of a 0-100 range: full performance across it, linear falloff to the bounds outside it.
cache.Upsert(new GearData
{
Name = "Thermal", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 10,
Durability = 100, MinimumTemperature = 0, MaximumTemperature = 100,
OptimalTemperature = 50, PlateauWidth = 20
});
cache.FlushAsync().Wait();
return cache;
}
// --- R-heat shape: Performance() is 1 across the plateau and falls linearly to 0 at the bounds ---
[Fact]
public void PerformanceIsFullAcrossThePlateau()
{
using var cache = OpenCatalogWithThermalGear();
var data = cache.GetByName<GearData>("Thermal");
Assert.Equal(1f, data.Performance(40), 5);
Assert.Equal(1f, data.Performance(50), 5);
Assert.Equal(1f, data.Performance(60), 5);
}
[Fact]
public void PerformanceFallsLinearlyOutsideThePlateau()
{
using var cache = OpenCatalogWithThermalGear();
var data = cache.GetByName<GearData>("Thermal");
// Halfway from the plateau's low edge (40) down to the minimum (0) -> halfway performance.
Assert.Equal(.5f, data.Performance(20), 5);
Assert.Equal(0f, data.Performance(0), 5);
Assert.Equal(0f, data.Performance(100), 5);
}
// S7 (docs/stats-and-power-cut.md Cut 1 findings): a design is dead at and beyond its bounds -- that is the
// point of R-heat's ruling, not an accident. A mutation restoring the old curve's "1 at any temperature"
// behaviour outside the bounds must fail this, at the boundary itself and strictly beyond it on both sides.
[Fact]
public void PerformanceIsZeroAtAndBeyondEachBound()
{
using var cache = OpenCatalogWithThermalGear();
var data = cache.GetByName<GearData>("Thermal");
Assert.Equal(0f, data.Performance(0), 5);
Assert.Equal(0f, data.Performance(-10), 5);
Assert.Equal(0f, data.Performance(100), 5);
Assert.Equal(0f, data.Performance(150), 5);
}
// --- Validation: fails loudly at catalog load AND at every write through CultRecordRefs.Upsert (S6: the
// writable path used to trust Open()'s one-time check and could write an invalid catalog that only failed
// the next time somebody reopened it). Each test below proves the earlier refusal at Upsert time, then
// confirms the record never reached disk by reopening read-only and finding it absent. ---
private static string NewRoot()
{
var root = Path.Combine(Path.GetTempPath(), "aetheria-heatresponse-invalid-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
return root;
}
[Fact]
public void UpsertRefusesAnOptimumOutsideItsBounds()
{
var root = NewRoot();
try
{
var catalog = Path.Combine(root, "Aetheria.cc");
using var cache = AetheriaStores.Open(catalog, catalogWritable: true);
cache.Upsert(new TestCatalogGlobal { Name = "Temperament" });
var ex = Assert.Throws<InvalidOperationException>(() => cache.Upsert(new GearData
{
Name = "BadOptimum", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 1,
MinimumTemperature = 0, MaximumTemperature = 100, OptimalTemperature = 150, PlateauWidth = 0
}));
Assert.Contains("BadOptimum", ex.Message);
cache.FlushAsync().Wait();
Assert.Null(cache.GetAll<GearData>().SingleOrDefault(g => g.Name == "BadOptimum"));
}
finally { Directory.Delete(root, true); }
}
[Fact]
public void UpsertRefusesANegativePlateauWidth()
{
var root = NewRoot();
try
{
var catalog = Path.Combine(root, "Aetheria.cc");
using var cache = AetheriaStores.Open(catalog, catalogWritable: true);
cache.Upsert(new TestCatalogGlobal { Name = "Temperament" });
var ex = Assert.Throws<InvalidOperationException>(() => cache.Upsert(new GearData
{
Name = "BadPlateau", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 1,
MinimumTemperature = 0, MaximumTemperature = 100, OptimalTemperature = 50, PlateauWidth = -1
}));
Assert.Contains("BadPlateau", ex.Message);
}
finally { Directory.Delete(root, true); }
}
// S5/S6: a zero-span range (Tractor Beam's exact bug) is refused, not silently accepted as an authored
// immunity.
[Fact]
public void UpsertRefusesAZeroSpanRange()
{
var root = NewRoot();
try
{
var catalog = Path.Combine(root, "Aetheria.cc");
using var cache = AetheriaStores.Open(catalog, catalogWritable: true);
cache.Upsert(new TestCatalogGlobal { Name = "Temperament" });
var ex = Assert.Throws<InvalidOperationException>(() => cache.Upsert(new GearData
{
Name = "ZeroSpan", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 1,
MinimumTemperature = 50, MaximumTemperature = 50, OptimalTemperature = 50, PlateauWidth = 0
}));
Assert.Contains("ZeroSpan", ex.Message);
}
finally { Directory.Delete(root, true); }
}
// S5/S6: NaN in any of the four heat-response fields is refused.
[Theory]
[InlineData(float.NaN, 100f, 50f, 20f)]
[InlineData(0f, float.NaN, 50f, 20f)]
[InlineData(0f, 100f, float.NaN, 20f)]
[InlineData(0f, 100f, 50f, float.NaN)]
public void UpsertRefusesNaNInAnyHeatResponseField(float min, float max, float optimum, float plateau)
{
var root = NewRoot();
try
{
var catalog = Path.Combine(root, "Aetheria.cc");
using var cache = AetheriaStores.Open(catalog, catalogWritable: true);
cache.Upsert(new TestCatalogGlobal { Name = "Temperament" });
var ex = Assert.Throws<InvalidOperationException>(() => cache.Upsert(new GearData
{
Name = "NaNHeat", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 1,
MinimumTemperature = min, MaximumTemperature = max, OptimalTemperature = optimum, PlateauWidth = plateau
}));
Assert.Contains("NaNHeat", ex.Message);
}
finally { Directory.Delete(root, true); }
}
// S5/S6: the plateau clamps to the bounds rather than poking past them -- an authored half-width that would
// reach past Minimum or Maximum is refused at write time, naming the item, rather than silently clamped.
[Theory]
[InlineData(0f, 100f, 10f, 40f)] // low edge: 10 - 20 = -10 < 0
[InlineData(0f, 100f, 90f, 40f)] // high edge: 90 + 20 = 110 > 100
public void UpsertRefusesAPlateauThatPokesPastItsBounds(float min, float max, float optimum, float plateau)
{
var root = NewRoot();
try
{
var catalog = Path.Combine(root, "Aetheria.cc");
using var cache = AetheriaStores.Open(catalog, catalogWritable: true);
cache.Upsert(new TestCatalogGlobal { Name = "Temperament" });
var ex = Assert.Throws<InvalidOperationException>(() => cache.Upsert(new GearData
{
Name = "PokingPlateau", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 1,
MinimumTemperature = min, MaximumTemperature = max, OptimalTemperature = optimum, PlateauWidth = plateau
}));
Assert.Contains("PokingPlateau", ex.Message);
}
finally { Directory.Delete(root, true); }
}
// --- The wear lever (Entity.cs UpdatePerformance): inside the plateau the thermal term is zero ---
[Fact]
public void HeldInsidePlateauTakesNoThermalWear()
{
using var cache = OpenCatalogWithThermalGear();
var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { });
var equipped = BuildEquippedAt(cache, items, 50f, out var ship);
Assert.Equal(1f, equipped.ThermalPerformance, 5);
Assert.Equal(0f, equipped.Wear, 5); // no thermal term (Performance == 1) and no deltaTemp (settled)
}
[Fact]
public void HeldOutsidePlateauTakesThermalWear()
{
using var cache = OpenCatalogWithThermalGear();
var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { });
var equipped = BuildEquippedAt(cache, items, 10f, out var ship); // outside 40-60, inside 0-100
Assert.True(equipped.ThermalPerformance < 1f);
Assert.True(equipped.Wear > 0f);
}
[Fact]
public void AFastSwingAcrossThePlateauStillWears()
{
using var cache = OpenCatalogWithThermalGear();
var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { });
var equipped = BuildEquippedAt(cache, items, 10f, out var ship);
// Jump straight into the plateau in one tick: ThermalPerformance ends at 1 (no thermal term), but the
// swing itself (deltaTemp) still costs.
SetTemperature(equipped, ship, 50f);
equipped.UpdatePerformance();
Assert.Equal(1f, equipped.ThermalPerformance, 5);
Assert.True(equipped.Wear > 0f);
}
}