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>
218 lines
9.7 KiB
Python
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())
|