gabriel / muse public
test_read_commit_supercharge.py python
448 lines 18.2 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
1 """Supercharge tests for ``muse read-commit``.
2
3 Coverage tiers
4 --------------
5 - Unit: _short_id helper — prefix preservation, hex length
6 - Integration: duration_ms + exit_code in JSON; text short-ID format
7 - Data integrity: sha256: prefix on all ID fields; valid JSON output
8 - Edge cases: --fields empty/duplicate; HEAD~N beyond depth; unknown branch
9 - Merge: parent2_commit_id in output
10 - Performance: single read under threshold
11 """
12 from __future__ import annotations
13
14 import datetime
15 import json
16 import pathlib
17 import re
18 import time
19
20 import pytest
21
22 from muse.core.errors import ExitCode
23 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
24 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
25 from tests.cli_test_helper import CliRunner, InvokeResult
26 from muse.core._types import fake_id, long_id, split_id
27
28 runner = CliRunner()
29
30 _SNAP_ID: str = compute_snapshot_id({})
31 _COMMITTED_AT: datetime.datetime = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
32
33 _SHA256_FULL = re.compile(r"^sha256:[0-9a-f]{64}$")
34 _SHA256_SHORT_19 = re.compile(r"^sha256:[0-9a-f]{12}$") # "sha256:" (7) + 12 hex = 19 chars
35
36
37 # ---------------------------------------------------------------------------
38 # Helpers
39 # ---------------------------------------------------------------------------
40
41
42 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
43 repo = tmp_path / "repo"
44 muse = repo / ".muse"
45 for sub in ("objects", "commits", "snapshots", "refs/heads"):
46 (muse / sub).mkdir(parents=True)
47 (muse / "HEAD").write_text("ref: refs/heads/main")
48 (muse / "repo.json").write_text(json.dumps({"repo_id": "test-repo", "domain": "code"}))
49 return repo
50
51
52 def _commit(
53 repo: pathlib.Path,
54 *,
55 branch: str = "main",
56 message: str = "test commit",
57 author: str = "tester",
58 parent: str | None = None,
59 parent2: str | None = None,
60 agent_id: str = "",
61 model_id: str = "",
62 snap_id: str | None = None,
63 committed_at: datetime.datetime | None = None,
64 ) -> str:
65 """Write a commit with a real content-addressed ID; return the commit_id."""
66 sid = snap_id or _SNAP_ID
67 ts = committed_at or _COMMITTED_AT
68 parent_ids: list[str] = [p for p in (parent, parent2) if p]
69 commit_id = compute_commit_id(
70 repo_id="test-repo",
71 parent_ids=parent_ids,
72 snapshot_id=sid,
73 message=message,
74 committed_at_iso=ts.isoformat(),
75 author=author,)
76 write_snapshot(repo, SnapshotRecord(
77 snapshot_id=sid,
78 manifest={},
79 created_at=ts,
80 ))
81 rec = CommitRecord(
82 commit_id=commit_id,
83 repo_id="test-repo",
84 created_on_branch=branch,
85 snapshot_id=sid,
86 message=message,
87 committed_at=ts,
88 author=author,
89 parent_commit_id=parent,
90 parent2_commit_id=parent2,
91 agent_id=agent_id,
92 model_id=model_id,
93 )
94 write_commit(repo, rec)
95 return commit_id
96
97
98 def _rc(repo: pathlib.Path, *args: str) -> InvokeResult:
99 from muse.cli.app import main as cli
100 return runner.invoke(
101 cli,
102 ["read-commit", *args],
103 env={"MUSE_REPO_ROOT": str(repo)},
104 )
105
106
107 def _rcj(repo: pathlib.Path, *args: str) -> InvokeResult:
108 """Like _rc but always passes --json."""
109 return _rc(repo, "--json", *args)
110
111
112 # ---------------------------------------------------------------------------
113 # Unit — _short_id
114 # ---------------------------------------------------------------------------
115
116
117 class TestShortId:
118 """_short_id must keep the sha256: prefix and truncate to exactly 12 hex chars."""
119
120 def test_short_id_keeps_sha256_prefix(self) -> None:
121 from muse.cli.commands.read_commit import _short_id
122 cid = long_id("a" * 64)
123 assert _short_id(cid).startswith("sha256:")
124
125 def test_short_id_12_hex_chars_after_prefix(self) -> None:
126 from muse.cli.commands.read_commit import _short_id
127 cid = long_id("deadbeef" * 8)
128 result = _short_id(cid)
129 assert result == "sha256:deadbeefdeadbeef"[:19] # sha256: + 12 hex
130
131 def test_short_id_total_length_is_19(self) -> None:
132 from muse.cli.commands.read_commit import _short_id
133 cid = long_id("c0ffee" * 11)# 66 hex, take first 64
134 result = _short_id(cid[:71]) # sha256: + 64 hex
135 assert len(result) == 19 # "sha256:" (7) + 12 hex
136
137 def test_short_id_bare_hex_fallback(self) -> None:
138 """Bare hex without sha256: prefix — truncate to 12 chars."""
139 from muse.cli.commands.read_commit import _short_id
140 bare = "a" * 64
141 result = _short_id(bare)
142 assert len(result) == 12
143
144 def test_short_id_matches_regex(self) -> None:
145 from muse.cli.commands.read_commit import _short_id
146 cid = long_id("abcdef01" * 8)
147 assert _SHA256_SHORT_19.match(_short_id(cid))
148
149
150 # ---------------------------------------------------------------------------
151 # Integration — duration_ms and exit_code in JSON output
152 # ---------------------------------------------------------------------------
153
154
155 class TestDurationAndExitCode:
156 def test_duration_ms_present_on_success(self, tmp_path: pathlib.Path) -> None:
157 repo = _make_repo(tmp_path)
158 cid = _commit(repo, message="timing test")
159 data = json.loads(_rcj(repo, cid).output)
160 assert "duration_ms" in data, "duration_ms must be present in JSON success output"
161
162 def test_exit_code_zero_on_success(self, tmp_path: pathlib.Path) -> None:
163 repo = _make_repo(tmp_path)
164 cid = _commit(repo, message="exit code test")
165 data = json.loads(_rcj(repo, cid).output)
166 assert data["exit_code"] == 0
167
168 def test_duration_ms_is_float(self, tmp_path: pathlib.Path) -> None:
169 repo = _make_repo(tmp_path)
170 cid = _commit(repo, message="float timing")
171 data = json.loads(_rcj(repo, cid).output)
172 assert isinstance(data["duration_ms"], float)
173
174 def test_duration_ms_non_negative(self, tmp_path: pathlib.Path) -> None:
175 repo = _make_repo(tmp_path)
176 cid = _commit(repo, message="positive timing")
177 data = json.loads(_rcj(repo, cid).output)
178 assert data["duration_ms"] >= 0.0
179
180 def test_fields_filter_preserves_duration_ms(self, tmp_path: pathlib.Path) -> None:
181 """duration_ms is command metadata, not a commit field — --fields must not drop it."""
182 repo = _make_repo(tmp_path)
183 cid = _commit(repo, message="fields + duration")
184 data = json.loads(_rcj(repo, "--fields", "commit_id,message", cid).output)
185 assert "duration_ms" in data, "--fields must not filter out duration_ms"
186
187 def test_fields_filter_preserves_exit_code(self, tmp_path: pathlib.Path) -> None:
188 """exit_code is command metadata — --fields must not drop it."""
189 repo = _make_repo(tmp_path)
190 cid = _commit(repo, message="fields + exit_code")
191 data = json.loads(_rcj(repo, "--fields", "commit_id", cid).output)
192 assert "exit_code" in data, "--fields must not filter out exit_code"
193
194 def test_duration_ms_3dp_precision(self, tmp_path: pathlib.Path) -> None:
195 """duration_ms must be rounded to 3 decimal places (millisecond precision)."""
196 repo = _make_repo(tmp_path)
197 cid = _commit(repo, message="precision test")
198 data = json.loads(_rcj(repo, cid).output)
199 ms = data["duration_ms"]
200 # round-trips through json.dumps — check at most 3 decimal places
201 assert round(ms, 3) == ms
202
203
204 # ---------------------------------------------------------------------------
205 # Integration — text format short ID
206 # ---------------------------------------------------------------------------
207
208
209 class TestTextFormatShortId:
210 """Text format must emit sha256:<12-hex> (19 chars), not the old 12-char bare slice."""
211
212 def _short_token(self, line: str) -> str | None:
213 """Extract the first sha256:... token from a text output line."""
214 for tok in line.split():
215 if _SHA256_SHORT_19.match(tok):
216 return tok
217 return None
218
219 def test_text_short_id_has_sha256_prefix(self, tmp_path: pathlib.Path) -> None:
220 repo = _make_repo(tmp_path)
221 cid = _commit(repo, message="short id prefix test")
222 result = _rc(repo, cid)
223 assert result.exit_code == 0
224 line = result.output.strip()
225 tok = self._short_token(line)
226 assert tok is not None, f"no sha256:<12-hex> token in text output: {line!r}"
227 assert tok.startswith("sha256:")
228
229 def test_text_short_id_has_12_hex_chars(self, tmp_path: pathlib.Path) -> None:
230 repo = _make_repo(tmp_path)
231 cid = _commit(repo, message="short id hex length test")
232 result = _rc(repo, cid)
233 line = result.output.strip()
234 tok = self._short_token(line)
235 assert tok is not None, f"no sha256:<12-hex> token in text output: {line!r}"
236 assert tok.startswith("sha256:")
237 hex_part = tok[len("sha256:"):]
238 assert len(hex_part) == 12, f"expected 12 hex chars after prefix, got {len(hex_part)}: {tok!r}"
239
240 def test_text_short_id_total_length_is_19(self, tmp_path: pathlib.Path) -> None:
241 repo = _make_repo(tmp_path)
242 cid = _commit(repo, message="short id length test")
243 result = _rc(repo, cid)
244 line = result.output.strip()
245 tok = self._short_token(line)
246 assert tok is not None
247 assert len(tok) == 19, f"short ID must be exactly 19 chars, got {len(tok)}: {tok!r}"
248
249 def test_text_short_id_is_prefix_of_full_id(self, tmp_path: pathlib.Path) -> None:
250 repo = _make_repo(tmp_path)
251 cid = _commit(repo, message="short id is prefix test")
252 result = _rc(repo, cid)
253 line = result.output.strip()
254 tok = self._short_token(line)
255 assert tok is not None
256 assert cid.startswith(tok), f"{tok!r} is not a prefix of {cid!r}"
257
258
259 # ---------------------------------------------------------------------------
260 # Data integrity
261 # ---------------------------------------------------------------------------
262
263
264 class TestDataIntegrity:
265 def test_commit_id_has_sha256_prefix(self, tmp_path: pathlib.Path) -> None:
266 repo = _make_repo(tmp_path)
267 cid = _commit(repo, message="id prefix test")
268 data = json.loads(_rcj(repo, cid).output)
269 assert _SHA256_FULL.match(data["commit_id"]), \
270 f"commit_id must be sha256:<64hex>, got {data['commit_id']!r}"
271
272 def test_snapshot_id_has_sha256_prefix(self, tmp_path: pathlib.Path) -> None:
273 repo = _make_repo(tmp_path)
274 cid = _commit(repo, message="snapshot id test")
275 data = json.loads(_rcj(repo, cid).output)
276 assert _SHA256_FULL.match(data["snapshot_id"]), \
277 f"snapshot_id must be sha256:<64hex>, got {data['snapshot_id']!r}"
278
279 def test_parent_commit_id_has_sha256_prefix(self, tmp_path: pathlib.Path) -> None:
280 repo = _make_repo(tmp_path)
281 parent = _commit(repo, message="parent")
282 child = _commit(repo, message="child", parent=parent)
283 data = json.loads(_rcj(repo, child).output)
284 assert _SHA256_FULL.match(data["parent_commit_id"]), \
285 f"parent_commit_id must be sha256:<64hex>, got {data['parent_commit_id']!r}"
286
287 def test_json_output_is_valid_json(self, tmp_path: pathlib.Path) -> None:
288 repo = _make_repo(tmp_path)
289 cid = _commit(repo, message="valid json test")
290 result = _rcj(repo, cid)
291 assert result.exit_code == 0
292 # Must not raise
293 data = json.loads(result.output)
294 assert isinstance(data, dict)
295
296 def test_message_with_special_chars_in_json(self, tmp_path: pathlib.Path) -> None:
297 """Control chars and quotes in message must not break JSON output."""
298 repo = _make_repo(tmp_path)
299 # tab, backslash, double-quote — all must be escaped in JSON
300 msg = 'feat: say "hello"\twith backslash \\'
301 cid = _commit(repo, message=msg)
302 result = _rcj(repo, cid)
303 assert result.exit_code == 0
304 data = json.loads(result.output)
305 assert data["message"] == msg
306
307 def test_message_with_unicode_in_json(self, tmp_path: pathlib.Path) -> None:
308 repo = _make_repo(tmp_path)
309 msg = "feat: 音楽 🎵 café naïve"
310 cid = _commit(repo, message=msg)
311 result = _rcj(repo, cid)
312 assert result.exit_code == 0
313 data = json.loads(result.output)
314 assert data["message"] == msg
315
316
317 # ---------------------------------------------------------------------------
318 # Edge cases — --fields
319 # ---------------------------------------------------------------------------
320
321
322 class TestFieldsEdgeCases:
323 def test_fields_empty_string_errors(self, tmp_path: pathlib.Path) -> None:
324 """--fields '' with no real field names should error (empty requested set)."""
325 repo = _make_repo(tmp_path)
326 cid = _commit(repo, message="empty fields test")
327 result = _rc(repo, "--fields", "", cid)
328 # Empty --fields is ambiguous — should either error or return only metadata.
329 # At minimum the output must be valid JSON.
330 assert result.exit_code == 0 or result.exit_code == ExitCode.USER_ERROR
331
332 def test_fields_duplicate_deduplicated(self, tmp_path: pathlib.Path) -> None:
333 """Duplicate field names in --fields must not crash and produce one key."""
334 repo = _make_repo(tmp_path)
335 cid = _commit(repo, message="duplicate fields test")
336 result = _rcj(repo, "--fields", "commit_id,commit_id,message", cid)
337 assert result.exit_code == 0
338 data = json.loads(result.output)
339 # Only one commit_id key, one message key
340 assert "commit_id" in data
341 assert "message" in data
342
343 def test_fields_whitespace_only_errors(self, tmp_path: pathlib.Path) -> None:
344 """--fields ' , ' (only whitespace/commas) should error."""
345 repo = _make_repo(tmp_path)
346 cid = _commit(repo, message="whitespace fields test")
347 result = _rc(repo, "--fields", " , ", cid)
348 # Parts after strip are empty — should error
349 assert result.exit_code == 0 or result.exit_code == ExitCode.USER_ERROR
350
351
352 # ---------------------------------------------------------------------------
353 # Edge cases — symbolic refs
354 # ---------------------------------------------------------------------------
355
356
357 class TestSymbolicRefEdgeCases:
358 def test_head_tilde_exceeds_chain_depth_errors(self, tmp_path: pathlib.Path) -> None:
359 """HEAD~99 on a 1-commit repo must exit with USER_ERROR, not crash."""
360 repo = _make_repo(tmp_path)
361 cid = _commit(repo, branch="main", message="only commit")
362 (repo / ".muse" / "refs" / "heads" / "main").write_text(cid)
363 result = _rc(repo, "HEAD~99")
364 assert result.exit_code == ExitCode.USER_ERROR
365 assert "Traceback" not in result.output
366
367 def test_unknown_branch_name_errors(self, tmp_path: pathlib.Path) -> None:
368 """A branch name that doesn't exist must exit USER_ERROR cleanly."""
369 repo = _make_repo(tmp_path)
370 _commit(repo, message="root")
371 result = _rc(repo, "nonexistent-branch-xyz")
372 assert result.exit_code == ExitCode.USER_ERROR
373 assert "Traceback" not in result.output
374
375
376 # ---------------------------------------------------------------------------
377 # Merge commit
378 # ---------------------------------------------------------------------------
379
380
381 class TestMergeCommit:
382 def test_parent2_commit_id_in_json_output(self, tmp_path: pathlib.Path) -> None:
383 """Merge commits must expose parent2_commit_id in JSON output."""
384 repo = _make_repo(tmp_path)
385 p1 = _commit(repo, message="parent one")
386 p2 = _commit(repo, message="parent two", committed_at=datetime.datetime(2026, 1, 2, tzinfo=datetime.timezone.utc))
387 merge = _commit(repo, message="merge commit", parent=p1, parent2=p2,
388 committed_at=datetime.datetime(2026, 1, 3, tzinfo=datetime.timezone.utc))
389 data = json.loads(_rcj(repo, merge).output)
390 assert data["parent_commit_id"] == p1
391 assert data["parent2_commit_id"] == p2
392
393 def test_parent2_has_sha256_prefix(self, tmp_path: pathlib.Path) -> None:
394 repo = _make_repo(tmp_path)
395 p1 = _commit(repo, message="p1")
396 p2 = _commit(repo, message="p2", committed_at=datetime.datetime(2026, 1, 2, tzinfo=datetime.timezone.utc))
397 merge = _commit(repo, message="merge", parent=p1, parent2=p2,
398 committed_at=datetime.datetime(2026, 1, 3, tzinfo=datetime.timezone.utc))
399 data = json.loads(_rcj(repo, merge).output)
400 assert _SHA256_FULL.match(data["parent2_commit_id"]), \
401 f"parent2_commit_id must be sha256:<64hex>, got {data['parent2_commit_id']!r}"
402
403
404 # ---------------------------------------------------------------------------
405 # Performance
406 # ---------------------------------------------------------------------------
407
408
409 class TestPerformance:
410 def test_single_read_under_500ms(self, tmp_path: pathlib.Path) -> None:
411 """A single read-commit invocation must complete in under 500ms."""
412 repo = _make_repo(tmp_path)
413 cid = _commit(repo, message="perf test")
414 t0 = time.monotonic()
415 result = _rc(repo, cid)
416 duration_ms = (time.monotonic() - t0) * 1000
417 assert result.exit_code == 0
418 assert duration_ms < 500, f"read-commit took {duration_ms:.1f}ms — over 500ms threshold"
419
420 def test_duration_ms_in_output_plausible(self, tmp_path: pathlib.Path) -> None:
421 """duration_ms in the JSON output must be less than 500ms for a warm read."""
422 repo = _make_repo(tmp_path)
423 cid = _commit(repo, message="plausible timing")
424 data = json.loads(_rcj(repo, cid).output)
425 assert data["duration_ms"] < 500, \
426 f"duration_ms={data['duration_ms']} — suspiciously slow or not measuring correctly"
427
428
429 class TestRegisterFlags:
430 def _parse(self, *args: str) -> "argparse.Namespace":
431 import argparse
432 from muse.cli.commands.read_commit import register
433 p = argparse.ArgumentParser()
434 subs = p.add_subparsers()
435 register(subs)
436 return p.parse_args(["read-commit", fake_id("a"), *args])
437
438 def test_json_short_flag(self):
439 args = self._parse("-j")
440 assert args.json_out is True
441
442 def test_json_long_flag(self):
443 args = self._parse("--json")
444 assert args.json_out is True
445
446 def test_default_no_json(self):
447 args = self._parse()
448 assert args.json_out is False
File History 2 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 137 days ago