gabriel / muse public
test_plumbing_snapshot_diff.py python
191 lines 7.1 KB
e88283c9 feat(plumbing): add 7 new interrogation commands to complete plumbing layer Gabriel Cardona <gabriel@tellurstori.com> 2d ago
1 """Tests for ``muse plumbing snapshot-diff``.
2
3 Verifies categorisation of added/modified/deleted paths, resolution of
4 snapshot IDs, commit IDs, and branch names, text-format output, and error
5 handling for unresolvable refs.
6 """
7
8 from __future__ import annotations
9
10 import datetime
11 import hashlib
12 import json
13 import pathlib
14
15 from typer.testing import CliRunner
16
17 from muse.cli.app import cli
18 from muse.core.errors import ExitCode
19 from muse.core.object_store import write_object
20 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
21
22 runner = CliRunner()
23
24
25 # ---------------------------------------------------------------------------
26 # Helpers
27 # ---------------------------------------------------------------------------
28
29
30 def _sha(data: bytes | str) -> str:
31 raw = data if isinstance(data, bytes) else data.encode()
32 return hashlib.sha256(raw).hexdigest()
33
34
35 def _init_repo(path: pathlib.Path) -> pathlib.Path:
36 muse = path / ".muse"
37 (muse / "commits").mkdir(parents=True)
38 (muse / "snapshots").mkdir(parents=True)
39 (muse / "objects").mkdir(parents=True)
40 (muse / "refs" / "heads").mkdir(parents=True)
41 (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
42 (muse / "repo.json").write_text(
43 json.dumps({"repo_id": "test-repo", "domain": "midi"}), encoding="utf-8"
44 )
45 return path
46
47
48 def _env(repo: pathlib.Path) -> dict[str, str]:
49 return {"MUSE_REPO_ROOT": str(repo)}
50
51
52 def _obj(repo: pathlib.Path, content: bytes) -> str:
53 oid = _sha(content)
54 write_object(repo, oid, content)
55 return oid
56
57
58 def _snap(repo: pathlib.Path, manifest: dict[str, str]) -> str:
59 sid = _sha(json.dumps(sorted(manifest.items())))
60 write_snapshot(
61 repo,
62 SnapshotRecord(
63 snapshot_id=sid,
64 manifest=manifest,
65 created_at=datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc),
66 ),
67 )
68 return sid
69
70
71 def _commit(repo: pathlib.Path, tag: str, sid: str, branch: str = "main") -> str:
72 cid = _sha(tag)
73 write_commit(
74 repo,
75 CommitRecord(
76 commit_id=cid,
77 repo_id="test-repo",
78 branch=branch,
79 snapshot_id=sid,
80 message=tag,
81 committed_at=datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc),
82 author="tester",
83 parent_commit_id=None,
84 ),
85 )
86 ref = repo / ".muse" / "refs" / "heads" / branch
87 ref.write_text(cid, encoding="utf-8")
88 return cid
89
90
91 # ---------------------------------------------------------------------------
92 # Tests
93 # ---------------------------------------------------------------------------
94
95
96 class TestSnapshotDiff:
97 def test_added_deleted_categorised_correctly(self, tmp_path: pathlib.Path) -> None:
98 repo = _init_repo(tmp_path)
99 shared = _obj(repo, b"shared")
100 new_obj = _obj(repo, b"new")
101 sid_a = _snap(repo, {"shared.mid": shared, "old.mid": shared})
102 sid_b = _snap(repo, {"shared.mid": shared, "new.mid": new_obj})
103 result = runner.invoke(cli, ["plumbing", "snapshot-diff", sid_a, sid_b], env=_env(repo))
104 assert result.exit_code == 0, result.output
105 data = json.loads(result.stdout)
106 assert [e["path"] for e in data["added"]] == ["new.mid"]
107 assert [e["path"] for e in data["deleted"]] == ["old.mid"]
108 assert data["modified"] == []
109 assert data["total_changes"] == 2
110
111 def test_modified_entry_contains_both_object_ids(self, tmp_path: pathlib.Path) -> None:
112 repo = _init_repo(tmp_path)
113 v1 = _obj(repo, b"v1")
114 v2 = _obj(repo, b"v2")
115 sid_a = _snap(repo, {"track.mid": v1})
116 sid_b = _snap(repo, {"track.mid": v2})
117 result = runner.invoke(cli, ["plumbing", "snapshot-diff", sid_a, sid_b], env=_env(repo))
118 assert result.exit_code == 0, result.output
119 data = json.loads(result.stdout)
120 assert len(data["modified"]) == 1
121 mod = data["modified"][0]
122 assert mod["path"] == "track.mid"
123 assert mod["object_id_a"] == v1
124 assert mod["object_id_b"] == v2
125
126 def test_zero_changes_when_snapshots_identical(self, tmp_path: pathlib.Path) -> None:
127 repo = _init_repo(tmp_path)
128 obj = _obj(repo, b"same")
129 sid = _snap(repo, {"f.mid": obj})
130 result = runner.invoke(cli, ["plumbing", "snapshot-diff", sid, sid], env=_env(repo))
131 assert result.exit_code == 0, result.output
132 data = json.loads(result.stdout)
133 assert data["total_changes"] == 0
134
135 def test_resolves_by_branch_name(self, tmp_path: pathlib.Path) -> None:
136 repo = _init_repo(tmp_path)
137 obj_a = _obj(repo, b"a")
138 obj_b = _obj(repo, b"b")
139 _commit(repo, "cmt-main", _snap(repo, {"a.mid": obj_a}), branch="main")
140 _commit(repo, "cmt-dev", _snap(repo, {"b.mid": obj_b}), branch="dev")
141 (repo / ".muse" / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
142 result = runner.invoke(cli, ["plumbing", "snapshot-diff", "main", "dev"], env=_env(repo))
143 assert result.exit_code == 0, result.output
144 data = json.loads(result.stdout)
145 assert data["total_changes"] == 2
146
147 def test_text_format_shows_status_letters(self, tmp_path: pathlib.Path) -> None:
148 repo = _init_repo(tmp_path)
149 shared = _obj(repo, b"s")
150 new_obj = _obj(repo, b"n")
151 sid_a = _snap(repo, {"gone.mid": shared})
152 sid_b = _snap(repo, {"new.mid": new_obj})
153 result = runner.invoke(
154 cli, ["plumbing", "snapshot-diff", "--format", "text", sid_a, sid_b], env=_env(repo)
155 )
156 assert result.exit_code == 0, result.output
157 assert "A new.mid" in result.stdout
158 assert "D gone.mid" in result.stdout
159
160 def test_stat_flag_appends_summary(self, tmp_path: pathlib.Path) -> None:
161 repo = _init_repo(tmp_path)
162 sid_a = _snap(repo, {"gone.mid": _obj(repo, b"g")})
163 sid_b = _snap(repo, {"new.mid": _obj(repo, b"n")})
164 result = runner.invoke(
165 cli,
166 ["plumbing", "snapshot-diff", "--format", "text", "--stat", sid_a, sid_b],
167 env=_env(repo),
168 )
169 assert result.exit_code == 0, result.output
170 assert "added" in result.stdout
171 assert "deleted" in result.stdout
172
173 def test_unresolvable_ref_exits_user_error(self, tmp_path: pathlib.Path) -> None:
174 repo = _init_repo(tmp_path)
175 result = runner.invoke(
176 cli, ["plumbing", "snapshot-diff", "no-such-thing", "also-missing"], env=_env(repo)
177 )
178 assert result.exit_code == ExitCode.USER_ERROR
179 assert "error" in json.loads(result.stdout)
180
181 def test_results_sorted_lexicographically(self, tmp_path: pathlib.Path) -> None:
182 repo = _init_repo(tmp_path)
183 sid_a = _snap(repo, {})
184 sid_b = _snap(
185 repo, {"z.mid": _obj(repo, b"z"), "a.mid": _obj(repo, b"a"), "m.mid": _obj(repo, b"m")}
186 )
187 result = runner.invoke(cli, ["plumbing", "snapshot-diff", sid_a, sid_b], env=_env(repo))
188 assert result.exit_code == 0, result.output
189 data = json.loads(result.stdout)
190 added_paths = [e["path"] for e in data["added"]]
191 assert added_paths == sorted(added_paths)