Aetheria/tests/mutation_tests_shield_reserve.py
Meta Vens ad3a3d40cb Pin the shield reserve ruling: 7 rules, each with a mutation
ShieldReserveTests.cs covers what Cut 4's InputCapacitorTests.cs cannot: a hit within
the reserve is absorbed and spends charge; a hit beyond it breaks the shield; the
breaking hit is not partially absorbed; a broken shield absorbs nothing (gated on
Broken itself, not merely on the reserve reading empty); a break empties the reserve
regardless of pre-hit charge; a broken shield restores on RestoreDuration and not
RefillDuration; a partial bus grant stretches the refill proportionally.

mutation_tests_shield_reserve.py mutates each rule's own line and confirms the named
test goes red for exactly that reason, plus a no-op control. All 8 (7 rules + control)
verified against the pinned CultLib worktree.

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

218 lines
9.7 KiB
Python

#!/usr/bin/env python3
"""Mutation tests for the shield reserve ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19).
Supersedes Cut 4's derived reserve sizing (mutation_tests_stats_power_cut4.py's own shield rule, still intact
and still pinned there against InputCapacitorTests.cs -- a hit within the reserve may draw a partial amount).
This script covers what changed on top of that: ShieldData now authors Capacity/RefillDuration/RestoreDuration
directly, a hit the reserve cannot fully cover breaks the shield instead of a silent, charge-preserving refusal,
the breaking hit itself passes through with no partial absorption, the reserve empties to zero on break (not
left at its pre-hit charge), and a broken shield restores on RestoreDuration, never RefillDuration. Each rule
mutates one exact, unique anchor of source text and asserts the named test in ShieldReserveTests.cs goes red for
exactly that reason. A no-op control mutation proves the read/replace/restore path itself is transparent.
Usage:
python tests/mutation_tests_shield_reserve.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"
SHIELD_CS = "Assets/Scripts/ServerShared/Behaviors/Shield.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=SHIELD_CS,
anchor=" public void TakeHit(DamageType type, float damage)",
mutated=" public void TakeHit(DamageType type, float damage)",
test="ShieldReserveTests.HitWithinReserveIsAbsorbedAndSpendsCharge",
expect="green",
),
# --- A hit within the reserve is absorbed and spends charge: TakeHit must actually draw the reserve down,
# --- not just gate on CanTakeHit and leave Charge untouched. ---
Mutation(
rule="a hit within the reserve must spend charge, not just be permitted",
file=SHIELD_CS,
anchor=" if (Item != null) _reserve.TrySpend(damage * EnergyUsage);",
mutated=" if (Item != null) { }",
test="ShieldReserveTests.HitWithinReserveIsAbsorbedAndSpendsCharge",
expect="red",
),
# --- A hit beyond the reserve breaks the shield: refusing the hit is not enough on its own -- Broken must
# --- actually flip, or the shield is just Cut 4's silent refusal again with new authored numbers. ---
Mutation(
rule="a hit beyond the reserve must set Broken, not just refuse",
file=SHIELD_CS,
anchor=(
" if (_reserve.CanSpend(damage * EnergyUsage)) return true;\n"
" Broken = true;"
),
mutated=" if (_reserve.CanSpend(damage * EnergyUsage)) return true;",
test="ShieldReserveTests.HitBeyondReserveBreaksTheShield",
expect="red",
),
# --- Operator's second ruling: the breaking hit passes through in full, no partial absorption -- TakeHit
# --- (and its TrySpend/AddHeat) must never run for the hit that broke the shield. Routing the breaking hit
# --- through TakeHit instead of a direct break-and-empty absorbs/processes it, which the ruling forbids.
# --- Caught by the restore-timing assertion: TrySpend's own atomicity refuses (cost > charge) and leaves
# --- Charge at its pre-hit value instead of the emptied reserve.
Mutation(
rule="the breaking hit must not be absorbed via TakeHit -- it passes through untouched",
file=SHIELD_CS,
anchor=(
" Broken = true;\n"
" _reserve.AddCharge(-_reserve.Charge); // emptied by the break, not left at its pre-hit charge\n"
" return false;"
),
mutated=(
" Broken = true;\n"
" TakeHit(type, damage);\n"
" return false;"
),
test="ShieldReserveTests.BreakingHitIsNotPartiallyAbsorbed",
expect="red",
),
# --- A broken shield absorbs nothing -- gated on Broken itself, not on the reserve happening to read empty.
# --- Deleting the Broken short-circuit lets a partially-restored (but still Broken) reserve answer purely
# --- on its own charge arithmetic, absorbing a hit it should still be refusing outright.
Mutation(
rule="a broken shield must refuse every hit regardless of reserve arithmetic",
file=SHIELD_CS,
anchor=(
" if (Item == null) return true;\n"
" if (Broken) return false;"
),
mutated=" if (Item == null) return true;",
test="ShieldReserveTests.BrokenShieldAbsorbsNothing",
expect="red",
),
# --- A break empties the reserve to zero, not left at its pre-hit charge -- the restore duration must mean
# --- the same thing every time. Removing the drain leaves whatever charge the reserve held at the moment
# --- it broke, letting a shield that broke nearly full restore far faster than one that broke empty.
Mutation(
rule="a break must empty the reserve, not leave its pre-hit charge intact",
file=SHIELD_CS,
anchor=" Broken = true;\n _reserve.AddCharge(-_reserve.Charge); // emptied by the break, not left at its pre-hit charge",
mutated=" Broken = true;",
test="ShieldReserveTests.BreakEmptiesTheReserveRegardlessOfChargeHeldAtTheMoment",
expect="red",
),
# --- A broken shield restores on RestoreDuration, not RefillDuration -- the two must stay genuinely
# --- separate speeds through the one InputCapacitor mechanism. Feeding RefillDuration in both branches
# --- collapses the punish window: a shield would come back exactly as fast broken as it refills while up.
Mutation(
rule="a broken shield must derive its rate from RestoreDuration, not RefillDuration",
file=SHIELD_CS,
anchor=" var duration = Broken ? Evaluate(_data.RestoreDuration) : Evaluate(_data.RefillDuration);",
mutated=" var duration = Evaluate(_data.RefillDuration);",
test="ShieldReserveTests.BrokenShieldRestoresOnRestoreDurationNotRefillDuration",
expect="red",
),
# --- A partial grant from the bus stretches both durations proportionally -- Shield must scale its banked
# --- charge by Item.PowerSupply like every other IPowerConsumer, not assume it always receives the whole
# --- request regardless of what the bus actually granted.
Mutation(
rule="the reserve must only bank the fraction PowerBus actually granted, not the whole request",
file=SHIELD_CS,
anchor=" _reserve.AddCharge(_reserve.RequestedFill(dt) * Item.PowerSupply);",
mutated=" _reserve.AddCharge(_reserve.RequestedFill(dt));",
test="ShieldReserveTests.PartialGrantStretchesTheRefillDurationProportionally",
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())