Files
reaper-control/tests/test_compose_integration.py
renato97 48bc271afc feat: SDD workflow — test sync, song generation + validation, ReaScript hybrid pipeline
- compose-test-sync: fix 3 failing tests (NOTE_TO_MIDI, DrumLoopAnalyzer mock, section name)
- generate-song: CLI wrapper + RPP validator (6 structural checks) + 4 e2e tests
- reascript-hybrid: ReaScriptGenerator + command protocol + CLI + 16 unit tests
- 110/110 tests passing
- Full SDD cycle (propose→spec→design→tasks→apply→verify) for all 3 changes
2026-05-03 22:00:26 -03:00

246 lines
9.2 KiB
Python

"""Integration tests for scripts/compose.py — drumloop-first 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
from src.composer.drum_analyzer import DrumLoopAnalysis, Transient, BeatGrid
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _fake_analysis():
return DrumLoopAnalysis(
file_path="fake_drumloop.wav",
bpm=95.0,
duration=8.0,
beats=[0.0, 0.6316, 1.2632, 1.8947, 2.5263, 3.1579, 3.7895, 4.4211],
transients=[
Transient(time=0.0, type="kick", energy=0.8, spectral_centroid=100),
Transient(time=0.6316, type="hihat", energy=0.4, spectral_centroid=8000),
Transient(time=1.2632, type="snare", energy=0.7, spectral_centroid=3000),
Transient(time=1.8947, type="hihat", energy=0.3, spectral_centroid=7000),
Transient(time=2.5263, type="kick", energy=0.8, spectral_centroid=100),
Transient(time=3.1579, type="snare", energy=0.6, spectral_centroid=3500),
Transient(time=3.7895, type="hihat", energy=0.4, spectral_centroid=9000),
],
beat_grid=BeatGrid(
quarter=[0.0, 0.6316, 1.2632, 1.8947, 2.5263, 3.1579, 3.7895, 4.4211],
eighth=[i * 0.3158 for i in range(16)],
sixteenth=[i * 0.1579 for i in range(32)],
),
key="Am",
key_confidence=0.85,
energy_profile=[0.8, 0.4, 0.7, 0.3, 0.8, 0.6, 0.4, 0.3],
bar_count=2,
sample_rate=44100,
)
def _mock_main(tmp_path, extra_args=None):
output = tmp_path / "track.rpp"
fake_analysis = _fake_analysis()
with patch("scripts.compose.SampleSelector") as mock_cls:
mock_sel = MagicMock()
mock_sel._samples = [
{
"role": "drumloop",
"perceptual": {"tempo": 95.0},
"musical": {"key": "Am", "mode": "minor"},
"character": "dark",
"original_path": "fake_drumloop.wav",
"original_name": "fake_drumloop.wav",
"file_hash": "abc123",
},
{
"role": "snare",
"perceptual": {"tempo": 0},
"musical": {"key": "X"},
"character": "sharp",
"original_path": "fake_clap.wav",
"original_name": "fake_clap.wav",
"file_hash": "clap123",
},
{
"role": "vocal",
"perceptual": {"tempo": 95.0},
"musical": {"key": "Am", "mode": "minor"},
"character": "melodic",
"original_path": "fake_vocal.wav",
"original_name": "fake_vocal.wav",
"file_hash": "vox123",
},
]
mock_sel.select.return_value = [
MagicMock(sample={
"original_path": "fake_clap.wav",
"file_hash": "clap123",
}),
]
mock_sel.select_diverse.return_value = [
{
"original_path": "fake_vocal.wav",
"file_hash": "vox123",
},
]
mock_cls.return_value = mock_sel
from scripts.compose import main
original_argv = sys.argv
try:
argv = ["compose", "--output", str(output)]
if extra_args:
argv.extend(extra_args)
sys.argv = argv
main()
finally:
sys.argv = original_argv
return output
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestComposeRppOutput:
def test_compose_produces_rpp_file(self, tmp_path):
output = _mock_main(tmp_path)
assert output.exists(), f"Expected {output} to exist"
def test_compose_rpp_has_min_6_tracks(self, tmp_path):
output = _mock_main(tmp_path)
content = output.read_text(encoding="utf-8")
track_count = content.count("<TRACK")
assert track_count >= 6, f"Expected >= 6 tracks, got {track_count}"
def test_compose_has_fxchain(self, tmp_path):
output = _mock_main(tmp_path)
content = output.read_text(encoding="utf-8")
assert "FXCHAIN" in content, "Expected FXCHAIN in output"
def test_compose_has_midi_source(self, tmp_path):
output = _mock_main(tmp_path)
content = output.read_text(encoding="utf-8")
assert "SOURCE MIDI" in content, "Expected MIDI source in output"
def test_compose_has_audio_source(self, tmp_path):
output = _mock_main(tmp_path)
content = output.read_text(encoding="utf-8")
assert "SOURCE WAVE" in content, "Expected WAVE source in output"
def test_compose_invalid_bpm_raises(self, tmp_path):
with pytest.raises(ValueError, match="bpm must be > 0"):
_mock_main(tmp_path, ["--bpm", "0"])
def test_compose_negative_bpm_raises(self, tmp_path):
with pytest.raises(ValueError, match="bpm must be > 0"):
_mock_main(tmp_path, ["--bpm", "-10"])
class TestDrumloopFirstTracks:
def test_all_tracks_created(self, tmp_path):
output = _mock_main(tmp_path)
content = output.read_text(encoding="utf-8")
for name in ("Drumloop", "Bass", "Chords", "Lead", "Clap", "Pad", "Reverb", "Delay"):
assert name in content, f"Expected track '{name}' in output"
def test_clap_on_dembow_beats(self, tmp_path):
from scripts.compose import build_clap_track, SECTIONS
from src.core.schema import SectionDef
sections = [SectionDef(name="chorus", bars=4, energy=1.0)]
offsets = [0.0]
mock_selector = MagicMock()
mock_selector.select.return_value = [
MagicMock(sample={"original_path": "clap.wav"}),
]
track = build_clap_track(mock_selector, sections, offsets)
positions = [c.position for c in track.clips]
assert 2.0 in positions, "Clap on beat 2 (backbeat)"
assert 3.5 in positions, "Clap on beat 3.5 (dembow)"
def test_bass_uses_kick_free_zones(self):
from scripts.compose import build_bass_track
from src.core.schema import SectionDef
analysis = _fake_analysis()
sections = [SectionDef(name="verse", bars=4, energy=1.0)]
offsets = [0.0]
track = build_bass_track(sections, offsets, "A", True)
assert len(track.clips) > 0, "Bass should have clips"
assert all(n.duration == 1.5 for n in track.clips[0].midi_notes), "Bass notes should be 1.5 beats (808 pattern)"
def test_chords_change_on_downbeats(self):
from scripts.compose import build_chords_track
from src.core.schema import SectionDef
analysis = _fake_analysis()
sections = [SectionDef(name="verse", bars=8, energy=1.0)]
offsets = [0.0]
track = build_chords_track(sections, offsets, "A", True)
starts = sorted(set(n.start for n in track.clips[0].midi_notes))
for s in starts:
assert s % 4.0 == 0.0, f"Chord change at beat {s} — should be on downbeat"
def test_melody_uses_pentatonic(self):
from scripts.compose import build_melody_track
from src.core.schema import SectionDef
sections = [SectionDef(name="chorus", bars=4, energy=1.0)]
offsets = [0.0]
track = build_melody_track(sections, offsets, "A", True, seed=42)
assert len(track.clips) > 0, "Melody should have clips"
pitches = {n.pitch for n in track.clips[0].midi_notes}
assert len(pitches) > 1, "Melody should use multiple notes"
def test_master_chain_present(self, tmp_path):
output = _mock_main(tmp_path)
content = output.read_text(encoding="utf-8")
assert "Pro-Q" in content, "Expected Pro-Q 3 in master chain"
assert "Pro-C" in content, "Expected Pro-C 2 in master chain"
assert "Pro-L" in content, "Expected Pro-L 2 in master chain"
def test_sends_wired(self, tmp_path):
output = _mock_main(tmp_path)
content = output.read_text(encoding="utf-8")
assert "AUXRECV" in content, "Expected send routing in output"
class TestBackwardCompat:
def test_imports_exist(self):
from scripts.compose import (
build_section_tracks, create_return_tracks, EFFECT_ALIASES,
build_fx_chain, build_sampler_plugin,
)
assert callable(build_section_tracks)
assert callable(create_return_tracks)
assert callable(build_fx_chain)
assert callable(build_sampler_plugin)
assert isinstance(EFFECT_ALIASES, dict)
def test_create_return_tracks(self):
from scripts.compose import create_return_tracks
tracks = create_return_tracks()
assert len(tracks) == 2
assert tracks[0].name == "Reverb"
assert tracks[1].name == "Delay"
assert len(tracks[0].plugins) > 0
assert len(tracks[1].plugins) > 0