"""Tests for the core MIDI engine.""" import pytest from src.midi.types import ( MIDIMessage, MIDIMessageType, MIDIMapping, ParameterType, ) from src.midi.midi_engine import MIDIEngine class TestMIDIEngine: @pytest.fixture def engine(self): return MIDIEngine() def test_process_cc_event_matched(self, engine): mapping = MIDIMapping( msg_type=MIDIMessageType.CONTROL_CHANGE, channel=0, controller=7, param_type=ParameterType.VOLUME, param_channel=0, ) engine.add_mapping(mapping) msg = MIDIMessage( msg_type=MIDIMessageType.CONTROL_CHANGE, channel=0, controller=7, value=64, ) results = engine.process_event(msg) assert len(results) == 1 result_mapping, result_value = results[0] assert result_mapping.param_type == ParameterType.VOLUME assert result_mapping.param_channel == 0 assert 0.4 < result_value < 0.6 # 64/127 ≈ 0.504 def test_process_cc_event_no_match(self, engine): mapping = MIDIMapping( msg_type=MIDIMessageType.CONTROL_CHANGE, channel=0, controller=7, ) engine.add_mapping(mapping) msg = MIDIMessage( msg_type=MIDIMessageType.CONTROL_CHANGE, channel=0, controller=10, value=100, ) results = engine.process_event(msg) assert len(results) == 0 def test_multiple_mappings_same_cc(self, engine): """Two mappings for the same CC should both fire.""" m1 = MIDIMapping( msg_type=MIDIMessageType.CONTROL_CHANGE, channel=0, controller=7, param_type=ParameterType.VOLUME, param_channel=0, ) m2 = MIDIMapping( msg_type=MIDIMessageType.CONTROL_CHANGE, channel=0, controller=7, param_type=ParameterType.PAN, param_channel=0, ) engine.set_mappings([m1, m2]) msg = MIDIMessage( msg_type=MIDIMessageType.CONTROL_CHANGE, channel=0, controller=7, value=100, ) results = engine.process_event(msg) assert len(results) == 2 def test_disabled_mapping_skipped(self, engine): mapping = MIDIMapping( msg_type=MIDIMessageType.CONTROL_CHANGE, channel=0, controller=7, enabled=False, ) engine.add_mapping(mapping) msg = MIDIMessage( msg_type=MIDIMessageType.CONTROL_CHANGE, channel=0, controller=7, value=100, ) results = engine.process_event(msg) assert len(results) == 0 def test_callback_fires(self, engine): mapping = MIDIMapping( msg_type=MIDIMessageType.CONTROL_CHANGE, channel=0, controller=7, ) engine.add_mapping(mapping) fired = [] engine.on_mapped(lambda m, v, r: fired.append((m.param_type, v))) msg = MIDIMessage( msg_type=MIDIMessageType.CONTROL_CHANGE, channel=0, controller=7, value=127, ) engine.process_event(msg) assert len(fired) == 1 def test_set_mappings_replaces(self, engine): m1 = MIDIMapping(controller=7) engine.set_mappings([m1]) assert len(engine.get_mappings()) == 1 m2 = MIDIMapping(controller=10) engine.set_mappings([m2]) assert len(engine.get_mappings()) == 1 assert engine.get_mappings()[0].controller == 10 def test_remove_mapping(self, engine): m = MIDIMapping( msg_type=MIDIMessageType.CONTROL_CHANGE, channel=0, controller=7, param_type=ParameterType.VOLUME, param_channel=0, ) engine.add_mapping(m) assert len(engine.get_mappings()) == 1 engine.remove_mapping(m) assert len(engine.get_mappings()) == 0 def test_find_mappings_for(self, engine): m1 = MIDIMapping(controller=7, param_type=ParameterType.VOLUME, param_channel=0) m2 = MIDIMapping(controller=10, param_type=ParameterType.VOLUME, param_channel=0) m3 = MIDIMapping(controller=11, param_type=ParameterType.PAN, param_channel=0) engine.set_mappings([m1, m2, m3]) vol_mappings = engine.find_mappings_for(ParameterType.VOLUME, 0) assert len(vol_mappings) == 2 def test_stats(self, engine): engine.start() mapping = MIDIMapping(controller=7) engine.add_mapping(mapping) for _ in range(10): msg = MIDIMessage( msg_type=MIDIMessageType.CONTROL_CHANGE, channel=0, controller=7, value=64, ) engine.process_event(msg) stats = engine.stats assert stats["events_total"] == 10 assert stats["events_mapped"] == 10 assert stats["mappings_active"] == 1 class TestNRPNStateMachine: @pytest.fixture def engine(self): return MIDIEngine() def test_nrpn_complete_cycle(self, engine): """Full NRPN: CC 99 → CC 98 → CC 6 → CC 38.""" mapping = MIDIMapping( msg_type=MIDIMessageType.CONTROL_CHANGE, channel=0, is_nrpn=True, nrpn_number=(0x01 << 7) | 0x02, midi_min=0, midi_max=16383, # 14-bit NRPN range param_type=ParameterType.VOLUME, param_channel=0, ) engine.add_mapping(mapping) # Step 1: NRPN MSB (CC 99) r1 = engine.process_event(MIDIMessage( MIDIMessageType.CONTROL_CHANGE, 0, 99, 0x01, )) assert len(r1) == 0 # Intermediate # Step 2: NRPN LSB (CC 98) r2 = engine.process_event(MIDIMessage( MIDIMessageType.CONTROL_CHANGE, 0, 98, 0x02, )) assert len(r2) == 0 # Intermediate # Step 3: Data Entry MSB (CC 6) r3 = engine.process_event(MIDIMessage( MIDIMessageType.CONTROL_CHANGE, 0, 6, 0x40, )) assert len(r3) == 0 # Waiting for LSB # Step 4: Data Entry LSB (CC 38) r4 = engine.process_event(MIDIMessage( MIDIMessageType.CONTROL_CHANGE, 0, 38, 0x20, )) assert len(r4) == 1 # Complete! mapping_out, value = r4[0] assert mapping_out.is_nrpn # Value = (0x40 << 7) | 0x20 = 0x2020 = 8224 # Scaled: 8224 / 16383 ≈ 0.502 assert value == pytest.approx(8224 / 16383, abs=0.01) def test_nrpn_reset_on_regular_cc(self, engine): """Sending a regular CC after NRPN preamble should reset state.""" # Start NRPN engine.process_event(MIDIMessage( MIDIMessageType.CONTROL_CHANGE, 0, 99, 0x01, )) engine.process_event(MIDIMessage( MIDIMessageType.CONTROL_CHANGE, 0, 98, 0x02, )) # Send a regular CC (should pass through and reset NRPN state) mapping = MIDIMapping(controller=7) engine.add_mapping(mapping) r = engine.process_event(MIDIMessage( MIDIMessageType.CONTROL_CHANGE, 0, 7, 100, )) assert len(r) == 1 # Regular CC passes through assert not r[0][0].is_nrpn def test_nrpn_incomplete_msb_lsb_ignored(self, engine): """CC 6 without prior CC 99/98 should not create NRPN.""" r = engine.process_event(MIDIMessage( MIDIMessageType.CONTROL_CHANGE, 0, 6, 100, )) assert len(r) == 0 # NRPN state incomplete def test_nrpn_different_channels_independent(self, engine): """NRPN state is per-channel.""" # Channel 0 NRPN engine.process_event(MIDIMessage(MIDIMessageType.CONTROL_CHANGE, 0, 99, 0x01)) engine.process_event(MIDIMessage(MIDIMessageType.CONTROL_CHANGE, 0, 98, 0x02)) # Channel 1 NRPN (should not interfere) engine.process_event(MIDIMessage(MIDIMessageType.CONTROL_CHANGE, 1, 99, 0x03)) engine.process_event(MIDIMessage(MIDIMessageType.CONTROL_CHANGE, 1, 98, 0x04)) # Complete channel 1 mapping = MIDIMapping( channel=1, # Match the message channel is_nrpn=True, nrpn_number=(0x03 << 7) | 0x04, midi_max=16383, param_type=ParameterType.VOLUME, param_channel=1, ) engine.add_mapping(mapping) r = engine.process_event(MIDIMessage(MIDIMessageType.CONTROL_CHANGE, 1, 6, 50)) engine.process_event(MIDIMessage(MIDIMessageType.CONTROL_CHANGE, 1, 38, 0)) assert len(r) == 0 # Wait for CC38 r2 = engine.process_event(MIDIMessage(MIDIMessageType.CONTROL_CHANGE, 1, 38, 30)) assert len(r2) == 1 assert r2[0][0].nrpn_number == (0x03 << 7) | 0x04