#!/usr/bin/env python3 """Test suite for pa_editor.py — validates all operation types and error handling. Run: python tools/migration/test_pa_editor.py """ import json import os import shutil import subprocess import sys import tempfile SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) PA_EDITOR = os.path.join(SCRIPT_DIR, "pa_editor.py") PROJECT_ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, "..", "..")) PASS = 0 FAIL = 0 def test(name: str, passed: bool, detail: str = ""): global PASS, FAIL if passed: PASS += 1 print(f" PASS {name}") else: FAIL += 1 print(f" FAIL {name}" + (f" — {detail}" if detail else "")) def run_editor(manifest_path: str, *args: str) -> tuple[int, str, str]: cmd = [ sys.executable, PA_EDITOR, manifest_path, *args, "--project-root", PROJECT_ROOT, ] result = subprocess.run(cmd, capture_output=True, text=True, cwd=PROJECT_ROOT) return result.returncode, result.stdout, result.stderr def write_file(path: str, content: str): os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w", encoding="utf-8", newline="\n") as f: f.write(content) def read_file(path: str) -> str: with open(path, "r", encoding="utf-8") as f: return f.read() def write_manifest(tmpdir: str, manifest: dict) -> str: path = os.path.join(tmpdir, "manifest.json") with open(path, "w", encoding="utf-8") as f: json.dump(manifest, f, indent=2) return path SAMPLE_JAVA = """\ package test; import net.minecraft.nbt.NBTTagCompound; import net.minecraft.entity.player.EntityPlayer; import java.util.List; public class Sample { private int id; public void load(NBTTagCompound nbt) { id = nbt.getInteger("id"); } public void interact(EntityPlayer player) { player.addChatMessage(null); } } """ def run_tests(): tmpdir = tempfile.mkdtemp( prefix="pa_editor_test_", dir=os.path.join(PROJECT_ROOT, "tools", "migration") ) try: _run_all(tmpdir) finally: shutil.rmtree(tmpdir, ignore_errors=True) print(f"\n{'=' * 50}") print(f" RESULTS: {PASS} passed, {FAIL} failed") print(f"{'=' * 50}") return FAIL == 0 def _run_all(tmpdir: str): print("\n=== PA Editor Test Suite ===\n") # ---------------------------------------------------------------- # TEST 1: Replace by pattern # ---------------------------------------------------------------- f1 = os.path.join(tmpdir, "Replace.java") write_file(f1, SAMPLE_JAVA) rel1 = os.path.relpath(f1, PROJECT_ROOT) manifest = write_manifest( tmpdir, { "edits": [ { "path": rel1, "ops": [ { "op": "replace", "target": { "pattern": "^import net\\.minecraft\\.nbt\\.NBTTagCompound;" }, "content": "import api.INbt;", }, ], } ] }, ) code, out, _ = run_editor(manifest, "--force") test("replace-by-pattern: exit 0", code == 0) content = read_file(f1) test("replace-by-pattern: import changed", "import api.INbt;" in content) test( "replace-by-pattern: old import gone", "import net.minecraft.nbt.NBTTagCompound;" not in content, ) # ---------------------------------------------------------------- # TEST 2: Insert before/after # ---------------------------------------------------------------- f2 = os.path.join(tmpdir, "Insert.java") write_file(f2, SAMPLE_JAVA) rel2 = os.path.relpath(f2, PROJECT_ROOT) manifest = write_manifest( tmpdir, { "edits": [ { "path": rel2, "ops": [ { "op": "insert", "target": {"line": 1}, "position": "before", "content": "// Header comment", }, { "op": "insert", "target": {"pattern": "^public class"}, "position": "after", "content": " // class body start", }, ], } ] }, ) code, out, _ = run_editor(manifest, "--force") test("insert: exit 0", code == 0) content = read_file(f2) lines = content.split("\n") test("insert-before: first line is comment", lines[0] == "// Header comment") test( "insert-after: class body comment exists", " // class body start" in content ) # ---------------------------------------------------------------- # TEST 3: Delete lines # ---------------------------------------------------------------- f3 = os.path.join(tmpdir, "Delete.java") write_file(f3, SAMPLE_JAVA) rel3 = os.path.relpath(f3, PROJECT_ROOT) manifest = write_manifest( tmpdir, { "edits": [ { "path": rel3, "ops": [ { "op": "delete", "target": {"pattern": "^import net\\.minecraft\\.nbt"}, "count": 1, }, { "op": "delete", "target": {"pattern": "^import net\\.minecraft\\.entity"}, "count": 1, }, ], } ] }, ) code, out, _ = run_editor(manifest, "--force") test("delete: exit 0", code == 0) content = read_file(f3) test("delete: MC imports removed", "net.minecraft" not in content) test("delete: java import preserved", "java.util.List" in content) # ---------------------------------------------------------------- # TEST 4: Multi-line insert # ---------------------------------------------------------------- f4 = os.path.join(tmpdir, "MultiInsert.java") write_file(f4, SAMPLE_JAVA) rel4 = os.path.relpath(f4, PROJECT_ROOT) manifest = write_manifest( tmpdir, { "edits": [ { "path": rel4, "ops": [ { "op": "insert", "target": {"pattern": "^public class"}, "position": "before", "content": "/**\n * Migrated class.\n * PA types only.\n */", }, ], } ] }, ) code, _, _ = run_editor(manifest, "--force") test("multi-line-insert: exit 0", code == 0) content = read_file(f4) test("multi-line-insert: javadoc present", "* Migrated class." in content) test("multi-line-insert: javadoc 3 lines", "* PA types only." in content) # ---------------------------------------------------------------- # TEST 5: Multi-line replace (count > 1) # ---------------------------------------------------------------- f5 = os.path.join(tmpdir, "MultiReplace.java") write_file(f5, SAMPLE_JAVA) rel5 = os.path.relpath(f5, PROJECT_ROOT) manifest = write_manifest( tmpdir, { "edits": [ { "path": rel5, "ops": [ { "op": "replace", "target": {"pattern": "^import net\\.minecraft\\.nbt"}, "count": 2, "content": "import api.INbt;\nimport api.IPlayer;", }, ], } ] }, ) code, _, _ = run_editor(manifest, "--force") test("multi-replace: exit 0", code == 0) content = read_file(f5) test( "multi-replace: new imports present", "import api.INbt;" in content and "import api.IPlayer;" in content, ) test("multi-replace: old imports gone", "net.minecraft" not in content) # ---------------------------------------------------------------- # TEST 6: Dry-run does not write # ---------------------------------------------------------------- f6 = os.path.join(tmpdir, "DryRun.java") write_file(f6, SAMPLE_JAVA) rel6 = os.path.relpath(f6, PROJECT_ROOT) original = read_file(f6) manifest = write_manifest( tmpdir, { "edits": [ { "path": rel6, "ops": [ { "op": "replace", "target": {"line": 1}, "content": "// MODIFIED", } ], } ] }, ) code, out, _ = run_editor(manifest, "--dry-run") test("dry-run: exit 0", code == 0) test("dry-run: file unchanged", read_file(f6) == original) test("dry-run: output shows DRY RUN", "DRY RUN" in out) # ---------------------------------------------------------------- # TEST 7: Dry-run JSON output # ---------------------------------------------------------------- code, out, _ = run_editor(manifest, "--dry-run", "--json") test("dry-run-json: exit 0", code == 0) data = json.loads(out) test("dry-run-json: has diffs", "diffs" in data) test("dry-run-json: dry_run flag true", data.get("dry_run") is True) # ---------------------------------------------------------------- # TEST 8: Backup creates .bak files # ---------------------------------------------------------------- f8 = os.path.join(tmpdir, "Backup.java") write_file(f8, SAMPLE_JAVA) rel8 = os.path.relpath(f8, PROJECT_ROOT) original = read_file(f8) manifest = write_manifest( tmpdir, { "edits": [ { "path": rel8, "ops": [ { "op": "replace", "target": {"line": 1}, "content": "// MODIFIED", } ], } ] }, ) code, _, _ = run_editor(manifest, "--force", "--backup") test("backup: exit 0", code == 0) test("backup: .bak exists", os.path.isfile(f8 + ".bak")) test("backup: .bak has original content", read_file(f8 + ".bak") == original) # ---------------------------------------------------------------- # TEST 9: Error — file not found # ---------------------------------------------------------------- manifest = write_manifest( tmpdir, { "edits": [ { "path": os.path.join(tmpdir, "NONEXISTENT.java"), "ops": [{"op": "replace", "target": {"line": 1}, "content": "x"}], } ] }, ) code, out, _ = run_editor(manifest, "--force") test("error-file-not-found: exit 1", code == 1) test("error-file-not-found: error in output", "file not found" in out) # ---------------------------------------------------------------- # TEST 10: Error — line out of bounds # ---------------------------------------------------------------- f10 = os.path.join(tmpdir, "OOB.java") write_file(f10, SAMPLE_JAVA) rel10 = os.path.relpath(f10, PROJECT_ROOT) manifest = write_manifest( tmpdir, { "edits": [ { "path": rel10, "ops": [{"op": "delete", "target": {"line": 9999}, "count": 1}], } ] }, ) code, out, _ = run_editor(manifest, "--force") test("error-oob: exit 1", code == 1) test("error-oob: error mentions bounds", "out of bounds" in out) # ---------------------------------------------------------------- # TEST 11: Error — pattern not found # ---------------------------------------------------------------- f11 = os.path.join(tmpdir, "PatternFail.java") write_file(f11, SAMPLE_JAVA) rel11 = os.path.relpath(f11, PROJECT_ROOT) manifest = write_manifest( tmpdir, { "edits": [ { "path": rel11, "ops": [ { "op": "replace", "target": {"pattern": "WILL_NEVER_MATCH"}, "content": "x", } ], } ] }, ) code, out, _ = run_editor(manifest, "--force") test("error-pattern-not-found: exit 1", code == 1) test("error-pattern-not-found: error mentions pattern", "not found" in out) # ---------------------------------------------------------------- # TEST 12: Error — invalid regex # ---------------------------------------------------------------- f12 = os.path.join(tmpdir, "BadRegex.java") write_file(f12, SAMPLE_JAVA) rel12 = os.path.relpath(f12, PROJECT_ROOT) manifest = write_manifest( tmpdir, { "edits": [ { "path": rel12, "ops": [ { "op": "replace", "target": {"pattern": "[invalid"}, "content": "x", } ], } ] }, ) code, out, _ = run_editor(manifest, "--force") test("error-bad-regex: exit 1", code == 1) test("error-bad-regex: error mentions regex", "invalid regex" in out) # ---------------------------------------------------------------- # TEST 13: Atomicity — partial failure prevents ALL writes # ---------------------------------------------------------------- f13a = os.path.join(tmpdir, "Atomic_A.java") f13b = os.path.join(tmpdir, "Atomic_B.java") write_file(f13a, SAMPLE_JAVA) write_file(f13b, SAMPLE_JAVA) orig_a = read_file(f13a) rel13a = os.path.relpath(f13a, PROJECT_ROOT) rel13b = os.path.relpath(f13b, PROJECT_ROOT) manifest = write_manifest( tmpdir, { "edits": [ { "path": rel13a, "ops": [ { "op": "insert", "target": {"line": 1}, "position": "before", "content": "// SHOULD NOT APPEAR", } ], }, { "path": rel13b, "ops": [ { "op": "replace", "target": {"pattern": "WILL_NEVER_MATCH"}, "content": "x", } ], }, ] }, ) code, out, _ = run_editor(manifest, "--force") test("atomicity: exit 1", code == 1) test("atomicity: file A unchanged", read_file(f13a) == orig_a, read_file(f13a)[:50]) # ---------------------------------------------------------------- # TEST 14: Multi-file success # ---------------------------------------------------------------- f14a = os.path.join(tmpdir, "Multi_A.java") f14b = os.path.join(tmpdir, "Multi_B.java") f14c = os.path.join(tmpdir, "Multi_C.java") write_file(f14a, SAMPLE_JAVA) write_file(f14b, SAMPLE_JAVA) write_file(f14c, SAMPLE_JAVA) rel14a = os.path.relpath(f14a, PROJECT_ROOT) rel14b = os.path.relpath(f14b, PROJECT_ROOT) rel14c = os.path.relpath(f14c, PROJECT_ROOT) manifest = write_manifest( tmpdir, { "edits": [ { "path": rel14a, "ops": [ { "op": "replace", "target": {"pattern": "^import net\\.minecraft\\.nbt"}, "content": "import api.INbt;", } ], }, { "path": rel14b, "ops": [ { "op": "delete", "target": {"pattern": "^import net\\.minecraft\\.entity"}, "count": 1, } ], }, { "path": rel14c, "ops": [ { "op": "insert", "target": {"line": 1}, "position": "before", "content": "// File C header", } ], }, ] }, ) code, out, _ = run_editor(manifest, "--force", "--json") test("multi-file: exit 0", code == 0) data = json.loads(out) test("multi-file: 3 files modified", data.get("files_modified") == 3) test("multi-file A: import replaced", "import api.INbt;" in read_file(f14a)) test( "multi-file B: import deleted", "import net.minecraft.entity.player.EntityPlayer;" not in read_file(f14b), ) test("multi-file C: header added", read_file(f14c).startswith("// File C header")) # ---------------------------------------------------------------- # TEST 15: Pattern occurrence targeting # ---------------------------------------------------------------- f15 = os.path.join(tmpdir, "Occurrence.java") write_file(f15, "line A\nline B\nline A\nline C\nline A\n") rel15 = os.path.relpath(f15, PROJECT_ROOT) manifest = write_manifest( tmpdir, { "edits": [ { "path": rel15, "ops": [ { "op": "replace", "target": {"pattern": "^line A", "occurrence": 2}, "content": "line A_REPLACED", } ], } ] }, ) code, _, _ = run_editor(manifest, "--force") test("occurrence: exit 0", code == 0) lines = read_file(f15).split("\n") test("occurrence: line 1 unchanged", lines[0] == "line A") test("occurrence: line 3 replaced", lines[2] == "line A_REPLACED") test("occurrence: line 5 unchanged", lines[4] == "line A") # ---------------------------------------------------------------- # TEST 16: Content as list of strings # ---------------------------------------------------------------- f16 = os.path.join(tmpdir, "ListContent.java") write_file(f16, SAMPLE_JAVA) rel16 = os.path.relpath(f16, PROJECT_ROOT) manifest = write_manifest( tmpdir, { "edits": [ { "path": rel16, "ops": [ { "op": "insert", "target": {"line": 1}, "position": "before", "content": ["// Line 1", "// Line 2", "// Line 3"], } ], } ] }, ) code, _, _ = run_editor(manifest, "--force") test("list-content: exit 0", code == 0) lines = read_file(f16).split("\n") test( "list-content: 3 lines inserted", lines[0] == "// Line 1" and lines[1] == "// Line 2" and lines[2] == "// Line 3", ) # ---------------------------------------------------------------- # TEST 17: Structural validation (missing fields) # ---------------------------------------------------------------- manifest = write_manifest( tmpdir, {"edits": [{"path": "x.java", "ops": [{"op": "insert"}]}]} ) code, out, _ = run_editor(manifest, "--force") test("validation: missing content/target", code == 1) manifest = write_manifest( tmpdir, { "edits": [ { "path": "x.java", "ops": [{"op": "bogus", "target": {"line": 1}, "content": "x"}], } ] }, ) code, out, _ = run_editor(manifest, "--force") test("validation: invalid op name", code == 1) if __name__ == "__main__": ok = run_tests() sys.exit(0 if ok else 1)