Aetheria/tests/mutation_tests_shield_panel_cut3.py
Meta Vens 5ae5c518a2 Add the Cut 3 batchmode probe and its mutation harness
ShieldPanelCut3Verify.cs stands in for NUnit coverage the same way Cut 1 and
Cut 2's probes do (Tests.asmdef cannot see Assembly-CSharp). It builds an
envelope/prototype/interceptor rig purely in code and steps ShieldPanel's
private Update() by reflection, since Cut 1 removed [ExecuteAlways] and
Update() otherwise never runs in edit mode. It needs GPU access, so unlike
Cut 1/2's probes it runs without -nographics.

Checks: same-frame injection (D17), the gate staying open on the following
frame (D20), reuse striking the existing panel and eroding its minimum temper,
pool exhaustion holding the cap with no allocation once full, null-guarded
Absorb, no rebuild across a pool return/reuse cycle (driven directly via
OnEnable/OnDisable reflection, since Object.Instantiate's OnEnable is not
synchronous in every context this runs in), and the D21 Penrose/Hex density
ratio at radius 1.5/3/5/10.

mutation_tests_shield_panel_cut3.py mutates each rule Cut 3 establishes back
to its pre-fix or pre-rule shape and asserts the probe goes red naming it,
plus a no-op control. Kept separate from _cut1.py/_cut2.py (each owns its own
cut's rules) and from mutation_tests.py (the item-provenance cut's headless
dotnet runner, no path to a Unity batchmode check).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 13:51:44 +02:00

222 lines
11 KiB
Python

