test_patch_supercharge.py
file-level
1
files
1
commits
0
hotspots
0
🧊 dead
0
💥 blast risk
| 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 |