cgcardona / muse public
test_describe.py python
643 lines 18.7 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """Tests for ``muse describe``.
2
3 All async tests call ``_describe_async`` directly with an in-memory SQLite
4 session and a ``tmp_path`` repo root — no real Postgres or running process
5 required. Commits are seeded via ``_commit_async`` so the two commands
6 are tested as an integrated pair.
7
8 Coverage:
9 - HEAD describe with no parent (root commit)
10 - HEAD describe with parent (diff shows changed files)
11 - Explicit commit ID describe
12 - --compare A B mode
13 - --depth brief / standard / verbose output
14 - --json output format
15 - --dimensions passthrough
16 - --auto-tag heuristic
17 - No commits → clean exit
18 - Outside repo → exit code 2
19 - --compare with wrong arg count → exit code 1
20 - Commit not found → exit code 1
21 """
22 from __future__ import annotations
23
24 import json
25 import os
26 import pathlib
27 import uuid
28
29 import pytest
30 import typer
31 from sqlalchemy.ext.asyncio import AsyncSession
32
33 from maestro.muse_cli.commands.commit import _commit_async
34 from maestro.muse_cli.commands.describe import (
35 DescribeDepth,
36 DescribeResult,
37 _describe_async,
38 _diff_manifests,
39 _infer_dimensions,
40 _suggest_tag,
41 )
42 from maestro.muse_cli.errors import ExitCode
43
44
45 # ---------------------------------------------------------------------------
46 # Helpers (mirrors test_log.py pattern)
47 # ---------------------------------------------------------------------------
48
49
50 def _init_muse_repo(root: pathlib.Path, repo_id: str | None = None) -> str:
51 rid = repo_id or str(uuid.uuid4())
52 muse = root / ".muse"
53 (muse / "refs" / "heads").mkdir(parents=True)
54 (muse / "repo.json").write_text(
55 json.dumps({"repo_id": rid, "schema_version": "1"})
56 )
57 (muse / "HEAD").write_text("refs/heads/main")
58 (muse / "refs" / "heads" / "main").write_text("")
59 return rid
60
61
62 def _write_workdir(root: pathlib.Path, files: dict[str, bytes]) -> None:
63 workdir = root / "muse-work"
64 workdir.mkdir(exist_ok=True)
65 for name, content in files.items():
66 (workdir / name).write_bytes(content)
67
68
69 async def _make_commit(
70 root: pathlib.Path,
71 session: AsyncSession,
72 message: str,
73 files: dict[str, bytes],
74 ) -> str:
75 """Create one commit with the given files and message."""
76 _write_workdir(root, files)
77 return await _commit_async(message=message, root=root, session=session)
78
79
80 # ---------------------------------------------------------------------------
81 # Unit tests — pure functions
82 # ---------------------------------------------------------------------------
83
84
85 class TestDiffManifests:
86 def test_diff_manifests_identical(self) -> None:
87 m = {"a.mid": "aaa", "b.mid": "bbb"}
88 changed, added, removed = _diff_manifests(m, m)
89 assert changed == []
90 assert added == []
91 assert removed == []
92
93 def test_diff_manifests_modified(self) -> None:
94 base = {"a.mid": "old"}
95 target = {"a.mid": "new"}
96 changed, added, removed = _diff_manifests(base, target)
97 assert changed == ["a.mid"]
98 assert added == []
99 assert removed == []
100
101 def test_diff_manifests_added(self) -> None:
102 base: dict[str, str] = {}
103 target = {"new.mid": "hash"}
104 changed, added, removed = _diff_manifests(base, target)
105 assert changed == []
106 assert added == ["new.mid"]
107 assert removed == []
108
109 def test_diff_manifests_removed(self) -> None:
110 base = {"gone.mid": "hash"}
111 target: dict[str, str] = {}
112 changed, added, removed = _diff_manifests(base, target)
113 assert changed == []
114 assert added == []
115 assert removed == ["gone.mid"]
116
117 def test_diff_manifests_mixed(self) -> None:
118 base = {"a.mid": "1", "b.mid": "2", "c.mid": "3"}
119 target = {"a.mid": "changed", "c.mid": "3", "d.mid": "4"}
120 changed, added, removed = _diff_manifests(base, target)
121 assert changed == ["a.mid"]
122 assert added == ["d.mid"]
123 assert removed == ["b.mid"]
124
125 def test_diff_manifests_sorted_output(self) -> None:
126 base = {"z.mid": "1"}
127 target = {"a.mid": "2", "z.mid": "1"}
128 _, added, _ = _diff_manifests(base, target)
129 assert added == ["a.mid"]
130
131
132 class TestInferDimensions:
133 def test_infer_dimensions_no_changes(self) -> None:
134 dims = _infer_dimensions([], [], [], [])
135 assert dims == []
136
137 def test_infer_dimensions_structural_singular(self) -> None:
138 dims = _infer_dimensions(["a.mid"], [], [], [])
139 assert len(dims) == 1
140 assert "1 file" in dims[0]
141
142 def test_infer_dimensions_structural_plural(self) -> None:
143 dims = _infer_dimensions(["a.mid", "b.mid"], [], [], [])
144 assert "2 files" in dims[0]
145
146 def test_infer_dimensions_requested_passthrough(self) -> None:
147 dims = _infer_dimensions(["a.mid"], [], [], ["rhythm", "harmony"])
148 assert dims == ["rhythm", "harmony"]
149
150 def test_infer_dimensions_requested_strips_whitespace(self) -> None:
151 dims = _infer_dimensions([], [], [], [" rhythm ", " harmony "])
152 assert dims == ["rhythm", "harmony"]
153
154
155 class TestSuggestTag:
156 def test_suggest_tag_no_change(self) -> None:
157 assert _suggest_tag([], 0) == "no-change"
158
159 def test_suggest_tag_single_file(self) -> None:
160 assert _suggest_tag(["structural"], 1) == "single-file-edit"
161
162 def test_suggest_tag_minor(self) -> None:
163 assert _suggest_tag(["structural"], 3) == "minor-revision"
164
165 def test_suggest_tag_major(self) -> None:
166 assert _suggest_tag(["structural"], 10) == "major-revision"
167
168
169 class TestDescribeResult:
170 def _make_result(self, **kwargs: object) -> DescribeResult:
171 defaults: dict[str, object] = dict(
172 commit_id="a" * 64,
173 message="test msg",
174 depth=DescribeDepth.standard,
175 parent_id=None,
176 compare_commit_id=None,
177 changed_files=[],
178 added_files=[],
179 removed_files=[],
180 dimensions=[],
181 auto_tag=None,
182 )
183 defaults.update(kwargs)
184 return DescribeResult(**defaults) # type: ignore[arg-type]
185
186 def test_file_count_empty(self) -> None:
187 r = self._make_result()
188 assert r.file_count() == 0
189
190 def test_file_count_sum(self) -> None:
191 r = self._make_result(
192 changed_files=["a.mid"],
193 added_files=["b.mid", "c.mid"],
194 removed_files=[],
195 )
196 assert r.file_count() == 3
197
198 def test_to_dict_has_required_keys(self) -> None:
199 r = self._make_result(changed_files=["x.mid"])
200 d = r.to_dict()
201 assert "commit" in d
202 assert "message" in d
203 assert "depth" in d
204 assert "changed_files" in d
205 assert "added_files" in d
206 assert "removed_files" in d
207 assert "dimensions" in d
208 assert "file_count" in d
209 assert "parent" in d
210 assert "note" in d
211
212 def test_to_dict_compare_commit_included_when_set(self) -> None:
213 r = self._make_result(compare_commit_id="b" * 64)
214 d = r.to_dict()
215 assert "compare_commit" in d
216
217 def test_to_dict_auto_tag_included_when_set(self) -> None:
218 r = self._make_result(auto_tag="minor-revision")
219 d = r.to_dict()
220 assert d["auto_tag"] == "minor-revision"
221
222
223 # ---------------------------------------------------------------------------
224 # Integration tests — _describe_async with real DB
225 # ---------------------------------------------------------------------------
226
227
228 @pytest.mark.anyio
229 async def test_describe_root_commit_no_parent(
230 tmp_path: pathlib.Path,
231 muse_cli_db_session: AsyncSession,
232 ) -> None:
233 """Root commit (no parent) shows all files as added."""
234 _init_muse_repo(tmp_path)
235 await _make_commit(
236 tmp_path, muse_cli_db_session, "init session",
237 {"beat.mid": b"MIDI1", "keys.mid": b"MIDI2"}
238 )
239
240 result = await _describe_async(
241 root=tmp_path,
242 session=muse_cli_db_session,
243 commit_id=None,
244 compare_a=None,
245 compare_b=None,
246 depth=DescribeDepth.standard,
247 dimensions_raw=None,
248 as_json=False,
249 auto_tag=False,
250 )
251
252 # Root commit: no parent, so all files are "added"
253 assert result.parent_id is None
254 assert result.file_count() == 2
255 assert len(result.added_files) == 2
256 assert result.changed_files == []
257 assert result.removed_files == []
258
259
260 @pytest.mark.anyio
261 async def test_describe_head_shows_diff_from_parent(
262 tmp_path: pathlib.Path,
263 muse_cli_db_session: AsyncSession,
264 ) -> None:
265 """HEAD describe shows files changed relative to its parent."""
266 _init_muse_repo(tmp_path)
267 await _make_commit(
268 tmp_path, muse_cli_db_session, "first take",
269 {"beat.mid": b"v1", "keys.mid": b"v1"}
270 )
271 await _make_commit(
272 tmp_path, muse_cli_db_session, "update beat",
273 {"beat.mid": b"v2", "keys.mid": b"v1"} # keys unchanged, beat modified
274 )
275
276 result = await _describe_async(
277 root=tmp_path,
278 session=muse_cli_db_session,
279 commit_id=None,
280 compare_a=None,
281 compare_b=None,
282 depth=DescribeDepth.standard,
283 dimensions_raw=None,
284 as_json=False,
285 auto_tag=False,
286 )
287
288 # Only beat.mid changed between first and second commit
289 # Manifest keys are relative to muse-work/ directory (not the repo root)
290 assert result.changed_files == ["beat.mid"]
291 assert result.added_files == []
292 assert result.removed_files == []
293 assert result.file_count() == 1
294 assert result.message == "update beat"
295
296
297 @pytest.mark.anyio
298 async def test_describe_explicit_commit_id(
299 tmp_path: pathlib.Path,
300 muse_cli_db_session: AsyncSession,
301 ) -> None:
302 """Describing an explicit commit ID works correctly."""
303 _init_muse_repo(tmp_path)
304 cid1 = await _make_commit(
305 tmp_path, muse_cli_db_session, "take one",
306 {"track_1.mid": b"data"}
307 )
308 # Make a second commit so HEAD != cid1
309 await _make_commit(
310 tmp_path, muse_cli_db_session, "take two",
311 {"track_1.mid": b"data", "track_2.mid": b"more"}
312 )
313
314 result = await _describe_async(
315 root=tmp_path,
316 session=muse_cli_db_session,
317 commit_id=cid1,
318 compare_a=None,
319 compare_b=None,
320 depth=DescribeDepth.standard,
321 dimensions_raw=None,
322 as_json=False,
323 auto_tag=False,
324 )
325
326 assert result.commit_id == cid1
327 assert result.message == "take one"
328
329
330 @pytest.mark.anyio
331 async def test_describe_compare_mode(
332 tmp_path: pathlib.Path,
333 muse_cli_db_session: AsyncSession,
334 ) -> None:
335 """--compare A B produces a diff between two explicit commits."""
336 _init_muse_repo(tmp_path)
337 cid_a = await _make_commit(
338 tmp_path, muse_cli_db_session, "baseline",
339 {"beat.mid": b"v1"}
340 )
341 cid_b = await _make_commit(
342 tmp_path, muse_cli_db_session, "add melody",
343 {"beat.mid": b"v1", "melody.mid": b"new"}
344 )
345
346 result = await _describe_async(
347 root=tmp_path,
348 session=muse_cli_db_session,
349 commit_id=None,
350 compare_a=cid_a,
351 compare_b=cid_b,
352 depth=DescribeDepth.standard,
353 dimensions_raw=None,
354 as_json=False,
355 auto_tag=False,
356 )
357
358 assert result.commit_id == cid_b
359 assert result.compare_commit_id == cid_a
360 # Manifest keys are relative to muse-work/ directory
361 assert "melody.mid" in result.added_files
362 assert result.file_count() == 1
363
364
365 @pytest.mark.anyio
366 async def test_describe_depth_brief(
367 tmp_path: pathlib.Path,
368 muse_cli_db_session: AsyncSession,
369 capsys: pytest.CaptureFixture[str],
370 ) -> None:
371 """--depth brief produces a one-line summary."""
372 _init_muse_repo(tmp_path)
373 await _make_commit(
374 tmp_path, muse_cli_db_session, "init",
375 {"beat.mid": b"data"}
376 )
377
378 result = await _describe_async(
379 root=tmp_path,
380 session=muse_cli_db_session,
381 commit_id=None,
382 compare_a=None,
383 compare_b=None,
384 depth=DescribeDepth.brief,
385 dimensions_raw=None,
386 as_json=False,
387 auto_tag=False,
388 )
389
390 from maestro.muse_cli.commands.describe import _render_brief
391 _render_brief(result)
392 out = capsys.readouterr().out
393
394 # Brief: short commit ID and file count, no commit message
395 assert result.commit_id[:8] in out
396 assert "file change" in out
397 # No verbose detail
398 assert "Note:" not in out
399
400
401 @pytest.mark.anyio
402 async def test_describe_depth_standard(
403 tmp_path: pathlib.Path,
404 muse_cli_db_session: AsyncSession,
405 capsys: pytest.CaptureFixture[str],
406 ) -> None:
407 """--depth standard includes message, files, dimensions, and note."""
408 _init_muse_repo(tmp_path)
409 await _make_commit(
410 tmp_path, muse_cli_db_session, "add chorus",
411 {"chorus.mid": b"x"}
412 )
413
414 result = await _describe_async(
415 root=tmp_path,
416 session=muse_cli_db_session,
417 commit_id=None,
418 compare_a=None,
419 compare_b=None,
420 depth=DescribeDepth.standard,
421 dimensions_raw=None,
422 as_json=False,
423 auto_tag=False,
424 )
425
426 from maestro.muse_cli.commands.describe import _render_standard
427 _render_standard(result)
428 out = capsys.readouterr().out
429
430 assert "add chorus" in out
431 assert "chorus.mid" in out
432 assert "Dimensions analyzed:" in out
433 assert "Note:" in out
434
435
436 @pytest.mark.anyio
437 async def test_describe_depth_verbose(
438 tmp_path: pathlib.Path,
439 muse_cli_db_session: AsyncSession,
440 capsys: pytest.CaptureFixture[str],
441 ) -> None:
442 """--depth verbose includes full commit ID and parent."""
443 _init_muse_repo(tmp_path)
444 cid1 = await _make_commit(
445 tmp_path, muse_cli_db_session, "first",
446 {"a.mid": b"1"}
447 )
448 await _make_commit(
449 tmp_path, muse_cli_db_session, "second",
450 {"a.mid": b"2"}
451 )
452
453 result = await _describe_async(
454 root=tmp_path,
455 session=muse_cli_db_session,
456 commit_id=None,
457 compare_a=None,
458 compare_b=None,
459 depth=DescribeDepth.verbose,
460 dimensions_raw=None,
461 as_json=False,
462 auto_tag=False,
463 )
464
465 from maestro.muse_cli.commands.describe import _render_verbose
466 _render_verbose(result)
467 out = capsys.readouterr().out
468
469 # Full commit ID visible
470 assert result.commit_id in out
471 # Parent shown
472 assert cid1 in out
473 # File status prefix
474 assert "M " in out
475
476
477 @pytest.mark.anyio
478 async def test_describe_json_output(
479 tmp_path: pathlib.Path,
480 muse_cli_db_session: AsyncSession,
481 capsys: pytest.CaptureFixture[str],
482 ) -> None:
483 """--json outputs valid JSON with the expected structure."""
484 _init_muse_repo(tmp_path)
485 await _make_commit(
486 tmp_path, muse_cli_db_session, "json test",
487 {"track.mid": b"data"}
488 )
489
490 result = await _describe_async(
491 root=tmp_path,
492 session=muse_cli_db_session,
493 commit_id=None,
494 compare_a=None,
495 compare_b=None,
496 depth=DescribeDepth.standard,
497 dimensions_raw=None,
498 as_json=True,
499 auto_tag=False,
500 )
501
502 from maestro.muse_cli.commands.describe import _render_result
503 capsys.readouterr() # discard ✅ output from _commit_async calls
504 _render_result(result, as_json=True)
505 out = capsys.readouterr().out
506 data = json.loads(out)
507
508 assert "commit" in data
509 assert "message" in data
510 assert data["message"] == "json test"
511 assert "changed_files" in data
512 assert "added_files" in data
513 assert "removed_files" in data
514 assert "file_count" in data
515 assert "note" in data
516
517
518 @pytest.mark.anyio
519 async def test_describe_dimensions_passthrough(
520 tmp_path: pathlib.Path,
521 muse_cli_db_session: AsyncSession,
522 ) -> None:
523 """--dimensions passes user-specified dimensions through to result."""
524 _init_muse_repo(tmp_path)
525 await _make_commit(
526 tmp_path, muse_cli_db_session, "dim test",
527 {"beat.mid": b"x"}
528 )
529
530 result = await _describe_async(
531 root=tmp_path,
532 session=muse_cli_db_session,
533 commit_id=None,
534 compare_a=None,
535 compare_b=None,
536 depth=DescribeDepth.standard,
537 dimensions_raw="rhythm,harmony",
538 as_json=False,
539 auto_tag=False,
540 )
541
542 assert "rhythm" in result.dimensions
543 assert "harmony" in result.dimensions
544
545
546 @pytest.mark.anyio
547 async def test_describe_auto_tag(
548 tmp_path: pathlib.Path,
549 muse_cli_db_session: AsyncSession,
550 ) -> None:
551 """--auto-tag adds a non-empty tag to the result."""
552 _init_muse_repo(tmp_path)
553 await _make_commit(
554 tmp_path, muse_cli_db_session, "tagged commit",
555 {"x.mid": b"y"}
556 )
557
558 result = await _describe_async(
559 root=tmp_path,
560 session=muse_cli_db_session,
561 commit_id=None,
562 compare_a=None,
563 compare_b=None,
564 depth=DescribeDepth.standard,
565 dimensions_raw=None,
566 as_json=False,
567 auto_tag=True,
568 )
569
570 assert result.auto_tag is not None
571 assert len(result.auto_tag) > 0
572
573
574 @pytest.mark.anyio
575 async def test_describe_no_commits_exits_zero(
576 tmp_path: pathlib.Path,
577 muse_cli_db_session: AsyncSession,
578 capsys: pytest.CaptureFixture[str],
579 ) -> None:
580 """``muse describe`` on a repo with no commits exits 0 with a message."""
581 _init_muse_repo(tmp_path)
582
583 with pytest.raises(typer.Exit) as exc_info:
584 await _describe_async(
585 root=tmp_path,
586 session=muse_cli_db_session,
587 commit_id=None,
588 compare_a=None,
589 compare_b=None,
590 depth=DescribeDepth.standard,
591 dimensions_raw=None,
592 as_json=False,
593 auto_tag=False,
594 )
595
596 assert exc_info.value.exit_code == ExitCode.SUCCESS
597 out = capsys.readouterr().out
598 assert "No commits" in out
599
600
601 @pytest.mark.anyio
602 async def test_describe_unknown_commit_exits_user_error(
603 tmp_path: pathlib.Path,
604 muse_cli_db_session: AsyncSession,
605 capsys: pytest.CaptureFixture[str],
606 ) -> None:
607 """Passing an unknown commit ID exits with USER_ERROR."""
608 _init_muse_repo(tmp_path)
609
610 bogus_id = "d" * 64
611 with pytest.raises(typer.Exit) as exc_info:
612 await _describe_async(
613 root=tmp_path,
614 session=muse_cli_db_session,
615 commit_id=bogus_id,
616 compare_a=None,
617 compare_b=None,
618 depth=DescribeDepth.standard,
619 dimensions_raw=None,
620 as_json=False,
621 auto_tag=False,
622 )
623
624 assert exc_info.value.exit_code == ExitCode.USER_ERROR
625 out = capsys.readouterr().out
626 assert "not found" in out.lower()
627
628
629 def test_describe_outside_repo_exits_2(tmp_path: pathlib.Path) -> None:
630 """``muse describe`` outside a .muse/ directory exits with code 2."""
631 from typer.testing import CliRunner
632
633 from maestro.muse_cli.app import cli
634
635 runner = CliRunner()
636 prev = os.getcwd()
637 try:
638 os.chdir(tmp_path)
639 result = runner.invoke(cli, ["describe"], catch_exceptions=False)
640 finally:
641 os.chdir(prev)
642
643 assert result.exit_code == ExitCode.REPO_NOT_FOUND