#!/usr/bin/env python3
"""Mutation tests for docs/shield-panel-cut.md Cut 3 (the interceptor presenter and panel pool).
Assets/Scripts/Editor/ShieldPanelCut3Verify.cs is the batchmode probe Cut 3 added, for the same
reason Cut 1 and Cut 2 have one (the map's Q7): Tests.asmdef cannot see Assembly-CSharp. Unlike
Cut 1/2's probes this one drives actual GraphicsBuffer dispatches (it steps ShieldPanel's private
Update() by reflection, since Cut 1 deliberately removed [ExecuteAlways] -- D13), so it must run
WITHOUT -nographics.
Each case here mutates the *real* source in one specific way the cut's own text says it fixed or
established as a rule, runs ShieldPanelCut3Verify.Run in Unity batchmode, asserts it goes red for
the reason the cut names, then restores the source exactly. A control case first proves the
unmutated tree passes, so a case that "fails" for the wrong reason (a broken harness, not a caught
mutation) cannot hide.
This is a separate script rather than an addition to mutation_tests_shield_panel_cut1.py or
_cut2.py (each of those owns its own cut's rules and touches only that cut's files) or
mutation_tests.py (the item-provenance cut's headless dotnet-test runner, with no path to a Unity
batchmode check). It touches only files Cut 3 owns: ShieldPanel.cs (the D17/D20/pooling fixes),
Tilings.cs (D21), and ShieldInterceptor.cs (reuse/exhaustion).
Run from the repo root: python tests/mutation_tests_shield_panel_cut3.py
Exit code 0 only if every case behaved as Cut 3's verification section demands.
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
UNITY_EXE = r"C:\Program Files\Unity\Hub\Editor\6000.3.24f1\Editor\Unity.exe"
SHIELD_FIELD = REPO_ROOT / "Assets" / "Shaders" / "Compute" / "ShieldField"
SHIELD_PANEL_CS = SHIELD_FIELD / "ShieldPanel.cs"
TILINGS_CS = SHIELD_FIELD / "Tilings.cs"
SHIELD_INTERCEPTOR_CS = REPO_ROOT / "Assets" / "Scripts" / "Gameplay" / "ShieldInterceptor.cs"
def run_probe(log_path: Path) -> tuple[int, str]:
proc = subprocess.run(
[UNITY_EXE, "-batchmode", "-quit", "-projectPath", str(REPO_ROOT),
"-executeMethod", "Aetheria.EditorTools.ShieldPanelCut3Verify.Run",
"-logFile", str(log_path)],
timeout=600,
)
text = log_path.read_text(encoding="utf-8", errors="replace")
return proc.returncode, text
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 main() -> int:
scratch = REPO_ROOT / "tests" / "_scratch_shield_panel_cut3"
scratch.mkdir(parents=True, exist_ok=True)
failures: list[str] = []
original_panel = SHIELD_PANEL_CS.read_text(encoding="utf-8")
original_tilings = TILINGS_CS.read_text(encoding="utf-8")
original_interceptor = SHIELD_INTERCEPTOR_CS.read_text(encoding="utf-8")
def restore_all() -> None:
SHIELD_PANEL_CS.write_text(original_panel, encoding="utf-8")
TILINGS_CS.write_text(original_tilings, encoding="utf-8")
SHIELD_INTERCEPTOR_CS.write_text(original_interceptor, encoding="utf-8")
try:
# --- control: the real, unmutated tree must pass ---
print("\n=== control: unmutated tree ===")
code, text = run_probe(scratch / "control.log")
if code != 0 or "[ShieldPanelCut3Verify] PASS" not in text:
failures.append("control: expected exit 0 and PASS on the unmutated tree")
else:
print("control OK: exit 0, PASS")
# --- D20: reverting the backdated arm instant to the pre-Cut-3 hardcoded 0f does NOT
# swallow the spawn-frame hit itself (that's the seeded bState, D17's own fix, still doing
# its job for frame 1) -- it reopens a narrower, one-frame-later symptom: KUpdate resets
# growth back to 0 at the end of frame 1, so frame 2's own injection is what gets gated.
# This is exactly why the probe checks frame 2 separately from frame 1. ---
print("\n=== mutation: D20 regression (panelSpawnTime hardcoded back to 0f) ===")
try:
replace_unique(
SHIELD_PANEL_CS,
'sim.SetFloat("_PanelSpawnTime", panelSpawnTime);',
'sim.SetFloat("_PanelSpawnTime", 0f);',
)
code, text = run_probe(scratch / "d17_spawntime.log")
caught = code != 0 and "D20 not fixed" in text
if not caught:
failures.append("D20 regression (spawn time): probe did not go red naming D20")
else:
print("OK: caught — probe named D20 not fixed")
finally:
restore_all()
# --- D17: the other half of the fix -- if growth is never seeded ahead of time (left at
# KInit's 0), the gate blocks the spawn-frame hit even with panelSpawnTime correct. ---
print("\n=== mutation: D17 regression (growth seed always 0) ===")
try:
replace_unique(
SHIELD_PANEL_CS,
"growthSeed[i].growth = Mathf.Clamp01(\n"
" (growDuration - data.cells[i].spawnDelay) / Mathf.Max(growDuration, 1e-3f));",
"growthSeed[i].growth = 0f; // mutation_tests_shield_panel_cut3.py: reintroduce D17",
)
code, text = run_probe(scratch / "d17_seed.log")
caught = code != 0 and "D17 not fixed" in text
if not caught:
failures.append("D17 regression (growth seed): probe did not go red naming D17")
else:
print("OK: caught — probe named D17 not fixed")
finally:
restore_all()
# --- Pooling: reverting OnEnable/OnDisable to the pre-Cut-3 unconditional Rebuild/
# ReleaseAll must rebuild the tiling on every pooled reuse, which the pool exists to avoid
# (§2.4: 1.3-21ms vs 8-11us) -- CheckPoolReuseDoesNotRebuild is the rule that pins this. ---
print("\n=== mutation: pooling regression (OnEnable rebuilds every reuse again) ===")
try:
replace_unique(
SHIELD_PANEL_CS,
"void OnEnable() { if (data == null) Rebuild(); }\n"
" void OnDisable() { }\n"
" void OnDestroy() { ReleaseAll(); }",
"void OnEnable() { Rebuild(); } // mutation_tests_shield_panel_cut3.py: reintroduce the pre-Cut-3 rebuild-on-every-reuse bug\n"
" void OnDisable() { ReleaseAll(); }",
)
code, text = run_probe(scratch / "pooling.log")
caught = code != 0 and "OnEnable is rebuilding (or OnDisable is releasing)" in text
if not caught:
failures.append("pooling regression: probe did not go red naming the rebuild-on-reuse regression")
else:
print("OK: caught — probe named the rebuild-on-reuse regression")
finally:
restore_all()
# --- Reuse: a second hit inside a living panel must strike it, not spawn a new one. Force
# FindReusable to always miss and the rule collapses -- the reuse probe must see live count
# go to 2 on the second (nearby) strike instead of staying at 1. ---
print("\n=== mutation: reuse regression (FindReusable always misses) ===")
try:
replace_unique(
SHIELD_INTERCEPTOR_CS,
" int FindReusable(Vector3 point)\n {\n float threshold = PanelRadius * ReuseFraction;",
" int FindReusable(Vector3 point)\n {\n return -1; // mutation_tests_shield_panel_cut3.py: never reuse\n"
" float threshold = PanelRadius * ReuseFraction;",
)
code, text = run_probe(scratch / "reuse.log")
caught = code != 0 and "spawned a new panel" in text
if not caught:
failures.append("reuse regression: probe did not go red naming the spurious new panel")
else:
print("OK: caught — probe named the spurious new panel")
finally:
restore_all()
# --- Pool exhaustion: MaxLivePanels must cap the live set. Removing the eviction branch (so
# exhaustion always spawns a fresh instance instead of recycling the oldest) must blow the cap. ---
print("\n=== mutation: exhaustion regression (cap never enforced) ===")
try:
replace_unique(
SHIELD_INTERCEPTOR_CS,
" ShieldPanel panel;\n"
" Prototype panelProto;\n"
" if (_live.Count >= MaxLivePanels)\n",
" ShieldPanel panel;\n"
" Prototype panelProto;\n"
" if (false) // mutation_tests_shield_panel_cut3.py: never evict, blow the cap\n",
)
code, text = run_probe(scratch / "exhaustion.log")
caught = code != 0 and "the cap did not hold" in text
if not caught:
failures.append("exhaustion regression: probe did not go red naming the blown cap")
else:
print("OK: caught — probe named the blown cap")
finally:
restore_all()
# --- D21: reverting the Round-to-nearest fix to the pre-Cut-3 CeilToInt(...) + 1 formula
# must blow past the small-factor bound again (24x/8x order-of-magnitude, not 2-5x). ---
print("\n=== mutation: D21 regression (Penrose iteration count reverts to Ceil+1) ===")
try:
replace_unique(
TILINGS_CS,
" int iterations = Mathf.Clamp(\n"
" Mathf.RoundToInt(Mathf.Log(Mathf.Max(radius / Mathf.Max(cellSize, 1e-4f), 1f), Phi)),\n"
" 1, 12);",
" int iterations = Mathf.Clamp(\n"
" Mathf.CeilToInt(Mathf.Log(Mathf.Max(radius / Mathf.Max(cellSize, 1e-4f), 1f), Phi)) + 1,\n"
" 1, 12); // mutation_tests_shield_panel_cut3.py: reintroduce D21's overshoot",
)
code, text = run_probe(scratch / "d21.log")
caught = code != 0 and "not within a small factor" in text
if not caught:
failures.append("D21 regression: probe did not go red naming the small-factor violation")
else:
print("OK: caught — probe named the small-factor violation")
finally:
restore_all()
finally:
# belt-and-suspenders: never leave a mutated file behind even if something above threw
restore_all()
print("\n=== summary ===")
if failures:
for f in failures:
print(f"FAIL: {f}")
return 1
print(f"All cases behaved as Cut 3's verification section demands. Logs: {scratch}")
return 0
if __name__ == "__main__":
sys.exit(main())