diff --git a/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs new file mode 100644 index 000000000..cc30e415c --- /dev/null +++ b/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs @@ -0,0 +1,329 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using UniRx; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; +using int2 = CultMath.int2; + +// Cut 4 (docs/fire-control-cut.md): pins the two rules this cut actually adds -- a continuous weapon is a +// sequence of rolls rather than a continuous truth, and splash (mine/airburst blast) is one rule applied to +// every entity in radius, directional over each target's own hull. FireAuthorityTests.cs already pins the +// roll itself (seeding, commit horizon, the shield-absorb branch, the damage rule); this file builds its own +// minimal fixtures rather than reusing that one's InstantWeapon-shaped Engagement, the same convention that +// file's own comment establishes. +public sealed class FireControlCut4Tests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-firecontrolcut4-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + + public FireControlCut4Tests() => Directory.CreateDirectory(_root); + + private readonly List _openCaches = new List(); + + public void Dispose() + { + foreach (var c in _openCaches) c.Dispose(); + Directory.Delete(_root, true); + } + + private static GameplaySettings TestSettings() => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp(), + TargetDetectionInfoThreshold = .1f, + FiringArc = 170, + CommitHorizon = .5f, + SchematicCellSize = 1f, + UnaidedAccuracy = .05f, + AgentMinHitProbability = 0f, + BeamResolveInterval = .1f + }; + + private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; + + // A 5x5 fully-solid hull, the same shape FireAuthorityTests.Build uses. + private static HullData MakeHullData(float armor = 0) => new HullData + { + Name = "Hull", HullType = HullType.Ship, Shape = SolidShape(), Durability = 1000, Mass = 1000, Armor = armor, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + }; + + private static Shape SolidShape() + { + var shape = new Shape(5, 5); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + return shape; + } + + // ---- Cut 4's beam rule: ConstantWeapon rolls once per BeamResolveInterval, not once per tick and not + // once for the whole burst. ---- + + private sealed class BeamEngagement + { + public ItemManager Items; + public Zone Zone; + public Ship Shooter; + public Ship Target; + public ConstantWeapon Weapon; + } + + private BeamEngagement BuildBeam(GameplaySettings settings, float damage, float targetRange = 100) + { + var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true); + _openCaches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.Upsert(MakeHullData()); + cache.Upsert(new GearData + { + Name = "Beam", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new ConstantWeaponData + { + Damage = Constant(damage), Range = Constant(1000), MinRange = Constant(0), + Velocity = Constant(0), Spread = Constant(0), DamageSpread = Constant(0), Penetration = Constant(0), + DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } + } } + }); + cache.Upsert(new GearData + { + Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new TargetingSystemData + { + Accuracy = Constant(1), Resolution = Constant(1), Precision = Constant(0), Tracking = Constant(1000) + } } + }); + // Powers the beam: with no Reactor, PowerBus grants nothing and ConstantWeapon.Execute safes itself + // off (Item.PowerSupply <= 1e-4f) after the first tick, same rake FireAuthorityTests.ShieldTakesHit + // notes for its own Reactor/Shield pair. + cache.Upsert(new GearData + { + Name = "Reactor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new ReactorData { Charge = Constant(1000), Efficiency = Constant(1), OverloadEfficiency = Constant(1), ThrottlingFactor = Constant(2) } } + }); + cache.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger(); + ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[2] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[5] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[9] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); + + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var shooterHull = new EquippableItem { Data = hullRef, Durability = 1000, Lot = 1 }; + var shooter = new Ship(items, zone, shooterHull, new EntitySettings()); + + var gunRef = items.ItemData.RefOf(items.ItemData.GetByName("Beam")); + var gun = new EquippableItem { Data = gunRef, Durability = 1, Lot = 2 }; + Assert.True(shooter.TryEquip(gun, new int2(0, 0))); + var weaponItem = shooter.Equipment.Single(e => e.EquippableItem == gun); + var weapon = (ConstantWeapon) weaponItem.Behaviors.Single(b => b is Weapon); + + var targetingRef = items.ItemData.RefOf(items.ItemData.GetByName("Targeting")); + var targeting = new EquippableItem { Data = targetingRef, Durability = 1, Lot = 3 }; + Assert.True(shooter.TryFindSpace(targeting, out var tpos)); + Assert.True(shooter.TryEquip(targeting, tpos)); + + var reactorRef = items.ItemData.RefOf(items.ItemData.GetByName("Reactor")); + var reactor = new EquippableItem { Data = reactorRef, Durability = 10, Lot = 5 }; + Assert.True(shooter.TryFindSpace(reactor, out var rpos)); + Assert.True(shooter.TryEquip(reactor, rpos)); + + var targetHull = new EquippableItem { Data = hullRef, Durability = 1000, Lot = 4 }; + var target = new Ship(items, zone, targetHull, new EntitySettings()); + var targetGunRef = items.ItemData.RefOf(items.ItemData.GetByName("Beam")); + var targetGun = new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 9 }; + Assert.True(target.TryEquip(targetGun, new int2(0, 0))); + + zone.Entities.Add(shooter); + zone.Entities.Add(target); + shooter.Activate(); + target.Activate(); + + shooter.Position = float3.zero; + target.Position = float3(0, 0, targetRange); + shooter.Target.Value = target; + shooter.EntityInfoGathered[target] = 1f; + shooter.SetIff(target, true); + + zone.Update(0f); // warm up resolved stats, same as FireAuthorityTests.Build + + return new BeamEngagement { Items = items, Zone = zone, Shooter = shooter, Target = target, Weapon = weapon }; + } + + // A beam is a sequence of discrete rolls, not a continuous truth: over one second it produces exactly + // 1/BeamResolveInterval outcomes, each for Damage*BeamResolveInterval, totalling Damage. Interval (.25s) + // and tick (.125s) are both exact dyadic fractions so float accumulation cannot drift the count -- and the + // tick is strictly smaller than the interval, so the two are distinguishable from a "one roll per tick" + // mutant (which would produce 8 outcomes here, not 4) and from a "one roll per burst" mutant (1, not 4). + [Fact] + public void BeamRollsPerInterval() + { + var settings = TestSettings(); + settings.BeamResolveInterval = .25f; + var e = BuildBeam(settings, damage: 10); + + var outcomes = new List(); + e.Zone.ShotResolved.Subscribe(outcome => { outcomes.Add(outcome); }); + + e.Weapon.Activate(); + for (var i = 0; i < 8; i++) e.Zone.Update(.125f); // 1 second in 8 ticks of .125s + + Assert.Equal(4, outcomes.Count); // 1s / .25s per roll + Assert.All(outcomes, o => Assert.True(o.Hit)); + Assert.Equal(10f, outcomes.Count * 10f * settings.BeamResolveInterval, 2); // Damage * interval, per roll + } + + // ---- Regression: FireControl.Fire must run whether or not a presentation is listening. Both + // InstantWeapon.Execute and ConstantWeapon.Execute used to write `OnFire?.Invoke(FireControl.Fire(...))` + // inline -- C#'s null-conditional short-circuits the WHOLE expression, argument included, whenever the + // event has no subscriber, so with no Unity EntityInstance wired up (a headless run, + // docs/headless-playground-cut.md) the shot silently never fired. Caught by this cut's own + // BeamRollsPerInterval fixture; this test pins the discrete-weapon half of the same fix, which no + // existing test could catch because FireAuthorityTests.cs always calls FireControl.Fire directly, never + // through Trigger()/Execute(). Mutation: inline the call back into the null-conditional. ---- + [Fact] + public void InstantWeaponFiresWithoutAnyOnFireSubscriber() + { + var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true); + _openCaches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.Upsert(MakeHullData()); + cache.Upsert(new GearData + { + Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new InstantWeaponData + { + Damage = Constant(10), Range = Constant(1000), MinRange = Constant(0), Velocity = Constant(0), + Spread = Constant(0), DamageSpread = Constant(0), Penetration = Constant(0), + Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000), + DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } + } } + }); + cache.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger { Lots = { [1] = new Lot { Origin = new Attributed(), Quality = 1 } } }; + var items = new ItemManager(cache, ledger, TestSettings(), _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); + + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var shooterHull = new EquippableItem { Data = hullRef, Durability = 1000, Lot = 1 }; + var shooter = new Ship(items, zone, shooterHull, new EntitySettings()); + var gunRef = items.ItemData.RefOf(items.ItemData.GetByName("Gun")); + var gun = new EquippableItem { Data = gunRef, Durability = 1, Lot = 1 }; + Assert.True(shooter.TryEquip(gun, new int2(0, 0))); + var weapon = (InstantWeapon) shooter.Equipment.Single(e => e.EquippableItem == gun).Behaviors.Single(b => b is Weapon); + + zone.Entities.Add(shooter); + shooter.Activate(); + zone.Update(0f); // warm up resolved stats + + // No target, no OnFire subscriber -- exactly a headless shooter with no target and no presentation. + var resolvedCount = 0; + zone.ShotResolved.Subscribe(_ => resolvedCount++); + + weapon.Activate(); // Trigger() -> queues a burst + zone.Update(.016f); // one ordinary tick -- Execute() should fire and the zero-velocity shot resolves same tick + + Assert.Equal(1, resolvedCount); + } + + // ---- Cut 4's splash rule: FireControl.Splash is an unconditional area effect (no roll), directional per + // target's own hull facing. Tested directly against the function, no weapon/gear catalog needed -- Entity. + // MapEntity (called from the Entity constructor) is enough to give a Ship its Armor/GearOccupancy/Hull. ---- + + private ProvenanceLedger _splashLedger; + + private (ItemManager Items, Zone Zone) BuildSplashZone(GameplaySettings settings, float armor = 0) + { + var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true); + _openCaches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.Upsert(MakeHullData(armor)); + cache.FlushAsync().Wait(); + + _splashLedger = new ProvenanceLedger(); + var items = new ItemManager(cache, _splashLedger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); + return (items, zone); + } + + private Ship AddShip(ItemManager items, Zone zone, float3 position, float2 direction, int lot) + { + _splashLedger.Lots[lot] = new Lot { Origin = new Attributed(), Quality = 1 }; + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var hull = new EquippableItem { Data = hullRef, Durability = 1000, Lot = lot }; + var ship = new Ship(items, zone, hull, new EntitySettings()); + ship.Position = position; + ship.Direction = direction; + zone.Entities.Add(ship); + return ship; + } + + // Splash is one rule, not per-effect: every entity within radius takes damage, one outside radius takes + // none. Mutation: damage only the nearest entity in range instead of every one. + [Fact] + public void SplashHitsEveryEntityInRadius() + { + var (items, zone) = BuildSplashZone(TestSettings()); + var near = AddShip(items, zone, float3(10, 0, 0), float2(0, 1), 1); + var far = AddShip(items, zone, float3(20, 0, 0), float2(0, 1), 2); + var outOfRange = AddShip(items, zone, float3(1000, 0, 0), float2(0, 1), 3); + + var nearBefore = near.Hull.Durability; + var farBefore = far.Hull.Durability; + var outBefore = outOfRange.Hull.Durability; + + FireControl.Splash(zone, float3.zero, radius: 30, damage: 50, damageType: DamageType.Kinetic); + + Assert.True(near.Hull.Durability < nearBefore); + Assert.True(far.Hull.Durability < farBefore); + Assert.Equal(outBefore, outOfRange.Hull.Durability); + } + + // Splash is directional over each target's own hull: cells facing the blast take it, cells facing away do + // not. Mutation: damage the whole shape regardless of direction. + [Fact] + public void SplashIsDirectional() + { + // Nonzero armor so an undamaged cell is distinguishable from a damaged one -- with armor 0 every cell + // starts and stays at 0 regardless of which half took the schematic hit. + var (items, zone) = BuildSplashZone(TestSettings(), armor: 10); + // Facing +Z (the default); the blast sits behind the ship along -Z, so the near half of the hull + // (negative-Z cells, closest to the blast) should take damage and the far half (positive-Z) should not. + var ship = AddShip(items, zone, float3(0, 0, 10), float2(0, 1), 1); + + FireControl.Splash(zone, float3(0, 0, 0), radius: 50, damage: 1000, damageType: DamageType.Kinetic); + + var hullData = (HullData) items.GetData(ship.Hull); + var center = hullData.Shape.CenterOfMass; + var nearCellDamaged = false; + var farCellUntouched = false; + foreach (var v in hullData.Shape.Coordinates) + { + var offset = (float2) v - center; + if (offset.y < -.5f && ship.Armor[v.x, v.y] < ship.MaxArmor[v.x, v.y]) nearCellDamaged = true; + if (offset.y > .5f && ship.Armor[v.x, v.y] >= ship.MaxArmor[v.x, v.y]) farCellUntouched = true; + } + + Assert.True(nearCellDamaged); + Assert.True(farCellUntouched); + } +} diff --git a/tests/mutation_tests_fire_control_cut4.py b/tests/mutation_tests_fire_control_cut4.py new file mode 100644 index 000000000..87fb19e22 --- /dev/null +++ b/tests/mutation_tests_fire_control_cut4.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +"""Mutation tests for docs/fire-control-cut.md's Cut 4 ("The unused kinds join the same path") -- the last five +weapon kinds (ConstantLaser, ConstantLightning, ConstantParticleWeapon, HitscanEffect, Mine) stop deciding +their own hits through live Physics queries and join FireControl, the same owner Cut 3 gave the other four. + +Four rules are pinned here, each against tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs: + + 1. BeamRollsPerInterval: a continuous weapon (ConstantWeapon) is a sequence of discrete rolls, one per + GameplaySettings.BeamResolveInterval, not a roll per tick and not one roll for the whole burst. + 2. InstantWeaponFiresWithoutAnyOnFireSubscriber / (implicitly) BeamRollsPerInterval again: FireControl.Fire + must run whether or not a presentation is listening -- a real Cut 3/4 defect this campaign found and + fixed. Inlining the call back into `OnFire?.Invoke(FireControl.Fire(...))` silently stops every shot from + firing in a headless run (no Unity EntityInstance ever subscribes OnFire/OnBeamShot), because C#'s + null-conditional short-circuits the whole expression, argument included, when the event has no + subscriber. Pinned on both InstantWeapon.cs and ConstantWeapon.cs since Cut 3 introduced the pattern once + and Cut 4 copied it a second time. + 3. SplashHitsEveryEntityInRadius: FireControl.Splash (mine/airburst blast) is one rule applied to every + entity within radius, not just the nearest. + 4. SplashIsDirectional: splash damage lands on the half of each target's own hull that faces the blast, not + the whole hull. + +A no-op control mutation proves the read/replace/restore path is transparent. + +Usage: + python tests/mutation_tests_fire_control_cut4.py --cultlib-root + +Only touches files under this repository; every mutation is reversed before the script exits, including on +failure. +""" +from __future__ import annotations + +import argparse +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +TEST_PROJECT = REPO_ROOT / "tests" / "Aetheria.Shared.Tests" +CONSTANT_WEAPON_CS = "Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs" +INSTANT_WEAPON_CS = "Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs" +FIRE_CONTROL_CS = "Assets/Scripts/ServerShared/FireControl.cs" + + +@dataclass +class Mutation: + rule: str + file: str + anchor: str + mutated: str + test: str + expect: str # "red" (mutant must be killed) or "green" (the no-op control) + + +MUTATIONS: list[Mutation] = [ + # --- No-op control: proves the anchor/replace/restore mechanism is transparent --- + Mutation( + rule="control (no-op round trip)", + file=CONSTANT_WEAPON_CS, + anchor=" while (_beamTimer >= interval)", + mutated=" while (_beamTimer >= interval)", + test="FireControlCut4Tests.BeamRollsPerInterval", + expect="green", + ), + + # --- A beam is a sequence of rolls, not a continuous truth: roll per tick (many more, much smaller + # --- outcomes over the same second) must be killed. --- + Mutation( + rule="ConstantWeapon must roll once per BeamResolveInterval, not once per tick", + file=CONSTANT_WEAPON_CS, + anchor=" while (_beamTimer >= interval)", + mutated=" while (true)", + test="FireControlCut4Tests.BeamRollsPerInterval", + expect="red", + ), + + # --- The opposite direction: rolling once for the whole burst (never draining the timer back below + # --- interval) must also be killed -- a single outcome instead of a sequence. --- + Mutation( + rule="ConstantWeapon must not roll once for the whole burst", + file=CONSTANT_WEAPON_CS, + anchor=" _beamTimer -= interval;", + mutated=" _beamTimer = float.NegativeInfinity;", + test="FireControlCut4Tests.BeamRollsPerInterval", + expect="red", + ), + + # --- The real defect this campaign found: FireControl.Fire must run whether or not OnBeamShot has a + # --- subscriber. Inlining it back into the null-conditional silently stops every beam from firing in a + # --- headless run (nothing ever subscribes OnBeamShot). --- + Mutation( + rule="ConstantWeapon.Execute must evaluate FireControl.Fire even with no OnBeamShot subscriber", + file=CONSTANT_WEAPON_CS, + anchor=" var shotId = FireControl.Fire(this, Item, Entity, Damage * interval);\n OnBeamShot?.Invoke(shotId);", + mutated=" OnBeamShot?.Invoke(FireControl.Fire(this, Item, Entity, Damage * interval));", + test="FireControlCut4Tests.BeamRollsPerInterval", + expect="red", + ), + + # --- The same defect, in InstantWeapon.cs (Cut 3's original copy of the pattern) -- caught only by this + # --- cut's own regression test, since FireAuthorityTests.cs always calls FireControl.Fire directly and + # --- never exercises Trigger()/Execute() with zero OnFire subscribers. --- + Mutation( + rule="InstantWeapon.Execute must evaluate FireControl.Fire even with no OnFire subscriber", + file=INSTANT_WEAPON_CS, + anchor=" var shotId = FireControl.Fire(this, Item, Entity);\n OnFire?.Invoke(shotId);", + mutated=" OnFire?.Invoke(FireControl.Fire(this, Item, Entity));", + test="FireControlCut4Tests.InstantWeaponFiresWithoutAnyOnFireSubscriber", + expect="red", + ), + + # --- Splash is one rule, not per-effect: damaging only the nearest entity in range instead of every one + # --- must be killed. --- + Mutation( + rule="FireControl.Splash must damage every entity in radius, not only the nearest", + file=FIRE_CONTROL_CS, + anchor=" var toTarget = (target.Position - position).xz;\n if (length(toTarget) > radius) continue;", + mutated=" var toTarget = (target.Position - position).xz;\n if (length(toTarget) > radius) continue;\n if (zone.Entities.Where(e => length((e.Position - position).xz) <= radius).OrderBy(e => length((e.Position - position).xz)).First() != target) continue;", + test="FireControlCut4Tests.SplashHitsEveryEntityInRadius", + expect="red", + ), + + # --- Splash is directional over each target's own hull: damaging the whole shape regardless of facing + # --- must be killed. --- + Mutation( + rule="FireControl.Splash must damage only the half of the hull facing the blast, not the whole shape", + file=FIRE_CONTROL_CS, + anchor=" if (dot(normalize((float2) v - hullData.Shape.CenterOfMass), localDirection) < 0)\n hitShape[v] = true;", + mutated=" hitShape[v] = true;", + test="FireControlCut4Tests.SplashIsDirectional", + expect="red", + ), +] + + +def replace_unique(path: Path, anchor: str, mutated: str) -> None: + original = path.read_text(encoding="utf-8") + count = original.count(anchor) + if count != 1: + raise SystemExit(f"{path}: anchor must occur exactly once, found {count}\n---\n{anchor}") + path.write_text(original.replace(anchor, mutated), encoding="utf-8") + + +def run_test(cultlib_root: str, test: str) -> tuple[int, str]: + proc = subprocess.run( + ["dotnet", "test", str(TEST_PROJECT), f"-p:CultLibRoot={cultlib_root}", + "--filter", f"FullyQualifiedName~{test}"], + capture_output=True, text=True, timeout=300, + ) + return proc.returncode, proc.stdout + proc.stderr + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--cultlib-root", required=True) + args = parser.parse_args() + + failures: list[str] = [] + originals: dict[str, str] = {} + for m in MUTATIONS: + if m.file not in originals: + originals[m.file] = (REPO_ROOT / m.file).read_text(encoding="utf-8") + + def restore_all() -> None: + for file, text in originals.items(): + (REPO_ROOT / file).write_text(text, encoding="utf-8") + + try: + for m in MUTATIONS: + print(f"\n=== {m.rule} (expect {m.expect}) ===") + path = REPO_ROOT / m.file + replace_unique(path, m.anchor, m.mutated) + code, output = run_test(args.cultlib_root, m.test) + passed = "Passed!" in output and code == 0 + if m.expect == "green": + if not passed: + failures.append(f"{m.rule}: expected green (test passing), got failure\n{output[-2000:]}") + else: + print(f"OK: {m.test} passed under a no-op mutation, as expected") + else: + if passed: + failures.append(f"{m.rule}: expected red (mutant killed), but {m.test} still passed") + else: + print(f"OK: {m.test} went red under the mutation, as expected") + # Restore this file immediately so the next mutation starts from a clean tree. + path.write_text(originals[m.file], encoding="utf-8") + finally: + restore_all() + + if failures: + print("\n=== FAILURES ===") + for f in failures: + print(f"- {f}") + return 1 + + print("\nAll mutations behaved as declared.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())