aboutsummaryrefslogtreecommitdiff
path: root/claude-templates/.ai/scripts/tests/test_cj_remove_block.py
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-15 16:56:39 -0500
committerCraig Jennings <c@cjennings.net>2026-05-15 16:56:39 -0500
commitc1d4e3c4a42abd01bc7ef83b1d6ae036ee32ef1d (patch)
tree3e6dcc682cbf2311409e7f71d83a7d4088392068 /claude-templates/.ai/scripts/tests/test_cj_remove_block.py
parent2b471da4bab014a2e096f63edc7aac235fc40fdd (diff)
parent69c5e4ace81586c05dea6a9a3afd54dafa61a73b (diff)
downloadrulesets-c1d4e3c4a42abd01bc7ef83b1d6ae036ee32ef1d.tar.gz
rulesets-c1d4e3c4a42abd01bc7ef83b1d6ae036ee32ef1d.zip
Merge commit '69c5e4ace81586c05dea6a9a3afd54dafa61a73b' as 'claude-templates'
Diffstat (limited to 'claude-templates/.ai/scripts/tests/test_cj_remove_block.py')
-rw-r--r--claude-templates/.ai/scripts/tests/test_cj_remove_block.py157
1 files changed, 157 insertions, 0 deletions
diff --git a/claude-templates/.ai/scripts/tests/test_cj_remove_block.py b/claude-templates/.ai/scripts/tests/test_cj_remove_block.py
new file mode 100644
index 0000000..2c8dade
--- /dev/null
+++ b/claude-templates/.ai/scripts/tests/test_cj_remove_block.py
@@ -0,0 +1,157 @@
+"""Tests for cj-remove-block.py — idempotent removal of cj annotations by line range.
+
+The script removes lines [start, end] (1-indexed, inclusive) from an org file but
+validates first that those lines actually look like a cj annotation. Refusing on
+mismatch protects against accidentally trimming the wrong block when line numbers
+drift between scan and remove calls.
+"""
+
+import subprocess
+from pathlib import Path
+
+import pytest
+
+SCRIPT = Path(__file__).parent.parent / "cj-remove-block.py"
+
+
+@pytest.fixture
+def run_remove(tmp_path):
+ """Write content to a temp org file, run cj-remove-block, return new contents."""
+ def _run(content: str, start: int, end: int) -> str:
+ f = tmp_path / "test.org"
+ f.write_text(content)
+ subprocess.run(
+ ["python3", str(SCRIPT),
+ "--file", str(f),
+ "--start", str(start),
+ "--end", str(end)],
+ check=True,
+ capture_output=True,
+ )
+ return f.read_text()
+ return _run
+
+
+@pytest.fixture
+def run_remove_expecting_failure(tmp_path):
+ """Write content, run cj-remove-block expecting non-zero exit; return CalledProcessError."""
+ def _run(content: str, start: int, end: int):
+ f = tmp_path / "test.org"
+ f.write_text(content)
+ with pytest.raises(subprocess.CalledProcessError) as excinfo:
+ subprocess.run(
+ ["python3", str(SCRIPT),
+ "--file", str(f),
+ "--start", str(start),
+ "--end", str(end)],
+ check=True,
+ capture_output=True,
+ )
+ return excinfo.value, f.read_text() # file should be unchanged on failure
+ return _run
+
+
+# ----------------------------------------------------------------------
+# Source-block removal
+# ----------------------------------------------------------------------
+
+class TestCjRemoveBlockSourceBlock:
+ """Removing #+begin_src cj: ... #+end_src blocks."""
+
+ def test_cj_remove_block_minimal_three_line_source_block(self, run_remove):
+ """Normal: the three lines of a minimal source-block are removed."""
+ content = "* S\n#+begin_src cj: comment\nbody\n#+end_src\nafter\n"
+ result = run_remove(content, start=2, end=4)
+ assert result == "* S\nafter\n"
+
+ def test_cj_remove_block_source_block_multiline_body(self, run_remove):
+ """Normal: source-block with multi-line body removed cleanly."""
+ content = "* S\n#+begin_src cj: comment\nline 1\nline 2\nline 3\n#+end_src\nafter\n"
+ result = run_remove(content, start=2, end=6)
+ assert result == "* S\nafter\n"
+
+ def test_cj_remove_block_preserves_lines_before_and_after(self, run_remove):
+ """Normal: surrounding lines outside the range stay intact."""
+ content = "before\n#+begin_src cj: comment\nx\n#+end_src\nafter\n"
+ result = run_remove(content, start=2, end=4)
+ assert result == "before\nafter\n"
+
+ def test_cj_remove_block_source_block_with_label_variant(self, run_remove):
+ """Boundary: source-block with no trailing label (#+begin_src cj:) also removable."""
+ content = "* S\n#+begin_src cj:\nbody\n#+end_src\nafter\n"
+ result = run_remove(content, start=2, end=4)
+ assert result == "* S\nafter\n"
+
+ def test_cj_remove_block_case_insensitive_fence(self, run_remove):
+ """Boundary: case-variant fences (#+BEGIN_SRC / #+END_SRC) also removable."""
+ content = "* S\n#+BEGIN_SRC cj: comment\nbody\n#+END_SRC\nafter\n"
+ result = run_remove(content, start=2, end=4)
+ assert result == "* S\nafter\n"
+
+
+# ----------------------------------------------------------------------
+# Legacy-inline removal
+# ----------------------------------------------------------------------
+
+class TestCjRemoveBlockLegacyInline:
+ """Removing single-line legacy `cj: ...` annotations."""
+
+ def test_cj_remove_block_legacy_inline_single_line(self, run_remove):
+ """Normal: single legacy-inline cj line removed."""
+ content = "* S\ncj: legacy note\nafter\n"
+ result = run_remove(content, start=2, end=2)
+ assert result == "* S\nafter\n"
+
+ def test_cj_remove_block_legacy_inline_at_eof(self, run_remove):
+ """Boundary: legacy-inline cj at last line; file ends cleanly."""
+ content = "* S\ncj: at end\n"
+ result = run_remove(content, start=2, end=2)
+ assert result == "* S\n"
+
+
+# ----------------------------------------------------------------------
+# Refusal-on-mismatch safety
+# ----------------------------------------------------------------------
+
+class TestCjRemoveBlockSafety:
+ """Refuses to remove if the specified range doesn't look like a cj annotation."""
+
+ def test_cj_remove_block_refuses_non_cj_single_line(self, run_remove_expecting_failure):
+ """Error: a single non-cj line is rejected."""
+ err, post_content = run_remove_expecting_failure(
+ "* S\nthis is not a cj line\nafter\n", start=2, end=2,
+ )
+ assert err.returncode != 0
+ # File must be unchanged
+ assert post_content == "* S\nthis is not a cj line\nafter\n"
+
+ def test_cj_remove_block_refuses_mismatched_fence(self, run_remove_expecting_failure):
+ """Error: multi-line range where line N isn't an opening fence is rejected."""
+ err, post_content = run_remove_expecting_failure(
+ "* S\nbody1\nbody2\n#+end_src\nafter\n", start=2, end=4,
+ )
+ assert err.returncode != 0
+ assert "body1" in post_content # file unchanged
+
+ def test_cj_remove_block_refuses_missing_closing_fence(self, run_remove_expecting_failure):
+ """Error: multi-line range where line M isn't a closing fence is rejected."""
+ err, post_content = run_remove_expecting_failure(
+ "* S\n#+begin_src cj: comment\nbody\nnot-a-close\nafter\n", start=2, end=4,
+ )
+ assert err.returncode != 0
+ assert "not-a-close" in post_content
+
+ def test_cj_remove_block_refuses_out_of_bounds(self, run_remove_expecting_failure):
+ """Error: range outside the file is rejected, file unchanged."""
+ err, post_content = run_remove_expecting_failure(
+ "* S\nafter\n", start=5, end=7,
+ )
+ assert err.returncode != 0
+ assert post_content == "* S\nafter\n"
+
+ def test_cj_remove_block_refuses_inverted_range(self, run_remove_expecting_failure):
+ """Error: end < start is rejected, file unchanged."""
+ original = "* S\n#+begin_src cj: comment\nbody\n#+end_src\n"
+ err, post_content = run_remove_expecting_failure(original, start=4, end=2)
+ assert err.returncode != 0
+ assert post_content == original