diff --git a/.gitignore b/.gitignore index 0524e8cb8..75b9cad4f 100644 --- a/.gitignore +++ b/.gitignore @@ -100,6 +100,7 @@ Vault/Aetheria/.obsidian/plugins/copilot/data.json # come back. .scratch-* scratch-* +tests/_scratch_* .codex/ .codex-tmp/ .evepkg/ diff --git a/Assets/Scripts/Editor/ShieldPanelCut1Verify.cs b/Assets/Scripts/Editor/ShieldPanelCut1Verify.cs new file mode 100644 index 000000000..58e53a1ad --- /dev/null +++ b/Assets/Scripts/Editor/ShieldPanelCut1Verify.cs @@ -0,0 +1,143 @@ +using System; +using System.Linq; +using ShieldField; +using UnityEditor; +using UnityEditor.Rendering; +using UnityEngine; + +namespace Aetheria.EditorTools +{ + /// + /// 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. + /// + 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( + "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( + "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]; + } + } +} diff --git a/Assets/Scripts/Editor/ShieldPanelCut1Verify.cs.meta b/Assets/Scripts/Editor/ShieldPanelCut1Verify.cs.meta new file mode 100644 index 000000000..a09fb26c3 --- /dev/null +++ b/Assets/Scripts/Editor/ShieldPanelCut1Verify.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 22124e7322748c84a80cc32b7d601bce \ No newline at end of file diff --git a/tests/mutation_tests_shield_panel_cut1.py b/tests/mutation_tests_shield_panel_cut1.py new file mode 100644 index 000000000..00128c413 --- /dev/null +++ b/tests/mutation_tests_shield_panel_cut1.py @@ -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())