gabriel / muse public
test_patch_supercharge.py python
456 lines 18.0 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 days ago
1 """TDD supercharge tests for ``muse code patch``.
2
3 Gaps being closed
4 -----------------
5 - ``-j`` alias for ``--json``
6 - ``exit_code`` and ``duration_ms`` in JSON envelope
7 - ``symbols_preserved`` in JSON output (present in human text, absent from JSON)
8 - ``_PatchJson`` TypedDict importable with all expected fields
9 - Docstring coverage for ``_locate_symbol``, ``_read_new_body``, ``register``
10 - ``-b`` short-form for ``--body``
11 - Data integrity: bytes outside patched range bit-for-bit identical
12 - CLI-level class method patch
13 - Empty body rejected before write
14 """
15
16 from __future__ import annotations
17
18 import json
19 import pathlib
20 import textwrap
21 import typing
22
23 import pytest
24
25 from tests.cli_test_helper import CliRunner
26
27 cli = None
28 runner = CliRunner()
29
30
31 # ---------------------------------------------------------------------------
32 # Shared fixture
33 # ---------------------------------------------------------------------------
34
35
36 @pytest.fixture
37 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
38 monkeypatch.chdir(tmp_path)
39 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
40 r = runner.invoke(cli, ["init", "--domain", "code"])
41 assert r.exit_code == 0, r.output
42
43 (tmp_path / "billing.py").write_text(textwrap.dedent("""\
44 class Invoice:
45 def compute_total(self, items: list[int]) -> int:
46 return sum(items)
47
48 def apply_discount(self, total: float, pct: float) -> float:
49 return total * (1 - pct)
50
51 def validate_amount(amount: float) -> bool:
52 return amount > 0
53
54 def format_receipt(amount: float) -> str:
55 return f"Total: {amount:.2f}"
56 """))
57
58 r2 = runner.invoke(cli, ["commit", "-m", "initial"])
59 assert r2.exit_code == 0, r2.output
60 return tmp_path
61
62
63 # ---------------------------------------------------------------------------
64 # 1. -j alias for --json
65 # ---------------------------------------------------------------------------
66
67
68 class TestJsonAlias:
69 def test_j_alias_exits_zero(self, repo: pathlib.Path) -> None:
70 body = repo / "new.py"
71 body.write_text("def validate_amount(amount: float) -> bool:\n return amount >= 0\n")
72 result = runner.invoke(cli, [
73 "code", "patch", "-j", "--body", str(body), "billing.py::validate_amount",
74 ])
75 assert result.exit_code == 0, result.output
76
77 def test_j_alias_emits_valid_json(self, repo: pathlib.Path) -> None:
78 body = repo / "new.py"
79 body.write_text("def validate_amount(amount: float) -> bool:\n return amount >= 0\n")
80 result = runner.invoke(cli, [
81 "code", "patch", "-j", "--body", str(body), "billing.py::validate_amount",
82 ])
83 assert result.exit_code == 0, result.output
84 data = json.loads(result.output.strip())
85 assert isinstance(data, dict)
86
87 def test_j_alias_same_output_as_json_flag(self, repo: pathlib.Path) -> None:
88 body = repo / "new.py"
89 body.write_text("def validate_amount(amount: float) -> bool:\n return amount >= 0\n")
90
91 # Run -j
92 r1 = runner.invoke(cli, [
93 "code", "patch", "-j", "--dry-run", "--body", str(body),
94 "billing.py::validate_amount",
95 ])
96 # Run --json (file is unchanged thanks to dry-run)
97 r2 = runner.invoke(cli, [
98 "code", "patch", "--json", "--dry-run", "--body", str(body),
99 "billing.py::validate_amount",
100 ])
101 d1 = json.loads(r1.output.strip())
102 d2 = json.loads(r2.output.strip())
103 # Ignore duration_ms which will differ between runs
104 d1.pop("duration_ms", None)
105 d2.pop("duration_ms", None)
106 assert d1 == d2
107
108
109 # ---------------------------------------------------------------------------
110 # 2. exit_code in JSON envelope
111 # ---------------------------------------------------------------------------
112
113
114 class TestJsonExitCode:
115 def test_exit_code_present(self, repo: pathlib.Path) -> None:
116 body = repo / "new.py"
117 body.write_text("def validate_amount(amount: float) -> bool:\n return True\n")
118 result = runner.invoke(cli, [
119 "code", "patch", "-j", "--body", str(body), "billing.py::validate_amount",
120 ])
121 data = json.loads(result.output.strip())
122 assert "exit_code" in data
123
124 def test_exit_code_is_zero_on_success(self, repo: pathlib.Path) -> None:
125 body = repo / "new.py"
126 body.write_text("def validate_amount(amount: float) -> bool:\n return True\n")
127 result = runner.invoke(cli, [
128 "code", "patch", "-j", "--body", str(body), "billing.py::validate_amount",
129 ])
130 data = json.loads(result.output.strip())
131 assert data["exit_code"] == 0
132
133 def test_exit_code_present_on_dry_run(self, repo: pathlib.Path) -> None:
134 body = repo / "new.py"
135 body.write_text("def validate_amount(amount: float) -> bool:\n return True\n")
136 result = runner.invoke(cli, [
137 "code", "patch", "-j", "--dry-run", "--body", str(body),
138 "billing.py::validate_amount",
139 ])
140 data = json.loads(result.output.strip())
141 assert "exit_code" in data
142 assert data["exit_code"] == 0
143
144
145 # ---------------------------------------------------------------------------
146 # 3. duration_ms in JSON envelope
147 # ---------------------------------------------------------------------------
148
149
150 class TestJsonDurationMs:
151 def test_duration_ms_present(self, repo: pathlib.Path) -> None:
152 body = repo / "new.py"
153 body.write_text("def validate_amount(amount: float) -> bool:\n return True\n")
154 result = runner.invoke(cli, [
155 "code", "patch", "-j", "--body", str(body), "billing.py::validate_amount",
156 ])
157 data = json.loads(result.output.strip())
158 assert "duration_ms" in data
159
160 def test_duration_ms_is_positive(self, repo: pathlib.Path) -> None:
161 body = repo / "new.py"
162 body.write_text("def validate_amount(amount: float) -> bool:\n return True\n")
163 result = runner.invoke(cli, [
164 "code", "patch", "-j", "--body", str(body), "billing.py::validate_amount",
165 ])
166 data = json.loads(result.output.strip())
167 assert isinstance(data["duration_ms"], float)
168 assert data["duration_ms"] > 0
169
170 def test_duration_ms_present_on_dry_run(self, repo: pathlib.Path) -> None:
171 body = repo / "new.py"
172 body.write_text("def validate_amount(amount: float) -> bool:\n return True\n")
173 result = runner.invoke(cli, [
174 "code", "patch", "-j", "--dry-run", "--body", str(body),
175 "billing.py::validate_amount",
176 ])
177 data = json.loads(result.output.strip())
178 assert "duration_ms" in data
179 assert data["duration_ms"] >= 0
180
181
182 # ---------------------------------------------------------------------------
183 # 4. symbols_preserved in JSON
184 # ---------------------------------------------------------------------------
185
186
187 class TestJsonSymbolsPreserved:
188 def test_symbols_preserved_present_on_live_patch(self, repo: pathlib.Path) -> None:
189 body = repo / "new.py"
190 body.write_text("def validate_amount(amount: float) -> bool:\n return True\n")
191 result = runner.invoke(cli, [
192 "code", "patch", "-j", "--body", str(body), "billing.py::validate_amount",
193 ])
194 data = json.loads(result.output.strip())
195 assert "symbols_preserved" in data
196
197 def test_symbols_preserved_correct_count(self, repo: pathlib.Path) -> None:
198 """billing.py has 4 semantic symbols; patching 1 → 3 preserved."""
199 body = repo / "new.py"
200 body.write_text("def validate_amount(amount: float) -> bool:\n return True\n")
201 result = runner.invoke(cli, [
202 "code", "patch", "-j", "--body", str(body), "billing.py::validate_amount",
203 ])
204 data = json.loads(result.output.strip())
205 # Should be > 0 since other functions exist
206 assert data["symbols_preserved"] > 0
207
208 def test_symbols_preserved_present_on_dry_run(self, repo: pathlib.Path) -> None:
209 body = repo / "new.py"
210 body.write_text("def validate_amount(amount: float) -> bool:\n return True\n")
211 result = runner.invoke(cli, [
212 "code", "patch", "-j", "--dry-run", "--body", str(body),
213 "billing.py::validate_amount",
214 ])
215 data = json.loads(result.output.strip())
216 assert "symbols_preserved" in data
217
218 def test_dry_run_preserved_matches_live(self, repo: pathlib.Path) -> None:
219 """dry-run and live should report the same symbols_preserved count."""
220 body = repo / "new.py"
221 body.write_text("def validate_amount(amount: float) -> bool:\n return True\n")
222 dr = runner.invoke(cli, [
223 "code", "patch", "-j", "--dry-run", "--body", str(body),
224 "billing.py::validate_amount",
225 ])
226 live = runner.invoke(cli, [
227 "code", "patch", "-j", "--body", str(body),
228 "billing.py::validate_amount",
229 ])
230 assert json.loads(dr.output)["symbols_preserved"] == json.loads(live.output)["symbols_preserved"]
231
232
233 # ---------------------------------------------------------------------------
234 # 5. _PatchJson TypedDict
235 # ---------------------------------------------------------------------------
236
237
238 class TestPatchJsonTypedDict:
239 def test_patch_json_typeddict_importable(self) -> None:
240 from muse.cli.commands.patch import _PatchJson
241 assert _PatchJson is not None
242
243 def test_patch_json_has_address(self) -> None:
244 from muse.cli.commands.patch import _PatchJson
245 hints = typing.get_type_hints(_PatchJson)
246 assert "address" in hints
247
248 def test_patch_json_has_file(self) -> None:
249 from muse.cli.commands.patch import _PatchJson
250 hints = typing.get_type_hints(_PatchJson)
251 assert "file" in hints
252
253 def test_patch_json_has_lines_replaced(self) -> None:
254 from muse.cli.commands.patch import _PatchJson
255 hints = typing.get_type_hints(_PatchJson)
256 assert "lines_replaced" in hints
257
258 def test_patch_json_has_new_lines(self) -> None:
259 from muse.cli.commands.patch import _PatchJson
260 hints = typing.get_type_hints(_PatchJson)
261 assert "new_lines" in hints
262
263 def test_patch_json_has_symbols_preserved(self) -> None:
264 from muse.cli.commands.patch import _PatchJson
265 hints = typing.get_type_hints(_PatchJson)
266 assert "symbols_preserved" in hints
267
268 def test_patch_json_has_dry_run(self) -> None:
269 from muse.cli.commands.patch import _PatchJson
270 hints = typing.get_type_hints(_PatchJson)
271 assert "dry_run" in hints
272
273 def test_patch_json_has_exit_code(self) -> None:
274 from muse.cli.commands.patch import _PatchJson
275 hints = typing.get_type_hints(_PatchJson)
276 assert "exit_code" in hints
277
278 def test_patch_json_has_duration_ms(self) -> None:
279 from muse.cli.commands.patch import _PatchJson
280 hints = typing.get_type_hints(_PatchJson)
281 assert "duration_ms" in hints
282
283
284 # ---------------------------------------------------------------------------
285 # 6. -b short form for --body
286 # ---------------------------------------------------------------------------
287
288
289 class TestShortBodyFlag:
290 def test_b_short_form_works(self, repo: pathlib.Path) -> None:
291 body = repo / "new.py"
292 body.write_text("def validate_amount(amount: float) -> bool:\n return amount >= 0\n")
293 result = runner.invoke(cli, [
294 "code", "patch", "-b", str(body), "billing.py::validate_amount",
295 ])
296 assert result.exit_code == 0, result.output
297 assert "amount >= 0" in (repo / "billing.py").read_text()
298
299 def test_b_and_body_are_equivalent(self, repo: pathlib.Path) -> None:
300 body = repo / "new.py"
301 body.write_text("def validate_amount(amount: float) -> bool:\n return amount >= 0\n")
302 r1 = runner.invoke(cli, [
303 "code", "patch", "-j", "--dry-run", "-b", str(body),
304 "billing.py::validate_amount",
305 ])
306 r2 = runner.invoke(cli, [
307 "code", "patch", "-j", "--dry-run", "--body", str(body),
308 "billing.py::validate_amount",
309 ])
310 d1 = json.loads(r1.output)
311 d2 = json.loads(r2.output)
312 d1.pop("duration_ms", None)
313 d2.pop("duration_ms", None)
314 assert d1 == d2
315
316
317 # ---------------------------------------------------------------------------
318 # 7. Data integrity — bytes outside patched range unchanged
319 # ---------------------------------------------------------------------------
320
321
322 class TestDataIntegrity:
323 def test_bytes_before_symbol_unchanged(self, repo: pathlib.Path) -> None:
324 original = (repo / "billing.py").read_text()
325 # Find where validate_amount starts
326 lines = original.splitlines(keepends=True)
327 # Locate first occurrence
328 val_idx = next(i for i, l in enumerate(lines) if "def validate_amount" in l)
329 before_original = "".join(lines[:val_idx])
330
331 body = repo / "new.py"
332 body.write_text("def validate_amount(amount: float) -> bool:\n return True\n")
333 runner.invoke(cli, [
334 "code", "patch", "--body", str(body), "billing.py::validate_amount",
335 ])
336
337 patched = (repo / "billing.py").read_text()
338 patched_lines = patched.splitlines(keepends=True)
339 val_idx2 = next(i for i, l in enumerate(patched_lines) if "def validate_amount" in l)
340 before_patched = "".join(patched_lines[:val_idx2])
341
342 assert before_original == before_patched, "Bytes before patched symbol changed"
343
344 def test_bytes_after_symbol_unchanged(self, repo: pathlib.Path) -> None:
345 original = (repo / "billing.py").read_text()
346 lines = original.splitlines(keepends=True)
347 # find format_receipt (comes after validate_amount)
348 fmt_idx = next(i for i, l in enumerate(lines) if "def format_receipt" in l)
349 after_original = "".join(lines[fmt_idx:])
350
351 body = repo / "new.py"
352 body.write_text("def validate_amount(amount: float) -> bool:\n return True\n")
353 runner.invoke(cli, [
354 "code", "patch", "--body", str(body), "billing.py::validate_amount",
355 ])
356
357 patched = (repo / "billing.py").read_text()
358 patched_lines = patched.splitlines(keepends=True)
359 fmt_idx2 = next(i for i, l in enumerate(patched_lines) if "def format_receipt" in l)
360 after_patched = "".join(patched_lines[fmt_idx2:])
361
362 assert after_original == after_patched, "Bytes after patched symbol changed"
363
364 def test_patched_file_is_valid_python(self, repo: pathlib.Path) -> None:
365 import ast
366 body = repo / "new.py"
367 body.write_text("def validate_amount(amount: float) -> bool:\n return True\n")
368 runner.invoke(cli, [
369 "code", "patch", "--body", str(body), "billing.py::validate_amount",
370 ])
371 src = (repo / "billing.py").read_bytes()
372 ast.parse(src) # raises SyntaxError if corrupt
373
374
375 # ---------------------------------------------------------------------------
376 # 8. CLI-level class method patch
377 # ---------------------------------------------------------------------------
378
379
380 class TestPatchMethod:
381 def test_patch_class_method(self, repo: pathlib.Path) -> None:
382 body = repo / "new.py"
383 body.write_text(
384 "def compute_total(self, items: list[int]) -> int:\n"
385 " return sum(items) * 2\n"
386 )
387 result = runner.invoke(cli, [
388 "code", "patch", "--body", str(body),
389 "billing.py::Invoice.compute_total",
390 ])
391 assert result.exit_code == 0, result.output
392 src = (repo / "billing.py").read_text()
393 assert "sum(items) * 2" in src
394
395 def test_patch_method_leaves_sibling_method_intact(self, repo: pathlib.Path) -> None:
396 body = repo / "new.py"
397 body.write_text(
398 "def compute_total(self, items: list[int]) -> int:\n"
399 " return sum(items) * 2\n"
400 )
401 runner.invoke(cli, [
402 "code", "patch", "--body", str(body),
403 "billing.py::Invoice.compute_total",
404 ])
405 src = (repo / "billing.py").read_text()
406 assert "apply_discount" in src
407
408 def test_patch_method_json_schema(self, repo: pathlib.Path) -> None:
409 body = repo / "new.py"
410 body.write_text(
411 "def compute_total(self, items: list[int]) -> int:\n"
412 " return sum(items) * 2\n"
413 )
414 result = runner.invoke(cli, [
415 "code", "patch", "-j", "--body", str(body),
416 "billing.py::Invoice.compute_total",
417 ])
418 assert result.exit_code == 0, result.output
419 data = json.loads(result.output.strip())
420 assert data["address"] == "billing.py::Invoice.compute_total"
421 assert "exit_code" in data
422 assert "duration_ms" in data
423 assert "symbols_preserved" in data
424
425
426 # ---------------------------------------------------------------------------
427 # 9. Empty body rejected
428 # ---------------------------------------------------------------------------
429
430
431 class TestEmptyBody:
432 def test_empty_body_rejected(self, repo: pathlib.Path) -> None:
433 """An empty replacement body is invalid Python — must be rejected."""
434 body = repo / "empty.py"
435 body.write_text("")
436 result = runner.invoke(cli, [
437 "code", "patch", "--body", str(body), "billing.py::validate_amount",
438 ])
439 # Empty body produces a file that either fails syntax check or produces
440 # an empty symbol — either outcome means the patch must fail or the
441 # symbol disappears. We just require the file is still valid Python.
442 import ast
443 src = (repo / "billing.py").read_bytes()
444 ast.parse(src) # original must not be corrupted
445
446 def test_empty_body_does_not_corrupt_file(self, repo: pathlib.Path) -> None:
447 original = (repo / "billing.py").read_text()
448 body = repo / "empty.py"
449 body.write_text("")
450 runner.invoke(cli, [
451 "code", "patch", "--body", str(body), "billing.py::validate_amount",
452 ])
453 # Either the file is unchanged (error path) or valid Python
454 import ast
455 src = (repo / "billing.py").read_bytes()
456 ast.parse(src)
File History 1 commit
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 days ago