refactor: migrate from FL Studio to REAPER with rpp library

Replace FL Studio binary .flp output with REAPER text-based .rpp output
using the rpp Python library (Perlence/rpp).

- Add core/schema.py: DAW-agnostic data types (SongDefinition, TrackDef,
  ClipDef, MidiNote, PluginDef)
- Add reaper_builder/: RPP file generation via rpp.Element + headless
  render via reaper.exe CLI
- Add composer/converters.py: bridge rhythm.py/melodic.py note dicts
  to core.schema MidiNote objects
- Rewrite scripts/compose.py: real generator pipeline with --render flag
- Delete src/flp_builder/, src/scanner/, mcp/, flstudio-mcp/, old scripts
- Add 40 passing tests (schema, builder, converters, compose, render)
This commit is contained in:
renato97
2026-05-03 09:13:35 -03:00
parent 1e2316a5a4
commit af6d61c8a1
47 changed files with 1589 additions and 4990 deletions

View File

@@ -0,0 +1,170 @@
"""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 <TRACK blocks."""
output = tmp_path / "track.rpp"
with patch("scripts.compose.SampleSelector") as mock_selector_cls:
mock_selector = MagicMock()
mock_selector.select_one.return_value = 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
content = output.read_text(encoding="utf-8")
track_count = content.count("<TRACK")
assert track_count >= 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

95
tests/test_converters.py Normal file
View File

