- 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
305 lines
9.7 KiB
Python
305 lines
9.7 KiB
Python
"""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
|