Both scripts aborted with "anchor must occur exactly once, found 0" -- later cuts moved the code their anchors pinned: - cut3's fourth mutation anchored PowerBus.Step's old flat "write GrantRatio to every consumer" loop, which Cut 5 replaced with AllocateTiers' own tiered closing loop (draw.Item.PowerSupply = ratios[draw.Tier]). Re-anchored to that loop; the rule (every draw gets written, not just the first) and its test (PowerBusTests.EveryConsumingItemReadsTheSameGrantRatio, a single-tier fixture) are unchanged. - cut4's shield mutation anchored CanTakeHit's old single-expression body (`return Item == null || _reserve.CanSpend(...)`), which the 2026-09-19 shield reserve ruling split into an Item-null check, a Broken check and this CanSpend call as its own line, and F5 (this same pass) then made the whole method a pure query. Re-anchored to the CanSpend line; the rule (a partial draw is allowed, not gated on covering the whole reserve) and its test are unchanged. Both scripts now run clean end to end against the pinned CultLib worktree: every mutation kills its named test, the no-op control does not. Scope note: mutation_tests_stats_power_cut0/1/2 also abort on stale anchors (spot-checked while re-anchoring cut3/cut4, then the check was cancelled partway through cut1 once the pattern was clear) -- not touched here, as F8 named only cut3 and cut4. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
175 lines
7.8 KiB
Python
175 lines
7.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Mutation tests for docs/stats-and-power-cut.md Cut 3 (the power bus replaces TryConsumeEnergy).
|
|
|
|
Cut 3 deletes Entity.CanConsumeEnergy/TryConsumeEnergy and Reactor's own capacitor handling, replacing them with
|
|
PowerBus: a single per-Entity step that turns every IPowerConsumer's request plus the tick's reactor generation
|
|
and stored capacitor charge into one shared grant ratio, refusing demand that supply cannot cover instead of
|
|
taxing the shortfall onto the reactor as heat. This script mutates each rule's own line of code and asserts the
|
|
named test in PowerBusTests.cs goes red for exactly that reason. A no-op control mutation proves the
|
|
read/replace/restore path is transparent.
|
|
|
|
Usage:
|
|
python tests/mutation_tests_stats_power_cut3.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"
|
|
|
|
|
|
@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="Assets/Scripts/ServerShared/PowerBus.cs",
|
|
anchor=" TotalGeneration = reactors.Sum(r => r.Generation(dt));",
|
|
mutated=" TotalGeneration = reactors.Sum(r => r.Generation(dt));",
|
|
test="PowerBusTests.GrantIsCappedAtGenerationPlusStoredCapacitorCharge",
|
|
expect="green",
|
|
),
|
|
|
|
# --- Cut 3 verification bullet 1 (§0.5): "a ship whose requests exceed generation gets a total grant equal
|
|
# --- to generation plus available capacitor charge, and not more." Dropping the capacitor draw on a deficit
|
|
# --- caps the grant at generation alone -- still capped, but at the wrong ceiling, which is exactly the kind
|
|
# --- of silent under-delivery a ceiling test has to catch as sharply as the old unlimited-reactor bug. ---
|
|
Mutation(
|
|
rule="a deficit must draw stored capacitor charge, not just generation",
|
|
file="Assets/Scripts/ServerShared/PowerBus.cs",
|
|
anchor=" var chargeDrawn = min(preCapacitorNet, availableCharge);",
|
|
mutated=" var chargeDrawn = 0f;",
|
|
test="PowerBusTests.GrantIsCappedAtGenerationPlusStoredCapacitorCharge",
|
|
expect="red",
|
|
),
|
|
|
|
# --- Cut 3 verification bullet 2 (§0.5): "a refused request produces no effect at all -- not a partial one,
|
|
# --- and not an overload-heat receipt." This is the map's own named mutation: "make the bus grant the full
|
|
# --- request and heat the reactor; it goes red" -- forcing overload to 0 finances the whole deficit for
|
|
# --- free, the exact HEAD bug ("every draw succeeds while one reactor is online") Cut 3 exists to remove. ---
|
|
Mutation(
|
|
rule="unmet demand after generation and stored charge must be refused (overload), never financed for free",
|
|
file="Assets/Scripts/ServerShared/PowerBus.cs",
|
|
anchor=" overload = preCapacitorNet - chargeDrawn;",
|
|
mutated=" overload = 0f;",
|
|
test="PowerBusTests.ZeroSupplyRefusesTheWholeRequest",
|
|
expect="red",
|
|
),
|
|
|
|
# --- Cut 3 verification bullet 3 (§0.5): "two ships with identical loadouts fitted in opposite equip order
|
|
# --- get identical grants. That is the death of the hidden priority." The invariant that makes equip order
|
|
# --- unable to matter is that every consuming item reads back its own tier's GrantRatio; restricting the
|
|
# --- write to only the first draw found reintroduces exactly the "whoever equipped first" priority the cut
|
|
# --- kills, just relocated from SortPosition into this loop.
|
|
# ---
|
|
# --- F8 (docs/stats-and-power-cut.md, Soul pass 2026-09-19) re-anchor: Cut 3's own flat "write GrantRatio to
|
|
# --- every consumer" loop (Step) was replaced by Cut 5's tiered AllocateTiers, whose closing loop writes each
|
|
# --- draw's own tier ratio instead -- the anchor below follows that move; the rule and the test it's pinned
|
|
# --- against (EveryConsumingItemReadsTheSameGrantRatio, a single-tier fixture) are unchanged. ---
|
|
Mutation(
|
|
rule="every consuming item must be written its own tier's GrantRatio, not just the first draw found",
|
|
file="Assets/Scripts/ServerShared/PowerBus.cs",
|
|
anchor=(
|
|
" foreach (var draw in draws)\n"
|
|
" {\n"
|
|
" draw.Item.PowerSupply = ratios[draw.Tier];"
|
|
),
|
|
mutated=(
|
|
" foreach (var draw in draws.Take(1))\n"
|
|
" {\n"
|
|
" draw.Item.PowerSupply = ratios[draw.Tier];"
|
|
),
|
|
test="PowerBusTests.EveryConsumingItemReadsTheSameGrantRatio",
|
|
expect="red",
|
|
),
|
|
|
|
# Entity.TrySpendCapacitorCharge/CanSpendCapacitorCharge -- the four instant draws' named, temporary
|
|
# exception this file used to pin -- died with their last caller in Cut 4
|
|
# (docs/stats-and-power-cut.md, Cut 4). See mutation_tests_stats_power_cut4.py's InputCapacitor.TrySpend
|
|
# case for the same atomicity rule, now scoped to each behaviour's own buffer.
|
|
]
|
|
|
|
|
|
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())
|