cgcardona / muse public
test_inspect.py python
516 lines 15.8 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """Tests for ``muse inspect`` — structured JSON of the Muse commit graph.
2
3 All async tests call ``_inspect_async`` directly with an in-memory SQLite
4 session and a ``tmp_path`` repo root — no real Postgres or running
5 process required. Commits are seeded via ``_commit_async``.
6
7 Naming convention: test_inspect_<behavior>_<scenario>
8 """
9 from __future__ import annotations
10
11 import json
12 import pathlib
13 import uuid
14
15 import pytest
16 from sqlalchemy.ext.asyncio import AsyncSession
17
18 from maestro.muse_cli.commands.commit import _commit_async
19 from maestro.muse_cli.commands.inspect import _inspect_async
20 from maestro.muse_cli.errors import ExitCode
21 from maestro.services.muse_inspect import (
22 InspectFormat,
23 MuseInspectResult,
24 build_inspect_result,
25 render_dot,
26 render_json,
27 render_mermaid,
28 )
29
30
31 # ---------------------------------------------------------------------------
32 # Test helpers
33 # ---------------------------------------------------------------------------
34
35
36 def _init_muse_repo(root: pathlib.Path, repo_id: str | None = None) -> str:
37 """Initialise a minimal ``.muse/`` directory structure for tests."""
38 rid = repo_id or str(uuid.uuid4())
39 muse = root / ".muse"
40 (muse / "refs" / "heads").mkdir(parents=True)
41 (muse / "repo.json").write_text(
42 json.dumps({"repo_id": rid, "schema_version": "1"})
43 )
44 (muse / "HEAD").write_text("refs/heads/main")
45 (muse / "refs" / "heads" / "main").write_text("")
46 return rid
47
48
49 def _write_workdir(root: pathlib.Path, files: dict[str, bytes]) -> None:
50 workdir = root / "muse-work"
51 workdir.mkdir(exist_ok=True)
52 for name, content in files.items():
53 (workdir / name).write_bytes(content)
54
55
56 async def _make_commits(
57 root: pathlib.Path,
58 session: AsyncSession,
59 messages: list[str],
60 file_seed: int = 0,
61 ) -> list[str]:
62 """Create N commits with unique file content and return their IDs."""
63 commit_ids: list[str] = []
64 for i, msg in enumerate(messages):
65 _write_workdir(root, {f"track_{file_seed + i}.mid": f"MIDI-{file_seed + i}".encode()})
66 cid = await _commit_async(message=msg, root=root, session=session)
67 commit_ids.append(cid)
68 return commit_ids
69
70
71 def _get_repo_id(root: pathlib.Path) -> str:
72 data: dict[str, str] = json.loads((root / ".muse" / "repo.json").read_text())
73 return data["repo_id"]
74
75
76 # ---------------------------------------------------------------------------
77 # Regression: test_inspect_outputs_json_graph
78 # ---------------------------------------------------------------------------
79
80
81 @pytest.mark.anyio
82 async def test_inspect_outputs_json_graph(
83 tmp_path: pathlib.Path,
84 muse_cli_db_session: AsyncSession,
85 capsys: pytest.CaptureFixture[str],
86 ) -> None:
87 """``muse inspect`` outputs valid JSON with the full commit graph (regression test)."""
88 _init_muse_repo(tmp_path)
89 cids = await _make_commits(tmp_path, muse_cli_db_session, ["take 1", "take 2", "take 3"])
90
91 capsys.readouterr()
92 result = await _inspect_async(
93 root=tmp_path,
94 session=muse_cli_db_session,
95 ref=None,
96 depth=None,
97 branches=False,
98 fmt=InspectFormat.json,
99 )
100
101 out = capsys.readouterr().out
102 payload = json.loads(out)
103
104 assert "repo_id" in payload
105 assert payload["current_branch"] == "main"
106 assert "branches" in payload
107 assert "commits" in payload
108 assert isinstance(payload["commits"], list)
109 assert len(payload["commits"]) == 3
110
111 commit_ids_in_output = {c["commit_id"] for c in payload["commits"]}
112 for cid in cids:
113 assert cid in commit_ids_in_output
114
115
116 # ---------------------------------------------------------------------------
117 # Unit: test_inspect_result_commits_newest_first
118 # ---------------------------------------------------------------------------
119
120
121 @pytest.mark.anyio
122 async def test_inspect_result_commits_newest_first(
123 tmp_path: pathlib.Path,
124 muse_cli_db_session: AsyncSession,
125 ) -> None:
126 """Commits in the result are newest-first."""
127 _init_muse_repo(tmp_path)
128 cids = await _make_commits(tmp_path, muse_cli_db_session, ["oldest", "middle", "newest"])
129
130 result = await build_inspect_result(
131 muse_cli_db_session,
132 tmp_path,
133 ref=None,
134 depth=None,
135 include_branches=False,
136 )
137
138 assert result.commits[0].commit_id == cids[2]
139 assert result.commits[-1].commit_id == cids[0]
140
141
142 # ---------------------------------------------------------------------------
143 # Unit: test_inspect_depth_limits_commits
144 # ---------------------------------------------------------------------------
145
146
147 @pytest.mark.anyio
148 async def test_inspect_depth_limits_commits(
149 tmp_path: pathlib.Path,
150 muse_cli_db_session: AsyncSession,
151 ) -> None:
152 """``--depth 2`` limits the traversal to 2 commits."""
153 _init_muse_repo(tmp_path)
154 await _make_commits(tmp_path, muse_cli_db_session, ["c1", "c2", "c3", "c4", "c5"])
155
156 result = await build_inspect_result(
157 muse_cli_db_session,
158 tmp_path,
159 ref=None,
160 depth=2,
161 include_branches=False,
162 )
163
164 assert len(result.commits) == 2
165
166
167 # ---------------------------------------------------------------------------
168 # Unit: test_inspect_branches_flag_includes_all_branches
169 # ---------------------------------------------------------------------------
170
171
172 @pytest.mark.anyio
173 async def test_inspect_branches_flag_includes_all_branches(
174 tmp_path: pathlib.Path,
175 muse_cli_db_session: AsyncSession,
176 ) -> None:
177 """``--branches`` includes commits from all branch heads."""
178 _init_muse_repo(tmp_path)
179 cids = await _make_commits(tmp_path, muse_cli_db_session, ["main commit"])
180
181 # Simulate a second branch by writing a ref file pointing to the same commit.
182 (tmp_path / ".muse" / "refs" / "heads" / "feature").write_text(cids[0])
183
184 result = await build_inspect_result(
185 muse_cli_db_session,
186 tmp_path,
187 ref=None,
188 depth=None,
189 include_branches=True,
190 )
191
192 assert "feature" in result.branches
193 assert "main" in result.branches
194
195
196 # ---------------------------------------------------------------------------
197 # Unit: test_inspect_result_includes_branch_pointers
198 # ---------------------------------------------------------------------------
199
200
201 @pytest.mark.anyio
202 async def test_inspect_result_includes_branch_pointers(
203 tmp_path: pathlib.Path,
204 muse_cli_db_session: AsyncSession,
205 ) -> None:
206 """``branches`` dict maps branch names to their HEAD commit IDs."""
207 _init_muse_repo(tmp_path)
208 cids = await _make_commits(tmp_path, muse_cli_db_session, ["v1", "v2"])
209
210 result = await build_inspect_result(
211 muse_cli_db_session,
212 tmp_path,
213 ref=None,
214 depth=None,
215 include_branches=False,
216 )
217
218 assert result.branches["main"] == cids[1] # HEAD = newest commit
219
220
221 # ---------------------------------------------------------------------------
222 # Unit: test_inspect_commit_fields_are_populated
223 # ---------------------------------------------------------------------------
224
225
226 @pytest.mark.anyio
227 async def test_inspect_commit_fields_are_populated(
228 tmp_path: pathlib.Path,
229 muse_cli_db_session: AsyncSession,
230 ) -> None:
231 """Each commit node includes all required fields from the issue spec."""
232 _init_muse_repo(tmp_path)
233 cids = await _make_commits(tmp_path, muse_cli_db_session, ["test commit"])
234
235 result = await build_inspect_result(
236 muse_cli_db_session,
237 tmp_path,
238 ref=None,
239 depth=None,
240 include_branches=False,
241 )
242
243 commit = result.commits[0]
244 assert commit.commit_id == cids[0]
245 assert commit.short_id == cids[0][:8]
246 assert commit.branch == "main"
247 assert commit.message == "test commit"
248 assert commit.snapshot_id != ""
249 assert commit.committed_at != ""
250 assert isinstance(commit.metadata, dict)
251 assert isinstance(commit.tags, list)
252
253
254 # ---------------------------------------------------------------------------
255 # Unit: test_inspect_parent_chain_preserved
256 # ---------------------------------------------------------------------------
257
258
259 @pytest.mark.anyio
260 async def test_inspect_parent_chain_preserved(
261 tmp_path: pathlib.Path,
262 muse_cli_db_session: AsyncSession,
263 ) -> None:
264 """Parent links in the result correctly chain commits together."""
265 _init_muse_repo(tmp_path)
266 cids = await _make_commits(tmp_path, muse_cli_db_session, ["first", "second", "third"])
267
268 result = await build_inspect_result(
269 muse_cli_db_session,
270 tmp_path,
271 ref=None,
272 depth=None,
273 include_branches=False,
274 )
275
276 commits_by_id = {c.commit_id: c for c in result.commits}
277 # third → second → first
278 assert commits_by_id[cids[2]].parent_commit_id == cids[1]
279 assert commits_by_id[cids[1]].parent_commit_id == cids[0]
280 assert commits_by_id[cids[0]].parent_commit_id is None
281
282
283 # ---------------------------------------------------------------------------
284 # Format: test_inspect_format_dot_outputs_dot_graph
285 # ---------------------------------------------------------------------------
286
287
288 @pytest.mark.anyio
289 async def test_inspect_format_dot_outputs_dot_graph(
290 tmp_path: pathlib.Path,
291 muse_cli_db_session: AsyncSession,
292 capsys: pytest.CaptureFixture[str],
293 ) -> None:
294 """``--format dot`` emits a valid Graphviz DOT graph."""
295 _init_muse_repo(tmp_path)
296 cids = await _make_commits(tmp_path, muse_cli_db_session, ["beat 1", "beat 2"])
297
298 capsys.readouterr()
299 await _inspect_async(
300 root=tmp_path,
301 session=muse_cli_db_session,
302 ref=None,
303 depth=None,
304 branches=False,
305 fmt=InspectFormat.dot,
306 )
307 out = capsys.readouterr().out
308
309 assert "digraph muse_graph" in out
310 for cid in cids:
311 assert cid in out
312 assert "->" in out
313
314
315 # ---------------------------------------------------------------------------
316 # Format: test_inspect_format_mermaid_outputs_mermaid
317 # ---------------------------------------------------------------------------
318
319
320 @pytest.mark.anyio
321 async def test_inspect_format_mermaid_outputs_mermaid(
322 tmp_path: pathlib.Path,
323 muse_cli_db_session: AsyncSession,
324 capsys: pytest.CaptureFixture[str],
325 ) -> None:
326 """``--format mermaid`` emits a Mermaid.js graph definition."""
327 _init_muse_repo(tmp_path)
328 cids = await _make_commits(tmp_path, muse_cli_db_session, ["riff 1", "riff 2"])
329
330 capsys.readouterr()
331 await _inspect_async(
332 root=tmp_path,
333 session=muse_cli_db_session,
334 ref=None,
335 depth=None,
336 branches=False,
337 fmt=InspectFormat.mermaid,
338 )
339 out = capsys.readouterr().out
340
341 assert "graph LR" in out
342 for cid in cids:
343 assert cid[:8] in out
344 assert "-->" in out
345
346
347 # ---------------------------------------------------------------------------
348 # Format: render_json unit test
349 # ---------------------------------------------------------------------------
350
351
352 @pytest.mark.anyio
353 async def test_inspect_render_json_is_valid_json(
354 tmp_path: pathlib.Path,
355 muse_cli_db_session: AsyncSession,
356 ) -> None:
357 """``render_json`` returns valid JSON matching the issue spec shape."""
358 _init_muse_repo(tmp_path)
359 await _make_commits(tmp_path, muse_cli_db_session, ["chord 1", "chord 2"])
360
361 result = await build_inspect_result(
362 muse_cli_db_session,
363 tmp_path,
364 ref=None,
365 depth=None,
366 include_branches=False,
367 )
368 json_str = render_json(result)
369 payload = json.loads(json_str)
370
371 assert set(payload.keys()) == {"repo_id", "current_branch", "branches", "commits"}
372 assert payload["current_branch"] == "main"
373 assert len(payload["commits"]) == 2
374 first_commit = payload["commits"][0]
375 assert "commit_id" in first_commit
376 assert "short_id" in first_commit
377 assert "parent_commit_id" in first_commit
378 assert "snapshot_id" in first_commit
379 assert "metadata" in first_commit
380 assert "tags" in first_commit
381
382
383 # ---------------------------------------------------------------------------
384 # Format: render_dot unit test
385 # ---------------------------------------------------------------------------
386
387
388 @pytest.mark.anyio
389 async def test_inspect_render_dot_contains_nodes_and_edges(
390 tmp_path: pathlib.Path,
391 muse_cli_db_session: AsyncSession,
392 ) -> None:
393 """``render_dot`` contains one node per commit and edge for each parent link."""
394 _init_muse_repo(tmp_path)
395 cids = await _make_commits(tmp_path, muse_cli_db_session, ["n1", "n2", "n3"])
396
397 result = await build_inspect_result(
398 muse_cli_db_session,
399 tmp_path,
400 ref=None,
401 depth=None,
402 include_branches=False,
403 )
404 dot = render_dot(result)
405
406 # Three commit nodes
407 for cid in cids:
408 assert cid in dot
409 # Two parent edges (n3→n2, n2→n1)
410 assert dot.count("->") >= 2
411 assert "digraph" in dot
412
413
414 # ---------------------------------------------------------------------------
415 # Format: render_mermaid unit test
416 # ---------------------------------------------------------------------------
417
418
419 @pytest.mark.anyio
420 async def test_inspect_render_mermaid_contains_nodes_and_edges(
421 tmp_path: pathlib.Path,
422 muse_cli_db_session: AsyncSession,
423 ) -> None:
424 """``render_mermaid`` contains one node per commit and a ``-->`` edge per parent."""
425 _init_muse_repo(tmp_path)
426 cids = await _make_commits(tmp_path, muse_cli_db_session, ["m1", "m2"])
427
428 result = await build_inspect_result(
429 muse_cli_db_session,
430 tmp_path,
431 ref=None,
432 depth=None,
433 include_branches=False,
434 )
435 mermaid = render_mermaid(result)
436
437 for cid in cids:
438 assert cid[:8] in mermaid
439 assert "graph LR" in mermaid
440 assert "-->" in mermaid
441
442
443 # ---------------------------------------------------------------------------
444 # Edge case: test_inspect_empty_repo_returns_empty_commits
445 # ---------------------------------------------------------------------------
446
447
448 @pytest.mark.anyio
449 async def test_inspect_empty_repo_returns_empty_commits(
450 tmp_path: pathlib.Path,
451 muse_cli_db_session: AsyncSession,
452 capsys: pytest.CaptureFixture[str],
453 ) -> None:
454 """``muse inspect`` on an empty repo returns zero commits and valid JSON."""
455 _init_muse_repo(tmp_path)
456
457 capsys.readouterr()
458 result = await _inspect_async(
459 root=tmp_path,
460 session=muse_cli_db_session,
461 ref=None,
462 depth=None,
463 branches=False,
464 fmt=InspectFormat.json,
465 )
466
467 out = capsys.readouterr().out
468 payload = json.loads(out)
469 assert payload["commits"] == []
470 assert result.commits == []
471
472
473 # ---------------------------------------------------------------------------
474 # Edge case: test_inspect_invalid_ref_raises_value_error
475 # ---------------------------------------------------------------------------
476
477
478 @pytest.mark.anyio
479 async def test_inspect_invalid_ref_raises_value_error(
480 tmp_path: pathlib.Path,
481 muse_cli_db_session: AsyncSession,
482 ) -> None:
483 """A ref that cannot be resolved raises ValueError."""
484 _init_muse_repo(tmp_path)
485 await _make_commits(tmp_path, muse_cli_db_session, ["only commit"])
486
487 with pytest.raises(ValueError, match="Cannot resolve ref"):
488 await build_inspect_result(
489 muse_cli_db_session,
490 tmp_path,
491 ref="deadbeef00000000",
492 depth=None,
493 include_branches=False,
494 )
495
496
497 # ---------------------------------------------------------------------------
498 # CLI skeleton: test_inspect_outside_repo_exits_repo_not_found
499 # ---------------------------------------------------------------------------
500
501
502 def test_inspect_outside_repo_exits_repo_not_found(tmp_path: pathlib.Path) -> None:
503 """``muse inspect`` outside a .muse/ directory exits with REPO_NOT_FOUND."""
504 import os
505 from typer.testing import CliRunner
506 from maestro.muse_cli.app import cli
507
508 runner = CliRunner()
509 prev = os.getcwd()
510 try:
511 os.chdir(tmp_path)
512 result = runner.invoke(cli, ["inspect"], catch_exceptions=False)
513 finally:
514 os.chdir(prev)
515
516 assert result.exit_code == ExitCode.REPO_NOT_FOUND