cgcardona / muse public
test_muse_key.py python
575 lines 16.8 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """Tests for ``muse key`` — CLI interface, flag parsing, key helpers, 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 ``_key_detect_async`` and ``_key_history_async`` directly
8 with an in-memory SQLite session (the stub does not query the DB, so the session
9 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.key import (
29 KeyDetectResult,
30 KeyHistoryEntry,
31 _format_detect,
32 _format_history,
33 _key_detect_async,
34 _key_history_async,
35 parse_key,
36 relative_key,
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 key 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 — parse_key
84 # ---------------------------------------------------------------------------
85
86
87 def test_parse_key_major_sharp() -> None:
88 """parse_key handles sharp-tonic major keys."""
89 tonic, mode = parse_key("F# major")
90 assert tonic == "F#"
91 assert mode == "major"
92
93
94 def test_parse_key_minor_flat() -> None:
95 """parse_key handles flat-tonic minor keys."""
96 tonic, mode = parse_key("Eb minor")
97 assert tonic == "Eb"
98 assert mode == "minor"
99
100
101 def test_parse_key_case_insensitive_mode() -> None:
102 """parse_key normalises mode to lowercase."""
103 tonic, mode = parse_key("C Major")
104 assert mode == "major"
105
106
107 def test_parse_key_invalid_tonic_raises() -> None:
108 """parse_key raises ValueError for unknown tonics."""
109 with pytest.raises(ValueError, match="Unknown tonic"):
110 parse_key("H minor")
111
112
113 def test_parse_key_invalid_mode_raises() -> None:
114 """parse_key raises ValueError for unknown modes."""
115 with pytest.raises(ValueError, match="Unknown mode"):
116 parse_key("C dorian")
117
118
119 def test_parse_key_wrong_format_raises() -> None:
120 """parse_key raises ValueError when the string has != 2 parts."""
121 with pytest.raises(ValueError, match="Key must be"):
122 parse_key("F#minor")
123
124
125 # ---------------------------------------------------------------------------
126 # Unit — relative_key
127 # ---------------------------------------------------------------------------
128
129
130 def test_relative_key_a_minor_is_c_major() -> None:
131 """Relative major of A minor is C major."""
132 assert relative_key("A", "minor") == "C major"
133
134
135 def test_relative_key_c_major_is_a_minor() -> None:
136 """Relative minor of C major is A minor."""
137 assert relative_key("C", "major") == "A minor"
138
139
140 def test_relative_key_f_sharp_minor_is_a_major() -> None:
141 """Relative major of F# minor is A major."""
142 assert relative_key("F#", "minor") == "A major"
143
144
145 def test_relative_key_eb_major_is_c_minor() -> None:
146 """Relative minor of Eb major is C minor (via enharmonic: Eb → D#)."""
147 result = relative_key("Eb", "major")
148 assert result == "C minor"
149
150
151 def test_relative_key_wraps_around_chromatic() -> None:
152 """Relative key calculation wraps correctly at B/C boundary."""
153 # B minor → relative major is D major (3 semitones up from B = D)
154 assert relative_key("B", "minor") == "D major"
155
156
157 # ---------------------------------------------------------------------------
158 # Unit — formatters
159 # ---------------------------------------------------------------------------
160
161
162 def test_format_detect_text() -> None:
163 """_format_detect emits key, commit, and branch in text mode."""
164 result: KeyDetectResult = KeyDetectResult(
165 key="C major",
166 tonic="C",
167 mode="major",
168 relative="",
169 commit="a1b2c3d4",
170 branch="main",
171 track="all",
172 source="stub",
173 )
174 output = _format_detect(result, as_json=False)
175 assert "C major" in output
176 assert "a1b2c3d4" in output
177 assert "main" in output
178 assert "stub" in output
179
180
181 def test_format_detect_text_with_relative() -> None:
182 """_format_detect includes the relative key when populated."""
183 result: KeyDetectResult = KeyDetectResult(
184 key="A minor",
185 tonic="A",
186 mode="minor",
187 relative="C major",
188 commit="deadbeef",
189 branch="feature",
190 track="all",
191 source="stub",
192 )
193 output = _format_detect(result, as_json=False)
194 assert "Relative: C major" in output
195
196
197 def test_format_detect_json_valid() -> None:
198 """_format_detect emits parseable JSON with all expected keys."""
199 result: KeyDetectResult = KeyDetectResult(
200 key="F# minor",
201 tonic="F#",
202 mode="minor",
203 relative="A major",
204 commit="cafe1234",
205 branch="dev",
206 track="bass",
207 source="annotation",
208 )
209 raw = _format_detect(result, as_json=True)
210 payload = json.loads(raw)
211 assert payload["key"] == "F# minor"
212 assert payload["tonic"] == "F#"
213 assert payload["mode"] == "minor"
214 assert payload["relative"] == "A major"
215 assert payload["source"] == "annotation"
216
217
218 def test_format_history_text() -> None:
219 """_format_history emits one line per entry in text mode."""
220 entries: list[KeyHistoryEntry] = [
221 KeyHistoryEntry(commit="aaa", key="C major", tonic="C", mode="major", source="stub"),
222 KeyHistoryEntry(commit="bbb", key="F minor", tonic="F", mode="minor", source="annotation"),
223 ]
224 output = _format_history(entries, as_json=False)
225 assert "aaa" in output
226 assert "C major" in output
227 assert "bbb" in output
228 assert "F minor" in output
229
230
231 def test_format_history_json() -> None:
232 """_format_history emits parseable JSON list."""
233 entries: list[KeyHistoryEntry] = [
234 KeyHistoryEntry(commit="aaa", key="C major", tonic="C", mode="major", source="stub"),
235 ]
236 raw = _format_history(entries, as_json=True)
237 payload = json.loads(raw)
238 assert isinstance(payload, list)
239 assert payload[0]["key"] == "C major"
240
241
242 def test_format_history_empty() -> None:
243 """_format_history returns a descriptive message for empty history."""
244 output = _format_history([], as_json=False)
245 assert "no key history" in output.lower()
246
247
248 # ---------------------------------------------------------------------------
249 # Async core — _key_detect_async
250 # ---------------------------------------------------------------------------
251
252
253 @pytest.mark.anyio
254 async def test_key_detect_async_returns_result(
255 tmp_path: pathlib.Path,
256 db_session: AsyncSession,
257 ) -> None:
258 """_key_detect_async returns a valid KeyDetectResult."""
259 _init_muse_repo(tmp_path)
260 _commit_ref(tmp_path)
261
262 result = await _key_detect_async(
263 root=tmp_path,
264 session=db_session,
265 commit=None,
266 track=None,
267 show_relative=False,
268 )
269
270 assert result["key"]
271 assert result["tonic"]
272 assert result["mode"] in ("major", "minor")
273 assert result["commit"]
274 assert result["branch"]
275 assert result["track"] == "all"
276 assert result["source"] in ("stub", "detected", "annotation")
277
278
279 @pytest.mark.anyio
280 async def test_key_detect_async_track_filter(
281 tmp_path: pathlib.Path,
282 db_session: AsyncSession,
283 ) -> None:
284 """--track populates the track field in the result."""
285 _init_muse_repo(tmp_path)
286 _commit_ref(tmp_path)
287
288 result = await _key_detect_async(
289 root=tmp_path,
290 session=db_session,
291 commit=None,
292 track="bass",
293 show_relative=False,
294 )
295
296 assert result["track"] == "bass"
297
298
299 @pytest.mark.anyio
300 async def test_key_detect_async_relative(
301 tmp_path: pathlib.Path,
302 db_session: AsyncSession,
303 ) -> None:
304 """--relative populates the relative field."""
305 _init_muse_repo(tmp_path)
306 _commit_ref(tmp_path)
307
308 result = await _key_detect_async(
309 root=tmp_path,
310 session=db_session,
311 commit=None,
312 track=None,
313 show_relative=True,
314 )
315
316 assert result["relative"] != ""
317
318
319 @pytest.mark.anyio
320 async def test_key_detect_async_no_relative_by_default(
321 tmp_path: pathlib.Path,
322 db_session: AsyncSession,
323 ) -> None:
324 """Relative field is empty when show_relative=False."""
325 _init_muse_repo(tmp_path)
326 _commit_ref(tmp_path)
327
328 result = await _key_detect_async(
329 root=tmp_path,
330 session=db_session,
331 commit=None,
332 track=None,
333 show_relative=False,
334 )
335
336 assert result["relative"] == ""
337
338
339 @pytest.mark.anyio
340 async def test_key_detect_async_explicit_commit(
341 tmp_path: pathlib.Path,
342 db_session: AsyncSession,
343 ) -> None:
344 """An explicit commit SHA appears in the result."""
345 _init_muse_repo(tmp_path)
346 _commit_ref(tmp_path)
347
348 result = await _key_detect_async(
349 root=tmp_path,
350 session=db_session,
351 commit="deadbeef",
352 track=None,
353 show_relative=False,
354 )
355
356 assert result["commit"] == "deadbeef"
357
358
359 # ---------------------------------------------------------------------------
360 # Async core — _key_history_async
361 # ---------------------------------------------------------------------------
362
363
364 @pytest.mark.anyio
365 async def test_key_history_async_returns_list(
366 tmp_path: pathlib.Path,
367 db_session: AsyncSession,
368 ) -> None:
369 """_key_history_async returns a non-empty list of KeyHistoryEntry."""
370 _init_muse_repo(tmp_path)
371 _commit_ref(tmp_path)
372
373 entries = await _key_history_async(
374 root=tmp_path,
375 session=db_session,
376 track=None,
377 )
378
379 assert isinstance(entries, list)
380 assert len(entries) >= 1
381 for entry in entries:
382 assert "commit" in entry
383 assert "key" in entry
384 assert "tonic" in entry
385 assert "mode" in entry
386
387
388 # ---------------------------------------------------------------------------
389 # CLI integration — CliRunner
390 # ---------------------------------------------------------------------------
391
392
393 def test_cli_key_help_lists_flags() -> None:
394 """``muse key --help`` shows all documented flags."""
395 result = runner.invoke(cli, ["key", "--help"])
396 assert result.exit_code == 0
397 for flag in ("--set", "--track", "--relative", "--history", "--json"):
398 assert flag in result.output, f"Flag '{flag}' not found in help output"
399
400
401 def test_cli_key_appears_in_muse_help() -> None:
402 """``muse --help`` lists the key subcommand."""
403 result = runner.invoke(cli, ["--help"])
404 assert result.exit_code == 0
405 assert "key" in result.output
406
407
408 def test_cli_key_outside_repo_exits_2(tmp_path: pathlib.Path) -> None:
409 """``muse key`` exits with REPO_NOT_FOUND when invoked outside a Muse repository."""
410 prev = os.getcwd()
411 try:
412 os.chdir(tmp_path)
413 result = runner.invoke(cli, ["key"], catch_exceptions=False)
414 finally:
415 os.chdir(prev)
416
417 assert result.exit_code == int(ExitCode.REPO_NOT_FOUND)
418 assert "not a muse repository" in result.output.lower()
419
420
421 def test_cli_key_set_valid_key(tmp_path: pathlib.Path) -> None:
422 """``muse key --set 'F# minor'`` annotates successfully."""
423 _init_muse_repo(tmp_path)
424 _commit_ref(tmp_path)
425
426 prev = os.getcwd()
427 try:
428 os.chdir(tmp_path)
429 result = runner.invoke(cli, ["key", "--set", "F# minor"], catch_exceptions=False)
430 finally:
431 os.chdir(prev)
432
433 assert result.exit_code == 0
434 assert "F#" in result.output
435 assert "minor" in result.output
436
437
438 def test_cli_key_set_invalid_key_exits_user_error(tmp_path: pathlib.Path) -> None:
439 """``muse key --set 'H minor'`` exits USER_ERROR for unknown tonic."""
440 _init_muse_repo(tmp_path)
441 _commit_ref(tmp_path)
442
443 prev = os.getcwd()
444 try:
445 os.chdir(tmp_path)
446 result = runner.invoke(cli, ["key", "--set", "H minor"], catch_exceptions=False)
447 finally:
448 os.chdir(prev)
449
450 assert result.exit_code == int(ExitCode.USER_ERROR)
451
452
453 def test_cli_key_set_json_output(tmp_path: pathlib.Path) -> None:
454 """``muse key --set 'Eb major' --json`` emits valid JSON."""
455 _init_muse_repo(tmp_path)
456 _commit_ref(tmp_path)
457
458 prev = os.getcwd()
459 try:
460 os.chdir(tmp_path)
461 result = runner.invoke(
462 cli, ["key", "--set", "Eb major", "--json"], catch_exceptions=False
463 )
464 finally:
465 os.chdir(prev)
466
467 assert result.exit_code == 0
468 payload = json.loads(result.output)
469 assert payload["key"] == "Eb major"
470 assert payload["tonic"] == "Eb"
471 assert payload["mode"] == "major"
472 assert payload["source"] == "annotation"
473
474
475 def test_cli_key_set_with_relative(tmp_path: pathlib.Path) -> None:
476 """``muse key --set 'A minor' --relative`` includes the relative key."""
477 _init_muse_repo(tmp_path)
478 _commit_ref(tmp_path)
479
480 prev = os.getcwd()
481 try:
482 os.chdir(tmp_path)
483 result = runner.invoke(
484 cli, ["key", "--set", "A minor", "--relative"], catch_exceptions=False
485 )
486 finally:
487 os.chdir(prev)
488
489 assert result.exit_code == 0
490 assert "C major" in result.output
491
492
493 def test_cli_key_default_detect(tmp_path: pathlib.Path) -> None:
494 """``muse key`` with no flags detects and prints the key."""
495 _init_muse_repo(tmp_path)
496 _commit_ref(tmp_path)
497
498 prev = os.getcwd()
499 try:
500 os.chdir(tmp_path)
501 result = runner.invoke(cli, ["key"], catch_exceptions=False)
502 finally:
503 os.chdir(prev)
504
505 assert result.exit_code == 0
506 assert "Key:" in result.output
507
508
509 def test_cli_key_json_mode(tmp_path: pathlib.Path) -> None:
510 """``muse key --json`` emits parseable JSON."""
511 _init_muse_repo(tmp_path)
512 _commit_ref(tmp_path)
513
514 prev = os.getcwd()
515 try:
516 os.chdir(tmp_path)
517 result = runner.invoke(cli, ["key", "--json"], catch_exceptions=False)
518 finally:
519 os.chdir(prev)
520
521 assert result.exit_code == 0
522 payload = json.loads(result.output)
523 assert "key" in payload
524 assert "tonic" in payload
525 assert "mode" in payload
526
527
528 def test_cli_key_history(tmp_path: pathlib.Path) -> None:
529 """``muse key --history`` prints the commit-to-key mapping."""
530 _init_muse_repo(tmp_path)
531 _commit_ref(tmp_path)
532
533 prev = os.getcwd()
534 try:
535 os.chdir(tmp_path)
536 result = runner.invoke(cli, ["key", "--history"], catch_exceptions=False)
537 finally:
538 os.chdir(prev)
539
540 assert result.exit_code == 0
541 assert result.output.strip() # non-empty output
542
543
544 def test_cli_key_history_json(tmp_path: pathlib.Path) -> None:
545 """``muse key --history --json`` emits a JSON list."""
546 _init_muse_repo(tmp_path)
547 _commit_ref(tmp_path)
548
549 prev = os.getcwd()
550 try:
551 os.chdir(tmp_path)
552 result = runner.invoke(cli, ["key", "--history", "--json"], catch_exceptions=False)
553 finally:
554 os.chdir(prev)
555
556 assert result.exit_code == 0
557 payload = json.loads(result.output)
558 assert isinstance(payload, list)
559 assert len(payload) >= 1
560
561
562 def test_cli_key_relative_flag(tmp_path: pathlib.Path) -> None:
563 """``muse key --relative`` includes a 'Relative:' line in output."""
564 _init_muse_repo(tmp_path)
565 _commit_ref(tmp_path)
566
567 prev = os.getcwd()
568 try:
569 os.chdir(tmp_path)
570 result = runner.invoke(cli, ["key", "--relative"], catch_exceptions=False)
571 finally:
572 os.chdir(prev)
573
574 assert result.exit_code == 0
575 assert "Relative:" in result.output