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:
renato97
2026-05-03 22:00:26 -03:00
parent 7729d5f12f
commit 48bc271afc
25 changed files with 2842 additions and 343 deletions

View File

@@ -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"

View 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"
)

View 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

View File

@@ -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

View File

@@ -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