gabriel / muse public
test_merge_tree_supercharge.py python
434 lines 16.3 KB
Raw
sha256:ff478cfdcdd4b7fd6de89cb68896601a981f945634463275ec333bd20ca36402 Merge branch 'dev' into main Human 21 days ago
1 """Supercharge tests for ``muse merge-tree``.
2
3 Coverage tiers
4 --------------
5 - JSON envelope: exit_code and duration_ms present on clean and conflict outcomes
6 - Error payload: errors go to stdout as JSON in --json mode, no dual stderr prose
7 - Data integrity: sha256: OID prefix preserved in merged_manifest; --base with
8 sha256:-prefixed commit ID accepted
9 - TypedDicts: _MergeTreeJson and _MergeTreeErrorJson with required annotations
10 - Docstring: module docstring covers exit_code and duration_ms
11 - No-prose pollution: JSON stdout is valid on all non-error paths
12 - Stress: 100-file manifest, 40% conflict rate — correct counts, correct exit code
13 """
14 from __future__ import annotations
15 from collections.abc import Mapping
16
17 import argparse
18 import datetime
19 import json
20 import pathlib
21 from typing import get_type_hints
22
23 from muse.core.object_store import write_object
24 from muse.core.ids import hash_commit as compute_commit_id, hash_snapshot as compute_snapshot_id
25 from muse.core.commits import (
26 CommitRecord,
27 write_commit,
28 )
29 from muse.core.snapshots import (
30 SnapshotRecord,
31 write_snapshot,
32 )
33 from tests.cli_test_helper import CliRunner, InvokeResult
34 from muse.core.types import blob_id
35 from muse.core.paths import ref_path, muse_dir
36
37 runner = CliRunner()
38
39 _REPO_ID = "mt-supercharge"
40 _DT = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
41
42
43 # ---------------------------------------------------------------------------
44 # Helpers
45 # ---------------------------------------------------------------------------
46
47
48
49
50 def _init_repo(tmp_path: pathlib.Path) -> pathlib.Path:
51 dot_muse = muse_dir(tmp_path)
52 for sub in ("objects", "commits", "snapshots", "refs/heads"):
53 (dot_muse / sub).mkdir(parents=True)
54 (dot_muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
55 (dot_muse / "repo.json").write_text(
56 json.dumps({"repo_id": _REPO_ID, "domain": "code"}), encoding="utf-8"
57 )
58 return tmp_path
59
60
61 def _env(root: pathlib.Path) -> Mapping[str, str]:
62 return {"MUSE_REPO_ROOT": str(root)}
63
64
65 def _write_obj(root: pathlib.Path, content: bytes) -> str:
66 oid = blob_id(content)
67 write_object(root, oid, content)
68 return oid
69
70
71 def _make_commit(
72 root: pathlib.Path,
73 manifest: Mapping[str, str],
74 branch: str,
75 parent: str | None = None,
76 msg: str = "test",
77 ) -> str:
78 snap_id = compute_snapshot_id(manifest)
79 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest, created_at=_DT))
80 parent_ids = [parent] if parent else []
81 cid = compute_commit_id( parent_ids=parent_ids,
82 snapshot_id=snap_id,
83 message=msg,
84 committed_at_iso=_DT.isoformat(),
85 )
86 write_commit(root, CommitRecord(
87 commit_id=cid, branch=branch,
88 snapshot_id=snap_id, message=msg, committed_at=_DT,
89 parent_commit_id=parent,
90 ))
91 ref = ref_path(root, branch)
92 ref.parent.mkdir(parents=True, exist_ok=True)
93 ref.write_text(cid, encoding="utf-8")
94 return cid
95
96
97 def _mt(root: pathlib.Path, *args: str) -> InvokeResult:
98 from muse.cli.app import main as cli
99 return runner.invoke(cli, ["merge-tree", *args], env=_env(root))
100
101
102 def _diverged(root: pathlib.Path) -> tuple[str, str, str]:
103 """base → branch-a (adds a.py) and base → branch-b (adds b.py). No conflict."""
104 base_oid = _write_obj(root, b"base")
105 base_cid = _make_commit(root, {"base.py": base_oid}, "main")
106 a_oid = _write_obj(root, b"a content")
107 a_cid = _make_commit(root, {"base.py": base_oid, "a.py": a_oid}, "branch-a", parent=base_cid)
108 b_oid = _write_obj(root, b"b content")
109 b_cid = _make_commit(root, {"base.py": base_oid, "b.py": b_oid}, "branch-b", parent=base_cid)
110 return base_cid, a_cid, b_cid
111
112
113 def _conflicted(root: pathlib.Path) -> tuple[str, str, str]:
114 """base → branch-a and branch-b both modify shared.py differently."""
115 v1 = _write_obj(root, b"v1")
116 base_cid = _make_commit(root, {"shared.py": v1}, "main")
117 va = _write_obj(root, b"version-a")
118 a_cid = _make_commit(root, {"shared.py": va}, "branch-a", parent=base_cid)
119 vb = _write_obj(root, b"version-b")
120 b_cid = _make_commit(root, {"shared.py": vb}, "branch-b", parent=base_cid)
121 return base_cid, a_cid, b_cid
122
123
124 # ---------------------------------------------------------------------------
125 # JSON envelope — exit_code
126 # ---------------------------------------------------------------------------
127
128
129 class TestJsonEnvelopeExitCode:
130 def test_clean_merge_has_exit_code_zero(self, tmp_path: pathlib.Path) -> None:
131 root = _init_repo(tmp_path)
132 _diverged(root)
133 r = _mt(root, "branch-a", "branch-b", "--json")
134 assert r.exit_code == 0
135 d = json.loads(r.output)
136 assert "exit_code" in d, "exit_code missing from clean merge envelope"
137 assert d["exit_code"] == 0
138
139 def test_conflict_merge_has_exit_code_nonzero(self, tmp_path: pathlib.Path) -> None:
140 root = _init_repo(tmp_path)
141 _conflicted(root)
142 r = _mt(root, "branch-a", "branch-b", "--json")
143 assert r.exit_code != 0
144 d = json.loads(r.output)
145 assert "exit_code" in d, "exit_code missing from conflict envelope"
146 assert d["exit_code"] != 0
147
148
149 # ---------------------------------------------------------------------------
150 # JSON envelope — duration_ms
151 # ---------------------------------------------------------------------------
152
153
154 class TestJsonEnvelopeDurationMs:
155 def test_clean_merge_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
156 root = _init_repo(tmp_path)
157 _diverged(root)
158 r = _mt(root, "branch-a", "branch-b", "--json")
159 d = json.loads(r.output)
160 assert "duration_ms" in d, "duration_ms missing from clean merge envelope"
161 assert isinstance(d["duration_ms"], float)
162 assert d["duration_ms"] >= 0.0
163
164 def test_conflict_merge_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
165 root = _init_repo(tmp_path)
166 _conflicted(root)
167 r = _mt(root, "branch-a", "branch-b", "--json")
168 d = json.loads(r.output)
169 assert "duration_ms" in d, "duration_ms missing from conflict envelope"
170 assert isinstance(d["duration_ms"], float)
171
172 def test_write_objects_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
173 root = _init_repo(tmp_path)
174 _diverged(root)
175 r = _mt(root, "branch-a", "branch-b", "--write-objects", "--json")
176 d = json.loads(r.output)
177 assert "duration_ms" in d
178
179
180 # ---------------------------------------------------------------------------
181 # Error payload — errors route to stdout as JSON in --json mode
182 # ---------------------------------------------------------------------------
183
184
185 class TestErrorPayload:
186 def test_bad_branch1_error_goes_to_stdout(self, tmp_path: pathlib.Path) -> None:
187 root = _init_repo(tmp_path)
188 r = _mt(root, "no-such", "also-no", "--json")
189 assert r.exit_code != 0
190 assert not r.stderr.strip(), f"unexpected stderr: {r.stderr!r}"
191 d = json.loads(r.output)
192 assert d["status"] == "error"
193
194 def test_bad_branch2_error_goes_to_stdout(self, tmp_path: pathlib.Path) -> None:
195 root = _init_repo(tmp_path)
196 base_oid = _write_obj(root, b"x")
197 _make_commit(root, {"x.py": base_oid}, "main")
198 r = _mt(root, "main", "nonexistent", "--json")
199 assert r.exit_code != 0
200 assert not r.stderr.strip(), f"unexpected stderr: {r.stderr!r}"
201 d = json.loads(r.output)
202 assert d["status"] == "error"
203
204 def test_no_common_ancestor_error_goes_to_stdout(self, tmp_path: pathlib.Path) -> None:
205 root = _init_repo(tmp_path)
206 oid = _write_obj(root, b"x")
207 _make_commit(root, {"x.py": oid}, "orphan-a")
208 oid2 = _write_obj(root, b"y")
209 _make_commit(root, {"y.py": oid2}, "orphan-b")
210 r = _mt(root, "orphan-a", "orphan-b", "--json")
211 assert r.exit_code != 0
212 assert not r.stderr.strip(), f"unexpected stderr: {r.stderr!r}"
213 d = json.loads(r.output)
214 assert d["status"] == "error"
215
216 def test_error_payload_has_status_error(self, tmp_path: pathlib.Path) -> None:
217 root = _init_repo(tmp_path)
218 r = _mt(root, "ghost", "phantom", "--json")
219 d = json.loads(r.output)
220 assert d["status"] == "error"
221
222 def test_error_payload_has_exit_code(self, tmp_path: pathlib.Path) -> None:
223 root = _init_repo(tmp_path)
224 r = _mt(root, "ghost", "phantom", "--json")
225 d = json.loads(r.output)
226 assert "exit_code" in d
227 assert d["exit_code"] != 0
228
229 def test_error_payload_has_error_field(self, tmp_path: pathlib.Path) -> None:
230 root = _init_repo(tmp_path)
231 r = _mt(root, "ghost", "phantom", "--json")
232 d = json.loads(r.output)
233 assert "error" in d
234 assert d["error"]
235
236 def test_no_duplicate_stderr_prose(self, tmp_path: pathlib.Path) -> None:
237 """In --json mode, errors must not also print ❌ prose to stderr."""
238 root = _init_repo(tmp_path)
239 r = _mt(root, "ghost", "phantom", "--json")
240 assert "❌" not in r.stderr
241
242
243 # ---------------------------------------------------------------------------
244 # Data integrity
245 # ---------------------------------------------------------------------------
246
247
248 class TestDataIntegrity:
249 def test_merged_manifest_oids_are_sha256_prefixed(self, tmp_path: pathlib.Path) -> None:
250 """All non-null object IDs in merged_manifest must carry sha256: prefix."""
251 root = _init_repo(tmp_path)
252 _diverged(root)
253 r = _mt(root, "branch-a", "branch-b", "--json")
254 d = json.loads(r.output)
255 for path, oid in d["merged_manifest"].items():
256 if oid is not None:
257 assert oid.startswith("sha256:"), (
258 f"OID for '{path}' missing sha256: prefix: {oid!r}"
259 )
260
261 def test_base_with_sha256_prefixed_commit_id(self, tmp_path: pathlib.Path) -> None:
262 """--base accepts sha256:-prefixed commit IDs (not just branch names)."""
263 root = _init_repo(tmp_path)
264 base_cid, a_cid, b_cid = _diverged(root)
265 r = _mt(root, "branch-a", "branch-b", "--base", base_cid, "--json")
266 assert r.exit_code == 0
267 d = json.loads(r.output)
268 assert d["base"] == base_cid
269
270 def test_branch_ids_echoed_in_response(self, tmp_path: pathlib.Path) -> None:
271 root = _init_repo(tmp_path)
272 base_cid, a_cid, b_cid = _diverged(root)
273 r = _mt(root, "branch-a", "branch-b", "--json")
274 d = json.loads(r.output)
275 assert d["branch1"] == a_cid
276 assert d["branch2"] == b_cid
277
278 def test_conflict_paths_have_null_oid(self, tmp_path: pathlib.Path) -> None:
279 root = _init_repo(tmp_path)
280 _conflicted(root)
281 r = _mt(root, "branch-a", "branch-b", "--json")
282 d = json.loads(r.output)
283 for path in d["conflicts"]:
284 assert d["merged_manifest"][path] is None
285
286 def test_snapshot_id_is_sha256_prefixed(self, tmp_path: pathlib.Path) -> None:
287 """--write-objects snapshot_id must carry sha256: prefix."""
288 root = _init_repo(tmp_path)
289 _diverged(root)
290 r = _mt(root, "branch-a", "branch-b", "--write-objects", "--json")
291 d = json.loads(r.output)
292 assert "snapshot_id" in d
293 assert d["snapshot_id"].startswith("sha256:"), (
294 f"snapshot_id missing sha256: prefix: {d['snapshot_id']!r}"
295 )
296
297
298 # ---------------------------------------------------------------------------
299 # No-prose pollution
300 # ---------------------------------------------------------------------------
301
302
303 class TestNoProsePollution:
304 def test_clean_merge_stdout_is_valid_json(self, tmp_path: pathlib.Path) -> None:
305 root = _init_repo(tmp_path)
306 _diverged(root)
307 r = _mt(root, "branch-a", "branch-b", "--json")
308 json.loads(r.output) # must not raise
309
310 def test_conflict_merge_stdout_is_valid_json(self, tmp_path: pathlib.Path) -> None:
311 root = _init_repo(tmp_path)
312 _conflicted(root)
313 json.loads(_mt(root, "branch-a", "branch-b", "--json").output)
314
315 def test_no_emoji_in_clean_json(self, tmp_path: pathlib.Path) -> None:
316 root = _init_repo(tmp_path)
317 _diverged(root)
318 r = _mt(root, "branch-a", "branch-b", "--json")
319 assert "✅" not in r.output
320 assert "❌" not in r.output
321
322
323 # ---------------------------------------------------------------------------
324 # TypedDicts
325 # ---------------------------------------------------------------------------
326
327
328 class TestTypedDicts:
329 def test_merge_tree_json_typeddict_exists(self) -> None:
330 from muse.cli.commands.merge_tree import _MergeTreeJson
331 assert _MergeTreeJson is not None
332
333 def test_merge_tree_error_json_typeddict_exists(self) -> None:
334 from muse.cli.commands.merge_tree import _MergeTreeErrorJson
335 assert _MergeTreeErrorJson is not None
336
337 def test_merge_tree_json_has_exit_code_annotation(self) -> None:
338 from muse.cli.commands.merge_tree import _MergeTreeJson
339 hints = get_type_hints(_MergeTreeJson)
340 assert "exit_code" in hints
341
342 def test_merge_tree_json_has_duration_ms_annotation(self) -> None:
343 from muse.cli.commands.merge_tree import _MergeTreeJson
344 hints = get_type_hints(_MergeTreeJson)
345 assert "duration_ms" in hints
346
347 def test_merge_tree_error_json_has_required_fields(self) -> None:
348 from muse.cli.commands.merge_tree import _MergeTreeErrorJson
349 hints = get_type_hints(_MergeTreeErrorJson)
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
359 class TestDocstring:
360 def _doc(self) -> str:
361 import muse.cli.commands.merge_tree as mod
362 return mod.__doc__ or ""
363
364 def test_docstring_documents_exit_code(self) -> None:
365 assert "exit_code" in self._doc()
366
367 def test_docstring_documents_duration_ms(self) -> None:
368 assert "duration_ms" in self._doc()
369
370
371 # ---------------------------------------------------------------------------
372 # Stress
373 # ---------------------------------------------------------------------------
374
375
376 class TestStress:
377 def test_100_files_40_pct_conflicts(self, tmp_path: pathlib.Path) -> None:
378 root = _init_repo(tmp_path)
379 n = 100
380 conflict_n = 40
381
382 base_manifest = {f"f{i:03d}.py": _write_obj(root, f"base-{i}".encode()) for i in range(n)}
383 base_cid = _make_commit(root, base_manifest, "main", msg="base")
384
385 a_manifest = dict(base_manifest)
386 for i in range(conflict_n):
387 a_manifest[f"f{i:03d}.py"] = _write_obj(root, f"a-{i}".encode())
388 _make_commit(root, a_manifest, "stress-a", parent=base_cid)
389
390 b_manifest = dict(base_manifest)
391 for i in range(conflict_n):
392 b_manifest[f"f{i:03d}.py"] = _write_obj(root, f"b-{i}".encode())
393 _make_commit(root, b_manifest, "stress-b", parent=base_cid)
394
395 r = _mt(root, "stress-a", "stress-b", "--json")
396 assert r.exit_code != 0
397 d = json.loads(r.output)
398 assert len(d["conflicts"]) == conflict_n
399 assert d["exit_code"] != 0
400 assert "duration_ms" in d
401
402
403 # ---------------------------------------------------------------------------
404 # TestRegisterFlags — argparse-level verification
405 # ---------------------------------------------------------------------------
406
407
408 class TestRegisterFlags:
409 """Verify that register() wires --json / -j correctly."""
410
411 def _make_parser(self) -> "argparse.ArgumentParser":
412 import argparse
413 from muse.cli.commands.merge_tree import register
414 ap = argparse.ArgumentParser()
415 subs = ap.add_subparsers()
416 register(subs)
417 return ap
418
419 def test_json_flag_long(self) -> None:
420 ns = self._make_parser().parse_args(["merge-tree", "feat/x", "dev", "--json"])
421 assert ns.json_out is True
422
423 def test_j_alias(self) -> None:
424 ns = self._make_parser().parse_args(["merge-tree", "feat/x", "dev", "-j"])
425 assert ns.json_out is True
426
427 def test_default_is_text(self) -> None:
428 ns = self._make_parser().parse_args(["merge-tree", "feat/x", "dev"])
429 assert ns.json_out is False
430
431 def test_dest_is_json_out(self) -> None:
432 ns = self._make_parser().parse_args(["merge-tree", "feat/x", "dev", "-j"])
433 assert hasattr(ns, "json_out")
434 assert not hasattr(ns, "fmt")
File History 1 commit
sha256:ff478cfdcdd4b7fd6de89cb68896601a981f945634463275ec333bd20ca36402 Merge branch 'dev' into main Human 21 days ago