"""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 from src.core.schema import ( SongDefinition, SongMeta, TrackDef, PluginDef, ) from src.reaper_builder import ( RPPBuilder, REAPER_BUILTINS, get_builtin_plugins, ) # ------------------------------------------------------------------ # 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 # ------------------------------------------------------------------ # Phase 4: Multi-Action Dispatch, Adaptive API, JSON Round-Trip # ------------------------------------------------------------------ class TestMultiActionDispatch: """4.1 Multi-action dispatch generates correct per-action functions.""" @pytest.mark.parametrize("action_list,expected_funcs,unexpected_funcs", [ # calibrate only → calibrate function present, render/add_plugins NOT emitted (["calibrate"], ["def calibrate("], ["def add_plugins(", "def configure_fx_params(", "def do_render("]), # add_plugins only → add_plugins and find_track present, calibrate/render NOT (["add_plugins"], ["def add_plugins(", "def find_track("], ["def calibrate(", "def configure_fx_params(", "def do_render("]), # add_plugins + verify_fx → both present, calibrate/render NOT (["add_plugins", "verify_fx"], ["def add_plugins(", "def verify_fx(", "def find_track("], ["def calibrate(", "def do_render("]), # full pipeline → all 4 action functions present (["add_plugins", "configure_fx_params", "calibrate", "render"], ["def add_plugins(", "def configure_fx_params(", "def calibrate(", "def do_render("], []), ]) def test_per_action_functions_conditional(self, tmp_path, action_list, expected_funcs, unexpected_funcs): cmd = ReaScriptCommand( version=1, action=action_list, 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") for func in expected_funcs: assert func in source, f"Expected '{func}' in output for actions={action_list}" for func in unexpected_funcs: assert func not in source, f"Unexpected '{func}' in output for actions={action_list}" def test_string_action_backward_compat(self, tmp_path): """String action 'calibrate' produces same output as list ['calibrate'].""" # Generate with string cmd_str = ReaScriptCommand( version=1, action="calibrate", rpp_path="C:/song.rpp", render_path="C:/song.wav", timeout=120, track_calibration=[], ) path_str = tmp_path / "str_action.py" gen = ReaScriptGenerator() gen.generate(path_str, cmd_str) source_str = path_str.read_text(encoding="utf-8") # Generate with list cmd_list = ReaScriptCommand( version=1, action=["calibrate"], rpp_path="C:/song.rpp", render_path="C:/song.wav", timeout=120, track_calibration=[], ) path_list = tmp_path / "list_action.py" gen.generate(path_list, cmd_list) source_list = path_list.read_text(encoding="utf-8") # Both should be identical assert source_str == source_list, ( "String action 'calibrate' should produce same output as list ['calibrate']" ) def test_empty_action_defaults_to_calibrate(self, tmp_path): """Empty action list defaults to ['calibrate'].""" cmd = ReaScriptCommand( version=1, action=[], 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 calibrate(" in source # Render functions are NOT emitted since action defaults to ["calibrate"] only assert "def do_render(" not in source def test_dispatch_loop_present_with_multiple_actions(self, tmp_path): """When multiple actions present, dispatch loop iterates them.""" cmd = ReaScriptCommand( version=1, action=["add_plugins", "verify_fx", "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") # Each action should appear in the dispatch if/elif chain assert '"add_plugins"' in source assert '"verify_fx"' in source assert '"calibrate"' in source # action_list variable exists assert "action_list" in source class TestAdaptiveApiCheck: """4.2 Adaptive check_api requires FX APIs only with add_plugins.""" def test_check_api_requires_fx_apis_with_add_plugins(self, tmp_path): cmd = ReaScriptCommand( version=1, action=["add_plugins"], 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 "TrackFX_AddByName" in source assert "TrackFX_SetParam" in source assert "check_api" in source def test_check_api_requires_fx_apis_with_configure_fx_params(self, tmp_path): cmd = ReaScriptCommand( version=1, action=["add_plugins", "configure_fx_params"], 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 "TrackFX_AddByName" in source assert "TrackFX_SetParam" in source def test_check_api_omits_fx_apis_for_calibrate_only(self, tmp_path): """String 'calibrate' (backward compat) should NOT include FX APIs.""" 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") # Verify check_api exists but does NOT include FX APIs assert "check_api" in source # The check_api function defines required list; TrackFX_AddByName should NOT be in that list # Find the check_api function body check_api_start = source.find("def check_api():") check_api_end = source.find("def ", check_api_start + 1) if check_api_end == -1: check_api_section = source[check_api_start:] else: check_api_section = source[check_api_start:check_api_end] assert "TrackFX_AddByName" not in check_api_section assert "TrackFX_SetParam" not in check_api_section def test_check_api_includes_fx_apis_with_add_plugins_in_list(self, tmp_path): """When add_plugins is in the action list, FX APIs are required.""" cmd = ReaScriptCommand( version=1, action=["add_plugins", "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") check_api_start = source.find("def check_api():") check_api_end = source.find("def ", check_api_start + 1) if check_api_end == -1: check_api_section = source[check_api_start:] else: check_api_section = source[check_api_start:check_api_end] assert "TrackFX_AddByName" in check_api_section assert "TrackFX_SetParam" in check_api_section class TestPluginsToAddRoundTrip: """4.3 plugins_to_add/added_plugins JSON round-trip serialization.""" def test_write_read_roundtrip_plugins_to_add(self, tmp_path): """Write command with plugins_to_add, read back, verify fields survive.""" cmd = ReaScriptCommand( version=1, action=["add_plugins", "configure_fx_params", "calibrate"], rpp_path="C:/song.rpp", render_path="C:/song.wav", timeout=120, track_calibration=[], plugins_to_add=[ {"track_name": "Bass", "fx_name": "ReaEQ", "params": {"2": 200.0, "5": 3.0}}, {"track_name": "Lead", "fx_name": "ReaComp", "params": {"0": -18.0}}, ], ) cmd_path = tmp_path / "cmd.json" write_command(cmd_path, cmd) # Read back raw JSON raw = json.loads(cmd_path.read_text(encoding="utf-8")) assert "plugins_to_add" in raw assert len(raw["plugins_to_add"]) == 2 assert raw["plugins_to_add"][0]["track_name"] == "Bass" assert raw["plugins_to_add"][0]["fx_name"] == "ReaEQ" assert raw["plugins_to_add"][0]["params"]["2"] == 200.0 def test_read_result_includes_added_plugins(self, tmp_path): """Read a result JSON with added_plugins field.""" data = { "version": 1, "status": "ok", "message": "", "fx_errors": [], "tracks_verified": 2, "added_plugins": [ {"fx_name": "ReaEQ", "instance_id": 0, "track_name": "Bass", "status": "ok"}, {"fx_name": "ReaComp", "instance_id": 0, "track_name": "Lead", "status": "ok"}, ], } res_path = tmp_path / "result.json" res_path.write_text(json.dumps(data), encoding="utf-8") result = read_result(res_path) assert len(result.added_plugins) == 2 assert result.added_plugins[0]["fx_name"] == "ReaEQ" assert result.added_plugins[0]["instance_id"] == 0 assert result.added_plugins[1]["status"] == "ok" def test_write_command_omits_empty_plugins_to_add(self, tmp_path): """When plugins_to_add is empty, it should NOT appear in JSON.""" cmd = ReaScriptCommand( version=1, action="calibrate", rpp_path="C:/song.rpp", render_path="C:/song.wav", timeout=120, track_calibration=[], plugins_to_add=[], ) cmd_path = tmp_path / "cmd.json" write_command(cmd_path, cmd) raw = json.loads(cmd_path.read_text(encoding="utf-8")) assert "plugins_to_add" not in raw def test_read_result_handles_missing_added_plugins(self, tmp_path): """Old result JSON without added_plugins should default to empty list.""" data = { "version": 1, "status": "ok", "message": "", "fx_errors": [], "tracks_verified": 0, } res_path = tmp_path / "result.json" res_path.write_text(json.dumps(data), encoding="utf-8") result = read_result(res_path) assert result.added_plugins == [] class TestBuiltinPluginSkipping: """4.4 builtin=True PluginDef skipped in RPP .rpp output, listed in command.""" def test_builtin_plugin_not_in_rpp_output(self, tmp_path): """A PluginDef with builtin=True is excluded from .rpp VST elements.""" song = SongDefinition( meta=SongMeta(bpm=95, key="Am", title="Test Builtin"), tracks=[ TrackDef( name="Bass", volume=0.85, plugins=[ PluginDef( name="ReaEQ", path="VST: ReaEQ (Cockos)", index=0, params={2: 200.0, 5: 3.0}, builtin=True, ), PluginDef( name="Serum_2", path="VST3i: Serum 2 (Xfer Records)", index=1, builtin=False, ), ], ), ], ) builder = RPPBuilder(song) output_path = tmp_path / "test_builtin.rpp" builder.write(output_path) rpp_content = output_path.read_text(encoding="utf-8") # ReaEQ (builtin=True) should NOT appear in the VST chain assert "ReaEQ" not in rpp_content, ( "Builtin plugin ReaEQ should NOT be written to .rpp VST section" ) # Serum_2 (builtin=False) SHOULD appear assert "Serum 2" in rpp_content, ( "Non-builtin plugin Serum 2 SHOULD be written to .rpp" ) def test_builtin_plugin_in_get_builtin_plugins(self): """get_builtin_plugins() returns builtin=True plugins for the command.""" song = SongDefinition( meta=SongMeta(bpm=95, key="Am", title="Test Builtin"), tracks=[ TrackDef( name="Bass", plugins=[ PluginDef( name="ReaEQ", path="VST: ReaEQ (Cockos)", index=0, params={2: 200.0, 5: 3.0}, builtin=True, ), PluginDef( name="ReaComp", path="VST: ReaComp (Cockos)", index=1, params={0: -18.0}, builtin=False, ), ], ), ], ) builtins = get_builtin_plugins(song) # ReaEQ has builtin=True → should appear assert len(builtins) >= 1 reaeq_entries = [b for b in builtins if b["fx_name"] == "ReaEQ"] assert len(reaeq_entries) == 1 assert reaeq_entries[0]["track_name"] == "Bass" # ReaComp with builtin=False BUT name in REAPER_BUILTINS → should also appear assert "ReaComp" in REAPER_BUILTINS reacomp_entries = [b for b in builtins if b["fx_name"] == "ReaComp"] assert len(reacomp_entries) == 1 def test_non_builtin_not_in_reaper_builtins(self): """A non-builtin, non-Cockos plugin name is NOT in REAPER_BUILTINS.""" assert "Serum_2" not in REAPER_BUILTINS assert "Omnisphere" not in REAPER_BUILTINS # But Cockos plugins ARE assert "ReaEQ" in REAPER_BUILTINS assert "ReaComp" in REAPER_BUILTINS def test_params_survive_in_get_builtin_plugins(self): """Plugin params are correctly converted to string-keyed dict.""" song = SongDefinition( meta=SongMeta(bpm=95, key="Am", title="Test Params"), tracks=[ TrackDef( name="Bass", plugins=[ PluginDef( name="ReaEQ", path="VST: ReaEQ (Cockos)", index=0, params={2: 200.0, 5: 3.0}, builtin=True, ), ], ), ], ) builtins = get_builtin_plugins(song) reaeq = builtins[0] assert reaeq["params"] == {"2": 200.0, "5": 3.0} # ------------------------------------------------------------------ # Phase 5: Generated Code Content Assertions # ------------------------------------------------------------------ class TestAddPluginsSourceContent: """Verify add_plugins generated source contains correct ReaScript API calls.""" @pytest.fixture def source_with_add_plugins(self, tmp_path: Path) -> str: """Generate script with add_plugins action and return source.""" cmd = ReaScriptCommand( version=1, action=["add_plugins"], rpp_path="C:/song.rpp", render_path="C:/song.wav", timeout=120, track_calibration=[], plugins_to_add=[ {"track_name": "Bass", "fx_name": "ReaEQ", "params": {"2": 200.0}}, ], ) path = tmp_path / "content_test.py" gen = ReaScriptGenerator() gen.generate(path, cmd) return path.read_text(encoding="utf-8") def test_add_plugins_calls_trackfx_addbyname(self, source_with_add_plugins: str): """Insert ReaEQ on target track — TrackFX_AddByName is called.""" assert "RPR_TrackFX_AddByName" in source_with_add_plugins def test_add_plugins_handles_track_not_found(self, source_with_add_plugins: str): """Track not found — error written, script continues.""" assert "track not found" in source_with_add_plugins.lower() assert "continue" in source_with_add_plugins def test_add_plugins_verifies_with_getfxname(self, source_with_add_plugins: str): """Plugin loaded successfully — GetFXName case-insensitive match.""" assert "RPR_TrackFX_GetFXName" in source_with_add_plugins assert ".lower()" in source_with_add_plugins # case-insensitive comparison def test_add_plugins_records_ok_status(self, source_with_add_plugins: str): """Plugin loaded — 'ok' status recorded in results.""" assert '"status": "ok"' in source_with_add_plugins def test_add_plugins_records_failed_status(self, source_with_add_plugins: str): """Plugin failed to load — 'failed to load' status recorded.""" assert "failed to load" in source_with_add_plugins class TestConfigureFxParamsSourceContent: """Verify configure_fx_params generated source contains correct parameter setting.""" @pytest.fixture def source_with_configure(self, tmp_path: Path) -> str: """Generate script with configure_fx_params action and return source.""" cmd = ReaScriptCommand( version=1, action=["add_plugins", "configure_fx_params"], rpp_path="C:/song.rpp", render_path="C:/song.wav", timeout=120, track_calibration=[], plugins_to_add=[ {"track_name": "Bass", "fx_name": "ReaEQ", "params": {"2": 200.0, "5": 3.0}}, ], ) path = tmp_path / "content_test.py" gen = ReaScriptGenerator() gen.generate(path, cmd) return path.read_text(encoding="utf-8") def test_configure_calls_setparam(self, source_with_configure: str): """Set ReaEQ frequency and gain — TrackFX_SetParam called.""" assert "RPR_TrackFX_SetParam" in source_with_configure def test_configure_iterates_params_dict(self, source_with_configure: str): """Iterates params dict, converts string keys to int indices.""" assert "int(param_idx_str)" in source_with_configure def test_configure_finds_fx_by_name(self, source_with_configure: str): """Looks up FX by name to get target index before setting params.""" assert "RPR_TrackFX_GetCount" in source_with_configure assert "RPR_TrackFX_GetFXName" in source_with_configure class TestNoBareExcept: """Verify generated script template has no bare except clauses.""" def test_no_bare_except_in_generated_source(self, tmp_path: Path): """Generated ReaScript must not contain bare 'except:' (only 'except ...').""" cmd = ReaScriptCommand( version=1, action=["add_plugins", "configure_fx_params", "calibrate", "render"], rpp_path="C:/song.rpp", render_path="C:/song.wav", timeout=120, track_calibration=[], plugins_to_add=[ {"track_name": "Bass", "fx_name": "ReaEQ", "params": {"2": 200.0}}, ], ) path = tmp_path / "no_bare_except.py" gen = ReaScriptGenerator() gen.generate(path, cmd) source = path.read_text(encoding="utf-8") # Check no bare "except:" (without exception type) lines = source.split("\n") for i, line in enumerate(lines): stripped = line.strip() if stripped.startswith("except:") and len(stripped) == len("except:"): pytest.fail( f"Bare 'except:' found at line {i + 1}: {line}" )