@@ -0,0 +1,95 @@
"""Tests for src/composer/converters.py — rhythm_to_midi, melodic_to_midi."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parents[1]))
import pytest
from src.composer.converters import rhythm_to_midi, melodic_to_midi
from src.core.schema import MidiNote
class TestRhythmToMidi:
"""Tests for rhythm_to_midi() — channel → GM pitch mapping."""
def test_rhythm_to_midi_kick_channel(self):
"""Channel 11 (kick) maps to pitch 36 with correct start/duration/velocity."""
note_dict = {
11: [
{"pos": 0.0, "len": 0.25, "key": 36, "vel": 115},
{"pos": 1.0, "len": 0.25, "key": 36, "vel": 100},
]
}
result = rhythm_to_midi(note_dict)
assert len(result) == 2
# Pitch is resolved from CHANNEL_PITCH, not from the dict's "key"
assert result[0].pitch == 36
assert result[0].start == 0.0
assert result[0].duration == 0.25
assert result[0].velocity == 115
assert result[1].pitch == 36
assert result[1].start == 1.0
assert result[1].duration == 0.25
assert result[1].velocity == 100
def test_rhythm_to_midi_hihat_channel(self):
"""Channel 15 (hihat) maps to pitch 42."""
note_dict = {15: [{"pos": 0.0, "len": 0.125, "key": 42, "vel": 90}]}
result = rhythm_to_midi(note_dict)
assert len(result) == 1
assert result[0].pitch == 42
assert result[0].start == 0.0
assert result[0].duration == 0.125
assert result[0].velocity == 90
def test_rhythm_to_midi_unknown_channel(self):
"""Unknown channel (not in CHANNEL_PITCH) defaults to pitch 60."""
note_dict = {99: [{"pos": 0.0, "len": 0.25, "key": 60, "vel": 100}]}
result = rhythm_to_midi(note_dict)
assert len(result) == 1
assert result[0].pitch == 60 # default fallback
assert result[0].start == 0.0
def test_rhythm_to_midi_multi_channel(self):
"""3 different channels return a flat list with all notes combined."""
note_dict = {
11: [{"pos": 0.0, "len": 0.25, "key": 36, "vel": 115}],
15: [{"pos": 0.5, "len": 0.125, "key": 42, "vel": 90}],
10: [{"pos": 1.0, "len": 0.25, "key": 39, "vel": 80}],
}
result = rhythm_to_midi(note_dict)
assert len(result) == 3
pitches = {n.pitch for n in result}
assert pitches == {36, 42, 39}
class TestMelodicToMidi:
"""Tests for melodic_to_midi() — key field used directly as pitch."""
def test_melodic_to_midi_uses_key_as_pitch(self):
"""key=60 → pitch 60 (key field is used directly, not mapped)."""
note_list = [
{"pos": 0.0, "len": 0.5, "key": 60, "vel": 100},
{"pos": 0.5, "len": 0.5, "key": 64, "vel": 90},
{"pos": 1.0, "len": 0.5, "key": 67, "vel": 95},
]
result = melodic_to_midi(note_list)
assert len(result) == 3
assert result[0].pitch == 60
assert result[1].pitch == 64
assert result[2].pitch == 67
assert result[0].start == 0.0
assert result[0].duration == 0.5
assert result[0].velocity == 100
def test_melodic_to_midi_empty_list(self):
"""Empty list returns empty list."""
result = melodic_to_midi([])
assert result == []

137
tests/test_core_schema.py Normal file
View File

@@ -0,0 +1,137 @@
"""Tests for src/core/schema.py — SongDefinition, TrackDef, ClipDef, MidiNote."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parents[1]))
import pytest
from src.core.schema import SongDefinition, SongMeta, TrackDef, ClipDef, MidiNote
class TestSongDefinitionInstantiation:
"""Test SongDefinition instantiation with valid data."""
def test_song_definition_with_valid_data(self):
"""SongDefinition instantiates with meta and tracks."""
meta = SongMeta(bpm=95, key="Am", title="Test Song")
song = SongDefinition(meta=meta, tracks=[])
assert song.meta.bpm == 95
assert song.meta.key == "Am"
assert song.meta.title == "Test Song"
assert song.tracks == []
def test_song_definition_with_multiple_tracks(self):
"""SongDefinition accepts multiple TrackDef entries."""
meta = SongMeta(bpm=95, key="Am")
track1 = TrackDef(name="Kick", volume=0.85)
track2 = TrackDef(name="Bass", volume=0.80)
song = SongDefinition(meta=meta, tracks=[track1, track2])
assert len(song.tracks) == 2
assert song.tracks[0].name == "Kick"
assert song.tracks[1].name == "Bass"
class TestTrackDefWithAudioClip:
"""Test TrackDef with audio clip (sample_path)."""
def test_track_with_audio_clip(self):
"""TrackDef with audio clip has audio_path set."""
clip = ClipDef(
position=0.0,
length=16.0,
name="Kick Loop",
audio_path="C:/samples/kick.wav",
)
track = TrackDef(name="Drums", clips=[clip])
assert len(track.clips) == 1
assert track.clips[0].is_audio
assert track.clips[0].audio_path == "C:/samples/kick.wav"
assert not track.clips[0].is_midi
def test_track_with_multiple_audio_clips(self):
"""TrackDef can hold multiple audio clips."""
clip1 = ClipDef(position=0.0, length=16.0, audio_path="C:/samples/kick.wav")
clip2 = ClipDef(position=16.0, length=16.0, audio_path="C:/samples/kick2.wav")
track = TrackDef(name="Drums", clips=[clip1, clip2])
assert len(track.clips) == 2
assert track.clips[0].audio_path == "C:/samples/kick.wav"
assert track.clips[1].audio_path == "C:/samples/kick2.wav"
class TestClipDefWithMidiNotes:
"""Test ClipDef with MIDI notes."""
def test_clip_with_midi_notes(self):
"""ClipDef with midi_notes is identified as MIDI clip."""
note = MidiNote(pitch=36, start=0.0, duration=1.0, velocity=100)
clip = ClipDef(
position=0.0,
length=16.0,
name="Kick Pattern",
midi_notes=[note],
)
assert clip.is_midi
assert not clip.is_audio
assert len(clip.midi_notes) == 1
assert clip.midi_notes[0].pitch == 36
def test_clip_with_multiple_midi_notes(self):
"""ClipDef can hold multiple MidiNote entries."""
notes = [
MidiNote(pitch=36, start=0.0, duration=0.25, velocity=115),
MidiNote(pitch=36, start=1.5, duration=0.25, velocity=105),
MidiNote(pitch=38, start=2.0, duration=0.15, velocity=100),
]
clip = ClipDef(position=0.0, length=16.0, name="Drum Pattern", midi_notes=notes)
assert len(clip.midi_notes) == 3
assert clip.midi_notes[0].pitch == 36
assert clip.midi_notes[1].pitch == 36
assert clip.midi_notes[2].pitch == 38
class TestValidationNegativeBPM:
"""Test validation: negative BPM raises ValueError."""
def test_negative_bpm_raises_value_error(self):
"""SongDefinition.validate() returns error for negative BPM."""
meta = SongMeta(bpm=-10, key="Am")
song = SongDefinition(meta=meta, tracks=[])
errors = song.validate()
assert any("bpm" in e.lower() for e in errors)
def test_zero_bpm_raises_value_error(self):
"""SongDefinition.validate() returns error for zero BPM."""
meta = SongMeta(bpm=0, key="Am")
song = SongDefinition(meta=meta, tracks=[])
errors = song.validate()
assert any("bpm" in e.lower() for e in errors)
def test_valid_bpm_passes(self):
"""SongDefinition.validate() passes for BPM 20-999."""
meta = SongMeta(bpm=95, key="Am")
song = SongDefinition(meta=meta, tracks=[])
errors = song.validate()
assert not any("bpm" in e.lower() for e in errors)
class TestMidiNote:
"""Test MidiNote dataclass."""
def test_midi_note_defaults(self):
"""MidiNote has sensible defaults for velocity."""
note = MidiNote(pitch=60, start=0.0, duration=1.0)
assert note.pitch == 60
assert note.start == 0.0
assert note.duration == 1.0
assert note.velocity == 64 # default
def test_midi_note_explicit_velocity(self):
"""MidiNote accepts explicit velocity."""
note = MidiNote(pitch=60, start=0.0, duration=1.0, velocity=127)
assert note.velocity == 127
def test_midi_note_velocity_clamping(self):
"""MidiNote does NOT clamp — accepts any int (caller's responsibility)."""
note = MidiNote(pitch=60, start=0.0, duration=1.0, velocity=200)
assert note.velocity == 200

