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
This commit is contained in:
@@ -48,8 +48,7 @@ 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, \
|
||||
patch("scripts.compose.DrumLoopAnalyzer") as mock_analyzer_cls:
|
||||
with patch("scripts.compose.SampleSelector") as mock_cls:
|
||||
mock_sel = MagicMock()
|
||||
mock_sel._samples = [
|
||||
{
|
||||
@@ -94,10 +93,6 @@ def _mock_main(tmp_path, extra_args=None):
|
||||
]
|
||||
mock_cls.return_value = mock_sel
|
||||
|
||||
mock_analyzer = MagicMock()
|
||||
mock_analyzer.analyze.return_value = fake_analysis
|
||||
mock_analyzer_cls.return_value = mock_analyzer
|
||||
|
||||
from scripts.compose import main
|
||||
original_argv = sys.argv
|
||||
try:
|
||||
@@ -174,7 +169,7 @@ class TestDrumloopFirstTracks:
|
||||
|
||||
track = build_clap_track(mock_selector, sections, offsets)
|
||||
positions = [c.position for c in track.clips]
|
||||
assert 1.0 in positions, "Clap on beat 2 (pos 1.0)"
|
||||
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):
|
||||
@@ -185,9 +180,9 @@ class TestDrumloopFirstTracks:
|
||||
sections = [SectionDef(name="verse", bars=4, energy=1.0)]
|
||||
offsets = [0.0]
|
||||
|
||||
track = build_bass_track(analysis, sections, offsets, "A", True)
|
||||
track = build_bass_track(sections, offsets, "A", True)
|
||||
assert len(track.clips) > 0, "Bass should have clips"
|
||||
assert all(n.duration == 0.5 for n in track.clips[0].midi_notes), "Bass notes should be 0.5 beats"
|
||||
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
|
||||
@@ -197,7 +192,7 @@ class TestDrumloopFirstTracks:
|
||||
sections = [SectionDef(name="verse", bars=8, energy=1.0)]
|
||||
offsets = [0.0]
|
||||
|
||||
track = build_chords_track(analysis, sections, offsets, "A", True)
|
||||
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"
|
||||
@@ -206,11 +201,10 @@ class TestDrumloopFirstTracks:
|
||||
from scripts.compose import build_melody_track
|
||||
from src.core.schema import SectionDef
|
||||
|
||||
analysis = _fake_analysis()
|
||||
sections = [SectionDef(name="verse", bars=4, energy=1.0)]
|
||||
sections = [SectionDef(name="chorus", bars=4, energy=1.0)]
|
||||
offsets = [0.0]
|
||||
|
||||
track = build_melody_track(analysis, sections, offsets, "A", True, seed=42)
|
||||
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"
|
||||
|
||||
97
tests/test_generate_song.py
Normal file
97
tests/test_generate_song.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""Tests for scripts/generate.py — E2E song generation and RPP validator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parents[1]))
|
||||
|
||||
|
||||
class TestGenerateCLI:
|
||||
"""Smoke and integration tests for the generate.py CLI."""
|
||||
|
||||
def test_generate_cli_smoke(self, tmp_path):
|
||||
"""CLI produces a non-empty .rpp file at the expected path."""
|
||||
output = tmp_path / "song.rpp"
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"scripts/generate.py",
|
||||
"--bpm", "95",
|
||||
"--key", "Am",
|
||||
"--output", str(output),
|
||||
"--seed", "42",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
assert result.returncode == 0, f"stderr: {result.stderr}"
|
||||
assert output.exists(), f"File not created: {output}"
|
||||
assert output.stat().st_size > 0, "File is empty"
|
||||
|
||||
def test_validate_passes_for_valid_output(self, tmp_path):
|
||||
"""With --validate, CLI returns 0 when validator sees no errors."""
|
||||
output = tmp_path / "song.rpp"
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"scripts/generate.py",
|
||||
"--bpm", "95",
|
||||
"--key", "Am",
|
||||
"--output", str(output),
|
||||
"--seed", "42",
|
||||
"--validate",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
assert result.returncode == 0, f"stderr: {result.stderr}\nstdout: {result.stdout}"
|
||||
|
||||
def test_validate_detects_track_count_violation(self, tmp_path):
|
||||
"""Validator flags a project with fewer than 9 tracks."""
|
||||
from src.validator.rpp_validator import validate_rpp_output
|
||||
|
||||
rpp_path = tmp_path / "bad.rpp"
|
||||
# Write a minimal .rpp with only 5 <TRACK> blocks
|
||||
content = (
|
||||
"<REAPER_PROJECT 0.1 \"7.65/win64\" 0 0\n"
|
||||
+ " <TRACK\n NAME \"t1\"\n>\n"
|
||||
+ " <TRACK\n NAME \"t2\"\n>\n"
|
||||
+ " <TRACK\n NAME \"t3\"\n>\n"
|
||||
+ " <TRACK\n NAME \"t4\"\n>\n"
|
||||
+ " <TRACK\n NAME \"t5\"\n>\n"
|
||||
+ ">"
|
||||
)
|
||||
rpp_path.write_text(content, encoding="utf-8")
|
||||
errors = validate_rpp_output(str(rpp_path))
|
||||
assert any("Expected 9" in e for e in errors), f"Got: {errors}"
|
||||
|
||||
def test_reproducibility_same_seed(self, tmp_path):
|
||||
"""Two runs with the same seed produce byte-identical output."""
|
||||
output_a = tmp_path / "song_a.rpp"
|
||||
output_b = tmp_path / "song_b.rpp"
|
||||
for out_path in (output_a, output_b):
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"scripts/generate.py",
|
||||
"--bpm", "95",
|
||||
"--key", "Am",
|
||||
"--output", str(out_path),
|
||||
"--seed", "42",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
assert result.returncode == 0, f"stderr: {result.stderr}"
|
||||
|
||||
assert output_a.read_bytes() == output_b.read_bytes(), (
|
||||
"Outputs differ — seed does not guarantee reproducibility"
|
||||
)
|
||||
304
tests/test_reaper_scripting.py
Normal file
304
tests/test_reaper_scripting.py
Normal file
@@ -0,0 +1,304 @@
|
||||
"""Tests for src/reaper_scripting/ — command protocol and ReaScriptGenerator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import json
|
||||
import tempfile
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parents[1]))
|
||||
|
||||
import pytest
|
||||
|
||||
from src.reaper_scripting.commands import (
|
||||
ProtocolVersionError,
|
||||
ReaScriptCommand,
|
||||
ReaScriptResult,
|
||||
read_result,
|
||||
write_command,
|
||||
)
|
||||
from src.reaper_scripting import ReaScriptGenerator
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Phase 1: Command/Result Protocol
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
class TestCommandSerialization:
|
||||
"""Test JSON round-trip for ReaScriptCommand."""
|
||||
|
||||
def test_write_command_produces_json_file(self, tmp_path):
|
||||
cmd = ReaScriptCommand(
|
||||
version=1,
|
||||
action="calibrate",
|
||||
rpp_path="C:/song.rpp",
|
||||
render_path="C:/song.wav",
|
||||
timeout=120,
|
||||
track_calibration=[],
|
||||
)
|
||||
path = tmp_path / "cmd.json"
|
||||
write_command(path, cmd)
|
||||
assert path.exists()
|
||||
|
||||
def test_roundtrip_command_fields(self, tmp_path):
|
||||
cmd = ReaScriptCommand(
|
||||
version=1,
|
||||
action="verify_fx",
|
||||
rpp_path="C:/song.rpp",
|
||||
render_path="C:/song.wav",
|
||||
timeout=60,
|
||||
track_calibration=[
|
||||
{"track_index": 0, "volume": 0.85, "pan": 0.0, "sends": []},
|
||||
],
|
||||
)
|
||||
path = tmp_path / "cmd.json"
|
||||
write_command(path, cmd)
|
||||
text = path.read_text(encoding="utf-8")
|
||||
data = json.loads(text)
|
||||
assert data["version"] == 1
|
||||
assert data["action"] == "verify_fx"
|
||||
assert data["rpp_path"] == "C:/song.rpp"
|
||||
assert data["render_path"] == "C:/song.wav"
|
||||
assert data["timeout"] == 60
|
||||
assert len(data["track_calibration"]) == 1
|
||||
|
||||
def test_read_result_roundtrip(self, tmp_path):
|
||||
cmd = ReaScriptCommand(
|
||||
version=1,
|
||||
action="calibrate",
|
||||
rpp_path="C:/song.rpp",
|
||||
render_path="C:/song.wav",
|
||||
timeout=120,
|
||||
track_calibration=[],
|
||||
)
|
||||
path = tmp_path / "cmd.json"
|
||||
write_command(path, cmd)
|
||||
result = read_result(path)
|
||||
assert result.version == 1
|
||||
assert result.status == "ok"
|
||||
assert result.integrated_lufs is None
|
||||
assert result.fx_errors == []
|
||||
|
||||
|
||||
class TestVersionMismatch:
|
||||
"""Test ProtocolVersionError on version mismatch."""
|
||||
|
||||
def test_version_mismatch_raises(self, tmp_path):
|
||||
path = tmp_path / "bad_result.json"
|
||||
path.write_text(
|
||||
json.dumps({"version": 2, "status": "ok", "message": "", "fx_errors": []}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with pytest.raises(ProtocolVersionError) as exc_info:
|
||||
read_result(path)
|
||||
assert "expected 1, got 2" in str(exc_info.value)
|
||||
|
||||
|
||||
class TestMissingFile:
|
||||
"""Test FileNotFoundError on missing file."""
|
||||
|
||||
def test_missing_command_raises_file_not_found(self):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
read_result(Path("nonexistent.json"))
|
||||
|
||||
def test_missing_result_raises_file_not_found(self, tmp_path):
|
||||
nonexistent = tmp_path / "does_not_exist.json"
|
||||
with pytest.raises(FileNotFoundError):
|
||||
read_result(nonexistent)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Phase 2: ReaScriptGenerator
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
class TestReaScriptGeneratorOutput:
|
||||
"""Test that ReaScriptGenerator produces valid Python code."""
|
||||
|
||||
def test_generate_produces_file(self, tmp_path):
|
||||
cmd = ReaScriptCommand(
|
||||
version=1,
|
||||
action="calibrate",
|
||||
rpp_path="C:/song.rpp",
|
||||
render_path="C:/song.wav",
|
||||
timeout=120,
|
||||
track_calibration=[],
|
||||
)
|
||||
path = tmp_path / "phase2.py"
|
||||
gen = ReaScriptGenerator()
|
||||
gen.generate(path, cmd)
|
||||
assert path.exists()
|
||||
|
||||
def test_generate_output_is_valid_python(self, tmp_path):
|
||||
cmd = ReaScriptCommand(
|
||||
version=1,
|
||||
action="calibrate",
|
||||
rpp_path="C:/song.rpp",
|
||||
render_path="C:/song.wav",
|
||||
timeout=120,
|
||||
track_calibration=[],
|
||||
)
|
||||
path = tmp_path / "phase2.py"
|
||||
gen = ReaScriptGenerator()
|
||||
gen.generate(path, cmd)
|
||||
source = path.read_text(encoding="utf-8")
|
||||
# Must not raise
|
||||
tree = ast.parse(source)
|
||||
assert tree is not None
|
||||
|
||||
def test_contains_required_api_calls(self, tmp_path):
|
||||
cmd = ReaScriptCommand(
|
||||
version=1,
|
||||
action="calibrate",
|
||||
rpp_path="C:/song.rpp",
|
||||
render_path="C:/song.wav",
|
||||
timeout=120,
|
||||
track_calibration=[],
|
||||
)
|
||||
path = tmp_path / "phase2.py"
|
||||
gen = ReaScriptGenerator()
|
||||
gen.generate(path, cmd)
|
||||
source = path.read_text(encoding="utf-8")
|
||||
required = [
|
||||
"Main_openProject",
|
||||
"TrackFX_GetCount",
|
||||
"TrackFX_GetFXName",
|
||||
"SetMediaTrackInfo_Value",
|
||||
"CreateTrackSend",
|
||||
"Main_RenderFile",
|
||||
"CalcMediaSrcLoudness",
|
||||
"GetFunctionMetadata",
|
||||
]
|
||||
for api in required:
|
||||
assert api in source, f"{api} not found in generated script"
|
||||
|
||||
def test_script_reads_command_path(self, tmp_path):
|
||||
cmd = ReaScriptCommand(
|
||||
version=1,
|
||||
action="calibrate",
|
||||
rpp_path="C:/song.rpp",
|
||||
render_path="C:/song.wav",
|
||||
timeout=120,
|
||||
track_calibration=[],
|
||||
)
|
||||
path = tmp_path / "phase2.py"
|
||||
gen = ReaScriptGenerator()
|
||||
gen.generate(path, cmd)
|
||||
source = path.read_text(encoding="utf-8")
|
||||
# Must reference the command JSON path
|
||||
assert "fl_control_command.json" in source
|
||||
assert "fl_control_result.json" in source
|
||||
|
||||
def test_script_has_handrolled_json_parser(self, tmp_path):
|
||||
cmd = ReaScriptCommand(
|
||||
version=1,
|
||||
action="calibrate",
|
||||
rpp_path="C:/song.rpp",
|
||||
render_path="C:/song.wav",
|
||||
timeout=120,
|
||||
track_calibration=[],
|
||||
)
|
||||
path = tmp_path / "phase2.py"
|
||||
gen = ReaScriptGenerator()
|
||||
gen.generate(path, cmd)
|
||||
source = path.read_text(encoding="utf-8")
|
||||
# Must NOT use Python's json module
|
||||
assert "import json" not in source
|
||||
# Must have hand-rolled parser
|
||||
assert "parse_json" in source
|
||||
assert "write_json" in source
|
||||
|
||||
def test_script_includes_api_check_function(self, tmp_path):
|
||||
cmd = ReaScriptCommand(
|
||||
version=1,
|
||||
action="verify_fx",
|
||||
rpp_path="C:/song.rpp",
|
||||
render_path="C:/song.wav",
|
||||
timeout=120,
|
||||
track_calibration=[],
|
||||
)
|
||||
path = tmp_path / "phase2.py"
|
||||
gen = ReaScriptGenerator()
|
||||
gen.generate(path, cmd)
|
||||
source = path.read_text(encoding="utf-8")
|
||||
assert "check_api" in source
|
||||
assert "GetFunctionMetadata" in source
|
||||
|
||||
def test_verify_fx_action_iterates_tracks(self, tmp_path):
|
||||
cmd = ReaScriptCommand(
|
||||
version=1,
|
||||
action="verify_fx",
|
||||
rpp_path="C:/song.rpp",
|
||||
render_path="",
|
||||
timeout=60,
|
||||
track_calibration=[],
|
||||
)
|
||||
path = tmp_path / "phase2.py"
|
||||
gen = ReaScriptGenerator()
|
||||
gen.generate(path, cmd)
|
||||
source = path.read_text(encoding="utf-8")
|
||||
# Must iterate all tracks to verify FX
|
||||
assert "CountTracks" in source
|
||||
assert "GetTrack" in source
|
||||
assert "fx_errors" in source
|
||||
|
||||
def test_calibrate_action_sets_track_volume_pan(self, tmp_path):
|
||||
cmd = ReaScriptCommand(
|
||||
version=1,
|
||||
action="calibrate",
|
||||
rpp_path="C:/song.rpp",
|
||||
render_path="C:/song.wav",
|
||||
timeout=120,
|
||||
track_calibration=[
|
||||
{"track_index": 0, "volume": 0.85, "pan": 0.0, "sends": []},
|
||||
{
|
||||
"track_index": 1,
|
||||
"volume": 0.7,
|
||||
"pan": -0.3,
|
||||
"sends": [{"dest_track_index": 5, "level": 0.05}],
|
||||
},
|
||||
],
|
||||
)
|
||||
path = tmp_path / "phase2.py"
|
||||
gen = ReaScriptGenerator()
|
||||
gen.generate(path, cmd)
|
||||
source = path.read_text(encoding="utf-8")
|
||||
# Volume and pan setting
|
||||
assert "D_VOL" in source
|
||||
assert "D_PAN" in source
|
||||
# Send creation
|
||||
assert "CreateTrackSend" in source
|
||||
|
||||
def test_error_handling_writes_error_result(self, tmp_path):
|
||||
cmd = ReaScriptCommand(
|
||||
version=1,
|
||||
action="calibrate",
|
||||
rpp_path="C:/song.rpp",
|
||||
render_path="C:/song.wav",
|
||||
timeout=120,
|
||||
track_calibration=[],
|
||||
)
|
||||
path = tmp_path / "phase2.py"
|
||||
gen = ReaScriptGenerator()
|
||||
gen.generate(path, cmd)
|
||||
source = path.read_text(encoding="utf-8")
|
||||
# Must handle errors gracefully and write error status
|
||||
assert '"status": "error"' in source or "'status': 'error'" in source
|
||||
assert "write_json" in source
|
||||
|
||||
def test_script_has_main_function(self, tmp_path):
|
||||
cmd = ReaScriptCommand(
|
||||
version=1,
|
||||
action="calibrate",
|
||||
rpp_path="C:/song.rpp",
|
||||
render_path="C:/song.wav",
|
||||
timeout=120,
|
||||
track_calibration=[],
|
||||
)
|
||||
path = tmp_path / "phase2.py"
|
||||
gen = ReaScriptGenerator()
|
||||
gen.generate(path, cmd)
|
||||
source = path.read_text(encoding="utf-8")
|
||||
assert "def main():" in source
|
||||
assert "main()" in source
|
||||
@@ -43,19 +43,10 @@ class TestComposeNoRender:
|
||||
|
||||
def test_main_without_render_produces_rpp(self, tmp_path):
|
||||
from unittest.mock import patch, MagicMock
|
||||
from src.composer.drum_analyzer import DrumLoopAnalysis, Transient, BeatGrid
|
||||
|
||||
output = tmp_path / "track.rpp"
|
||||
fake_analysis = DrumLoopAnalysis(
|
||||
file_path="f.wav", bpm=95.0, duration=8.0,
|
||||
beats=[0.0, 0.6316, 1.2632, 1.8947],
|
||||
transients=[Transient(time=0.0, type="kick", energy=0.8, spectral_centroid=100)],
|
||||
beat_grid=BeatGrid(quarter=[0.0, 0.6316], eighth=[], sixteenth=[]),
|
||||
key="Am", key_confidence=0.8, energy_profile=[0.5], bar_count=1,
|
||||
)
|
||||
|
||||
with patch("scripts.compose.SampleSelector") as mock_cls, \
|
||||
patch("scripts.compose.DrumLoopAnalyzer") as mock_a_cls:
|
||||
with patch("scripts.compose.SampleSelector") as mock_cls:
|
||||
mock_sel = MagicMock()
|
||||
mock_sel._samples = [
|
||||
{"role": "drumloop", "perceptual": {"tempo": 95.0}, "musical": {"key": "Am"},
|
||||
@@ -65,9 +56,6 @@ class TestComposeNoRender:
|
||||
mock_sel.select.return_value = [MagicMock(sample={"original_path": "c.wav"})]
|
||||
mock_sel.select_diverse.return_value = [{"original_path": "v.wav", "file_hash": "v"}]
|
||||
mock_cls.return_value = mock_sel
|
||||
mock_a = MagicMock()
|
||||
mock_a.analyze.return_value = fake_analysis
|
||||
mock_a_cls.return_value = mock_a
|
||||
|
||||
from scripts.compose import main
|
||||
orig = sys.argv
|
||||
|
||||
@@ -119,9 +119,9 @@ class TestMusicTheory:
|
||||
assert minor is False
|
||||
|
||||
def test_root_to_midi(self):
|
||||
from scripts.compose import root_to_midi
|
||||
assert root_to_midi("A", 4) == 69
|
||||
assert root_to_midi("C", 4) == 60
|
||||
from scripts.compose import NOTE_TO_MIDI
|
||||
assert NOTE_TO_MIDI["A"] + (4 + 1) * 12 == 69
|
||||
assert NOTE_TO_MIDI["C"] + (4 + 1) * 12 == 60
|
||||
|
||||
def test_build_chord_major(self):
|
||||
from scripts.compose import build_chord
|
||||
|
||||
Reference in New Issue
Block a user