gabriel / muse public
test_merge_supercharge.py python
367 lines 14.3 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 days ago
1 """Supercharge tests for ``muse merge``.
2
3 Coverage tiers
4 --------------
5 - JSON envelope: exit_code and duration_ms always present on all outcome types
6 (merged, fast_forward, up_to_date, conflict)
7 - Error payload: errors go to stdout as JSON in --json mode, no dual stderr prose
8 - TypedDicts: _MergeJson and _MergeErrorJson exist with required annotations
9 - Docstring: module docstring covers exit_code and duration_ms
10 - No-prose pollution: no emoji in JSON stdout on success paths
11 """
12 from __future__ import annotations
13
14 import datetime
15 import hashlib
16 import json
17 import pathlib
18 import uuid
19 from typing import get_type_hints
20
21 from muse.core.object_store import write_object
22 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
23 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
24 from tests.cli_test_helper import CliRunner
25 from muse.core._types import long_id
26
27 runner = CliRunner()
28
29
30 # ---------------------------------------------------------------------------
31 # Helpers
32 # ---------------------------------------------------------------------------
33
34 def _sha(data: bytes) -> str:
35 return long_id(hashlib.sha256(data).hexdigest())
36
37
38 def _env(root: pathlib.Path) -> dict[str, str]:
39 return {"MUSE_REPO_ROOT": str(root)}
40
41
42 def _init_repo(tmp_path: pathlib.Path) -> tuple[pathlib.Path, str]:
43 muse_dir = tmp_path / ".muse"
44 muse_dir.mkdir()
45 repo_id = str(uuid.uuid4())
46 (muse_dir / "repo.json").write_text(json.dumps({
47 "repo_id": repo_id, "domain": "code",
48 "default_branch": "main", "created_at": "2025-01-01T00:00:00+00:00",
49 }), encoding="utf-8")
50 (muse_dir / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
51 (muse_dir / "refs" / "heads").mkdir(parents=True)
52 (muse_dir / "snapshots").mkdir()
53 (muse_dir / "commits").mkdir()
54 (muse_dir / "objects").mkdir()
55 return tmp_path, repo_id
56
57
58 def _make_commit(
59 root: pathlib.Path, repo_id: str, branch: str = "main",
60 message: str = "test", manifest: dict | None = None,
61 ) -> str:
62 ref_file = root / ".muse" / "refs" / "heads" / branch
63 parent_id = ref_file.read_text().strip() if ref_file.exists() else None
64 m = manifest or {}
65 snap_id = compute_snapshot_id(m)
66 committed_at = datetime.datetime.now(datetime.timezone.utc)
67 commit_id = compute_commit_id(
68 parent_ids=[parent_id] if parent_id else [],
69 snapshot_id=snap_id, message=message,
70 committed_at_iso=committed_at.isoformat(),
71 )
72 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=m))
73 write_commit(root, CommitRecord(
74 commit_id=commit_id, repo_id=repo_id, branch=branch,
75 snapshot_id=snap_id, message=message, committed_at=committed_at,
76 parent_commit_id=parent_id,
77 ))
78 ref_file.parent.mkdir(parents=True, exist_ok=True)
79 ref_file.write_text(commit_id, encoding="utf-8")
80 return commit_id
81
82
83 def _write_obj(root: pathlib.Path, content: bytes) -> str:
84 oid = _sha(content)
85 write_object(root, oid, content)
86 return oid
87
88
89 def _merge(root: pathlib.Path, *args: str):
90 from muse.cli.app import main as cli
91 return runner.invoke(cli, ["merge", *args], env=_env(root))
92
93
94 # ---------------------------------------------------------------------------
95 # Repo fixtures
96 # ---------------------------------------------------------------------------
97
98 def _up_to_date_repo(tmp_path: pathlib.Path) -> pathlib.Path:
99 root, repo_id = _init_repo(tmp_path)
100 cid = _make_commit(root, repo_id, branch="main", message="base")
101 (root / ".muse" / "refs" / "heads" / "feature").write_text(cid)
102 return root
103
104
105 def _ff_repo(tmp_path: pathlib.Path) -> pathlib.Path:
106 root, repo_id = _init_repo(tmp_path)
107 base = _make_commit(root, repo_id, branch="main", message="base")
108 (root / ".muse" / "refs" / "heads" / "feature").write_text(base)
109 obj = _write_obj(root, b"new file")
110 _make_commit(root, repo_id, branch="feature", message="feat",
111 manifest={"new.py": obj})
112 return root
113
114
115 def _three_way_clean_repo(tmp_path: pathlib.Path) -> pathlib.Path:
116 root, repo_id = _init_repo(tmp_path)
117 base_obj = _write_obj(root, b"base")
118 base = _make_commit(root, repo_id, branch="main", message="base",
119 manifest={"base.py": base_obj})
120 (root / ".muse" / "refs" / "heads" / "feature").write_text(base)
121 main_obj = _write_obj(root, b"main addition")
122 _make_commit(root, repo_id, branch="main", message="main work",
123 manifest={"base.py": base_obj, "main.py": main_obj})
124 feat_obj = _write_obj(root, b"feat addition")
125 _make_commit(root, repo_id, branch="feature", message="feat work",
126 manifest={"base.py": base_obj, "feat.py": feat_obj})
127 # Write working tree to match main HEAD so require_clean_workdir passes.
128 (root / "base.py").write_bytes(b"base")
129 (root / "main.py").write_bytes(b"main addition")
130 return root
131
132
133 def _conflict_repo(tmp_path: pathlib.Path) -> pathlib.Path:
134 root, repo_id = _init_repo(tmp_path)
135 shared_v1 = _write_obj(root, b"shared v1")
136 base = _make_commit(root, repo_id, branch="main", message="base",
137 manifest={"shared.py": shared_v1})
138 (root / ".muse" / "refs" / "heads" / "feature").write_text(base)
139 shared_main = _write_obj(root, b"shared main version")
140 _make_commit(root, repo_id, branch="main", message="main mod",
141 manifest={"shared.py": shared_main})
142 shared_feat = _write_obj(root, b"shared feature version")
143 _make_commit(root, repo_id, branch="feature", message="feat mod",
144 manifest={"shared.py": shared_feat})
145 # Write working tree to match main HEAD so require_clean_workdir passes.
146 (root / "shared.py").write_bytes(b"shared main version")
147 return root
148
149
150 # ---------------------------------------------------------------------------
151 # JSON envelope — exit_code and duration_ms on all outcomes
152 # ---------------------------------------------------------------------------
153
154 class TestJsonEnvelopeExitCode:
155 """exit_code is present and correct across all merge outcome types."""
156
157 def test_up_to_date_has_exit_code(self, tmp_path: pathlib.Path) -> None:
158 root = _up_to_date_repo(tmp_path)
159 r = _merge(root, "feature", "--json")
160 d = json.loads(r.output)
161 assert "exit_code" in d, "exit_code missing from up_to_date envelope"
162 assert d["exit_code"] == 0
163
164 def test_fast_forward_has_exit_code(self, tmp_path: pathlib.Path) -> None:
165 root = _ff_repo(tmp_path)
166 r = _merge(root, "feature", "--json")
167 d = json.loads(r.output)
168 assert "exit_code" in d, "exit_code missing from fast_forward envelope"
169 assert d["exit_code"] == 0
170
171 def test_three_way_clean_has_exit_code(self, tmp_path: pathlib.Path) -> None:
172 root = _three_way_clean_repo(tmp_path)
173 r = _merge(root, "feature", "--json")
174 d = json.loads(r.output)
175 assert "exit_code" in d, "exit_code missing from merged envelope"
176 assert d["exit_code"] == 0
177
178 def test_conflict_has_exit_code(self, tmp_path: pathlib.Path) -> None:
179 root = _conflict_repo(tmp_path)
180 r = _merge(root, "feature", "--json")
181 d = json.loads(r.output)
182 assert "exit_code" in d, "exit_code missing from conflict envelope"
183 assert d["exit_code"] != 0
184
185 def test_dry_run_merged_has_exit_code(self, tmp_path: pathlib.Path) -> None:
186 root = _three_way_clean_repo(tmp_path)
187 r = _merge(root, "feature", "--dry-run", "--json")
188 d = json.loads(r.output)
189 assert "exit_code" in d
190 assert d["exit_code"] == 0
191
192 def test_dry_run_conflict_has_exit_code(self, tmp_path: pathlib.Path) -> None:
193 root = _conflict_repo(tmp_path)
194 r = _merge(root, "feature", "--dry-run", "--json")
195 d = json.loads(r.output)
196 assert "exit_code" in d
197 assert d["exit_code"] != 0
198
199
200 class TestJsonEnvelopeDurationMs:
201 """duration_ms is present and is a non-negative float on all outcome types."""
202
203 def test_up_to_date_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
204 root = _up_to_date_repo(tmp_path)
205 r = _merge(root, "feature", "--json")
206 d = json.loads(r.output)
207 assert "duration_ms" in d
208 assert isinstance(d["duration_ms"], float)
209 assert d["duration_ms"] >= 0.0
210
211 def test_fast_forward_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
212 root = _ff_repo(tmp_path)
213 r = _merge(root, "feature", "--json")
214 d = json.loads(r.output)
215 assert "duration_ms" in d
216 assert isinstance(d["duration_ms"], float)
217
218 def test_three_way_clean_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
219 root = _three_way_clean_repo(tmp_path)
220 r = _merge(root, "feature", "--json")
221 d = json.loads(r.output)
222 assert "duration_ms" in d
223 assert isinstance(d["duration_ms"], float)
224
225 def test_conflict_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
226 root = _conflict_repo(tmp_path)
227 r = _merge(root, "feature", "--json")
228 d = json.loads(r.output)
229 assert "duration_ms" in d
230 assert isinstance(d["duration_ms"], float)
231
232 def test_dry_run_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
233 root = _ff_repo(tmp_path)
234 r = _merge(root, "feature", "--dry-run", "--json")
235 d = json.loads(r.output)
236 assert "duration_ms" in d
237
238
239 # ---------------------------------------------------------------------------
240 # Error payload — errors go to stdout as JSON in --json mode
241 # ---------------------------------------------------------------------------
242
243 class TestErrorPayload:
244 """Errors emit {status: "error", error: "...", exit_code: N} on stdout in --json mode."""
245
246 def test_no_branch_error_is_json(self, tmp_path: pathlib.Path) -> None:
247 root, _ = _init_repo(tmp_path)
248 r = _merge(root, "--json") # no branch arg
249 assert r.exit_code != 0
250 d = json.loads(r.output)
251 assert d["status"] == "error"
252
253 def test_self_merge_error_is_json(self, tmp_path: pathlib.Path) -> None:
254 root, repo_id = _init_repo(tmp_path)
255 _make_commit(root, repo_id, branch="main")
256 r = _merge(root, "main", "--json")
257 assert r.exit_code != 0
258 d = json.loads(r.output)
259 assert d["status"] == "error"
260
261 def test_self_merge_no_duplicate_stderr_prose(self, tmp_path: pathlib.Path) -> None:
262 """In --json mode, errors should not also print emoji prose to stderr."""
263 root, repo_id = _init_repo(tmp_path)
264 _make_commit(root, repo_id, branch="main")
265 r = _merge(root, "main", "--json")
266 assert "❌" not in r.stderr
267
268 def test_error_payload_has_status_error(self, tmp_path: pathlib.Path) -> None:
269 root, _ = _init_repo(tmp_path)
270 r = _merge(root, "--json")
271 d = json.loads(r.output)
272 assert d["status"] == "error"
273
274 def test_error_payload_has_exit_code(self, tmp_path: pathlib.Path) -> None:
275 root, _ = _init_repo(tmp_path)
276 r = _merge(root, "--json")
277 d = json.loads(r.output)
278 assert "exit_code" in d
279 assert d["exit_code"] != 0
280
281 def test_error_payload_has_error_field(self, tmp_path: pathlib.Path) -> None:
282 root, _ = _init_repo(tmp_path)
283 r = _merge(root, "--json")
284 d = json.loads(r.output)
285 assert "error" in d
286 assert d["error"] # non-empty message
287
288 def test_unknown_branch_error_is_json(self, tmp_path: pathlib.Path) -> None:
289 root, repo_id = _init_repo(tmp_path)
290 _make_commit(root, repo_id, branch="main")
291 r = _merge(root, "no-such-branch", "--json")
292 assert r.exit_code != 0
293 d = json.loads(r.output)
294 assert d["status"] == "error"
295
296
297 # ---------------------------------------------------------------------------
298 # No-prose pollution
299 # ---------------------------------------------------------------------------
300
301 class TestNoProsePollution:
302 def test_up_to_date_stdout_valid_json(self, tmp_path: pathlib.Path) -> None:
303 root = _up_to_date_repo(tmp_path)
304 r = _merge(root, "feature", "--json")
305 json.loads(r.output) # must not raise
306
307 def test_fast_forward_stdout_valid_json(self, tmp_path: pathlib.Path) -> None:
308 root = _ff_repo(tmp_path)
309 r = _merge(root, "feature", "--json")
310 json.loads(r.output)
311
312 def test_merged_stdout_valid_json(self, tmp_path: pathlib.Path) -> None:
313 root = _three_way_clean_repo(tmp_path)
314 r = _merge(root, "feature", "--json")
315 json.loads(r.output)
316
317 def test_no_emoji_in_merged_json(self, tmp_path: pathlib.Path) -> None:
318 root = _three_way_clean_repo(tmp_path)
319 r = _merge(root, "feature", "--json")
320 assert "✅" not in r.output
321 assert "❌" not in r.output
322
323
324 # ---------------------------------------------------------------------------
325 # TypedDicts
326 # ---------------------------------------------------------------------------
327
328 class TestTypedDicts:
329 def test_merge_json_typeddict_exists(self) -> None:
330 from muse.cli.commands.merge import _MergeJson
331 assert _MergeJson is not None
332
333 def test_merge_error_json_typeddict_exists(self) -> None:
334 from muse.cli.commands.merge import _MergeErrorJson
335 assert _MergeErrorJson is not None
336
337 def test_merge_json_has_exit_code_annotation(self) -> None:
338 from muse.cli.commands.merge import _MergeJson
339 hints = get_type_hints(_MergeJson)
340 assert "exit_code" in hints
341
342 def test_merge_json_has_duration_ms_annotation(self) -> None:
343 from muse.cli.commands.merge import _MergeJson
344 hints = get_type_hints(_MergeJson)
345 assert "duration_ms" in hints
346
347 def test_merge_error_json_has_required_fields(self) -> None:
348 from muse.cli.commands.merge import _MergeErrorJson
349 hints = get_type_hints(_MergeErrorJson)
350 for field in ("status", "error", "exit_code"):
351 assert field in hints, f"Missing annotation: {field!r}"
352
353
354 # ---------------------------------------------------------------------------
355 # Docstring coverage
356 # ---------------------------------------------------------------------------
357
358 class TestDocstring:
359 def _doc(self) -> str:
360 import muse.cli.commands.merge as mod
361 return mod.__doc__ or ""
362
363 def test_docstring_documents_exit_code(self) -> None:
364 assert "exit_code" in self._doc()
365
366 def test_docstring_documents_duration_ms(self) -> None:
367 assert "duration_ms" in self._doc()
File History 1 commit
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 days ago