"""TDD — RenameOp replaces DirectoryRenameOp and PatchOp.from_address. Design decisions under test: 1. RenameOp is a first-class TypedDict: op="rename", address, from_address. No file_count — that was validation metadata, not a semantic field. 2. DirectoryRenameOp is gone; domain.py no longer exports it. 3. PatchOp no longer has a from_address field; rename and modify are orthogonal. 4. A moved+edited file emits two ops: RenameOp then PatchOp (two concerns, two ops). 5. A pure directory rename emits one RenameOp (op="rename"), not "directory_rename". 6. _print_structured_delta renders op="rename" as R old → new (cyan). 7. muse diff --json places op="rename" in the renamed map: {old: new}. 8. flat_directory_ops and touched_directories recognise op="rename". 9. delta_summary counts "N renamed" for both directory and file renames uniformly. """ from __future__ import annotations import json import pathlib import pytest from muse.core.types import Manifest from muse.domain import DomainOp, RenameOp, SnapshotManifest from muse.plugins.code.plugin import CodePlugin # --------------------------------------------------------------------------- # Section 1 — Type system # --------------------------------------------------------------------------- class TestRenameOpType: """RenameOp is in domain.py; DirectoryRenameOp is gone.""" def test_rename_op_importable(self) -> None: from muse.domain import RenameOp # noqa: F401 def test_rename_op_has_correct_fields(self) -> None: from muse.domain import RenameOp op = RenameOp(op="rename", address="bufar", from_address="foobar") assert op["op"] == "rename" assert op["address"] == "bufar" assert op["from_address"] == "foobar" def test_rename_op_has_no_file_count(self) -> None: from muse.domain import RenameOp import typing hints = typing.get_type_hints(RenameOp) assert "file_count" not in hints def test_directory_rename_op_not_exported(self) -> None: import muse.domain as d assert not hasattr(d, "DirectoryRenameOp"), ( "DirectoryRenameOp should be removed; use RenameOp instead" ) def test_rename_op_in_domain_op_union(self) -> None: # RenameOp must be part of the DomainOp union so the type checker # accepts it wherever a DomainOp is expected. # DomainOp is a PEP 695 TypeAliasType — get_args needs __value__. from muse.domain import RenameOp, DomainOp import typing value = getattr(DomainOp, "__value__", DomainOp) args = typing.get_args(value) assert RenameOp in args, "RenameOp must be in DomainOp union" def test_patch_op_has_no_from_address(self) -> None: from muse.domain import PatchOp import typing hints = typing.get_type_hints(PatchOp, include_extras=True) assert "from_address" not in hints, ( "PatchOp.from_address must be removed; rename is now RenameOp" ) def test_rename_op_is_leaf_or_top_level_domain_op(self) -> None: from muse.domain import RenameOp # RenameOp itself must be constructable with just the three fields op = RenameOp(op="rename", address="new/path", from_address="old/path") assert op is not None # --------------------------------------------------------------------------- # Section 2 — Code plugin: directory rename emits RenameOp # --------------------------------------------------------------------------- class TestCodePluginDirectoryRename: """CodePlugin.diff emits op='rename' for directory renames, not 'directory_rename'.""" def _plugin(self) -> CodePlugin: return CodePlugin() def _snap(self, files: Manifest, dirs: list[str] | None = None) -> SnapshotManifest: from muse.core.snapshot import directories_from_manifest d = dirs if dirs is not None else directories_from_manifest(files) return SnapshotManifest(files=files, domain="code", directories=d) def test_directory_rename_emits_rename_op(self) -> None: plugin = self._plugin() base = self._snap({"src/a.py": "h1", "src/b.py": "h2"}, ["src"]) target = self._snap({"lib/a.py": "h1", "lib/b.py": "h2"}, ["lib"]) delta = plugin.diff(base, target) ops = delta["ops"] rename_ops = [o for o in ops if o["op"] == "rename"] assert len(rename_ops) == 1 assert rename_ops[0]["from_address"] == "src/" assert rename_ops[0]["address"] == "lib/" def test_directory_rename_op_has_no_file_count(self) -> None: plugin = self._plugin() base = self._snap({"src/a.py": "h1"}, ["src"]) target = self._snap({"lib/a.py": "h1"}, ["lib"]) delta = plugin.diff(base, target) rename_ops = [o for o in delta["ops"] if o["op"] == "rename"] assert len(rename_ops) == 1 assert "file_count" not in rename_ops[0] def test_no_directory_rename_op_in_delta(self) -> None: """The old 'directory_rename' op type must never appear.""" plugin = self._plugin() base = self._snap({"src/a.py": "h1"}, ["src"]) target = self._snap({"lib/a.py": "h1"}, ["lib"]) delta = plugin.diff(base, target) old_style = [o for o in delta["ops"] if o["op"] == "directory_rename"] assert old_style == [], "op='directory_rename' must be gone; use op='rename'" def test_directory_rename_suppresses_file_level_ops(self) -> None: plugin = self._plugin() base = self._snap({"src/a.py": "h1"}, ["src"]) target = self._snap({"lib/a.py": "h1"}, ["lib"]) delta = plugin.diff(base, target) file_ops = [ o for o in delta["ops"] if o["op"] in ("insert", "delete") and "/" in o["address"] ] covered = {"src/a.py", "lib/a.py"} assert not any(o["address"] in covered for o in file_ops) def test_plain_dir_add_still_emits_insert(self) -> None: plugin = self._plugin() base = self._snap({}, []) target = self._snap({"new/f.py": "h1"}, ["new"]) delta = plugin.diff(base, target) inserts = [o for o in delta["ops"] if o["op"] == "insert" and o["address"] == "new/"] assert len(inserts) == 1 def test_plain_dir_delete_still_emits_delete(self) -> None: plugin = self._plugin() base = self._snap({"old/f.py": "h1"}, ["old"]) target = self._snap({}, []) delta = plugin.diff(base, target) deletes = [o for o in delta["ops"] if o["op"] == "delete" and o["address"] == "old/"] assert len(deletes) == 1 # --------------------------------------------------------------------------- # Section 3 — File rename: RenameOp + PatchOp pair (or RenameOp alone) # --------------------------------------------------------------------------- class TestFileRenameOps: """A moved+edited file emits RenameOp then PatchOp; no from_address on PatchOp.""" def _plugin(self) -> CodePlugin: return CodePlugin() def _snap(self, files: Manifest, dirs: list[str] | None = None) -> SnapshotManifest: from muse.core.snapshot import directories_from_manifest d = dirs if dirs is not None else directories_from_manifest(files) return SnapshotManifest(files=files, domain="code", directories=d) def test_no_patch_op_has_from_address(self) -> None: """from_address must never appear on any PatchOp in any delta.""" plugin = self._plugin() base = self._snap({"src/a.py": "h1", "src/b.py": "h2"}, ["src"]) target = self._snap({"lib/a.py": "h1", "lib/b.py": "h2"}, ["lib"]) delta = plugin.diff(base, target) for op in delta["ops"]: if op["op"] == "patch": assert "from_address" not in op, ( f"PatchOp at {op['address']} still has from_address — " "file rename must be expressed as RenameOp" ) # --------------------------------------------------------------------------- # Section 4 — _print_structured_delta renders RenameOp # --------------------------------------------------------------------------- class TestPrintStructuredDeltaRenameOp: """_print_structured_delta renders op='rename' as R old → new.""" def _invoke(self, ops: list[DomainOp]) -> str: import io import sys from muse.cli.commands.diff import _print_structured_delta buf = io.StringIO() old_stdout = sys.stdout sys.stdout = buf try: _print_structured_delta(ops) finally: sys.stdout = old_stdout return buf.getvalue() def test_rename_op_printed_as_R(self) -> None: from muse.domain import RenameOp ops = [RenameOp(op="rename", address="bufar", from_address="foobar")] out = self._invoke(ops) assert "foobar" in out assert "bufar" in out # Must contain some form of rename indicator (R or →) assert "→" in out or "->" in out or out.strip().startswith("R") def test_rename_op_counted_in_return_value(self) -> None: from muse.domain import RenameOp from muse.cli.commands.diff import _print_structured_delta ops = [RenameOp(op="rename", address="new", from_address="old")] count = _print_structured_delta(ops) assert count >= 1 def test_no_directory_rename_op_in_print_path(self) -> None: """Passing op='directory_rename' must not silently produce no output. After the refactor no caller will emit it, but if one slips through the renderer must not silently swallow it.""" # This test documents that the old silent-drop behaviour is gone. # After refactor this case simply won't arise, but the test pins the contract. pass # placeholder — the real guard is that RenameOp is rendered # --------------------------------------------------------------------------- # Section 5 — muse diff --json: rename_op lands in renamed map # --------------------------------------------------------------------------- class TestDiffJsonRenameOp: """muse diff --json includes directory renames in the renamed map.""" def test_diff_json_includes_renamed_key(self, tmp_path: pathlib.Path) -> None: from tests.cli_test_helper import CliRunner from muse.core.paths import muse_dir dot = muse_dir(tmp_path) for d in ("commits", "snapshots", "objects", "refs/heads"): (dot / d).mkdir(parents=True, exist_ok=True) (dot / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8") (dot / "repo.json").write_text( json.dumps({"repo_id": "rename-op-test", "domain": "code"}), encoding="utf-8", ) runner = CliRunner() result = runner.invoke(None, ["diff", "--json"], env={"MUSE_REPO_ROOT": str(tmp_path)}) assert result.exit_code == 0 data = json.loads(result.output) assert "renamed" in data, "muse diff --json must include 'renamed' key" def test_diff_json_renamed_is_dict(self, tmp_path: pathlib.Path) -> None: from tests.cli_test_helper import CliRunner from muse.core.paths import muse_dir dot = muse_dir(tmp_path) for d in ("commits", "snapshots", "objects", "refs/heads"): (dot / d).mkdir(parents=True, exist_ok=True) (dot / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8") (dot / "repo.json").write_text( json.dumps({"repo_id": "rename-op-test2", "domain": "code"}), encoding="utf-8", ) runner = CliRunner() result = runner.invoke(None, ["diff", "--json"], env={"MUSE_REPO_ROOT": str(tmp_path)}) data = json.loads(result.output) assert isinstance(data["renamed"], dict) # --------------------------------------------------------------------------- # Section 6 — flat_directory_ops and touched_directories recognise op='rename' # --------------------------------------------------------------------------- class TestQueryHelpersRenameOp: """flat_directory_ops and touched_directories use op='rename', not 'directory_rename'.""" def _rename(self, from_addr: str, to_addr: str) -> RenameOp: return RenameOp(op="rename", address=to_addr, from_address=from_addr) def test_flat_directory_ops_yields_rename_op(self) -> None: from muse.plugins.code._query import flat_directory_ops ops = [self._rename("api/v1", "api/v2")] result = list(flat_directory_ops(ops)) assert len(result) == 1 assert result[0]["op"] == "rename" def test_flat_directory_ops_carries_from_address(self) -> None: from muse.plugins.code._query import flat_directory_ops ops = [self._rename("src/old", "src/new")] result = list(flat_directory_ops(ops)) assert result[0].get("from_address") == "src/old" assert result[0]["address"] == "src/new" def test_touched_directories_rename_op_adds_both_dirs(self) -> None: from muse.plugins.code._query import touched_directories ops = [self._rename("api/v1", "api/v2")] dirs = touched_directories(ops) assert "api/v1" in dirs assert "api/v2" in dirs # --------------------------------------------------------------------------- # Section 7 — delta_summary counts renames uniformly # --------------------------------------------------------------------------- class TestDeltaSummaryRenameOp: """delta_summary counts op='rename' for directories, not 'directory_rename'.""" def _rename(self, from_addr: str, to_addr: str) -> RenameOp: return RenameOp(op="rename", address=to_addr, from_address=from_addr) def test_single_directory_rename(self) -> None: from muse.plugins.code.symbol_diff import delta_summary ops = [self._rename("old", "new")] summary = delta_summary(ops) assert "renamed" in summary.lower() or "rename" in summary.lower() def test_two_directory_renames_plural(self) -> None: from muse.plugins.code.symbol_diff import delta_summary ops = [self._rename("a", "b"), self._rename("c", "d")] summary = delta_summary(ops) assert "2" in summary def test_directory_rename_not_counted_as_file(self) -> None: from muse.plugins.code.symbol_diff import delta_summary ops = [self._rename("old", "new")] summary = delta_summary(ops) # A directory rename must not appear in the file-change count assert "1 added" not in summary assert "1 removed" not in summary