gabriel / muse public

test_patch_supercharge.py file-level

at sha256:c · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:4 Merge branch 'dev' into main · gabriel · Jun 17, 2026
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 for d in (d1, d2):
104 d.pop("duration_ms", None)
105 d.pop("timestamp", 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", "--body", 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", "--body", str(body),
304 "billing.py::validate_amount",
305 ])
306 r2 = runner.invoke(cli, [
307 "code", "patch", "--json", "--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 d1.pop("timestamp", None)
315 d2.pop("timestamp", None)
316 assert d1 == d2
317
318
319 # ---------------------------------------------------------------------------
320 # 7. Data integrity — bytes outside patched range unchanged
321 # ---------------------------------------------------------------------------
322
323
324 class TestDataIntegrity:
325 def test_bytes_before_symbol_unchanged(self, repo: pathlib.Path) -> None:
326 original = (repo / "billing.py").read_text()
327 # Find where validate_amount starts
328 lines = original.splitlines(keepends=True)
329 # Locate first occurrence
330 val_idx = next(i for i, l in enumerate(lines) if "def validate_amount" in l)
331 before_original = "".join(lines[:val_idx])
332
333 body = repo / "new.py"
334 body.write_text("def validate_amount(amount: float) -> bool:\n return True\n")
335 runner.invoke(cli, [
336 "code", "patch", "--body", str(body), "billing.py::validate_amount",
337 ])
338
339 patched = (repo / "billing.py").read_text()
340 patched_lines = patched.splitlines(keepends=True)
341 val_idx2 = next(i for i, l in enumerate(patched_lines) if "def validate_amount" in l)
342 before_patched = "".join(patched_lines[:val_idx2])
343
344 assert before_original == before_patched, "Bytes before patched symbol changed"
345
346 def test_bytes_after_symbol_unchanged(self, repo: pathlib.Path) -> None:
347 original = (repo / "billing.py").read_text()
348 lines = original.splitlines(keepends=True)
349 # find format_receipt (comes after validate_amount)
350 fmt_idx = next(i for i, l in enumerate(lines) if "def format_receipt" in l)
351 after_original = "".join(lines[fmt_idx:])
352
353 body = repo / "new.py"
354 body.write_text("def validate_amount(amount: float) -> bool:\n return True\n")
355 runner.invoke(cli, [
356 "code", "patch", "--body", str(body), "billing.py::validate_amount",
357 ])
358
359 patched = (repo / "billing.py").read_text()
360 patched_lines = patched.splitlines(keepends=True)
361 fmt_idx2 = next(i for i, l in enumerate(patched_lines) if "def format_receipt" in l)
362 after_patched = "".join(patched_lines[fmt_idx2:])
363
364 assert after_original == after_patched, "Bytes after patched symbol changed"
365
366 def test_patched_file_is_valid_python(self, repo: pathlib.Path) -> None:
367 import ast
368 body = repo / "new.py"
369 body.write_text("def validate_amount(amount: float) -> bool:\n return True\n")
370 runner.invoke(cli, [
371 "code", "patch", "--body", str(body), "billing.py::validate_amount",
372 ])
373 src = (repo / "billing.py").read_bytes()
374 ast.parse(src) # raises SyntaxError if corrupt
375
376
377 # ---------------------------------------------------------------------------
378 # 8. CLI-level class method patch
379 # ---------------------------------------------------------------------------
380
381
382 class TestPatchMethod:
383 def test_patch_class_method(self, repo: pathlib.Path) -> None:
384 body = repo / "new.py"
385 body.write_text(
386 "def compute_total(self, items: list[int]) -> int:\n"
387 " return sum(items) * 2\n"
388 )
389 result = runner.invoke(cli, [
390 "code", "patch", "--body", str(body),
391 "billing.py::Invoice.compute_total",
392 ])
393 assert result.exit_code == 0, result.output
394 src = (repo / "billing.py").read_text()
395 assert "sum(items) * 2" in src
396
397 def test_patch_method_leaves_sibling_method_intact(self, repo: pathlib.Path) -> None:
398 body = repo / "new.py"
399 body.write_text(
400 "def compute_total(self, items: list[int]) -> int:\n"
401 " return sum(items) * 2\n"
402 )
403 runner.invoke(cli, [
404 "code", "patch", "--body", str(body),
405 "billing.py::Invoice.compute_total",
406 ])
407 src = (repo / "billing.py").read_text()
408 assert "apply_discount" in src
409
410 def test_patch_method_json_schema(self, repo: pathlib.Path) -> None:
411 body = repo / "new.py"
412 body.write_text(
413 "def compute_total(self, items: list[int]) -> int:\n"
414 " return sum(items) * 2\n"
415 )
416 result = runner.invoke(cli, [
417 "code", "patch", "-j", "--body", str(body),
418 "billing.py::Invoice.compute_total",
419 ])
420 assert result.exit_code == 0, result.output
421 data = json.loads(result.output.strip())
422 assert data["address"] == "billing.py::Invoice.compute_total"
423 assert "exit_code" in data
424 assert "duration_ms" in data
425 assert "symbols_preserved" in data
426
427
428 # ---------------------------------------------------------------------------
429 # 9. Empty body rejected
430 # ---------------------------------------------------------------------------
431
432
433 class TestEmptyBody:
434 def test_empty_body_rejected(self, repo: pathlib.Path) -> None:
435 """An empty replacement body is invalid Python — must be rejected."""
436 body = repo / "empty.py"
437 body.write_text("")
438 result = runner.invoke(cli, [
439 "code", "patch", "--body", str(body), "billing.py::validate_amount",
440 ])
441 # Empty body produces a file that either fails syntax check or produces
442 # an empty symbol — either outcome means the patch must fail or the
443 # symbol disappears. We just require the file is still valid Python.
444 import ast
445 src = (repo / "billing.py").read_bytes()
446 ast.parse(src) # original must not be corrupted
447
448 def test_empty_body_does_not_corrupt_file(self, repo: pathlib.Path) -> None:
449 original = (repo / "billing.py").read_text()
450 body = repo / "empty.py"
451 body.write_text("")
452 runner.invoke(cli, [
453 "code", "patch", "--body", str(body), "billing.py::validate_amount",
454 ])
455 # Either the file is unchanged (error path) or valid Python
456 import ast
457 src = (repo / "billing.py").read_bytes()
458 ast.parse(src)
459
460
461 class TestRegisterFlags:
462 def _parse(self, *args: str) -> "argparse.Namespace":
463 import argparse
464 from muse.cli.commands.patch import register
465 p = argparse.ArgumentParser()
466 subs = p.add_subparsers()
467 register(subs)
468 return p.parse_args(["patch", "dummy::sym", "--body", "/dev/null", *args])
469
470 def test_json_short_flag(self) -> None:
471 args = self._parse("-j")
472 assert args.json_out is True
473
474 def test_json_long_flag(self) -> None:
475 args = self._parse("--json")
476 assert args.json_out is True
477
478 def test_default_no_json(self) -> None:
479 args = self._parse()
480 assert args.json_out is False
481