cgcardona / muse public
test_muse_chord_map.py python
517 lines 15.3 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """Tests for ``muse chord-map`` — CLI interface, flag parsing, and stub output.
2
3 All CLI-level tests use ``typer.testing.CliRunner`` against the full ``muse``
4 app so that argument parsing, flag handling, and exit codes are exercised
5 end-to-end.
6
7 Async core tests call ``_chord_map_async`` directly with an in-memory SQLite
8 session (defined as a local fixture — the stub does not query the DB, so the
9 session is injected only to satisfy the signature contract).
10 """
11 from __future__ import annotations
12
13 import json
14 import os
15 import pathlib
16 import uuid
17 from collections.abc import AsyncGenerator
18
19 import pytest
20 import pytest_asyncio
21 from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
22 from sqlalchemy.pool import StaticPool
23 from typer.testing import CliRunner
24
25 from maestro.db.database import Base
26 import maestro.muse_cli.models # noqa: F401 — registers MuseCli* with Base.metadata
27 from maestro.muse_cli.app import cli
28 from maestro.muse_cli.commands.chord_map import (
29 ChordMapResult,
30 _VALID_FORMATS,
31 _chord_map_async,
32 _render_json,
33 _render_mermaid,
34 _render_text,
35 _stub_chord_events,
36 _stub_voice_leading_steps,
37 )
38 from maestro.muse_cli.errors import ExitCode
39
40 runner = CliRunner()
41
42 # ---------------------------------------------------------------------------
43 # Fixtures
44 # ---------------------------------------------------------------------------
45
46
47 def _init_muse_repo(root: pathlib.Path, branch: str = "main") -> str:
48 """Create a minimal .muse/ layout with one empty commit ref."""
49 rid = str(uuid.uuid4())
50 muse = root / ".muse"
51 (muse / "refs" / "heads").mkdir(parents=True)
52 (muse / "repo.json").write_text(json.dumps({"repo_id": rid, "schema_version": "1"}))
53 (muse / "HEAD").write_text(f"refs/heads/{branch}")
54 (muse / "refs" / "heads" / branch).write_text("")
55 return rid
56
57
58 def _commit_ref(root: pathlib.Path, branch: str = "main") -> None:
59 """Write a fake commit ID into the branch ref so HEAD is non-empty."""
60 muse = root / ".muse"
61 (muse / "refs" / "heads" / branch).write_text("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2")
62
63
64 @pytest_asyncio.fixture
65 async def db_session() -> AsyncGenerator[AsyncSession, None]:
66 """In-memory SQLite session (stub chord-map does not actually query it)."""
67 engine = create_async_engine(
68 "sqlite+aiosqlite:///:memory:",
69 connect_args={"check_same_thread": False},
70 poolclass=StaticPool,
71 )
72 async with engine.begin() as conn:
73 await conn.run_sync(Base.metadata.create_all)
74 factory = async_sessionmaker(bind=engine, expire_on_commit=False)
75 async with factory() as session:
76 yield session
77 async with engine.begin() as conn:
78 await conn.run_sync(Base.metadata.drop_all)
79 await engine.dispose()
80
81
82 # ---------------------------------------------------------------------------
83 # Unit — stub data
84 # ---------------------------------------------------------------------------
85
86
87 def test_stub_chord_events_returns_expected_count() -> None:
88 """Stub produces the expected number of chord events."""
89 events = _stub_chord_events()
90 assert len(events) == 6
91
92
93 def test_stub_chord_events_bar_numbers_positive() -> None:
94 """All stub chord events have positive bar numbers."""
95 for event in _stub_chord_events():
96 assert event["bar"] >= 1
97 assert event["beat"] >= 1
98
99
100 def test_stub_chord_events_duration_positive() -> None:
101 """All stub chord events have positive duration."""
102 for event in _stub_chord_events():
103 assert event["duration"] > 0
104
105
106 def test_stub_chord_events_chord_nonempty() -> None:
107 """Every stub chord event has a non-empty chord symbol."""
108 for event in _stub_chord_events():
109 assert event["chord"]
110
111
112 def test_stub_voice_leading_steps_count() -> None:
113 """Stub produces one voice-leading step per chord transition."""
114 steps = _stub_voice_leading_steps()
115 events = _stub_chord_events()
116 assert len(steps) == len(events) - 1
117
118
119 def test_stub_voice_leading_steps_movements_nonempty() -> None:
120 """Each voice-leading step has at least one movement."""
121 for step in _stub_voice_leading_steps():
122 assert step["movements"]
123
124
125 def test_valid_formats_contains_expected() -> None:
126 """_VALID_FORMATS contains text, json, and mermaid."""
127 assert "text" in _VALID_FORMATS
128 assert "json" in _VALID_FORMATS
129 assert "mermaid" in _VALID_FORMATS
130
131
132 # ---------------------------------------------------------------------------
133 # Unit — renderers
134 # ---------------------------------------------------------------------------
135
136
137 def _make_result(*, voice_leading: bool = False) -> ChordMapResult:
138 vl = _stub_voice_leading_steps() if voice_leading else []
139 return ChordMapResult(
140 commit="a1b2c3d4",
141 branch="main",
142 track="all",
143 section="",
144 chords=_stub_chord_events(),
145 voice_leading=vl,
146 )
147
148
149 def test_render_text_includes_commit_ref() -> None:
150 """_render_text includes the commit ref in the header."""
151 result = _make_result()
152 output = _render_text(result)
153 assert "a1b2c3d4" in output
154
155
156 def test_render_text_includes_branch() -> None:
157 """_render_text shows the branch name."""
158 result = _make_result()
159 output = _render_text(result)
160 assert "main" in output
161
162
163 def test_render_text_includes_chord_symbols() -> None:
164 """_render_text contains all chord symbols from stub data."""
165 result = _make_result()
166 output = _render_text(result)
167 assert "Cmaj9" in output
168 assert "Am11" in output
169 assert "Dm7" in output
170 assert "G7" in output
171
172
173 def test_render_text_voice_leading_shows_arrows() -> None:
174 """_render_text with voice_leading includes arrow notation."""
175 result = _make_result(voice_leading=True)
176 output = _render_text(result)
177 assert "->" in output
178
179
180 def test_render_text_no_voice_leading_shows_blocks() -> None:
181 """_render_text without voice_leading shows block characters."""
182 result = _make_result(voice_leading=False)
183 output = _render_text(result)
184 assert "Bar" in output
185
186
187 def test_render_json_is_valid_json() -> None:
188 """_render_json emits parseable JSON."""
189 result = _make_result()
190 raw = _render_json(result)
191 payload = json.loads(raw)
192 assert payload["commit"] == "a1b2c3d4"
193 assert payload["branch"] == "main"
194
195
196 def test_render_json_chords_list() -> None:
197 """_render_json includes a 'chords' list with chord entries."""
198 result = _make_result()
199 payload = json.loads(_render_json(result))
200 assert isinstance(payload["chords"], list)
201 assert len(payload["chords"]) == len(_stub_chord_events())
202 for entry in payload["chords"]:
203 assert "chord" in entry
204 assert "bar" in entry
205 assert "duration" in entry
206
207
208 def test_render_json_voice_leading_empty_by_default() -> None:
209 """_render_json without voice_leading has an empty voice_leading list."""
210 result = _make_result(voice_leading=False)
211 payload = json.loads(_render_json(result))
212 assert payload["voice_leading"] == []
213
214
215 def test_render_json_voice_leading_populated() -> None:
216 """_render_json with voice_leading has non-empty voice_leading list."""
217 result = _make_result(voice_leading=True)
218 payload = json.loads(_render_json(result))
219 assert len(payload["voice_leading"]) > 0
220 step = payload["voice_leading"][0]
221 assert "from_chord" in step
222 assert "to_chord" in step
223 assert "movements" in step
224
225
226 def test_render_mermaid_starts_with_timeline() -> None:
227 """_render_mermaid begins with the Mermaid timeline keyword."""
228 result = _make_result()
229 output = _render_mermaid(result)
230 assert output.startswith("timeline")
231
232
233 def test_render_mermaid_includes_commit() -> None:
234 """_render_mermaid title includes the commit ref."""
235 result = _make_result()
236 output = _render_mermaid(result)
237 assert "a1b2c3d4" in output
238
239
240 def test_render_mermaid_includes_bars() -> None:
241 """_render_mermaid contains section labels for bars."""
242 result = _make_result()
243 output = _render_mermaid(result)
244 assert "Bar 1" in output
245 assert "Bar 3" in output
246
247
248 # ---------------------------------------------------------------------------
249 # Async core — _chord_map_async
250 # ---------------------------------------------------------------------------
251
252
253 @pytest.mark.anyio
254 async def test_chord_map_async_default(
255 tmp_path: pathlib.Path,
256 db_session: AsyncSession,
257 ) -> None:
258 """_chord_map_async with no args returns a non-empty ChordMapResult."""
259 _init_muse_repo(tmp_path)
260 _commit_ref(tmp_path)
261
262 result = await _chord_map_async(
263 root=tmp_path,
264 session=db_session,
265 commit=None,
266 section=None,
267 track=None,
268 bar_grid=True,
269 fmt="text",
270 voice_leading=False,
271 )
272
273 assert result["commit"]
274 assert result["branch"] == "main"
275 assert result["track"] == "all"
276 assert result["section"] == ""
277 assert len(result["chords"]) > 0
278 assert result["voice_leading"] == []
279
280
281 @pytest.mark.anyio
282 async def test_chord_map_async_explicit_commit(
283 tmp_path: pathlib.Path,
284 db_session: AsyncSession,
285 ) -> None:
286 """An explicit commit ref is reflected in the result."""
287 _init_muse_repo(tmp_path)
288 _commit_ref(tmp_path)
289
290 result = await _chord_map_async(
291 root=tmp_path,
292 session=db_session,
293 commit="deadbeef",
294 section=None,
295 track=None,
296 bar_grid=True,
297 fmt="text",
298 voice_leading=False,
299 )
300
301 assert result["commit"] == "deadbeef"
302
303
304 @pytest.mark.anyio
305 async def test_chord_map_async_track_filter(
306 tmp_path: pathlib.Path,
307 db_session: AsyncSession,
308 ) -> None:
309 """Passing --track sets the track field in the result."""
310 _init_muse_repo(tmp_path)
311 _commit_ref(tmp_path)
312
313 result = await _chord_map_async(
314 root=tmp_path,
315 session=db_session,
316 commit=None,
317 section=None,
318 track="piano",
319 bar_grid=True,
320 fmt="text",
321 voice_leading=False,
322 )
323
324 assert result["track"] == "piano"
325 for event in result["chords"]:
326 assert event["track"] == "piano"
327
328
329 @pytest.mark.anyio
330 async def test_chord_map_async_section_filter(
331 tmp_path: pathlib.Path,
332 db_session: AsyncSession,
333 ) -> None:
334 """Passing --section records the section in the result."""
335 _init_muse_repo(tmp_path)
336 _commit_ref(tmp_path)
337
338 result = await _chord_map_async(
339 root=tmp_path,
340 session=db_session,
341 commit=None,
342 section="verse",
343 track=None,
344 bar_grid=True,
345 fmt="text",
346 voice_leading=False,
347 )
348
349 assert result["section"] == "verse"
350
351
352 @pytest.mark.anyio
353 async def test_chord_map_async_voice_leading(
354 tmp_path: pathlib.Path,
355 db_session: AsyncSession,
356 ) -> None:
357 """--voice-leading populates the voice_leading field."""
358 _init_muse_repo(tmp_path)
359 _commit_ref(tmp_path)
360
361 result = await _chord_map_async(
362 root=tmp_path,
363 session=db_session,
364 commit=None,
365 section=None,
366 track=None,
367 bar_grid=True,
368 fmt="text",
369 voice_leading=True,
370 )
371
372 assert len(result["voice_leading"]) > 0
373 step = result["voice_leading"][0]
374 assert step["from_chord"]
375 assert step["to_chord"]
376 assert step["movements"]
377
378
379 @pytest.mark.anyio
380 async def test_chord_map_async_json_format(
381 tmp_path: pathlib.Path,
382 db_session: AsyncSession,
383 ) -> None:
384 """fmt='json' still returns a valid ChordMapResult from _chord_map_async."""
385 _init_muse_repo(tmp_path)
386 _commit_ref(tmp_path)
387
388 result = await _chord_map_async(
389 root=tmp_path,
390 session=db_session,
391 commit=None,
392 section=None,
393 track=None,
394 bar_grid=True,
395 fmt="json",
396 voice_leading=False,
397 )
398
399 assert isinstance(result["chords"], list)
400
401
402 # ---------------------------------------------------------------------------
403 # CLI integration — CliRunner
404 # ---------------------------------------------------------------------------
405
406
407 def test_cli_chord_map_outside_repo_exits(tmp_path: pathlib.Path) -> None:
408 """``muse chord-map`` exits with REPO_NOT_FOUND outside a Muse repo."""
409 prev = os.getcwd()
410 try:
411 os.chdir(tmp_path)
412 result = runner.invoke(cli, ["chord-map"], catch_exceptions=False)
413 finally:
414 os.chdir(prev)
415
416 assert result.exit_code == int(ExitCode.REPO_NOT_FOUND)
417 assert "not a muse repository" in result.output.lower()
418
419
420 def test_cli_chord_map_help_lists_flags() -> None:
421 """``muse chord-map --help`` shows all documented flags."""
422 result = runner.invoke(cli, ["chord-map", "--help"])
423 assert result.exit_code == 0
424 for flag in ("--section", "--track", "--bar-grid", "--format", "--voice-leading"):
425 assert flag in result.output, f"Flag '{flag}' missing from help"
426
427
428 def test_cli_chord_map_appears_in_muse_help() -> None:
429 """``muse --help`` lists the chord-map subcommand."""
430 result = runner.invoke(cli, ["--help"])
431 assert result.exit_code == 0
432 assert "chord-map" in result.output
433
434
435 def test_cli_chord_map_invalid_format_exits_user_error(tmp_path: pathlib.Path) -> None:
436 """``muse chord-map --format badformat`` exits with USER_ERROR."""
437 _init_muse_repo(tmp_path)
438 _commit_ref(tmp_path)
439 prev = os.getcwd()
440 try:
441 os.chdir(tmp_path)
442 result = runner.invoke(
443 cli, ["chord-map", "--format", "badformat"], catch_exceptions=False
444 )
445 finally:
446 os.chdir(prev)
447
448 assert result.exit_code == int(ExitCode.USER_ERROR)
449
450
451 def test_cli_chord_map_text_output(tmp_path: pathlib.Path) -> None:
452 """``muse chord-map`` (default text) includes chord symbols in output."""
453 _init_muse_repo(tmp_path)
454 _commit_ref(tmp_path)
455 prev = os.getcwd()
456 try:
457 os.chdir(tmp_path)
458 result = runner.invoke(cli, ["chord-map"], catch_exceptions=False)
459 finally:
460 os.chdir(prev)
461
462 assert result.exit_code == 0
463 assert "Chord map" in result.output
464 assert "Cmaj9" in result.output
465
466
467 def test_cli_chord_map_json_output(tmp_path: pathlib.Path) -> None:
468 """``muse chord-map --format json`` emits valid JSON."""
469 _init_muse_repo(tmp_path)
470 _commit_ref(tmp_path)
471 prev = os.getcwd()
472 try:
473 os.chdir(tmp_path)
474 result = runner.invoke(
475 cli, ["chord-map", "--format", "json"], catch_exceptions=False
476 )
477 finally:
478 os.chdir(prev)
479
480 assert result.exit_code == 0
481 payload = json.loads(result.output)
482 assert "chords" in payload
483 assert "commit" in payload
484
485
486 def test_cli_chord_map_mermaid_output(tmp_path: pathlib.Path) -> None:
487 """``muse chord-map --format mermaid`` emits a Mermaid timeline block."""
488 _init_muse_repo(tmp_path)
489 _commit_ref(tmp_path)
490 prev = os.getcwd()
491 try:
492 os.chdir(tmp_path)
493 result = runner.invoke(
494 cli, ["chord-map", "--format", "mermaid"], catch_exceptions=False
495 )
496 finally:
497 os.chdir(prev)
498
499 assert result.exit_code == 0
500 assert "timeline" in result.output
501
502
503 def test_cli_chord_map_voice_leading_output(tmp_path: pathlib.Path) -> None:
504 """``muse chord-map --voice-leading`` includes arrow notation in output."""
505 _init_muse_repo(tmp_path)
506 _commit_ref(tmp_path)
507 prev = os.getcwd()
508 try:
509 os.chdir(tmp_path)
510 result = runner.invoke(
511 cli, ["chord-map", "--voice-leading"], catch_exceptions=False
512 )
513 finally:
514 os.chdir(prev)
515
516 assert result.exit_code == 0
517 assert "->" in result.output