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

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