Hybrid pipeline: RPPBuilder writes VST3/MIDI/audio skeleton, ReaScript handles built-in plugins (ReaEQ, ReaComp) via TrackFX_AddByName + TrackFX_SetParam with multi-action dispatch, adaptive API check, and builtin plugin auto-detection from PLUGIN_REGISTRY. 326 tests (298 existing + 28 new), 12/12 spec scenarios compliant.
828 lines
30 KiB
Python
828 lines
30 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
|
|
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}"
|
|
)
|