diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-15 16:56:39 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-15 16:56:39 -0500 |
| commit | c1d4e3c4a42abd01bc7ef83b1d6ae036ee32ef1d (patch) | |
| tree | 3e6dcc682cbf2311409e7f71d83a7d4088392068 /claude-templates/.ai/scripts/tests/test_cj_remove_block.py | |
| parent | 2b471da4bab014a2e096f63edc7aac235fc40fdd (diff) | |
| parent | 69c5e4ace81586c05dea6a9a3afd54dafa61a73b (diff) | |
| download | rulesets-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.py | 157 |
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 |