View File

@@ -0,0 +1,176 @@
"""Tests for src/reaper_builder/ — RPPBuilder, rpp_writer."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parents[1]))
import pytest
import tempfile
from src.core.schema import SongDefinition, SongMeta, TrackDef, ClipDef, MidiNote
from src.reaper_builder import RPPBuilder
class TestRPPBuilderWrite:
"""Test RPPBuilder.write() produces valid .rpp output."""
def test_write_produces_reaper_project_marker(self):
"""RPPBuilder.write() produces a file containing 'REAPER_PROJECT'."""
meta = SongMeta(bpm=95, key="Am", title="Test")
song = SongDefinition(meta=meta, tracks=[])
builder = RPPBuilder(song)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
) as f:
tmp_path = f.name
try:
builder.write(tmp_path)
content = Path(tmp_path).read_text(encoding="utf-8")
assert "REAPER_PROJECT" in content
finally:
Path(tmp_path).unlink(missing_ok=True)
def test_write_produces_tempo_line(self):
"""Output contains TEMPO line with correct BPM."""
meta = SongMeta(bpm=95, key="Am")
song = SongDefinition(meta=meta, tracks=[])
builder = RPPBuilder(song)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
) as f:
tmp_path = f.name
try:
builder.write(tmp_path)
content = Path(tmp_path).read_text(encoding="utf-8")
assert "TEMPO 95 " in content
finally:
Path(tmp_path).unlink(missing_ok=True)
class TestRPPBuilderAudioTrack:
"""Test audio track generates SOURCE WAVE block with correct file path."""
def test_audio_track_generates_source_wave_block(self):
"""Audio clip produces <SOURCE WAVE> block with FILE path."""
meta = SongMeta(bpm=95, key="Am")
clip = ClipDef(
position=0.0,
length=16.0,
name="Kick Loop",
audio_path="C:/samples/kick.wav",
)
track = TrackDef(name="Drums", clips=[clip])
song = SongDefinition(meta=meta, tracks=[track])
builder = RPPBuilder(song)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
) as f:
tmp_path = f.name
try:
builder.write(tmp_path)
content = Path(tmp_path).read_text(encoding="utf-8")
assert "<SOURCE WAVE\n" in content
assert 'FILE C:/samples/kick.wav' in content
finally:
Path(tmp_path).unlink(missing_ok=True)
def test_audio_track_includes_track_name(self):
"""Audio track block contains the track NAME."""
meta = SongMeta(bpm=95, key="Am")
clip = ClipDef(position=0.0, length=16.0, audio_path="C:/kick.wav")
track = TrackDef(name="Kick", clips=[clip])
song = SongDefinition(meta=meta, tracks=[track])
builder = RPPBuilder(song)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
) as f:
tmp_path = f.name
try:
builder.write(tmp_path)
content = Path(tmp_path).read_text(encoding="utf-8")
assert "NAME Kick" in content
finally:
Path(tmp_path).unlink(missing_ok=True)
class TestRPPBuilderMidiTrack:
"""Test MIDI track generates MIDI event lines (E lines)."""
def test_midi_track_generates_e_lines(self):
"""MIDI clip produces E event lines in the output."""
meta = SongMeta(bpm=95, key="Am")
note = MidiNote(pitch=36, start=0.0, duration=0.25, velocity=115)
clip = ClipDef(position=0.0, length=16.0, name="Kick Pattern", midi_notes=[note])
track = TrackDef(name="Drums", clips=[clip])
song = SongDefinition(meta=meta, tracks=[track])
builder = RPPBuilder(song)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
) as f:
tmp_path = f.name
try:
builder.write(tmp_path)
content = Path(tmp_path).read_text(encoding="utf-8")
assert "<SOURCE MIDI\n" in content
assert "E " in content
finally:
Path(tmp_path).unlink(missing_ok=True)
def test_midi_track_note_on_off_pairs(self):
"""MIDI note produces note-on (90) and note-off (80) E lines."""
meta = SongMeta(bpm=95, key="Am")
notes = [
MidiNote(pitch=36, start=0.0, duration=0.25, velocity=115),
MidiNote(pitch=36, start=1.5, duration=0.25, velocity=105),
]
clip = ClipDef(position=0.0, length=16.0, name="Kick Pattern", midi_notes=notes)
track = TrackDef(name="Drums", clips=[clip])
song = SongDefinition(meta=meta, tracks=[track])
builder = RPPBuilder(song)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
) as f:
tmp_path = f.name
try:
builder.write(tmp_path)
content = Path(tmp_path).read_text(encoding="utf-8")
# Note on: status 90, pitch, velocity
assert "90" in content
# Note off: status 80, pitch, 00
assert "80" in content
finally:
Path(tmp_path).unlink(missing_ok=True)
class TestRPPBuilderMasterTrack:
"""Test that RPPBuilder includes a master track."""
def test_output_contains_master_track(self):
"""Generated .rpp contains a master track."""
meta = SongMeta(bpm=95, key="Am")
song = SongDefinition(meta=meta, tracks=[])
builder = RPPBuilder(song)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
) as f:
tmp_path = f.name
try:
builder.write(tmp_path)
content = Path(tmp_path).read_text(encoding="utf-8")
assert "NAME master" in content
finally:
Path(tmp_path).unlink(missing_ok=True)

105
tests/test_render.py Normal file
View File

@@ -0,0 +1,105 @@
"""Tests for src/reaper_builder/render.py — render_project."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parents[1]))
import pytest
from unittest.mock import patch, MagicMock
from src.reaper_builder.render import render_project
class TestRenderProjectFileNotFound:
"""Test render_project raises FileNotFoundError when reaper.exe path doesn't exist."""
def test_render_project_raises_file_not_found_for_nonexistent_reaper_exe(self):
"""FileNotFoundError is raised when reaper.exe does not exist."""
nonexistent = Path("C:/Program Files/NONEXISTENT_REAPER/reaper.exe")
if nonexistent.exists():
pytest.skip("Nonexistent path actually exists — cannot test this")
with pytest.raises(FileNotFoundError):
render_project(
rpp_path="input.rpp",
output_wav="output.wav",
reaper_exe=nonexistent,
)
def test_render_project_raises_file_not_found_default_path(self):
"""FileNotFoundError raised when default reaper.exe doesn't exist and none provided."""
# Patch DEFAULT_REAPER_EXE to a definitely-nonexistent path
with patch("src.reaper_builder.render.DEFAULT_REAPER_EXE", Path("/no/such/reaper.exe")):
with pytest.raises(FileNotFoundError):
render_project(
rpp_path="input.rpp",
output_wav="output.wav",
reaper_exe=None,
)
class TestRenderProjectSubprocessError:
"""Test render_project raises RuntimeError when subprocess returns non-zero exit code."""
def test_render_project_raises_runtime_error_on_nonzero_exit(self):
"""RuntimeError is raised when subprocess.run returns non-zero returncode."""
from src.reaper_builder.render import DEFAULT_REAPER_EXE
# Check if reaper exists — if not, skip
if not DEFAULT_REAPER_EXE.exists():
pytest.skip(f"REAPER not installed at {DEFAULT_REAPER_EXE}")
# Create a minimal valid .rpp for this test
import tempfile
with tempfile.NamedTemporaryFile(mode="w", suffix=".rpp", delete=False, encoding="utf-8") as f:
rpp_path = f.name
f.write('<REAPER_PROJECT 0.1 "6.0" 0\n>\n')
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
wav_path = f.name
try:
# Mock subprocess.run to return non-zero
mock_result = MagicMock()
mock_result.returncode = 1
mock_result.stdout = ""
mock_result.stderr = "Test error"
with patch("subprocess.run", return_value=mock_result):
with pytest.raises(RuntimeError) as exc_info:
render_project(rpp_path=rpp_path, output_wav=wav_path)
assert "1" in str(exc_info.value) or "failed" in str(exc_info.value).lower()
finally:
Path(rpp_path).unlink(missing_ok=True)
Path(wav_path).unlink(missing_ok=True)
def test_render_project_raises_runtime_error_with_error_message(self):
"""RuntimeError output includes stdout/stderr from REAPER failure."""
from src.reaper_builder.render import DEFAULT_REAPER_EXE
if not DEFAULT_REAPER_EXE.exists():
pytest.skip(f"REAPER not installed at {DEFAULT_REAPER_EXE}")
import tempfile
with tempfile.NamedTemporaryFile(mode="w", suffix=".rpp", delete=False, encoding="utf-8") as f:
rpp_path = f.name
f.write('<REAPER_PROJECT 0.1 "6.0" 0\n>\n')
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
wav_path = f.name
try:
mock_result = MagicMock()
mock_result.returncode = 2
mock_result.stdout = "standard output text"
mock_result.stderr = "error output text"
with patch("subprocess.run", return_value=mock_result):
with pytest.raises(RuntimeError) as exc_info:
render_project(rpp_path=rpp_path, output_wav=wav_path)
# Error message should include the exit code or stderr
err_str = str(exc_info.value)
assert "2" in err_str or "error output text" in err_str
finally:
Path(rpp_path).unlink(missing_ok=True)
Path(wav_path).unlink(missing_ok=True)

