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>
222 lines
11 KiB
Python
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())
|