1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
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
|