"""Tests for src/reaper_builder/ — RPPBuilder, rpp_writer.""" import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).parents[1])) import pytest import tempfile from src.core.schema import SongDefinition, SongMeta, TrackDef, ClipDef, MidiNote, PluginDef, CCEvent from src.reaper_builder import RPPBuilder class TestRPPBuilderWrite: """Test RPPBuilder.write() produces valid .rpp output.""" def test_write_produces_reaper_project_marker(self): """RPPBuilder.write() produces a file containing 'REAPER_PROJECT'.""" meta = SongMeta(bpm=95, key="Am", title="Test") song = SongDefinition(meta=meta, tracks=[]) builder = RPPBuilder(song) with tempfile.NamedTemporaryFile( mode="w", suffix=".rpp", delete=False, encoding="utf-8" ) as f: tmp_path = f.name try: builder.write(tmp_path) content = Path(tmp_path).read_text(encoding="utf-8") assert "REAPER_PROJECT" in content finally: Path(tmp_path).unlink(missing_ok=True) def test_write_produces_tempo_line(self): """Output contains TEMPO line with correct BPM.""" meta = SongMeta(bpm=95, key="Am") song = SongDefinition(meta=meta, tracks=[]) builder = RPPBuilder(song) with tempfile.NamedTemporaryFile( mode="w", suffix=".rpp", delete=False, encoding="utf-8" ) as f: tmp_path = f.name try: builder.write(tmp_path) content = Path(tmp_path).read_text(encoding="utf-8") assert "TEMPO 95 " in content finally: Path(tmp_path).unlink(missing_ok=True) class TestRPPBuilderAudioTrack: """Test audio track generates SOURCE WAVE block with correct file path.""" def test_audio_track_generates_source_wave_block(self): """Audio clip produces block with FILE path.""" meta = SongMeta(bpm=95, key="Am") clip = ClipDef( position=0.0, length=16.0, name="Kick Loop", audio_path="C:/samples/kick.wav", ) track = TrackDef(name="Drums", clips=[clip]) song = SongDefinition(meta=meta, tracks=[track]) builder = RPPBuilder(song) with tempfile.NamedTemporaryFile( mode="w", suffix=".rpp", delete=False, encoding="utf-8" ) as f: tmp_path = f.name try: builder.write(tmp_path) content = Path(tmp_path).read_text(encoding="utf-8") assert "= 1, f"Expected at least 1 FXCHAIN, got {fxchain_count}" # Master track FXCHAIN has master-specific FXID assert "FXID {" in content finally: Path(tmp_path).unlink(missing_ok=True) class TestVST3GUIDPresence: """Test that VST3 plugins output with uniqueid{GUID} tokens.""" def test_vst3_plugin_output_contains_guid(self): """VST3 element contains GUID from registry lookup.""" meta = SongMeta(bpm=95, key="Am", title="VST3 Test") plugin = PluginDef(name="Serum2", path="Serum2.vst3", index=0) track = TrackDef(name="Bass", clips=[], plugins=[plugin]) song = SongDefinition(meta=meta, tracks=[track]) builder = RPPBuilder(song) with tempfile.NamedTemporaryFile( mode="w", suffix=".rpp", delete=False, encoding="utf-8" ) as f: tmp_path = f.name try: builder.write(tmp_path) content = Path(tmp_path).read_text(encoding="utf-8") # Must contain the GUID token from VST3_REGISTRY["Serum2"] assert "691258006{56534558667350736572756D20320000}" in content # Must also contain correct display name and filename assert "VST3i: Serum 2 (Xfer Records)" in content assert "Serum2.vst3" in content finally: Path(tmp_path).unlink(missing_ok=True) def test_fabfilter_proq3_contains_guid(self): """FabFilter Pro-Q 3 outputs with correct GUID.""" meta = SongMeta(bpm=95, key="Am", title="VST3 Test") plugin = PluginDef(name="FabFilter Pro-Q 3", path="FabFilter Pro-Q 3.vst3", index=0) track = TrackDef(name="Lead", clips=[], plugins=[plugin]) song = SongDefinition(meta=meta, tracks=[track]) builder = RPPBuilder(song) with tempfile.NamedTemporaryFile( mode="w", suffix=".rpp", delete=False, encoding="utf-8" ) as f: tmp_path = f.name try: builder.write(tmp_path) content = Path(tmp_path).read_text(encoding="utf-8") # Must contain the GUID token from VST3_REGISTRY["FabFilter Pro-Q 3"] assert "756089518{72C4DB717A4D459AB97E51745D84B39D}" in content assert "VST3: Pro-Q 3 (FabFilter)" in content # Filename in RPP is "FabFilter" (shared binary for all FabFilter plugins) assert "FabFilter 0" in content finally: Path(tmp_path).unlink(missing_ok=True) class TestVST3PresetData: """Test that VST3 plugins include base64 preset data inside VST blocks.""" def test_serum2_vst_contains_preset_data(self): """Serum2 VST block contains base64 preset lines.""" meta = SongMeta(bpm=95, key="Am", title="VST3 Preset Test") plugin = PluginDef(name="Serum2", path="Serum2.vst3", index=0) track = TrackDef(name="Bass", clips=[], plugins=[plugin]) song = SongDefinition(meta=meta, tracks=[track]) builder = RPPBuilder(song) with tempfile.NamedTemporaryFile( mode="w", suffix=".rpp", delete=False, encoding="utf-8" ) as f: tmp_path = f.name try: builder.write(tmp_path) content = Path(tmp_path).read_text(encoding="utf-8") # Serum2 preset starts with this magic line (first base64 line) assert "Z4R+ae5e7f4CAAAAAQAAAAAAAAACAAAAAAAAAAIAAAABAAAAAAAAAAIAAAAAAAAAbQgAAAEAAAAAAAAA" in content # Last line of all presets is the same terminator assert "AFByb2dyYW0gMQAAAAAA" in content # A mid-preset line (line 2) assert "zQQAAAEAAABYZmVySnNvbgC5AAAAAAAAAHsiY29tcG9uZW50IjoicHJvY2Vzc29yIiwiaGFzaCI6IjgxZTEyMWYxNGI2Y2IyYjA2YzMzMjQzZDk1ZDIxYWIxIiwicHJv" in content finally: Path(tmp_path).unlink(missing_ok=True) def test_fabfilter_proq3_vst_contains_preset_data(self): """FabFilter Pro-Q 3 VST block contains base64 preset lines.""" meta = SongMeta(bpm=95, key="Am", title="VST3 Preset Test") plugin = PluginDef(name="FabFilter Pro-Q 3", path="FabFilter Pro-Q 3.vst3", index=0) track = TrackDef(name="Lead", clips=[], plugins=[plugin]) song = SongDefinition(meta=meta, tracks=[track]) builder = RPPBuilder(song) with tempfile.NamedTemporaryFile( mode="w", suffix=".rpp", delete=False, encoding="utf-8" ) as f: tmp_path = f.name try: builder.write(tmp_path) content = Path(tmp_path).read_text(encoding="utf-8") # Pro-Q 3 preset starts with this line assert "rgIRLe5e7f4EAAAAAQAAAAAAAAACAAAAAAAAAAQAAAAAAAAACAAAAAAAAAACAAAAAQAAAAAAAAACAAAAAAAAAAoGAAABAAAAAAAAAA==" in content assert "AFByb2dyYW0gMQAAAAAA" in content finally: Path(tmp_path).unlink(missing_ok=True) def test_registry_plugins_with_preset_data_are_in_output(self): """VST3 plugins with non-empty preset data include that data in output. New plugins added with empty preset lists ([]) are handled gracefully — vst3_element() only appends preset lines when preset_data is truthy. """ meta = SongMeta(bpm=95, key="Am", title="VST3 Preset Test") # Use actual filenames from registry so _build_plugin recognizes them as VST3 from src.reaper_builder import VST3_REGISTRY plugins = [ PluginDef(name=name, path=entry[1], index=i) for i, (name, entry) in enumerate(VST3_REGISTRY.items()) ] track = TrackDef(name="Test", clips=[], plugins=plugins) song = SongDefinition(meta=meta, tracks=[track]) builder = RPPBuilder(song) with tempfile.NamedTemporaryFile( mode="w", suffix=".rpp", delete=False, encoding="utf-8" ) as f: tmp_path = f.name try: builder.write(tmp_path) content = Path(tmp_path).read_text(encoding="utf-8") # Check that plugins WITH preset data have that data in output from src.reaper_builder import PLUGIN_PRESETS, VST3_REGISTRY vst3_keys = set(VST3_REGISTRY.keys()) for (name, role), preset_lines in PLUGIN_PRESETS.items(): # Only check VST3 plugins and default role (backward compat) if name not in vst3_keys or role != "": continue if len(preset_lines) > 0: # Check first preset line — most distinctive, no collision risk first_line = preset_lines[0] assert first_line in content, f"{name} preset line not found in output" finally: Path(tmp_path).unlink(missing_ok=True) class TestDVolRemoval: """Verify D_VOL is not emitted for any audio clip (removed as REAPER-incompatible).""" def test_audio_clip_with_vol_mult_no_dvol(self): """Audio clip with vol_mult=0.7 must NOT emit D_VOL in ITEM.""" meta = SongMeta(bpm=95, key="Am") clip = ClipDef( position=0.0, length=16.0, name="Test", audio_path="C:/test.wav", vol_mult=0.7, ) track = TrackDef(name="Test Track", clips=[clip]) song = SongDefinition(meta=meta, tracks=[track]) builder = RPPBuilder(song) with tempfile.NamedTemporaryFile( mode="w", suffix=".rpp", delete=False, encoding="utf-8" ) as f: tmp_path = f.name try: builder.write(tmp_path) content = Path(tmp_path).read_text(encoding="utf-8") assert "D_VOL" not in content, "D_VOL must not be emitted (removed for REAPER compat)" finally: Path(tmp_path).unlink(missing_ok=True) def test_audio_clip_default_vol_mult_no_dvol(self): """Audio clip with default vol_mult=1.0 emits NO D_VOL.""" meta = SongMeta(bpm=95, key="Am") clip = ClipDef( position=0.0, length=16.0, name="Test", audio_path="C:/test.wav", # default vol_mult=1.0 ) track = TrackDef(name="Test Track", clips=[clip]) song = SongDefinition(meta=meta, tracks=[track]) builder = RPPBuilder(song) with tempfile.NamedTemporaryFile( mode="w", suffix=".rpp", delete=False, encoding="utf-8" ) as f: tmp_path = f.name try: builder.write(tmp_path) content = Path(tmp_path).read_text(encoding="utf-8") assert "D_VOL" not in content, "No D_VOL expected" finally: Path(tmp_path).unlink(missing_ok=True) def test_midi_clip_vol_mult_scales_velocity(self): """MIDI clip vol_mult scales velocity in output E lines.""" meta = SongMeta(bpm=95, key="Am") note = MidiNote(pitch=60, start=0.0, duration=1.0, velocity=100) clip = ClipDef( position=0.0, length=16.0, name="Test MIDI", midi_notes=[note], vol_mult=0.5, ) track = TrackDef(name="MIDI Track", clips=[clip]) song = SongDefinition(meta=meta, tracks=[track]) builder = RPPBuilder(song) with tempfile.NamedTemporaryFile( mode="w", suffix=".rpp", delete=False, encoding="utf-8" ) as f: tmp_path = f.name try: builder.write(tmp_path) content = Path(tmp_path).read_text(encoding="utf-8") # Velocity should be 100 * 0.5 = 50 = 0x32 # The E line format: E 90 # pitch 60 = 0x3c, velocity 50 = 0x32 assert "90 3c 32" in content, f"Expected velocity 50 (0x32) in E line, got content fragment" finally: Path(tmp_path).unlink(missing_ok=True) class TestCCEmission: """Test that CC11 events are emitted as B0 0B E-lines.""" def test_midi_source_with_cc_events(self): """_build_midi_source emits B0 0B lines for clips with midi_cc.""" meta = SongMeta(bpm=95, key="Am") note = MidiNote(pitch=60, start=0.5, duration=1.0, velocity=100) cc = CCEvent(controller=11, time=0.0, value=50) clip = ClipDef( position=0.0, length=16.0, name="Test", midi_notes=[note], midi_cc=[cc], ) track = TrackDef(name="MIDI CC", clips=[clip]) song = SongDefinition(meta=meta, tracks=[track]) builder = RPPBuilder(song) with tempfile.NamedTemporaryFile( mode="w", suffix=".rpp", delete=False, encoding="utf-8" ) as f: tmp_path = f.name try: builder.write(tmp_path) content = Path(tmp_path).read_text(encoding="utf-8") # CC event at time 0, controller 11, value 50 = 0x32 assert "B0 0b 32" in content, f"Expected CC11 B0 0b line, got: {content}" finally: Path(tmp_path).unlink(missing_ok=True) def test_midi_source_cc_note_interleaved_order(self): """CC events interleaved with notes in time order.""" meta = SongMeta(bpm=95, key="Am") note = MidiNote(pitch=60, start=0.5, duration=1.0, velocity=100) cc1 = CCEvent(controller=11, time=0.0, value=50) cc2 = CCEvent(controller=11, time=0.25, value=127) clip = ClipDef( position=0.0, length=16.0, name="Test", midi_notes=[note], midi_cc=[cc1, cc2], ) track = TrackDef(name="MIDI CC", clips=[clip]) song = SongDefinition(meta=meta, tracks=[track]) builder = RPPBuilder(song) with tempfile.NamedTemporaryFile( mode="w", suffix=".rpp", delete=False, encoding="utf-8" ) as f: tmp_path = f.name try: builder.write(tmp_path) content = Path(tmp_path).read_text(encoding="utf-8") # Find E-lines — CC should come before note in time order e_lines = [l.strip() for l in content.split('\n') if l.strip().startswith('E ')] # First E-line should be CC at time 0 (B0 0b) assert any('B0' in l for l in e_lines), f"No CC E-lines found in {e_lines}" # Find position of first CC line vs first note line cc_indices = [i for i, l in enumerate(e_lines) if 'B0' in l] note_indices = [i for i, l in enumerate(e_lines) if '90' in l] assert cc_indices[0] < note_indices[0], \ f"CC at time 0 should come before note at time 0.5. E-lines: {e_lines}" finally: Path(tmp_path).unlink(missing_ok=True) def test_midi_source_no_cc_when_empty(self): """_build_midi_source emits no B0 lines when midi_cc is empty.""" meta = SongMeta(bpm=95, key="Am") note = MidiNote(pitch=60, start=0.0, duration=1.0, velocity=100) clip = ClipDef( position=0.0, length=16.0, name="Test", midi_notes=[note], midi_cc=[], ) track = TrackDef(name="MIDI No CC", clips=[clip]) song = SongDefinition(meta=meta, tracks=[track]) builder = RPPBuilder(song) with tempfile.NamedTemporaryFile( mode="w", suffix=".rpp", delete=False, encoding="utf-8" ) as f: tmp_path = f.name try: builder.write(tmp_path) content = Path(tmp_path).read_text(encoding="utf-8") assert "B0" not in content, f"No CC expected, got: {content}" finally: Path(tmp_path).unlink(missing_ok=True)