cgcardona / muse public
test_tempo_scale.py python
619 lines 19.5 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """Tests for ``muse tempo-scale`` — timing stretch/compress command.
2
3 Covers:
4 - compute_factor_from_bpm: factor computation, edge cases, errors
5 - apply_factor: correct BPM calculation
6 - _tempo_scale_async: result schema, factor resolution, determinism
7 - _format_result: text and JSON output modes
8 - CLI flag parsing via CliRunner (factor, commit, --bpm, --track,
9 --preserve-expressions, --message, --json)
10 - Validation errors: no args, mutual exclusion, out-of-range factor,
11 non-positive BPM
12 - Outside-repo invocation exits 2
13
14 All async tests use @pytest.mark.anyio with the shared muse_cli_db_session
15 fixture from tests/muse_cli/conftest.py.
16 """
17 from __future__ import annotations
18
19 import json
20 import os
21 import pathlib
22 import uuid
23
24 import pytest
25 from sqlalchemy.ext.asyncio import AsyncSession
26 from typer.testing import CliRunner
27
28 from maestro.muse_cli.app import cli
29 from maestro.muse_cli.commands.tempo_scale import (
30 FACTOR_MAX,
31 FACTOR_MIN,
32 TempoScaleResult,
33 _format_result,
34 _tempo_scale_async,
35 apply_factor,
36 compute_factor_from_bpm,
37 )
38 from maestro.muse_cli.errors import ExitCode
39
40 runner = CliRunner()
41
42
43 # ---------------------------------------------------------------------------
44 # Helpers
45 # ---------------------------------------------------------------------------
46
47
48 def _init_muse_repo(root: pathlib.Path, branch: str = "main") -> str:
49 """Create a minimal .muse/ layout with one commit ref and return repo_id."""
50 rid = str(uuid.uuid4())
51 muse = root / ".muse"
52 (muse / "refs" / "heads").mkdir(parents=True)
53 (muse / "repo.json").write_text(json.dumps({"repo_id": rid, "schema_version": "1"}))
54 (muse / "HEAD").write_text(f"refs/heads/{branch}")
55 (muse / "refs" / "heads" / branch).write_text("abc12345")
56 return rid
57
58
59 # ---------------------------------------------------------------------------
60 # compute_factor_from_bpm — pure function
61 # ---------------------------------------------------------------------------
62
63
64 def test_compute_factor_from_bpm_120_to_128() -> None:
65 """120 BPM to 128 BPM yields factor 128/120."""
66 factor = compute_factor_from_bpm(120.0, 128.0)
67 assert abs(factor - 128.0 / 120.0) < 1e-9
68
69
70 def test_tempo_scale_bpm_target_computes_correct_factor() -> None:
71 """Regression: --bpm 128 starting from 120 BPM produces factor ~1.0667."""
72 factor = compute_factor_from_bpm(120.0, 128.0)
73 assert abs(factor - (128.0 / 120.0)) < 1e-9, (
74 f"Expected {128.0 / 120.0}, got {factor}"
75 )
76
77
78 def test_compute_factor_from_bpm_half_time() -> None:
79 """Targeting 60 BPM from 120 yields 0.5 (half-time)."""
80 factor = compute_factor_from_bpm(120.0, 60.0)
81 assert abs(factor - 0.5) < 1e-9
82
83
84 def test_compute_factor_from_bpm_double_time() -> None:
85 """Targeting 240 BPM from 120 yields 2.0 (double-time)."""
86 factor = compute_factor_from_bpm(120.0, 240.0)
87 assert abs(factor - 2.0) < 1e-9
88
89
90 def test_compute_factor_from_bpm_same_bpm_yields_one() -> None:
91 """No change: source == target yields factor 1.0."""
92 factor = compute_factor_from_bpm(120.0, 120.0)
93 assert abs(factor - 1.0) < 1e-9
94
95
96 def test_compute_factor_from_bpm_raises_on_zero_source() -> None:
97 """Zero source BPM raises ValueError."""
98 with pytest.raises(ValueError, match="source_bpm must be positive"):
99 compute_factor_from_bpm(0.0, 128.0)
100
101
102 def test_compute_factor_from_bpm_raises_on_negative_source() -> None:
103 """Negative source BPM raises ValueError."""
104 with pytest.raises(ValueError, match="source_bpm must be positive"):
105 compute_factor_from_bpm(-10.0, 128.0)
106
107
108 def test_compute_factor_from_bpm_raises_on_zero_target() -> None:
109 """Zero target BPM raises ValueError."""
110 with pytest.raises(ValueError, match="target_bpm must be positive"):
111 compute_factor_from_bpm(120.0, 0.0)
112
113
114 def test_compute_factor_from_bpm_raises_on_negative_target() -> None:
115 """Negative target BPM raises ValueError."""
116 with pytest.raises(ValueError, match="target_bpm must be positive"):
117 compute_factor_from_bpm(120.0, -1.0)
118
119
120 # ---------------------------------------------------------------------------
121 # apply_factor — pure function
122 # ---------------------------------------------------------------------------
123
124
125 def test_apply_factor_double_time() -> None:
126 """Factor 2.0 doubles the BPM."""
127 assert apply_factor(120.0, 2.0) == 240.0
128
129
130 def test_apply_factor_half_time() -> None:
131 """Factor 0.5 halves the BPM."""
132 assert apply_factor(120.0, 0.5) == 60.0
133
134
135 def test_apply_factor_identity() -> None:
136 """Factor 1.0 leaves BPM unchanged."""
137 assert apply_factor(120.0, 1.0) == 120.0
138
139
140 def test_tempo_scale_half_time_doubles_all_offsets() -> None:
141 """Regression: factor 0.5 halves BPM (half-time = twice as slow)."""
142 result = apply_factor(120.0, 0.5)
143 assert result == 60.0, f"Expected 60.0, got {result}"
144
145
146 def test_apply_factor_128_bpm() -> None:
147 """Factor 128/120 starting from 120 BPM yields exactly 128 BPM."""
148 factor = compute_factor_from_bpm(120.0, 128.0)
149 new_bpm = apply_factor(120.0, factor)
150 assert abs(new_bpm - 128.0) < 0.0001
151
152
153 # ---------------------------------------------------------------------------
154 # _tempo_scale_async — schema and behaviour
155 # ---------------------------------------------------------------------------
156
157
158 @pytest.mark.anyio
159 async def test_tempo_scale_factor_updates_note_timings(
160 tmp_path: pathlib.Path,
161 muse_cli_db_session: AsyncSession,
162 ) -> None:
163 """_tempo_scale_async returns a result with the correct factor applied."""
164 _init_muse_repo(tmp_path)
165 result = await _tempo_scale_async(
166 root=tmp_path,
167 session=muse_cli_db_session,
168 commit=None,
169 factor=2.0,
170 bpm=None,
171 track=None,
172 preserve_expressions=False,
173 message=None,
174 )
175 assert result["factor"] == 2.0
176 assert result["new_bpm"] == apply_factor(result["source_bpm"], 2.0)
177
178
179 @pytest.mark.anyio
180 async def test_tempo_scale_bpm_128_from_120_bpm(
181 tmp_path: pathlib.Path,
182 muse_cli_db_session: AsyncSession,
183 ) -> None:
184 """--bpm 128 from a 120 BPM source produces new_bpm == 128."""
185 _init_muse_repo(tmp_path)
186 result = await _tempo_scale_async(
187 root=tmp_path,
188 session=muse_cli_db_session,
189 commit=None,
190 factor=None,
191 bpm=128.0,
192 track=None,
193 preserve_expressions=False,
194 message=None,
195 )
196 assert abs(result["new_bpm"] - 128.0) < 0.0001
197 assert result["source_bpm"] == 120.0
198
199
200 @pytest.mark.anyio
201 async def test_tempo_scale_updates_commit_tempo_metadata(
202 tmp_path: pathlib.Path,
203 muse_cli_db_session: AsyncSession,
204 ) -> None:
205 """Result contains source_commit, new_commit, and they differ."""
206 _init_muse_repo(tmp_path)
207 result = await _tempo_scale_async(
208 root=tmp_path,
209 session=muse_cli_db_session,
210 commit=None,
211 factor=1.5,
212 bpm=None,
213 track=None,
214 preserve_expressions=False,
215 message=None,
216 )
217 assert result["source_commit"]
218 assert result["new_commit"]
219 assert result["source_commit"] != result["new_commit"]
220
221
222 @pytest.mark.anyio
223 async def test_tempo_scale_preserve_expressions_scales_cc_events(
224 tmp_path: pathlib.Path,
225 muse_cli_db_session: AsyncSession,
226 ) -> None:
227 """preserve_expressions flag is reflected in the result."""
228 _init_muse_repo(tmp_path)
229 result = await _tempo_scale_async(
230 root=tmp_path,
231 session=muse_cli_db_session,
232 commit=None,
233 factor=0.5,
234 bpm=None,
235 track=None,
236 preserve_expressions=True,
237 message=None,
238 )
239 assert result["preserve_expressions"] is True
240
241
242 @pytest.mark.anyio
243 async def test_tempo_scale_returns_all_schema_keys(
244 tmp_path: pathlib.Path,
245 muse_cli_db_session: AsyncSession,
246 ) -> None:
247 """TempoScaleResult contains all expected keys."""
248 _init_muse_repo(tmp_path)
249 result = await _tempo_scale_async(
250 root=tmp_path,
251 session=muse_cli_db_session,
252 commit=None,
253 factor=1.0,
254 bpm=None,
255 track=None,
256 preserve_expressions=False,
257 message=None,
258 )
259 for key in (
260 "source_commit",
261 "new_commit",
262 "factor",
263 "source_bpm",
264 "new_bpm",
265 "track",
266 "preserve_expressions",
267 "message",
268 ):
269 assert key in result, f"Missing key: {key}"
270
271
272 @pytest.mark.anyio
273 async def test_tempo_scale_deterministic_output(
274 tmp_path: pathlib.Path,
275 muse_cli_db_session: AsyncSession,
276 ) -> None:
277 """Same inputs always produce the same new_commit (deterministic)."""
278 _init_muse_repo(tmp_path)
279 result_a = await _tempo_scale_async(
280 root=tmp_path,
281 session=muse_cli_db_session,
282 commit="abc12345",
283 factor=0.5,
284 bpm=None,
285 track="bass",
286 preserve_expressions=False,
287 message=None,
288 )
289 result_b = await _tempo_scale_async(
290 root=tmp_path,
291 session=muse_cli_db_session,
292 commit="abc12345",
293 factor=0.5,
294 bpm=None,
295 track="bass",
296 preserve_expressions=False,
297 message=None,
298 )
299 assert result_a["new_commit"] == result_b["new_commit"]
300
301
302 @pytest.mark.anyio
303 async def test_tempo_scale_custom_message_reflected(
304 tmp_path: pathlib.Path,
305 muse_cli_db_session: AsyncSession,
306 ) -> None:
307 """Custom --message is reflected in the result."""
308 _init_muse_repo(tmp_path)
309 result = await _tempo_scale_async(
310 root=tmp_path,
311 session=muse_cli_db_session,
312 commit=None,
313 factor=2.0,
314 bpm=None,
315 track=None,
316 preserve_expressions=False,
317 message="my custom message",
318 )
319 assert result["message"] == "my custom message"
320
321
322 @pytest.mark.anyio
323 async def test_tempo_scale_track_reflected(
324 tmp_path: pathlib.Path,
325 muse_cli_db_session: AsyncSession,
326 ) -> None:
327 """--track filter is reflected in the result."""
328 _init_muse_repo(tmp_path)
329 result = await _tempo_scale_async(
330 root=tmp_path,
331 session=muse_cli_db_session,
332 commit=None,
333 factor=2.0,
334 bpm=None,
335 track="keys",
336 preserve_expressions=False,
337 message=None,
338 )
339 assert result["track"] == "keys"
340
341
342 @pytest.mark.anyio
343 async def test_tempo_scale_no_track_defaults_to_all(
344 tmp_path: pathlib.Path,
345 muse_cli_db_session: AsyncSession,
346 ) -> None:
347 """Without --track, the result track field is 'all'."""
348 _init_muse_repo(tmp_path)
349 result = await _tempo_scale_async(
350 root=tmp_path,
351 session=muse_cli_db_session,
352 commit=None,
353 factor=1.0,
354 bpm=None,
355 track=None,
356 preserve_expressions=False,
357 message=None,
358 )
359 assert result["track"] == "all"
360
361
362 @pytest.mark.anyio
363 async def test_tempo_scale_raises_if_no_factor_and_no_bpm(
364 tmp_path: pathlib.Path,
365 muse_cli_db_session: AsyncSession,
366 ) -> None:
367 """ValueError raised when neither factor nor --bpm is provided."""
368 _init_muse_repo(tmp_path)
369 with pytest.raises(ValueError, match="Either factor or --bpm must be provided"):
370 await _tempo_scale_async(
371 root=tmp_path,
372 session=muse_cli_db_session,
373 commit=None,
374 factor=None,
375 bpm=None,
376 track=None,
377 preserve_expressions=False,
378 message=None,
379 )
380
381
382 # ---------------------------------------------------------------------------
383 # _format_result — output formatters
384 # ---------------------------------------------------------------------------
385
386
387 def _make_result(
388 factor: float = 2.0,
389 source_bpm: float = 120.0,
390 preserve_expressions: bool = False,
391 ) -> TempoScaleResult:
392 return TempoScaleResult(
393 source_commit="abc12345",
394 new_commit="deadbeef",
395 factor=factor,
396 source_bpm=source_bpm,
397 new_bpm=apply_factor(source_bpm, factor),
398 track="all",
399 preserve_expressions=preserve_expressions,
400 message="test message",
401 )
402
403
404 def test_format_result_json_is_valid() -> None:
405 """JSON output is parseable and contains all expected keys."""
406 result = _make_result()
407 output = _format_result(result, as_json=True)
408 parsed = json.loads(output)
409 for key in TempoScaleResult.__annotations__:
410 assert key in parsed, f"Missing key in JSON: {key}"
411
412
413 def test_format_result_text_contains_source_and_new_commit() -> None:
414 """Text output mentions both the source and new commit."""
415 result = _make_result()
416 output = _format_result(result, as_json=False)
417 assert "abc12345" in output
418 assert "deadbeef" in output
419
420
421 def test_format_result_text_contains_bpm_values() -> None:
422 """Text output includes both source and new BPM."""
423 result = _make_result(factor=2.0, source_bpm=120.0)
424 output = _format_result(result, as_json=False)
425 assert "120.0" in output
426 assert "240.0" in output
427
428
429 def test_format_result_text_shows_preserve_expressions() -> None:
430 """Text output notes expression scaling when the flag is set."""
431 result = _make_result(preserve_expressions=True)
432 output = _format_result(result, as_json=False)
433 assert "Expressions" in output or "expression" in output.lower()
434
435
436 def test_format_result_text_no_preserve_expressions_not_shown() -> None:
437 """Text output does NOT mention expressions when flag is off."""
438 result = _make_result(preserve_expressions=False)
439 output = _format_result(result, as_json=False)
440 assert "expression" not in output.lower()
441
442
443 # ---------------------------------------------------------------------------
444 # CLI integration — CliRunner
445 # ---------------------------------------------------------------------------
446
447
448 def test_cli_tempo_scale_factor_basic(tmp_path: pathlib.Path) -> None:
449 """``muse tempo-scale 2.0`` with a valid repo exits 0 and shows output."""
450 _init_muse_repo(tmp_path)
451 result = runner.invoke(cli, ["tempo-scale", "2.0"], env={"MUSE_REPO_ROOT": str(tmp_path)})
452 assert result.exit_code == ExitCode.SUCCESS, result.output
453 assert "Tempo scaled" in result.output
454
455
456 def test_cli_tempo_scale_half_time(tmp_path: pathlib.Path) -> None:
457 """``muse tempo-scale 0.5`` creates a half-time feel commit."""
458 _init_muse_repo(tmp_path)
459 result = runner.invoke(cli, ["tempo-scale", "0.5"], env={"MUSE_REPO_ROOT": str(tmp_path)})
460 assert result.exit_code == ExitCode.SUCCESS, result.output
461 assert "60.0 BPM" in result.output
462
463
464 def test_cli_tempo_scale_bpm_128(tmp_path: pathlib.Path) -> None:
465 """``muse tempo-scale --bpm 128`` scales to 128 BPM."""
466 _init_muse_repo(tmp_path)
467 result = runner.invoke(
468 cli,
469 ["tempo-scale", "--bpm", "128"],
470 env={"MUSE_REPO_ROOT": str(tmp_path)},
471 )
472 assert result.exit_code == ExitCode.SUCCESS, result.output
473 assert "128.0 BPM" in result.output
474
475
476 def test_cli_tempo_scale_json_output(tmp_path: pathlib.Path) -> None:
477 """``--json`` flag emits valid JSON with all expected keys.
478
479 Options are placed before the positional <factor> argument because Click
480 Groups disable interspersed-args parsing by default.
481 """
482 _init_muse_repo(tmp_path)
483 result = runner.invoke(
484 cli,
485 ["tempo-scale", "--json", "2.0"],
486 env={"MUSE_REPO_ROOT": str(tmp_path)},
487 )
488 assert result.exit_code == ExitCode.SUCCESS, result.output
489 parsed = json.loads(result.output)
490 assert "source_commit" in parsed
491 assert "new_commit" in parsed
492 assert "factor" in parsed
493 assert "new_bpm" in parsed
494
495
496 def test_cli_tempo_scale_with_commit_sha(tmp_path: pathlib.Path) -> None:
497 """Passing an explicit commit SHA is accepted."""
498 _init_muse_repo(tmp_path)
499 result = runner.invoke(
500 cli,
501 ["tempo-scale", "1.5", "deadbeef"],
502 env={"MUSE_REPO_ROOT": str(tmp_path)},
503 )
504 assert result.exit_code == ExitCode.SUCCESS, result.output
505
506
507 def test_cli_tempo_scale_with_track(tmp_path: pathlib.Path) -> None:
508 """``--track bass`` is accepted and reflected in JSON output.
509
510 Options precede the positional factor to satisfy Click Group parsing.
511 """
512 _init_muse_repo(tmp_path)
513 result = runner.invoke(
514 cli,
515 ["tempo-scale", "--track", "bass", "--json", "2.0"],
516 env={"MUSE_REPO_ROOT": str(tmp_path)},
517 )
518 assert result.exit_code == ExitCode.SUCCESS, result.output
519 parsed = json.loads(result.output)
520 assert parsed["track"] == "bass"
521
522
523 def test_cli_tempo_scale_preserve_expressions(tmp_path: pathlib.Path) -> None:
524 """``--preserve-expressions`` flag sets the flag in JSON output.
525
526 Options precede the positional factor to satisfy Click Group parsing.
527 """
528 _init_muse_repo(tmp_path)
529 result = runner.invoke(
530 cli,
531 ["tempo-scale", "--preserve-expressions", "--json", "0.5"],
532 env={"MUSE_REPO_ROOT": str(tmp_path)},
533 )
534 assert result.exit_code == ExitCode.SUCCESS, result.output
535 parsed = json.loads(result.output)
536 assert parsed["preserve_expressions"] is True
537
538
539 def test_cli_tempo_scale_custom_message(tmp_path: pathlib.Path) -> None:
540 """``--message`` is stored in JSON output.
541
542 Options precede the positional factor to satisfy Click Group parsing.
543 """
544 _init_muse_repo(tmp_path)
545 result = runner.invoke(
546 cli,
547 ["tempo-scale", "--message", "half-time remix", "--json", "2.0"],
548 env={"MUSE_REPO_ROOT": str(tmp_path)},
549 )
550 assert result.exit_code == ExitCode.SUCCESS, result.output
551 parsed = json.loads(result.output)
552 assert parsed["message"] == "half-time remix"
553
554
555 def test_cli_tempo_scale_no_args_exits_user_error(tmp_path: pathlib.Path) -> None:
556 """No factor and no --bpm exits with USER_ERROR (1)."""
557 _init_muse_repo(tmp_path)
558 result = runner.invoke(cli, ["tempo-scale"], env={"MUSE_REPO_ROOT": str(tmp_path)})
559 assert result.exit_code == ExitCode.USER_ERROR
560
561
562 def test_cli_tempo_scale_factor_and_bpm_mutually_exclusive(tmp_path: pathlib.Path) -> None:
563 """Providing both <factor> and --bpm exits with USER_ERROR (1).
564
565 ``--bpm`` is placed before the positional factor so Click parses it as
566 an option (not a positional arg) and our mutual-exclusion check fires.
567 """
568 _init_muse_repo(tmp_path)
569 result = runner.invoke(
570 cli,
571 ["tempo-scale", "--bpm", "128", "2.0"],
572 env={"MUSE_REPO_ROOT": str(tmp_path)},
573 )
574 assert result.exit_code == ExitCode.USER_ERROR
575
576
577 def test_cli_tempo_scale_out_of_range_factor(tmp_path: pathlib.Path) -> None:
578 """Factor outside [FACTOR_MIN, FACTOR_MAX] exits USER_ERROR (1)."""
579 _init_muse_repo(tmp_path)
580 result = runner.invoke(
581 cli,
582 ["tempo-scale", "999999"],
583 env={"MUSE_REPO_ROOT": str(tmp_path)},
584 )
585 assert result.exit_code == ExitCode.USER_ERROR
586
587
588 def test_cli_tempo_scale_zero_factor_exits_user_error(tmp_path: pathlib.Path) -> None:
589 """Factor of 0 (below FACTOR_MIN) exits USER_ERROR (1)."""
590 _init_muse_repo(tmp_path)
591 result = runner.invoke(
592 cli,
593 ["tempo-scale", "0.0"],
594 env={"MUSE_REPO_ROOT": str(tmp_path)},
595 )
596 assert result.exit_code == ExitCode.USER_ERROR
597
598
599 def test_cli_tempo_scale_non_positive_bpm_exits_user_error(tmp_path: pathlib.Path) -> None:
600 """--bpm 0 exits USER_ERROR (1)."""
601 _init_muse_repo(tmp_path)
602 result = runner.invoke(
603 cli,
604 ["tempo-scale", "--bpm", "0"],
605 env={"MUSE_REPO_ROOT": str(tmp_path)},
606 )
607 assert result.exit_code == ExitCode.USER_ERROR
608
609
610 def test_cli_tempo_scale_outside_repo_exits_repo_not_found(tmp_path: pathlib.Path) -> None:
611 """Running outside a Muse repo exits REPO_NOT_FOUND (2)."""
612 empty = tmp_path / "no_muse_here"
613 empty.mkdir()
614 result = runner.invoke(
615 cli,
616 ["tempo-scale", "2.0"],
617 env={"MUSE_REPO_ROOT": str(empty)},
618 )
619 assert result.exit_code == ExitCode.REPO_NOT_FOUND