gabriel / muse public
test_rename_op.py python
339 lines 14.4 KB
Raw
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