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:
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
|
||||
Reference in New Issue
Block a user