127
tests/test_render_cli.py Normal file
View File

@@ -0,0 +1,127 @@
"""Tests for scripts/compose.py render CLI flags."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parents[1]))
import pytest
import argparse
from unittest.mock import patch, MagicMock
from scripts.compose import main as compose_main
class TestRenderFlag:
"""Test --render and --render-output CLI arguments."""
def test_render_flag_defaults_to_false(self):
"""Without --render, the render flag should be False."""
parser = argparse.ArgumentParser()
parser.add_argument("--render", action="store_true")
parser.add_argument("--render-output", default=None)
args = parser.parse_args([])
assert args.render is False
def test_render_flag_true_when_provided(self):
"""With --render, the flag should be True."""
parser = argparse.ArgumentParser()
parser.add_argument("--render", action="store_true")
parser.add_argument("--render-output", default=None)
args = parser.parse_args(["--render"])
assert args.render is True
def test_render_output_defaults_to_none(self):
"""--render-output defaults to None when not provided."""
parser = argparse.ArgumentParser()
parser.add_argument("--render", action="store_true")
parser.add_argument("--render-output", default=None)
args = parser.parse_args([])
assert args.render_output is None
@patch("scripts.compose.render_project")
@patch("scripts.compose.RPPBuilder")
def test_render_triggers_render_project_call(self, mock_builder_cls, mock_render):
"""Calling main with --render invokes render_project."""
mock_builder = MagicMock()
mock_builder_cls.return_value = mock_builder
with patch("scripts.compose.SampleSelector") as mock_selector:
mock_sel_instance = MagicMock()
mock_sel_instance.select_one.return_value = None
mock_selector.return_value = mock_sel_instance
with patch("sys.argv", ["compose.py", "--genre", "reggaeton", "--render"]):
compose_main()
mock_render.assert_called_once()
call_args = mock_render.call_args
# First arg should be the .rpp path, second should be .wav path
rpp_path = call_args[0][0]
wav_path = call_args[0][1]
assert rpp_path.endswith(".rpp")
assert wav_path.endswith(".wav")
@patch("scripts.compose.render_project")
@patch("scripts.compose.RPPBuilder")
def test_without_render_does_not_call_render_project(self, mock_builder_cls, mock_render):
"""Calling main without --render does NOT invoke render_project."""
mock_builder = MagicMock()
mock_builder_cls.return_value = mock_builder
with patch("scripts.compose.SampleSelector") as mock_selector:
mock_sel_instance = MagicMock()
mock_sel_instance.select_one.return_value = None
mock_selector.return_value = mock_sel_instance
with patch("sys.argv", ["compose.py", "--genre", "reggaeton"]):
compose_main()
mock_render.assert_not_called()
@patch("scripts.compose.render_project")
@patch("scripts.compose.RPPBuilder")
def test_render_output_overrides_default_wav_path(self, mock_builder_cls, mock_render):
"""--render-output sets the WAV path explicitly."""
mock_builder = MagicMock()
mock_builder_cls.return_value = mock_builder
with patch("scripts.compose.SampleSelector") as mock_selector:
mock_sel_instance = MagicMock()
mock_sel_instance.select_one.return_value = None
mock_selector.return_value = mock_sel_instance
with patch("sys.argv", [
"compose.py",
"--genre", "reggaeton",
"--render",
"--render-output", "output/my_render.wav"
]):
compose_main()
mock_render.assert_called_once()
wav_path = mock_render.call_args[0][1]
assert wav_path == "output/my_render.wav"
@patch("scripts.compose.render_project")
@patch("scripts.compose.RPPBuilder")
def test_render_project_propagates_file_not_found_error(self, mock_builder_cls, mock_render):
"""FileNotFoundError from render_project is propagated to caller."""
mock_builder = MagicMock()
mock_builder_cls.return_value = mock_builder
mock_render.side_effect = FileNotFoundError("reaper.exe not found")
with patch("scripts.compose.SampleSelector") as mock_selector:
mock_sel_instance = MagicMock()
mock_sel_instance.select_one.return_value = None
mock_selector.return_value = mock_sel_instance
with patch("sys.argv", [
"compose.py",
"--genre", "reggaeton",
"--render",
]):
with pytest.raises(FileNotFoundError):
compose_main()