test_rename_op.py
python
sha256:a154bc65916614c833d5a40a10d81ba3eae0d0495b0afddd34dc34f18d5e91b8
fix: test suite alignment and typing audit — zero violations
Sonnet 4.6
minor
⚠ breaking
23 days ago
| 1 | """TDD — RenameOp replaces DirectoryRenameOp and PatchOp.from_address. |
| 2 | |
| 3 | Design decisions under test: |
| 4 | 1. RenameOp is a first-class TypedDict: op="rename", address, from_address. |
| 5 | No file_count — that was validation metadata, not a semantic field. |
| 6 | 2. DirectoryRenameOp is gone; domain.py no longer exports it. |
| 7 | 3. PatchOp no longer has a from_address field; rename and modify are orthogonal. |
| 8 | 4. A moved+edited file emits two ops: RenameOp then PatchOp (two concerns, two ops). |
| 9 | 5. A pure directory rename emits one RenameOp (op="rename"), not "directory_rename". |
| 10 | 6. _print_structured_delta renders op="rename" as R old → new (cyan). |
| 11 | 7. muse diff --json places op="rename" in the renamed map: {old: new}. |
| 12 | 8. flat_directory_ops and touched_directories recognise op="rename". |
| 13 | 9. delta_summary counts "N renamed" for both directory and file renames uniformly. |
| 14 | """ |
| 15 | |
| 16 | from __future__ import annotations |
| 17 | |
| 18 | import json |
| 19 | import pathlib |
| 20 | import pytest |
| 21 | |
| 22 | from muse.core.types import Manifest |
| 23 | from muse.domain import DomainOp, RenameOp, SnapshotManifest |
| 24 | from muse.plugins.code.plugin import CodePlugin |
| 25 | |
| 26 | # --------------------------------------------------------------------------- |
| 27 | # Section 1 — Type system |
| 28 | # --------------------------------------------------------------------------- |
| 29 | |
| 30 | |
| 31 | class TestRenameOpType: |
| 32 | """RenameOp is in domain.py; DirectoryRenameOp is gone.""" |
| 33 | |
| 34 | def test_rename_op_importable(self) -> None: |
| 35 | from muse.domain import RenameOp # noqa: F401 |
| 36 | |
| 37 | def test_rename_op_has_correct_fields(self) -> None: |
| 38 | from muse.domain import RenameOp |
| 39 | op = RenameOp(op="rename", address="bufar", from_address="foobar") |
| 40 | assert op["op"] == "rename" |
| 41 | assert op["address"] == "bufar" |
| 42 | assert op["from_address"] == "foobar" |
| 43 | |
| 44 | def test_rename_op_has_no_file_count(self) -> None: |
| 45 | from muse.domain import RenameOp |
| 46 | import typing |
| 47 | hints = typing.get_type_hints(RenameOp) |
| 48 | assert "file_count" not in hints |
| 49 | |
| 50 | def test_directory_rename_op_not_exported(self) -> None: |
| 51 | import muse.domain as d |
| 52 | assert not hasattr(d, "DirectoryRenameOp"), ( |
| 53 | "DirectoryRenameOp should be removed; use RenameOp instead" |
| 54 | ) |
| 55 | |
| 56 | def test_rename_op_in_domain_op_union(self) -> None: |
| 57 | # RenameOp must be part of the DomainOp union so the type checker |
| 58 | # accepts it wherever a DomainOp is expected. |
| 59 | # DomainOp is a PEP 695 TypeAliasType — get_args needs __value__. |
| 60 | from muse.domain import RenameOp, DomainOp |
| 61 | import typing |
| 62 | value = getattr(DomainOp, "__value__", DomainOp) |
| 63 | args = typing.get_args(value) |
| 64 | assert RenameOp in args, "RenameOp must be in DomainOp union" |
| 65 | |
| 66 | def test_patch_op_has_no_from_address(self) -> None: |
| 67 | from muse.domain import PatchOp |
| 68 | import typing |
| 69 | hints = typing.get_type_hints(PatchOp, include_extras=True) |
| 70 | assert "from_address" not in hints, ( |
| 71 | "PatchOp.from_address must be removed; rename is now RenameOp" |
| 72 | ) |
| 73 | |
| 74 | def test_rename_op_is_leaf_or_top_level_domain_op(self) -> None: |
| 75 | from muse.domain import RenameOp |
| 76 | # RenameOp itself must be constructable with just the three fields |
| 77 | op = RenameOp(op="rename", address="new/path", from_address="old/path") |
| 78 | assert op is not None |
| 79 | |
| 80 | |
| 81 | # --------------------------------------------------------------------------- |
| 82 | # Section 2 — Code plugin: directory rename emits RenameOp |
| 83 | # --------------------------------------------------------------------------- |
| 84 | |
| 85 | |
| 86 | class TestCodePluginDirectoryRename: |
| 87 | """CodePlugin.diff emits op='rename' for directory renames, not 'directory_rename'.""" |
| 88 | |
| 89 | def _plugin(self) -> CodePlugin: |
| 90 | return CodePlugin() |
| 91 | |
| 92 | def _snap(self, files: Manifest, dirs: list[str] | None = None) -> SnapshotManifest: |
| 93 | from muse.core.snapshot import directories_from_manifest |
| 94 | d = dirs if dirs is not None else directories_from_manifest(files) |
| 95 | return SnapshotManifest(files=files, domain="code", directories=d) |
| 96 | |
| 97 | def test_directory_rename_emits_rename_op(self) -> None: |
| 98 | plugin = self._plugin() |
| 99 | base = self._snap({"src/a.py": "h1", "src/b.py": "h2"}, ["src"]) |
| 100 | target = self._snap({"lib/a.py": "h1", "lib/b.py": "h2"}, ["lib"]) |
| 101 | delta = plugin.diff(base, target) |
| 102 | ops = delta["ops"] |
| 103 | rename_ops = [o for o in ops if o["op"] == "rename"] |
| 104 | assert len(rename_ops) == 1 |
| 105 | assert rename_ops[0]["from_address"] == "src/" |
| 106 | assert rename_ops[0]["address"] == "lib/" |
| 107 | |
| 108 | def test_directory_rename_op_has_no_file_count(self) -> None: |
| 109 | plugin = self._plugin() |
| 110 | base = self._snap({"src/a.py": "h1"}, ["src"]) |
| 111 | target = self._snap({"lib/a.py": "h1"}, ["lib"]) |
| 112 | delta = plugin.diff(base, target) |
| 113 | rename_ops = [o for o in delta["ops"] if o["op"] == "rename"] |
| 114 | assert len(rename_ops) == 1 |
| 115 | assert "file_count" not in rename_ops[0] |
| 116 | |
| 117 | def test_no_directory_rename_op_in_delta(self) -> None: |
| 118 | """The old 'directory_rename' op type must never appear.""" |
| 119 | plugin = self._plugin() |
| 120 | base = self._snap({"src/a.py": "h1"}, ["src"]) |
| 121 | target = self._snap({"lib/a.py": "h1"}, ["lib"]) |
| 122 | delta = plugin.diff(base, target) |
| 123 | old_style = [o for o in delta["ops"] if o["op"] == "directory_rename"] |
| 124 | assert old_style == [], "op='directory_rename' must be gone; use op='rename'" |
| 125 | |
| 126 | def test_directory_rename_suppresses_file_level_ops(self) -> None: |
| 127 | plugin = self._plugin() |
| 128 | base = self._snap({"src/a.py": "h1"}, ["src"]) |
| 129 | target = self._snap({"lib/a.py": "h1"}, ["lib"]) |
| 130 | delta = plugin.diff(base, target) |
| 131 | file_ops = [ |
| 132 | o for o in delta["ops"] |
| 133 | if o["op"] in ("insert", "delete") and "/" in o["address"] |
| 134 | ] |
| 135 | covered = {"src/a.py", "lib/a.py"} |
| 136 | assert not any(o["address"] in covered for o in file_ops) |
| 137 | |
| 138 | def test_plain_dir_add_still_emits_insert(self) -> None: |
| 139 | plugin = self._plugin() |
| 140 | base = self._snap({}, []) |
| 141 | target = self._snap({"new/f.py": "h1"}, ["new"]) |
| 142 | delta = plugin.diff(base, target) |
| 143 | inserts = [o for o in delta["ops"] if o["op"] == "insert" and o["address"] == "new/"] |
| 144 | assert len(inserts) == 1 |
| 145 | |
| 146 | def test_plain_dir_delete_still_emits_delete(self) -> None: |
| 147 | plugin = self._plugin() |
| 148 | base = self._snap({"old/f.py": "h1"}, ["old"]) |
| 149 | target = self._snap({}, []) |
| 150 | delta = plugin.diff(base, target) |
| 151 | deletes = [o for o in delta["ops"] if o["op"] == "delete" and o["address"] == "old/"] |
| 152 | assert len(deletes) == 1 |
| 153 | |
| 154 | |
| 155 | # --------------------------------------------------------------------------- |
| 156 | # Section 3 — File rename: RenameOp + PatchOp pair (or RenameOp alone) |
| 157 | # --------------------------------------------------------------------------- |
| 158 | |
| 159 | |
| 160 | class TestFileRenameOps: |
| 161 | """A moved+edited file emits RenameOp then PatchOp; no from_address on PatchOp.""" |
| 162 | |
| 163 | def _plugin(self) -> CodePlugin: |
| 164 | return CodePlugin() |
| 165 | |
| 166 | def _snap(self, files: Manifest, dirs: list[str] | None = None) -> SnapshotManifest: |
| 167 | from muse.core.snapshot import directories_from_manifest |
| 168 | d = dirs if dirs is not None else directories_from_manifest(files) |
| 169 | return SnapshotManifest(files=files, domain="code", directories=d) |
| 170 | |
| 171 | def test_no_patch_op_has_from_address(self) -> None: |
| 172 | """from_address must never appear on any PatchOp in any delta.""" |
| 173 | plugin = self._plugin() |
| 174 | base = self._snap({"src/a.py": "h1", "src/b.py": "h2"}, ["src"]) |
| 175 | target = self._snap({"lib/a.py": "h1", "lib/b.py": "h2"}, ["lib"]) |
| 176 | delta = plugin.diff(base, target) |
| 177 | for op in delta["ops"]: |
| 178 | if op["op"] == "patch": |
| 179 | assert "from_address" not in op, ( |
| 180 | f"PatchOp at {op['address']} still has from_address — " |
| 181 | "file rename must be expressed as RenameOp" |
| 182 | ) |
| 183 | |
| 184 | |
| 185 | # --------------------------------------------------------------------------- |
| 186 | # Section 4 — _print_structured_delta renders RenameOp |
| 187 | # --------------------------------------------------------------------------- |
| 188 | |
| 189 | |
| 190 | class TestPrintStructuredDeltaRenameOp: |
| 191 | """_print_structured_delta renders op='rename' as R old → new.""" |
| 192 | |
| 193 | def _invoke(self, ops: list[DomainOp]) -> str: |
| 194 | import io |
| 195 | import sys |
| 196 | from muse.cli.commands.diff import _print_structured_delta |
| 197 | buf = io.StringIO() |
| 198 | old_stdout = sys.stdout |
| 199 | sys.stdout = buf |
| 200 | try: |
| 201 | _print_structured_delta(ops) |
| 202 | finally: |
| 203 | sys.stdout = old_stdout |
| 204 | return buf.getvalue() |
| 205 | |
| 206 | def test_rename_op_printed_as_R(self) -> None: |
| 207 | from muse.domain import RenameOp |
| 208 | ops = [RenameOp(op="rename", address="bufar", from_address="foobar")] |
| 209 | out = self._invoke(ops) |
| 210 | assert "foobar" in out |
| 211 | assert "bufar" in out |
| 212 | # Must contain some form of rename indicator (R or →) |
| 213 | assert "→" in out or "->" in out or out.strip().startswith("R") |
| 214 | |
| 215 | def test_rename_op_counted_in_return_value(self) -> None: |
| 216 | from muse.domain import RenameOp |
| 217 | from muse.cli.commands.diff import _print_structured_delta |
| 218 | ops = [RenameOp(op="rename", address="new", from_address="old")] |
| 219 | count = _print_structured_delta(ops) |
| 220 | assert count >= 1 |
| 221 | |
| 222 | def test_no_directory_rename_op_in_print_path(self) -> None: |
| 223 | """Passing op='directory_rename' must not silently produce no output. |
| 224 | After the refactor no caller will emit it, but if one slips through |
| 225 | the renderer must not silently swallow it.""" |
| 226 | # This test documents that the old silent-drop behaviour is gone. |
| 227 | # After refactor this case simply won't arise, but the test pins the contract. |
| 228 | pass # placeholder — the real guard is that RenameOp is rendered |
| 229 | |
| 230 | |
| 231 | # --------------------------------------------------------------------------- |
| 232 | # Section 5 — muse diff --json: rename_op lands in renamed map |
| 233 | # --------------------------------------------------------------------------- |
| 234 | |
| 235 | |
| 236 | class TestDiffJsonRenameOp: |
| 237 | """muse diff --json includes directory renames in the renamed map.""" |
| 238 | |
| 239 | def test_diff_json_includes_renamed_key(self, tmp_path: pathlib.Path) -> None: |
| 240 | from tests.cli_test_helper import CliRunner |
| 241 | from muse.core.paths import muse_dir |
| 242 | |
| 243 | dot = muse_dir(tmp_path) |
| 244 | for d in ("commits", "snapshots", "objects", "refs/heads"): |
| 245 | (dot / d).mkdir(parents=True, exist_ok=True) |
| 246 | (dot / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8") |
| 247 | (dot / "repo.json").write_text( |
| 248 | json.dumps({"repo_id": "rename-op-test", "domain": "code"}), |
| 249 | encoding="utf-8", |
| 250 | ) |
| 251 | |
| 252 | runner = CliRunner() |
| 253 | result = runner.invoke(None, ["diff", "--json"], env={"MUSE_REPO_ROOT": str(tmp_path)}) |
| 254 | assert result.exit_code == 0 |
| 255 | data = json.loads(result.output) |
| 256 | assert "renamed" in data, "muse diff --json must include 'renamed' key" |
| 257 | |
| 258 | def test_diff_json_renamed_is_dict(self, tmp_path: pathlib.Path) -> None: |
| 259 | from tests.cli_test_helper import CliRunner |
| 260 | from muse.core.paths import muse_dir |
| 261 | |
| 262 | dot = muse_dir(tmp_path) |
| 263 | for d in ("commits", "snapshots", "objects", "refs/heads"): |
| 264 | (dot / d).mkdir(parents=True, exist_ok=True) |
| 265 | (dot / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8") |
| 266 | (dot / "repo.json").write_text( |
| 267 | json.dumps({"repo_id": "rename-op-test2", "domain": "code"}), |
| 268 | encoding="utf-8", |
| 269 | ) |
| 270 | |
| 271 | runner = CliRunner() |
| 272 | result = runner.invoke(None, ["diff", "--json"], env={"MUSE_REPO_ROOT": str(tmp_path)}) |
| 273 | data = json.loads(result.output) |
| 274 | assert isinstance(data["renamed"], dict) |
| 275 | |
| 276 | |
| 277 | # --------------------------------------------------------------------------- |
| 278 | # Section 6 — flat_directory_ops and touched_directories recognise op='rename' |
| 279 | # --------------------------------------------------------------------------- |
| 280 | |
| 281 | |
| 282 | class TestQueryHelpersRenameOp: |
| 283 | """flat_directory_ops and touched_directories use op='rename', not 'directory_rename'.""" |
| 284 | |
| 285 | def _rename(self, from_addr: str, to_addr: str) -> RenameOp: |
| 286 | return RenameOp(op="rename", address=to_addr, from_address=from_addr) |
| 287 | |
| 288 | def test_flat_directory_ops_yields_rename_op(self) -> None: |
| 289 | from muse.plugins.code._query import flat_directory_ops |
| 290 | ops = [self._rename("api/v1", "api/v2")] |
| 291 | result = list(flat_directory_ops(ops)) |
| 292 | assert len(result) == 1 |
| 293 | assert result[0]["op"] == "rename" |
| 294 | |
| 295 | def test_flat_directory_ops_carries_from_address(self) -> None: |
| 296 | from muse.plugins.code._query import flat_directory_ops |
| 297 | ops = [self._rename("src/old", "src/new")] |
| 298 | result = list(flat_directory_ops(ops)) |
| 299 | assert result[0].get("from_address") == "src/old" |
| 300 | assert result[0]["address"] == "src/new" |
| 301 | |
| 302 | def test_touched_directories_rename_op_adds_both_dirs(self) -> None: |
| 303 | from muse.plugins.code._query import touched_directories |
| 304 | ops = [self._rename("api/v1", "api/v2")] |
| 305 | dirs = touched_directories(ops) |
| 306 | assert "api/v1" in dirs |
| 307 | assert "api/v2" in dirs |
| 308 | |
| 309 | |
| 310 | # --------------------------------------------------------------------------- |
| 311 | # Section 7 — delta_summary counts renames uniformly |
| 312 | # --------------------------------------------------------------------------- |
| 313 | |
| 314 | |
| 315 | class TestDeltaSummaryRenameOp: |
| 316 | """delta_summary counts op='rename' for directories, not 'directory_rename'.""" |
| 317 | |
| 318 | def _rename(self, from_addr: str, to_addr: str) -> RenameOp: |
| 319 | return RenameOp(op="rename", address=to_addr, from_address=from_addr) |
| 320 | |
| 321 | def test_single_directory_rename(self) -> None: |
| 322 | from muse.plugins.code.symbol_diff import delta_summary |
| 323 | ops = [self._rename("old", "new")] |
| 324 | summary = delta_summary(ops) |
| 325 | assert "renamed" in summary.lower() or "rename" in summary.lower() |
| 326 | |
| 327 | def test_two_directory_renames_plural(self) -> None: |
| 328 | from muse.plugins.code.symbol_diff import delta_summary |
| 329 | ops = [self._rename("a", "b"), self._rename("c", "d")] |
| 330 | summary = delta_summary(ops) |
| 331 | assert "2" in summary |
| 332 | |
| 333 | def test_directory_rename_not_counted_as_file(self) -> None: |
| 334 | from muse.plugins.code.symbol_diff import delta_summary |
| 335 | ops = [self._rename("old", "new")] |
| 336 | summary = delta_summary(ops) |
| 337 | # A directory rename must not appear in the file-change count |
| 338 | assert "1 added" not in summary |
| 339 | assert "1 removed" not in summary |
File History
1 commit
sha256:a154bc65916614c833d5a40a10d81ba3eae0d0495b0afddd34dc34f18d5e91b8
fix: test suite alignment and typing audit — zero violations
Sonnet 4.6
minor
⚠
23 days ago