"""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