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