Aetheria/tests/mutation_tests_fire_control_cut4.py
Meta Vens 034e8d3e78 Fire control Cut 4: pin the beam roll, splash, and the OnFire short-circuit fix
tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs, builds its own minimal
fixtures rather than reusing FireAuthorityTests.cs's InstantWeapon-shaped
Engagement:

- BeamRollsPerInterval: over one second a beam produces exactly
  1/BeamResolveInterval outcomes 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 a "roll
  every tick" mutant and a "roll once for the burst" mutant both fail.
- InstantWeaponFiresWithoutAnyOnFireSubscriber: pins the InstantWeapon half
  of the null-conditional short-circuit fix, which no existing test could
  catch (FireAuthorityTests.cs always calls FireControl.Fire directly, never
  through Trigger()/Execute()).
- SplashHitsEveryEntityInRadius / SplashIsDirectional: FireControl.Splash
  called directly against a zone of bare Ship entities (Entity.MapEntity,
  run from the constructor, is enough to give a Ship Armor/GearOccupancy/
  Hull without Activate() or a weapon catalog).

tests/mutation_tests_fire_control_cut4.py is the committed harness: a no-op
control plus one mutation per rule above, run against
tests/Aetheria.Shared.Tests via `dotnet test -p:CultLibRoot=...`. All seven
mutations behaved as declared in this pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 15:17:35 +02:00

201 lines
9.2 KiB
Python

#!/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 <path-to-CultLib-45c2f40-worktree>
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())