cgcardona / muse public
test_status.py python
683 lines 21.6 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """Tests for ``muse status`` — working-tree diff and merge state display.
2
3 All DB-dependent tests use ``_status_async`` directly with an in-memory
4 SQLite session (via the ``muse_cli_db_session`` fixture in conftest.py)
5 so no real Postgres instance is required.
6
7 Async tests use ``@pytest.mark.anyio`` (configured for asyncio mode in
8 pyproject.toml).
9 """
10 from __future__ import annotations
11
12 import json
13 import os
14 import pathlib
15 import uuid
16
17 import pytest
18 from sqlalchemy.ext.asyncio import AsyncSession
19
20 from maestro.muse_cli.commands.commit import _commit_async
21 from maestro.muse_cli.commands.status import _status_async
22 from maestro.muse_cli.errors import ExitCode
23
24
25 # ---------------------------------------------------------------------------
26 # Helpers (mirror commit test helpers to keep tests self-contained)
27 # ---------------------------------------------------------------------------
28
29
30 def _init_muse_repo(root: pathlib.Path, repo_id: str | None = None) -> str:
31 """Create a minimal .muse/ layout."""
32 rid = repo_id or str(uuid.uuid4())
33 muse = root / ".muse"
34 (muse / "refs" / "heads").mkdir(parents=True)
35 (muse / "repo.json").write_text(
36 json.dumps({"repo_id": rid, "schema_version": "1"})
37 )
38 (muse / "HEAD").write_text("refs/heads/main")
39 (muse / "refs" / "heads" / "main").write_text("") # no commits yet
40 return rid
41
42
43 def _populate_workdir(root: pathlib.Path, files: dict[str, bytes] | None = None) -> None:
44 """Create muse-work/ with the given files."""
45 workdir = root / "muse-work"
46 workdir.mkdir(exist_ok=True)
47 if files is None:
48 files = {"beat.mid": b"MIDI-DATA"}
49 for name, content in files.items():
50 path = workdir / name
51 path.parent.mkdir(parents=True, exist_ok=True)
52 path.write_bytes(content)
53
54
55 # ---------------------------------------------------------------------------
56 # Clean working tree
57 # ---------------------------------------------------------------------------
58
59
60 @pytest.mark.anyio
61 async def test_status_clean_after_commit(
62 tmp_path: pathlib.Path,
63 muse_cli_db_session: AsyncSession,
64 capsys: pytest.CaptureFixture[str],
65 ) -> None:
66 """After a commit with no subsequent changes, status reports a clean tree."""
67 _init_muse_repo(tmp_path)
68 _populate_workdir(tmp_path, {"beat.mid": b"MIDI"})
69
70 await _commit_async(
71 message="initial commit",
72 root=tmp_path,
73 session=muse_cli_db_session,
74 )
75 # Flush so the snapshot row is visible to _status_async in the same session.
76 await muse_cli_db_session.flush()
77
78 await _status_async(root=tmp_path, session=muse_cli_db_session)
79
80 captured = capsys.readouterr()
81 assert "nothing to commit, working tree clean" in captured.out
82
83
84 # ---------------------------------------------------------------------------
85 # Uncommitted changes
86 # ---------------------------------------------------------------------------
87
88
89 @pytest.mark.anyio
90 async def test_status_shows_modified_file(
91 tmp_path: pathlib.Path,
92 muse_cli_db_session: AsyncSession,
93 capsys: pytest.CaptureFixture[str],
94 ) -> None:
95 """A file changed after the last commit appears as 'modified:'."""
96 _init_muse_repo(tmp_path)
97 _populate_workdir(tmp_path, {"beat.mid": b"VERSION1"})
98
99 await _commit_async(
100 message="initial",
101 root=tmp_path,
102 session=muse_cli_db_session,
103 )
104 await muse_cli_db_session.flush()
105
106 # Modify the file without committing.
107 (tmp_path / "muse-work" / "beat.mid").write_bytes(b"VERSION2")
108
109 await _status_async(root=tmp_path, session=muse_cli_db_session)
110
111 captured = capsys.readouterr()
112 assert "modified:" in captured.out
113 assert "beat.mid" in captured.out
114
115
116 @pytest.mark.anyio
117 async def test_status_shows_new_file(
118 tmp_path: pathlib.Path,
119 muse_cli_db_session: AsyncSession,
120 capsys: pytest.CaptureFixture[str],
121 ) -> None:
122 """A file added to muse-work/ after the last commit appears as 'new file:'."""
123 _init_muse_repo(tmp_path)
124 _populate_workdir(tmp_path, {"beat.mid": b"MIDI"})
125
126 await _commit_async(
127 message="initial",
128 root=tmp_path,
129 session=muse_cli_db_session,
130 )
131 await muse_cli_db_session.flush()
132
133 # Add a new file that was not in the committed snapshot.
134 (tmp_path / "muse-work" / "lead.mp3").write_bytes(b"MP3")
135
136 await _status_async(root=tmp_path, session=muse_cli_db_session)
137
138 captured = capsys.readouterr()
139 assert "new file:" in captured.out
140 assert "lead.mp3" in captured.out
141
142
143 @pytest.mark.anyio
144 async def test_status_shows_deleted_file(
145 tmp_path: pathlib.Path,
146 muse_cli_db_session: AsyncSession,
147 capsys: pytest.CaptureFixture[str],
148 ) -> None:
149 """A file removed from muse-work/ after the last commit appears as 'deleted:'."""
150 _init_muse_repo(tmp_path)
151 _populate_workdir(tmp_path, {"beat.mid": b"MIDI", "scratch.mid": b"TMP"})
152
153 await _commit_async(
154 message="initial",
155 root=tmp_path,
156 session=muse_cli_db_session,
157 )
158 await muse_cli_db_session.flush()
159
160 # Remove one file without committing.
161 (tmp_path / "muse-work" / "scratch.mid").unlink()
162
163 await _status_async(root=tmp_path, session=muse_cli_db_session)
164
165 captured = capsys.readouterr()
166 assert "deleted:" in captured.out
167 assert "scratch.mid" in captured.out
168
169
170 # ---------------------------------------------------------------------------
171 # Untracked files (no commits yet)
172 # ---------------------------------------------------------------------------
173
174
175 @pytest.mark.anyio
176 async def test_status_shows_untracked(
177 tmp_path: pathlib.Path,
178 muse_cli_db_session: AsyncSession,
179 capsys: pytest.CaptureFixture[str],
180 ) -> None:
181 """Files in muse-work/ on a branch with no commits are listed as untracked."""
182 _init_muse_repo(tmp_path)
183 _populate_workdir(tmp_path, {"beat.mid": b"MIDI", "lead.mp3": b"MP3"})
184
185 # Do NOT commit — branch has no history.
186 await _status_async(root=tmp_path, session=muse_cli_db_session)
187
188 captured = capsys.readouterr()
189 assert "Untracked files" in captured.out
190 assert "beat.mid" in captured.out
191 assert "lead.mp3" in captured.out
192
193
194 # ---------------------------------------------------------------------------
195 # In-progress merge
196 # ---------------------------------------------------------------------------
197
198
199 @pytest.mark.anyio
200 async def test_status_during_merge_shows_conflicts(
201 tmp_path: pathlib.Path,
202 muse_cli_db_session: AsyncSession,
203 capsys: pytest.CaptureFixture[str],
204 ) -> None:
205 """When MERGE_STATE.json is present, conflict paths appear with 'both modified:'."""
206 _init_muse_repo(tmp_path)
207
208 merge_state = {
209 "base_commit": "abc123",
210 "ours_commit": "def456",
211 "theirs_commit": "789abc",
212 "conflict_paths": ["beat.mid", "lead.mp3"],
213 "other_branch": "feature/variation-b",
214 }
215 (tmp_path / ".muse" / "MERGE_STATE.json").write_text(json.dumps(merge_state))
216
217 await _status_async(root=tmp_path, session=muse_cli_db_session)
218
219 captured = capsys.readouterr()
220 assert "You have unmerged paths" in captured.out
221 assert "both modified:" in captured.out
222 assert "beat.mid" in captured.out
223 assert "lead.mp3" in captured.out
224
225
226 # ---------------------------------------------------------------------------
227 # No commits yet (clean working tree)
228 # ---------------------------------------------------------------------------
229
230
231 @pytest.mark.anyio
232 async def test_status_no_commits_yet(
233 tmp_path: pathlib.Path,
234 muse_cli_db_session: AsyncSession,
235 capsys: pytest.CaptureFixture[str],
236 ) -> None:
237 """A repo with no commits and no muse-work/ files shows 'no commits yet'."""
238 _init_muse_repo(tmp_path)
239 # No muse-work/ directory, no commits.
240
241 await _status_async(root=tmp_path, session=muse_cli_db_session)
242
243 captured = capsys.readouterr()
244 assert "no commits yet" in captured.out
245
246
247 # ---------------------------------------------------------------------------
248 # Outside a repo
249 # ---------------------------------------------------------------------------
250
251
252 def test_status_outside_repo_exits_2(tmp_path: pathlib.Path) -> None:
253 """``muse status`` exits 2 when there is no ``.muse/`` directory."""
254 from typer.testing import CliRunner
255
256 from maestro.muse_cli.app import cli
257
258 runner = CliRunner()
259
260 prev = os.getcwd()
261 try:
262 os.chdir(tmp_path)
263 result = runner.invoke(cli, ["status"], catch_exceptions=False)
264 finally:
265 os.chdir(prev)
266
267 assert result.exit_code == int(ExitCode.REPO_NOT_FOUND)
268 assert "not a muse repository" in result.output.lower()
269
270
271 # ---------------------------------------------------------------------------
272 # --short flag
273 # ---------------------------------------------------------------------------
274
275
276 @pytest.mark.anyio
277 async def test_status_short_shows_modified_code(
278 tmp_path: pathlib.Path,
279 muse_cli_db_session: AsyncSession,
280 capsys: pytest.CaptureFixture[str],
281 ) -> None:
282 """--short emits 'M path' for a modified file (no verbose labels)."""
283 _init_muse_repo(tmp_path)
284 _populate_workdir(tmp_path, {"beat.mid": b"V1"})
285
286 await _commit_async(message="initial", root=tmp_path, session=muse_cli_db_session)
287 await muse_cli_db_session.flush()
288
289 (tmp_path / "muse-work" / "beat.mid").write_bytes(b"V2")
290
291 await _status_async(root=tmp_path, session=muse_cli_db_session, short=True)
292
293 out = capsys.readouterr().out
294 assert "M beat.mid" in out
295 assert "modified:" not in out # verbose label must not appear
296
297
298 @pytest.mark.anyio
299 async def test_status_short_shows_added_code(
300 tmp_path: pathlib.Path,
301 muse_cli_db_session: AsyncSession,
302 capsys: pytest.CaptureFixture[str],
303 ) -> None:
304 """--short emits 'A path' for an added file."""
305 _init_muse_repo(tmp_path)
306 _populate_workdir(tmp_path, {"beat.mid": b"MIDI"})
307
308 await _commit_async(message="initial", root=tmp_path, session=muse_cli_db_session)
309 await muse_cli_db_session.flush()
310
311 (tmp_path / "muse-work" / "lead.mp3").write_bytes(b"MP3")
312
313 await _status_async(root=tmp_path, session=muse_cli_db_session, short=True)
314
315 out = capsys.readouterr().out
316 assert "A lead.mp3" in out
317
318
319 @pytest.mark.anyio
320 async def test_status_short_shows_deleted_code(
321 tmp_path: pathlib.Path,
322 muse_cli_db_session: AsyncSession,
323 capsys: pytest.CaptureFixture[str],
324 ) -> None:
325 """--short emits 'D path' for a deleted file."""
326 _init_muse_repo(tmp_path)
327 _populate_workdir(tmp_path, {"beat.mid": b"MIDI", "scratch.mid": b"TMP"})
328
329 await _commit_async(message="initial", root=tmp_path, session=muse_cli_db_session)
330 await muse_cli_db_session.flush()
331
332 (tmp_path / "muse-work" / "scratch.mid").unlink()
333
334 await _status_async(root=tmp_path, session=muse_cli_db_session, short=True)
335
336 out = capsys.readouterr().out
337 assert "D scratch.mid" in out
338
339
340 @pytest.mark.anyio
341 async def test_status_short_untracked_shows_question_mark(
342 tmp_path: pathlib.Path,
343 muse_cli_db_session: AsyncSession,
344 capsys: pytest.CaptureFixture[str],
345 ) -> None:
346 """--short emits '? path' for untracked files (no commits yet)."""
347 _init_muse_repo(tmp_path)
348 _populate_workdir(tmp_path, {"beat.mid": b"MIDI"})
349
350 await _status_async(root=tmp_path, session=muse_cli_db_session, short=True)
351
352 out = capsys.readouterr().out
353 assert "? beat.mid" in out
354
355
356 # ---------------------------------------------------------------------------
357 # --branch flag
358 # ---------------------------------------------------------------------------
359
360
361 @pytest.mark.anyio
362 async def test_status_branch_only_shows_branch_line(
363 tmp_path: pathlib.Path,
364 muse_cli_db_session: AsyncSession,
365 capsys: pytest.CaptureFixture[str],
366 ) -> None:
367 """--branch emits only the 'On branch <name>' line with no file listing."""
368 _init_muse_repo(tmp_path)
369 _populate_workdir(tmp_path, {"beat.mid": b"V1"})
370
371 await _commit_async(message="initial", root=tmp_path, session=muse_cli_db_session)
372 await muse_cli_db_session.flush()
373
374 (tmp_path / "muse-work" / "beat.mid").write_bytes(b"V2")
375
376 await _status_async(root=tmp_path, session=muse_cli_db_session, branch_only=True)
377
378 out = capsys.readouterr().out
379 assert "On branch main" in out
380 assert "beat.mid" not in out # no file listing
381 assert "modified" not in out
382
383
384 @pytest.mark.anyio
385 async def test_status_branch_only_no_commits(
386 tmp_path: pathlib.Path,
387 muse_cli_db_session: AsyncSession,
388 capsys: pytest.CaptureFixture[str],
389 ) -> None:
390 """--branch on a repo with no commits shows the branch name."""
391 _init_muse_repo(tmp_path)
392
393 await _status_async(root=tmp_path, session=muse_cli_db_session, branch_only=True)
394
395 out = capsys.readouterr().out
396 assert "On branch main" in out
397 assert "no commits" not in out # branch_only suppresses extra info
398
399
400 # ---------------------------------------------------------------------------
401 # --porcelain flag
402 # ---------------------------------------------------------------------------
403
404
405 @pytest.mark.anyio
406 async def test_status_porcelain_header_emitted(
407 tmp_path: pathlib.Path,
408 muse_cli_db_session: AsyncSession,
409 capsys: pytest.CaptureFixture[str],
410 ) -> None:
411 """--porcelain emits '## <branch>' as the first line of status output."""
412 _init_muse_repo(tmp_path)
413 _populate_workdir(tmp_path, {"beat.mid": b"V1"})
414
415 await _commit_async(message="initial", root=tmp_path, session=muse_cli_db_session)
416 await muse_cli_db_session.flush()
417 capsys.readouterr() # discard commit's success line
418
419 await _status_async(root=tmp_path, session=muse_cli_db_session, porcelain=True)
420
421 out = capsys.readouterr().out
422 assert out.startswith("## main")
423
424
425 @pytest.mark.anyio
426 async def test_status_porcelain_clean_tree(
427 tmp_path: pathlib.Path,
428 muse_cli_db_session: AsyncSession,
429 capsys: pytest.CaptureFixture[str],
430 ) -> None:
431 """--porcelain with a clean working tree emits only the '## branch' header."""
432 _init_muse_repo(tmp_path)
433 _populate_workdir(tmp_path, {"beat.mid": b"MIDI"})
434
435 await _commit_async(message="initial", root=tmp_path, session=muse_cli_db_session)
436 await muse_cli_db_session.flush()
437 capsys.readouterr() # discard commit's success line
438
439 await _status_async(root=tmp_path, session=muse_cli_db_session, porcelain=True)
440
441 out = capsys.readouterr().out.strip()
442 assert out == "## main"
443
444
445 @pytest.mark.anyio
446 async def test_status_porcelain_modified_file(
447 tmp_path: pathlib.Path,
448 muse_cli_db_session: AsyncSession,
449 capsys: pytest.CaptureFixture[str],
450 ) -> None:
451 """--porcelain emits ' M path' (two-char code) for a modified file."""
452 _init_muse_repo(tmp_path)
453 _populate_workdir(tmp_path, {"beat.mid": b"V1"})
454
455 await _commit_async(message="initial", root=tmp_path, session=muse_cli_db_session)
456 await muse_cli_db_session.flush()
457
458 (tmp_path / "muse-work" / "beat.mid").write_bytes(b"V2")
459
460 await _status_async(root=tmp_path, session=muse_cli_db_session, porcelain=True)
461
462 out = capsys.readouterr().out
463 assert " M beat.mid" in out
464
465
466 @pytest.mark.anyio
467 async def test_status_porcelain_added_file(
468 tmp_path: pathlib.Path,
469 muse_cli_db_session: AsyncSession,
470 capsys: pytest.CaptureFixture[str],
471 ) -> None:
472 """--porcelain emits ' A path' for an added file."""
473 _init_muse_repo(tmp_path)
474 _populate_workdir(tmp_path, {"beat.mid": b"MIDI"})
475
476 await _commit_async(message="initial", root=tmp_path, session=muse_cli_db_session)
477 await muse_cli_db_session.flush()
478
479 (tmp_path / "muse-work" / "lead.mp3").write_bytes(b"MP3")
480
481 await _status_async(root=tmp_path, session=muse_cli_db_session, porcelain=True)
482
483 out = capsys.readouterr().out
484 assert " A lead.mp3" in out
485
486
487 @pytest.mark.anyio
488 async def test_status_porcelain_deleted_file(
489 tmp_path: pathlib.Path,
490 muse_cli_db_session: AsyncSession,
491 capsys: pytest.CaptureFixture[str],
492 ) -> None:
493 """--porcelain emits ' D path' for a deleted file."""
494 _init_muse_repo(tmp_path)
495 _populate_workdir(tmp_path, {"beat.mid": b"MIDI", "scratch.mid": b"TMP"})
496
497 await _commit_async(message="initial", root=tmp_path, session=muse_cli_db_session)
498 await muse_cli_db_session.flush()
499
500 (tmp_path / "muse-work" / "scratch.mid").unlink()
501
502 await _status_async(root=tmp_path, session=muse_cli_db_session, porcelain=True)
503
504 out = capsys.readouterr().out
505 assert " D scratch.mid" in out
506
507
508 @pytest.mark.anyio
509 async def test_status_porcelain_untracked(
510 tmp_path: pathlib.Path,
511 muse_cli_db_session: AsyncSession,
512 capsys: pytest.CaptureFixture[str],
513 ) -> None:
514 """--porcelain emits '?? path' for untracked files."""
515 _init_muse_repo(tmp_path)
516 _populate_workdir(tmp_path, {"beat.mid": b"MIDI"})
517
518 await _status_async(root=tmp_path, session=muse_cli_db_session, porcelain=True)
519
520 out = capsys.readouterr().out
521 assert "?? beat.mid" in out
522
523
524 # ---------------------------------------------------------------------------
525 # --sections flag
526 # ---------------------------------------------------------------------------
527
528
529 @pytest.mark.anyio
530 async def test_status_sections_groups_by_first_dir(
531 tmp_path: pathlib.Path,
532 muse_cli_db_session: AsyncSession,
533 capsys: pytest.CaptureFixture[str],
534 ) -> None:
535 """--sections groups changed files under '## <dir>' headers by first path component."""
536 _init_muse_repo(tmp_path)
537 _populate_workdir(
538 tmp_path,
539 {
540 "verse/bass.mid": b"V1",
541 "chorus/bass.mid": b"V1",
542 },
543 )
544
545 await _commit_async(message="initial", root=tmp_path, session=muse_cli_db_session)
546 await muse_cli_db_session.flush()
547
548 # Modify one file per section.
549 (tmp_path / "muse-work" / "verse" / "bass.mid").write_bytes(b"V2")
550 (tmp_path / "muse-work" / "chorus" / "bass.mid").write_bytes(b"V2")
551
552 await _status_async(root=tmp_path, session=muse_cli_db_session, sections=True)
553
554 out = capsys.readouterr().out
555 assert "## chorus" in out
556 assert "## verse" in out
557 assert "chorus/bass.mid" in out
558 assert "verse/bass.mid" in out
559
560
561 @pytest.mark.anyio
562 async def test_status_sections_root_files_ungrouped(
563 tmp_path: pathlib.Path,
564 muse_cli_db_session: AsyncSession,
565 capsys: pytest.CaptureFixture[str],
566 ) -> None:
567 """Files directly in muse-work/ (no sub-dir) appear under '## (root)' when --sections is active."""
568 _init_muse_repo(tmp_path)
569 _populate_workdir(tmp_path, {"beat.mid": b"V1"})
570
571 await _commit_async(message="initial", root=tmp_path, session=muse_cli_db_session)
572 await muse_cli_db_session.flush()
573
574 (tmp_path / "muse-work" / "beat.mid").write_bytes(b"V2")
575
576 await _status_async(root=tmp_path, session=muse_cli_db_session, sections=True)
577
578 out = capsys.readouterr().out
579 assert "## (root)" in out
580 assert "beat.mid" in out
581
582
583 # ---------------------------------------------------------------------------
584 # --tracks flag
585 # ---------------------------------------------------------------------------
586
587
588 @pytest.mark.anyio
589 async def test_status_tracks_groups_by_first_dir(
590 tmp_path: pathlib.Path,
591 muse_cli_db_session: AsyncSession,
592 capsys: pytest.CaptureFixture[str],
593 ) -> None:
594 """--tracks groups changed files under '## <dir>' headers by first path component."""
595 _init_muse_repo(tmp_path)
596 _populate_workdir(
597 tmp_path,
598 {
599 "drums/verse.mid": b"V1",
600 "bass/verse.mid": b"V1",
601 },
602 )
603
604 await _commit_async(message="initial", root=tmp_path, session=muse_cli_db_session)
605 await muse_cli_db_session.flush()
606
607 (tmp_path / "muse-work" / "drums" / "verse.mid").write_bytes(b"V2")
608 (tmp_path / "muse-work" / "bass" / "verse.mid").write_bytes(b"V2")
609
610 await _status_async(root=tmp_path, session=muse_cli_db_session, tracks=True)
611
612 out = capsys.readouterr().out
613 assert "## bass" in out
614 assert "## drums" in out
615 assert "bass/verse.mid" in out
616 assert "drums/verse.mid" in out
617
618
619 # ---------------------------------------------------------------------------
620 # Flag combinations
621 # ---------------------------------------------------------------------------
622
623
624 @pytest.mark.anyio
625 async def test_status_short_and_sections_combined(
626 tmp_path: pathlib.Path,
627 muse_cli_db_session: AsyncSession,
628 capsys: pytest.CaptureFixture[str],
629 ) -> None:
630 """--short --sections emits short-format codes within section group headers."""
631 _init_muse_repo(tmp_path)
632 _populate_workdir(
633 tmp_path,
634 {
635 "verse/bass.mid": b"V1",
636 "chorus/drums.mid": b"V1",
637 },
638 )
639
640 await _commit_async(message="initial", root=tmp_path, session=muse_cli_db_session)
641 await muse_cli_db_session.flush()
642
643 (tmp_path / "muse-work" / "verse" / "bass.mid").write_bytes(b"V2")
644 (tmp_path / "muse-work" / "chorus" / "drums.mid").write_bytes(b"V2")
645
646 await _status_async(root=tmp_path, session=muse_cli_db_session, short=True, sections=True)
647
648 out = capsys.readouterr().out
649 assert "## verse" in out
650 assert "## chorus" in out
651 assert "M verse/bass.mid" in out
652 assert "M chorus/drums.mid" in out
653 # verbose labels must not appear
654 assert "modified:" not in out
655
656
657 @pytest.mark.anyio
658 async def test_status_porcelain_and_tracks_combined(
659 tmp_path: pathlib.Path,
660 muse_cli_db_session: AsyncSession,
661 capsys: pytest.CaptureFixture[str],
662 ) -> None:
663 """--porcelain --tracks emits porcelain codes within track group headers."""
664 _init_muse_repo(tmp_path)
665 _populate_workdir(
666 tmp_path,
667 {
668 "bass/line.mid": b"V1",
669 "keys/pad.mid": b"V1",
670 },
671 )
672
673 await _commit_async(message="initial", root=tmp_path, session=muse_cli_db_session)
674 await muse_cli_db_session.flush()
675
676 (tmp_path / "muse-work" / "bass" / "line.mid").write_bytes(b"V2")
677
678 await _status_async(root=tmp_path, session=muse_cli_db_session, porcelain=True, tracks=True)
679
680 out = capsys.readouterr().out
681 assert "## main" in out # porcelain header
682 assert "## bass" in out
683 assert " M bass/line.mid" in out # two-char porcelain code