"""Integration tests for MIDI controller — PC, CC, expression pedal. Tests the full chain from MIDI handler → PedalApp callbacks → preset manager → pipeline parameter control. """ from __future__ import annotations from typing import Any import pytest from midi.handler import ( MIDIHandler, MIDIEvent, CC_EXPRESSION, RT_CLOCK, RT_START, RT_STOP, CONTROL_CHANGE, PROGRAM_CHANGE, NOTE_ON, ) from presets.manager import PresetManager from presets.types import FXBlock, FXType, MIDIMapping, Preset # ═══════════════════════════════════════════════════════════════════ # Fixtures # ═══════════════════════════════════════════════════════════════════ class MockPipeline: """Minimal mock of AudioPipeline for testing MIDI integration.""" def __init__(self) -> None: self._master_volume = 0.8 self._bypassed = False self._loaded_preset: Preset | None = None def load_preset(self, preset: Preset) -> None: self._loaded_preset = preset @pytest.fixture def preset_manager(tmp_path: Any) -> PresetManager: """PresetManager with a temp directory.""" pm = PresetManager(preset_dir=str(tmp_path / "presets")) return pm @pytest.fixture def mock_pipeline() -> MockPipeline: return MockPipeline() @pytest.fixture def handler() -> MIDIHandler: """MIDIHandler with no hardware interfaces.""" h = MIDIHandler() return h # ═══════════════════════════════════════════════════════════════════ # CC callback lambda tests (the bugfix) # ═══════════════════════════════════════════════════════════════════ class TestCCLambdaClosure: """Verify that register_cc closures capture the CC number correctly. This was the root cause bug: _on_midi_cc received the MIDI channel instead of the CC number, breaking expression pedal and preset mappings. """ def test_lambda_captures_cc_number(self, handler: MIDIHandler) -> None: """Each registered CC callback should know its CC number.""" results: list[int] = [] # Simulate what PedalApp._wire_midi_callbacks does for cc in range(128): handler.register_cc(cc, lambda val, ch, cc_num=cc: results.append(cc_num)) # Fire a few CCs through dispatch handler.dispatch(MIDIEvent(type="cc", cc_number=0, cc_value=64)) handler.dispatch(MIDIEvent(type="cc", cc_number=11, cc_value=100)) handler.dispatch(MIDIEvent(type="cc", cc_number=74, cc_value=50)) handler.dispatch(MIDIEvent(type="cc", cc_number=127, cc_value=127)) assert results == [0, 11, 74, 127] def test_expression_pedal_identified_correctly(self, handler: MIDIHandler) -> None: """Expression pedal (CC #11) should be distinguishable from other CCs.""" results: list[tuple[int, int, int]] = [] # Register callbacks like PedalApp does for cc in range(128): handler.register_cc( cc, lambda val, ch, cc_num=cc: results.append((val, cc_num, ch)) ) # Send expression pedal CC on MIDI channel 0 handler.dispatch( MIDIEvent(type="cc", cc_number=CC_EXPRESSION, cc_value=64, channel=0) ) assert len(results) == 1 val, cc_num, ch = results[0] assert cc_num == CC_EXPRESSION # Should be 11, not the MIDI channel assert val == 64 assert ch == 0 def test_midi_channel_preserved(self, handler: MIDIHandler) -> None: """MIDI channel should still be passed correctly.""" results: list[tuple[int, int, int]] = [] for cc in range(128): handler.register_cc( cc, lambda val, ch, cc_num=cc: results.append((val, cc_num, ch)) ) # CC#7 on MIDI channel 5 handler.dispatch(MIDIEvent(type="cc", cc_number=7, cc_value=100, channel=5)) assert len(results) == 1 val, cc_num, ch = results[0] assert cc_num == 7 assert val == 100 assert ch == 5 # ═══════════════════════════════════════════════════════════════════ # PedalApp callback tests # ═══════════════════════════════════════════════════════════════════ class TestPedalAppMIDICallbacks: """Test the PedalApp-level MIDI callback logic in isolation.""" def test_on_midi_pc_selects_preset( self, preset_manager: PresetManager, mock_pipeline: MockPipeline ) -> None: """Simulate PedalApp._on_midi_pc — PC message should select a preset.""" from main import PedalApp # Create a preset to select preset = Preset(name="Test", bank=1, program=2) preset_manager.save(preset) # Simulate PedalApp callback by calling preset_manager.midi_pc directly # (same as PedalApp._on_midi_pc does) result = preset_manager.midi_pc(channel=1, program=2) assert result.name == "Test" assert result.bank == 1 assert result.program == 2 assert preset_manager.current_bank == 1 assert preset_manager.current_program == 2 def test_on_midi_pc_creates_blank_on_empty_slot( self, preset_manager: PresetManager, mock_pipeline: MockPipeline ) -> None: """Selecting an empty slot via PC should create a blank preset.""" result = preset_manager.midi_pc(channel=2, program=0) assert result is not None assert result.bank == 2 assert result.program == 0 assert result.name == "New Bank 2 #0" def test_expression_pedal_sets_master_volume( self, mock_pipeline: MockPipeline ) -> None: """CC#11 = expression pedal → master volume update.""" # Simulate PedalApp._on_midi_cc with our fixed signature from main import PedalApp app = PedalApp.__new__(PedalApp) app.pipeline = mock_pipeline app.presets = None # Not needed for expression pedal path # Call with CC#11 = expression pedal app._on_midi_cc(value=64, cc_number=11, channel=0) # Master volume should be 64/127 ≈ 0.504 assert mock_pipeline._master_volume == pytest.approx(64.0 / 127.0) def test_expression_pedal_full_sweep( self, mock_pipeline: MockPipeline ) -> None: """Full expression pedal sweep from heel to toe.""" from main import PedalApp app = PedalApp.__new__(PedalApp) app.pipeline = mock_pipeline app.presets = None values = [0, 32, 64, 96, 127] expected = [0.0, 32 / 127, 64 / 127, 96 / 127, 127 / 127] for val, exp in zip(values, expected): app._on_midi_cc(value=val, cc_number=11, channel=0) assert mock_pipeline._master_volume == pytest.approx(exp) def test_cc_updates_preset_mapping( self, preset_manager: PresetManager, mock_pipeline: MockPipeline ) -> None: """CC should update a parameter mapped in the preset's MIDI mappings.""" from main import PedalApp # Create a preset with a MIDI mapping for delay feedback on CC#14 delay_param_key = "delay.feedback" preset = Preset( name="Delay Test", bank=0, program=0, chain=[ FXBlock(fx_type=FXType.DELAY, params={"feedback": 0.5, "mix": 0.3}), ], midi_mappings={ delay_param_key: MIDIMapping( cc_number=14, channel=0, min_val=0.0, max_val=1.0 ), }, ) preset_manager.save(preset) preset_manager.midi_pc(channel=0, program=0) # Select it # Set up PedalApp-like context app = PedalApp.__new__(PedalApp) app.pipeline = mock_pipeline app.presets = preset_manager # We need to track if the preset's param was updated. # _on_midi_cc logs the mapping but doesn't apply changes to the # pipeline yet — that's a future enhancement. For now, verify # the callback completes without error and the mapping is found. # CC#14 = delay.feedback = 100/127 app._on_midi_cc(value=100, cc_number=14, channel=0) # At minimum, it shouldn't crash. The pipeline's master_volume # should remain unchanged (CC#14 is not expression pedal). assert mock_pipeline._master_volume == 0.8 def test_cc_mapping_only_matches_correct_cc( self, preset_manager: PresetManager, mock_pipeline: MockPipeline ) -> None: """CC that doesn't match any mapping should not affect anything.""" from main import PedalApp preset = Preset( name="Test", bank=0, program=0, midi_mappings={"reverb.mix": MIDIMapping(cc_number=14)}, ) preset_manager.save(preset) preset_manager.midi_pc(channel=0, program=0) app = PedalApp.__new__(PedalApp) app.pipeline = mock_pipeline app.presets = preset_manager # CC#99 has no mapping — should be no-op app._on_midi_cc(value=64, cc_number=99, channel=0) # CC#14 has a mapping — should be found app._on_midi_cc(value=64, cc_number=14, channel=0) # Both should complete without error assert mock_pipeline._master_volume == 0.8 def test_no_pipeline_no_crash(self) -> None: """CC handler should not crash if pipeline or presets are missing.""" from main import PedalApp app = PedalApp.__new__(PedalApp) app.pipeline = None app.presets = None # These should be no-ops app._on_midi_cc(value=64, cc_number=11, channel=0) app._on_midi_pc(channel=0, program=0) # ═══════════════════════════════════════════════════════════════════ # Full parse → dispatch → callback chain tests # ═══════════════════════════════════════════════════════════════════ class TestFullMIDIChain: """End-to-end: raw MIDI bytes → handler → callback → application logic.""" def test_pc_full_chain(self, preset_manager: PresetManager) -> None: """PC message bytes → handler → preset change.""" handler = MIDIHandler() handler.set_pc_callback(lambda ch, pg: preset_manager.midi_pc(ch, pg)) # Save a preset at bank 3, program 1 preset_manager.save(Preset(name="PC Test", bank=3, program=1)) # Send MIDI Program Change: channel=3, program=1 ev = handler.parse(bytes([PROGRAM_CHANGE | 3, 1])) assert ev is not None handler.dispatch(ev) assert preset_manager.current_bank == 3 assert preset_manager.current_program == 1 loaded = preset_manager.load(3, 1) assert loaded.name == "PC Test" def test_expression_full_chain(self, mock_pipeline: MockPipeline) -> None: """CC#11 bytes → handler → expression pedal → master volume.""" handler = MIDIHandler() results: list[tuple[int, int, int]] = [] # Register like PedalApp does — lambda captures CC#11 for cc in range(128): handler.register_cc( cc, lambda val, ch, cc_num=cc: results.append((val, cc_num, ch)) ) # Send CC#11 = expression pedal, value 96 ev = handler.parse(bytes([CONTROL_CHANGE | 0, CC_EXPRESSION, 96])) assert ev is not None assert ev.type == "cc" assert ev.cc_number == CC_EXPRESSION assert ev.cc_value == 96 handler.dispatch(ev) # Verify the lambda was called with correct CC number assert len(results) == 1 val, cc_num, ch = results[0] assert cc_num == 11 # CC#11, not MIDI channel assert val == 96 def test_midi_channel_preserved_through_chain(self) -> None: """MIDI channel should survive parse → dispatch.""" handler = MIDIHandler() results: list[tuple[int, int, int]] = [] for cc in range(128): handler.register_cc( cc, lambda val, ch, cc_num=cc: results.append((val, cc_num, ch)) ) # CC#7 on MIDI channel 15 ev = handler.parse(bytes([CONTROL_CHANGE | 15, 7, 100])) assert ev is not None handler.dispatch(ev) assert len(results) == 1 val, cc_num, ch = results[0] assert val == 100 assert cc_num == 7 assert ch == 15 # MIDI channel preserved def test_pc_different_banks(self, preset_manager: PresetManager) -> None: """PC messages targeting different banks should switch correctly.""" handler = MIDIHandler() handler.set_pc_callback(lambda ch, pg: preset_manager.midi_pc(ch, pg)) # Save presets in different banks preset_manager.save(Preset(name="Bank 0", bank=0, program=0)) preset_manager.save(Preset(name="Bank 3", bank=3, program=2)) preset_manager.save(Preset(name="Bank 7", bank=7, program=1)) # Switch to bank 3, program 2 ev = handler.parse(bytes([PROGRAM_CHANGE | 3, 2])) assert ev is not None handler.dispatch(ev) assert preset_manager.current_bank == 3 assert preset_manager.current_program == 2 assert preset_manager.load(3, 2).name == "Bank 3" # Switch to bank 0, program 0 ev = handler.parse(bytes([PROGRAM_CHANGE | 0, 0])) assert ev is not None handler.dispatch(ev) assert preset_manager.current_bank == 0 assert preset_manager.current_program == 0 assert preset_manager.load(0, 0).name == "Bank 0" # ═══════════════════════════════════════════════════════════════════ # Expression pedal edge cases # ═══════════════════════════════════════════════════════════════════ class TestExpressionPedalEdgeCases: """Expression pedal boundary conditions.""" def test_expression_min_value(self, mock_pipeline: MockPipeline) -> None: """CC#11 value 0 should set master volume to 0.""" from main import PedalApp app = PedalApp.__new__(PedalApp) app.pipeline = mock_pipeline app.presets = None app._on_midi_cc(value=0, cc_number=11, channel=0) assert mock_pipeline._master_volume == 0.0 def test_expression_max_value(self, mock_pipeline: MockPipeline) -> None: """CC#11 value 127 should set master volume to 1.0.""" from main import PedalApp app = PedalApp.__new__(PedalApp) app.pipeline = mock_pipeline app.presets = None app._on_midi_cc(value=127, cc_number=11, channel=0) assert mock_pipeline._master_volume == 1.0 def test_expression_above_127_clipped_by_handler(self) -> None: """CC value >127 should be clipped at parse time.""" handler = MIDIHandler() # MIDI protocol constrains to 7-bit, but verify parse handles it ev = handler.parse(bytes([CONTROL_CHANGE | 0, 11, 127])) assert ev is not None assert ev.cc_value <= 127 def test_expression_on_wrong_channel_still_works( self, mock_pipeline: MockPipeline ) -> None: """Expression pedal (CC#11) should work on any MIDI channel.""" from main import PedalApp app = PedalApp.__new__(PedalApp) app.pipeline = mock_pipeline app.presets = None # Expression on MIDI channel 7 app._on_midi_cc(value=100, cc_number=11, channel=7) assert mock_pipeline._master_volume == pytest.approx(100.0 / 127.0) # ═══════════════════════════════════════════════════════════════════ # Program Change edge cases # ═══════════════════════════════════════════════════════════════════ class TestProgramChangeEdgeCases: """Program Change boundary conditions.""" def test_pc_program_127_valid(self, preset_manager: PresetManager) -> None: """Program 127 (max for MIDI) gets clamped to PRESETS_PER_BANK-1.""" preset_manager.save(Preset(name="Max PG", bank=0, program=127)) result = preset_manager.midi_pc(channel=0, program=127) # program is clamped to PRESETS_PER_BANK - 1 = 3 assert result.program == 3 def test_pc_program_clamped(self, preset_manager: PresetManager) -> None: """Program > 127 should be clamped to 3 (PRESETS_PER_BANK-1).""" # midi_pc calls _select_and_activate which clamps program # to [0, PRESETS_PER_BANK-1] = [0, 3] # So program=127 becomes 3 result = preset_manager.midi_pc(channel=0, program=127) assert result.program == 3 # Clamped to max index def test_pc_rapid_switching(self, preset_manager: PresetManager) -> None: """Rapid PC messages should switch presets without error.""" handler = MIDIHandler() handler.set_pc_callback(lambda ch, pg: preset_manager.midi_pc(ch, pg)) for bank in range(4): for prog in range(4): preset_manager.save( Preset(name=f"Rapid B{bank}P{prog}", bank=bank, program=prog) ) # Rapid-fire PC messages import time for bank in range(4): for prog in range(4): ev = handler.parse(bytes([PROGRAM_CHANGE | bank, prog])) assert ev is not None handler.dispatch(ev) time.sleep(0.001) # Small yield between dispatches # Should end on the last one assert preset_manager.current_bank == 3 assert preset_manager.current_program == 3