Add the Cut 1 batchmode probe and its mutation harness
Companion to a935b965: the actual verification tooling for the shield-panel
Cut 1 landing, split into its own commit since it's infrastructure rather
than the ported sources.
.gitignore: ignore tests/_scratch_* (the mutation harness's throwaway log
directory) alongside the existing scratch-* patterns.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
a935b965c9
commit
ff8b2ff7b6
4 changed files with 308 additions and 0 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -100,6 +100,7 @@ Vault/Aetheria/.obsidian/plugins/copilot/data.json
|
|||
# come back.
|
||||
.scratch-*
|
||||
scratch-*
|
||||
tests/_scratch_*
|
||||
.codex/
|
||||
.codex-tmp/
|
||||
.evepkg/
|
||||
|
|
|
|||
143
Assets/Scripts/Editor/ShieldPanelCut1Verify.cs
Normal file
143
Assets/Scripts/Editor/ShieldPanelCut1Verify.cs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using ShieldField;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Rendering;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Aetheria.EditorTools
|
||||
{
|
||||
/// <summary>
|
||||
/// Batchmode probe for the shield-panel Cut 1 landing (docs/shield-panel-cut.md).
|
||||
/// No NUnit coverage exists for these files (Tests.asmdef cannot see
|
||||
/// Assembly-CSharp — see the map's Q7), so this is the check that stands in
|
||||
/// for a unit test on two Cut 1 rules that a compile pass alone cannot prove:
|
||||
///
|
||||
/// 1. The shader and compute shader carry zero compile errors on built-in.
|
||||
/// 2. Penrose cell count actually scales with panelRadius (D18) instead of
|
||||
/// being a fixed function of the (now-removed) authored iteration count.
|
||||
///
|
||||
/// Run via: Unity -batchmode -projectPath <repo> -executeMethod
|
||||
/// Aetheria.EditorTools.ShieldPanelCut1Verify.Run -quit -logFile <path>
|
||||
/// Exits with a non-zero code if either rule is violated, so the log alone is
|
||||
/// not the only signal — a CI runner can gate on the exit code too.
|
||||
/// </summary>
|
||||
public static class ShieldPanelCut1Verify
|
||||
{
|
||||
public static void Run()
|
||||
{
|
||||
bool ok = true;
|
||||
ok &= CheckShaderMessages();
|
||||
ok &= CheckComputeMessages();
|
||||
ok &= CheckPenroseScalesWithRadius();
|
||||
|
||||
Debug.Log(ok
|
||||
? "[ShieldPanelCut1Verify] PASS"
|
||||
: "[ShieldPanelCut1Verify] FAIL — see errors above");
|
||||
|
||||
EditorApplication.Exit(ok ? 0 : 1);
|
||||
}
|
||||
|
||||
static bool CheckShaderMessages()
|
||||
{
|
||||
var shader = AssetDatabase.LoadAssetAtPath<Shader>(
|
||||
"Assets/Shaders/Compute/ShieldField/ShieldPanel.shader");
|
||||
if (shader == null)
|
||||
{
|
||||
Debug.LogError("[ShieldPanelCut1Verify] ShieldPanel.shader not found.");
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ok = true;
|
||||
|
||||
// The first query can observe the shader mid-compile and return a stale/empty
|
||||
// message set; warm it up before trusting the result.
|
||||
_ = ShaderUtil.GetShaderMessageCount(shader);
|
||||
_ = ShaderUtil.ShaderHasError(shader);
|
||||
var shaderMsgs = ShaderUtil.GetShaderMessages(shader);
|
||||
|
||||
foreach (var m in shaderMsgs)
|
||||
{
|
||||
if (m.severity == ShaderCompilerMessageSeverity.Error)
|
||||
{
|
||||
Debug.LogError($"[ShieldPanelCut1Verify] Shader error: {m.message} ({m.file}:{m.line})");
|
||||
ok = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.Log($"[ShieldPanelCut1Verify] Shader warning: {m.message} ({m.file}:{m.line})");
|
||||
}
|
||||
}
|
||||
|
||||
if (ShaderUtil.ShaderHasError(shader))
|
||||
{
|
||||
Debug.LogError("[ShieldPanelCut1Verify] ShieldPanel.shader has compile errors (ShaderHasError).");
|
||||
ok = false;
|
||||
}
|
||||
|
||||
Debug.Log($"[ShieldPanelCut1Verify] shader supported={!ShaderUtil.ShaderHasError(shader)} messages={shaderMsgs.Length}");
|
||||
return ok;
|
||||
}
|
||||
|
||||
static bool CheckComputeMessages()
|
||||
{
|
||||
var cs = AssetDatabase.LoadAssetAtPath<ComputeShader>(
|
||||
"Assets/Shaders/Compute/ShieldField/ShieldSim.compute");
|
||||
if (cs == null)
|
||||
{
|
||||
Debug.LogError("[ShieldPanelCut1Verify] ShieldSim.compute not found.");
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ok = true;
|
||||
var msgs = ShaderUtil.GetComputeShaderMessages(cs);
|
||||
foreach (var m in msgs)
|
||||
{
|
||||
if (m.severity == ShaderCompilerMessageSeverity.Error)
|
||||
{
|
||||
Debug.LogError($"[ShieldPanelCut1Verify] Compute shader error: {m.message} ({m.file}:{m.line})");
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
Debug.Log($"[ShieldPanelCut1Verify] compute messages={msgs.Length}, errors={msgs.Count(m => m.severity == ShaderCompilerMessageSeverity.Error)}");
|
||||
return ok;
|
||||
}
|
||||
|
||||
static bool CheckPenroseScalesWithRadius()
|
||||
{
|
||||
const float cellSize = 0.35f;
|
||||
float[] radii = { 1.5f, 10f };
|
||||
int[] penroseCounts = new int[radii.Length];
|
||||
int[] hexCounts = new int[radii.Length];
|
||||
|
||||
for (int i = 0; i < radii.Length; i++)
|
||||
{
|
||||
var pSoup = Tilings.Generate(TilingKind.PenroseP3, radii[i], cellSize, 1337);
|
||||
var pMerged = TilingBuilder.MergeRhombs(pSoup);
|
||||
var pData = TilingBuilder.Build(pMerged, weldEps: Mathf.Max(cellSize * 0.02f, 1e-4f),
|
||||
clipRadius: radii[i], seed: 1337);
|
||||
penroseCounts[i] = pData.CellCount;
|
||||
|
||||
var hSoup = Tilings.Generate(TilingKind.Hex, radii[i], cellSize, 1337);
|
||||
var hData = TilingBuilder.Build(hSoup, weldEps: Mathf.Max(cellSize * 0.02f, 1e-4f),
|
||||
clipRadius: radii[i], seed: 1337);
|
||||
hexCounts[i] = hData.CellCount;
|
||||
|
||||
Debug.Log($"[ShieldPanelCut1Verify] radius={radii[i]} cellSize={cellSize} " +
|
||||
$"PenroseP3(merged) cells={penroseCounts[i]} Hex cells={hexCounts[i]}");
|
||||
}
|
||||
|
||||
bool ok = penroseCounts[0] != penroseCounts[1];
|
||||
if (!ok)
|
||||
Debug.LogError($"[ShieldPanelCut1Verify] D18 not fixed: Penrose cell count identical " +
|
||||
$"({penroseCounts[0]}) at radius {radii[0]} and {radii[1]}.");
|
||||
else if (penroseCounts[1] < penroseCounts[0])
|
||||
Debug.LogError($"[ShieldPanelCut1Verify] D18 regression: Penrose cell count DECREASED " +
|
||||
$"from {penroseCounts[0]} (r={radii[0]}) to {penroseCounts[1]} (r={radii[1]}).");
|
||||
else
|
||||
Debug.Log("[ShieldPanelCut1Verify] D18 fixed: Penrose cell count increases with panelRadius.");
|
||||
|
||||
return ok && penroseCounts[1] > penroseCounts[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/Editor/ShieldPanelCut1Verify.cs.meta
Normal file
2
Assets/Scripts/Editor/ShieldPanelCut1Verify.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 22124e7322748c84a80cc32b7d601bce
|
||||
162
tests/mutation_tests_shield_panel_cut1.py
Normal file
162
tests/mutation_tests_shield_panel_cut1.py
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Mutation tests for docs/shield-panel-cut.md Cut 1 (the shield-panel source landing).
|
||||
|
||||
Assets/Scripts/Editor/ShieldPanelCut1Verify.cs is the batchmode probe Cut 1 added because no
|
||||
NUnit coverage can reach these files: Tests.asmdef cannot see Assembly-CSharp (the map's Q7), and
|
||||
the rules it defends are Unity-native (shader/compute compile errors, cell counts a compute
|
||||
kernel would read back). A probe nobody can break is a probe nobody can trust, so each case here
|
||||
mutates the *real* source in one specific way the cut's own text says it fixed, runs
|
||||
ShieldPanelCut1Verify.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 tests/mutation_tests.py (the item-provenance
|
||||
cut's source-anchor-mutation-plus-`dotnet test` runner, with no path to driving a Unity batchmode
|
||||
check) or tests/mutation_tests_addressables_cut.py (EngineAssetCheck's own harness, same reason
|
||||
this one exists, different rule). It touches only files this cut owns.
|
||||
|
||||
Run from the repo root: python tests/mutation_tests_shield_panel_cut1.py
|
||||
Exit code 0 only if every case behaved as Cut 1'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"
|
||||
TILINGS_CS = SHIELD_FIELD / "Tilings.cs"
|
||||
SHIELD_COMMON_HLSL = SHIELD_FIELD / "ShieldCommon.hlsl"
|
||||
SHIELD_PANEL_CS = SHIELD_FIELD / "ShieldPanel.cs"
|
||||
|
||||
|
||||
def run_probe(log_path: Path) -> tuple[int, str]:
|
||||
proc = subprocess.run(
|
||||
[UNITY_EXE, "-batchmode", "-nographics", "-quit", "-projectPath", str(REPO_ROOT),
|
||||
"-executeMethod", "Aetheria.EditorTools.ShieldPanelCut1Verify.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) -> str:
|
||||
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")
|
||||
return original
|
||||
|
||||
|
||||
def main() -> int:
|
||||
scratch = REPO_ROOT / "tests" / "_scratch_shield_panel_cut1"
|
||||
scratch.mkdir(parents=True, exist_ok=True)
|
||||
failures: list[str] = []
|
||||
|
||||
original_tilings = TILINGS_CS.read_text(encoding="utf-8")
|
||||
original_common = SHIELD_COMMON_HLSL.read_text(encoding="utf-8")
|
||||
original_panel = SHIELD_PANEL_CS.read_text(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 "[ShieldPanelCut1Verify] PASS" not in text:
|
||||
failures.append("control: expected exit 0 and PASS on the unmutated tree")
|
||||
else:
|
||||
print("control OK: exit 0, PASS")
|
||||
|
||||
# --- D18: Penrose must stop expressing panelRadius if the derivation is reverted ---
|
||||
print("\n=== mutation: D18 regression (Penrose iterations decoupled from radius again) ===")
|
||||
anchor = (
|
||||
" int iterations = Mathf.Clamp(\n"
|
||||
" Mathf.CeilToInt(Mathf.Log(Mathf.Max(radius / Mathf.Max(cellSize, 1e-4f), 1f), Phi)) + 1,\n"
|
||||
" 1, 12);\n"
|
||||
" return Penrose(radius, iterations);"
|
||||
)
|
||||
mutated = (
|
||||
" // mutation_tests_shield_panel_cut1.py: reintroduce the D18 bug — a fixed\n"
|
||||
" // iteration count, independent of radius or cellSize.\n"
|
||||
" return Penrose(radius, 5);"
|
||||
)
|
||||
try:
|
||||
replace_unique(TILINGS_CS, anchor, mutated)
|
||||
code, text = run_probe(scratch / "d18.log")
|
||||
if code == 0 or "D18 not fixed" not in text:
|
||||
failures.append("D18 regression: probe did not go red naming the D18 regression")
|
||||
else:
|
||||
print("OK: caught — probe exited non-zero and named the D18 regression")
|
||||
finally:
|
||||
TILINGS_CS.write_text(original_tilings, encoding="utf-8")
|
||||
|
||||
# --- D4: `centroid` is a reserved word in the shader/compute compiler; reintroducing it as
|
||||
# a CellStatic field name must produce a compile error somewhere it is used (ShieldCommon.hlsl
|
||||
# is shared by both ShieldSim.compute and ShieldPanel.shader) ---
|
||||
print("\n=== mutation: D4 regression (centroid field name reintroduced) ===")
|
||||
anchor = "struct CellStatic\n{\n float2 center;"
|
||||
mutated = "struct CellStatic\n{\n float2 centroid;"
|
||||
try:
|
||||
replace_unique(SHIELD_COMMON_HLSL, anchor, mutated)
|
||||
# Every use of `.center` on a CellStatic instance across the two GPU-side consumers
|
||||
# must track the field rename for this mutation to compile-check the reserved name
|
||||
# (not just silently fail on a dangling reference to a field that no longer exists
|
||||
# under that name — the point is the *reserved word*, not a typo).
|
||||
sim_cs_path = SHIELD_FIELD / "ShieldSim.compute"
|
||||
shader_path = SHIELD_FIELD / "ShieldPanel.shader"
|
||||
orig_sim = sim_cs_path.read_text(encoding="utf-8")
|
||||
orig_shader = shader_path.read_text(encoding="utf-8")
|
||||
sim_cs_path.write_text(orig_sim.replace("c.center", "c.centroid"), encoding="utf-8")
|
||||
shader_path.write_text(
|
||||
orig_shader.replace("x.c.center", "x.c.centroid").replace("nc.center", "nc.centroid"),
|
||||
encoding="utf-8",
|
||||
)
|
||||
try:
|
||||
code, text = run_probe(scratch / "d4.log")
|
||||
caught = ("Compute shader error" in text or "Shader error" in text) and "centroid" in text
|
||||
if code == 0 or not caught:
|
||||
failures.append("D4 regression: probe did not go red naming the reserved 'centroid' token")
|
||||
else:
|
||||
print("OK: caught — probe exited non-zero and named the reserved 'centroid' token")
|
||||
finally:
|
||||
sim_cs_path.write_text(orig_sim, encoding="utf-8")
|
||||
shader_path.write_text(orig_shader, encoding="utf-8")
|
||||
finally:
|
||||
SHIELD_COMMON_HLSL.write_text(original_common, encoding="utf-8")
|
||||
|
||||
# --- D2/D5: RenderParams.shaderPass does not exist in 6000.3.24f1; reintroducing it must
|
||||
# fail the C# compile, not silently resurrect the deleted per-pass draw path ---
|
||||
print("\n=== mutation: D2 regression (RenderParams.shaderPass reintroduced) ===")
|
||||
anchor = " Graphics.RenderPrimitivesIndirect(rp, MeshTopology.Triangles, argsFill, 1, 0);\n }"
|
||||
mutated = " rp.shaderPass = 0;\n Graphics.RenderPrimitivesIndirect(rp, MeshTopology.Triangles, argsFill, 1, 0);\n }"
|
||||
try:
|
||||
replace_unique(SHIELD_PANEL_CS, anchor, mutated)
|
||||
code, text = run_probe(scratch / "d2.log")
|
||||
if code == 0:
|
||||
failures.append("D2 regression: batchmode exited 0 despite a nonexistent RenderParams.shaderPass member")
|
||||
else:
|
||||
print("OK: caught — batchmode failed to compile with shaderPass reintroduced")
|
||||
finally:
|
||||
SHIELD_PANEL_CS.write_text(original_panel, encoding="utf-8")
|
||||
|
||||
finally:
|
||||
# belt-and-suspenders: never leave a mutated file behind even if something above threw
|
||||
TILINGS_CS.write_text(original_tilings, encoding="utf-8")
|
||||
SHIELD_COMMON_HLSL.write_text(original_common, encoding="utf-8")
|
||||
SHIELD_PANEL_CS.write_text(original_panel, encoding="utf-8")
|
||||
|
||||
print("\n=== summary ===")
|
||||
if failures:
|
||||
for f in failures:
|
||||
print(f"FAIL: {f}")
|
||||
return 1
|
||||
print(f"All cases behaved as Cut 1's verification section demands. Logs: {scratch}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Reference in a new issue