"""Integration tests for scripts/compose.py — end-to-end compose workflow.""" import sys from pathlib import Path from unittest.mock import patch, MagicMock sys.path.insert(0, str(Path(__file__).parents[1])) import pytest from src.core.schema import SongDefinition, SongMeta, TrackDef, ClipDef, MidiNote from src.reaper_builder import RPPBuilder # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def compose_via_builder( genre: str = "reggaeton", bpm: float = 95.0, key: str = "Am", output_path: str = "output/track.rpp", ) -> SongDefinition: """Build a SongDefinition the same way scripts/compose.py does, return it. This lets us test the compose logic without hitting the filesystem for samples. """ from src.composer.rhythm import get_notes from src.composer.melodic import bass_tresillo, lead_hook, chords_block, pad_sustain from src.composer.converters import rhythm_to_midi, melodic_to_midi genre_bar_map = {"reggaeton": 64, "trap": 32, "house": 64, "drill": 32} bar_count = genre_bar_map.get(genre.lower(), 48) # Drum tracks drum_tracks = [] for role, generator_name in [ ("kick", "kick_main_notes"), ("snare", "snare_verse_notes"), ("hihat", "hihat_16th_notes"), ("perc", "perc_combo_notes"), ]: note_dict = get_notes(generator_name, bar_count) midi_notes = rhythm_to_midi(note_dict) clip = ClipDef( position=0.0, length=bar_count * 4.0, name=f"{role.capitalize()} Pattern", midi_notes=midi_notes, ) drum_tracks.append(TrackDef(name=role.capitalize(), clips=[clip])) # Melodic tracks (no selector — audio_path stays None) for role, generator_fn in [ ("bass", bass_tresillo), ("lead", lead_hook), ("chords", chords_block), ("pad", pad_sustain), ]: note_list = generator_fn(key=key, bars=bar_count) midi_notes = melodic_to_midi(note_list) clip = ClipDef( position=0.0, length=bar_count * 4.0, name=f"{role.capitalize()} MIDI", midi_notes=midi_notes, ) drum_tracks.append(TrackDef(name=role.capitalize(), clips=[clip])) meta = SongMeta(bpm=bpm, key=key, title=f"{genre.capitalize()} Track") return SongDefinition(meta=meta, tracks=drum_tracks) # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- class TestComposeRppOutput: """Tests for compose workflow producing valid .rpp output.""" def test_compose_produces_rpp_file(self, tmp_path): """main() with valid args produces a .rpp file at the output path.""" output = tmp_path / "track.rpp" # Mock SampleSelector.select_one so we don't need actual sample files with patch("scripts.compose.SampleSelector") as mock_selector_cls: mock_selector = MagicMock() mock_selector.select_one.return_value = None # audio_path stays None mock_selector_cls.return_value = mock_selector from scripts.compose import main import sys original_argv = sys.argv try: sys.argv = [ "compose", "--genre", "reggaeton", "--bpm", "95", "--key", "Am", "--output", str(output), ] main() finally: sys.argv = original_argv assert output.exists(), f"Expected {output} to exist" def test_compose_rpp_has_min_4_tracks(self, tmp_path): """The .rpp output contains at least 4 = 4, f"Expected >= 4 tracks, got {track_count}" def test_compose_invalid_bpm_raises(self): """main() with bpm=0 raises ValueError.""" from scripts.compose import main import sys original_argv = sys.argv try: sys.argv = [ "compose", "--genre", "reggaeton", "--bpm", "0", "--key", "Am", "--output", "output/track.rpp", ] with pytest.raises(ValueError, match="bpm must be > 0"): main() finally: sys.argv = original_argv def test_compose_negative_bpm_raises(self): """main() with bpm=-10 raises ValueError.""" from scripts.compose import main import sys original_argv = sys.argv try: sys.argv = [ "compose", "--genre", "reggaeton", "--bpm", "-10", "--key", "Am", "--output", "output/track.rpp", ] with pytest.raises(ValueError, match="bpm must be > 0"): main() finally: sys.argv = original_argv