"""Tests for MIDI types and message handling.""" import pytest from src.midi.types import ( MIDIMessage, MIDIMessageType, MIDIMapping, MixerParameter, ParameterCategory, ParameterType, MIDISystemMessage, ) class TestMIDIMessageType: def test_from_status_cc(self): assert MIDIMessageType.from_status(0xB0) == MIDIMessageType.CONTROL_CHANGE assert MIDIMessageType.from_status(0xB3) == MIDIMessageType.CONTROL_CHANGE def test_from_status_note_on(self): assert MIDIMessageType.from_status(0x90) == MIDIMessageType.NOTE_ON assert MIDIMessageType.from_status(0x9F) == MIDIMessageType.NOTE_ON def test_from_status_note_off(self): assert MIDIMessageType.from_status(0x80) == MIDIMessageType.NOTE_OFF def test_channel(self): assert MIDIMessageType.channel(0xB0) == 0 assert MIDIMessageType.channel(0xB3) == 3 assert MIDIMessageType.channel(0xBF) == 15 class TestMIDIMessage: def test_cc_message(self): msg = MIDIMessage( msg_type=MIDIMessageType.CONTROL_CHANGE, channel=0, controller=7, value=100, ) assert msg.is_cc assert not msg.is_nrpn assert msg.controller == 7 assert msg.value == 100 assert msg.value_normalised == pytest.approx(100 / 127, abs=0.01) def test_note_on_message(self): msg = MIDIMessage( msg_type=MIDIMessageType.NOTE_ON, channel=1, controller=60, value=127, ) assert not msg.is_cc assert msg.channel == 1 def test_pitch_bend_message(self): msg = MIDIMessage( msg_type=MIDIMessageType.PITCH_BEND, channel=0, value=8192, # Centre ) assert msg.value_normalised == pytest.approx(0.0, abs=0.01) msg2 = MIDIMessage( msg_type=MIDIMessageType.PITCH_BEND, channel=0, value=16383, ) assert msg2.value_normalised == pytest.approx(1.0, abs=0.02) def test_nrpn_message(self): msg = MIDIMessage( msg_type=MIDIMessageType.CONTROL_CHANGE, channel=0, is_nrpn=True, nrpn_msb=0, nrpn_lsb=1, value=1000, ) assert msg.is_nrpn assert msg.nrpn_number == 1 # (0 << 7) | 1 def test_nrpn_number_14bit(self): msg = MIDIMessage( msg_type=MIDIMessageType.CONTROL_CHANGE, channel=0, is_nrpn=True, nrpn_msb=0x01, nrpn_lsb=0x02, ) assert msg.nrpn_number == (0x01 << 7) | 0x02 def test_repr(self): msg = MIDIMessage( msg_type=MIDIMessageType.CONTROL_CHANGE, channel=0, controller=7, value=100, source_device="TestDevice", ) r = repr(msg) assert "CONTROL_CHANGE" in r assert "CC=7" in r assert "val=100" in r assert "TestDevice" in r class TestMIDIMapping: def test_cc_mapping_match(self): m = MIDIMapping( msg_type=MIDIMessageType.CONTROL_CHANGE, channel=0, controller=7, param_type=ParameterType.VOLUME, param_channel=0, ) msg = MIDIMessage( msg_type=MIDIMessageType.CONTROL_CHANGE, channel=0, controller=7, value=100, ) assert m.matches(msg) def test_cc_mapping_no_match_wrong_channel(self): m = MIDIMapping( msg_type=MIDIMessageType.CONTROL_CHANGE, channel=0, controller=7, ) msg = MIDIMessage( msg_type=MIDIMessageType.CONTROL_CHANGE, channel=1, controller=7, value=100, ) assert not m.matches(msg) def test_cc_mapping_no_match_wrong_controller(self): m = MIDIMapping( msg_type=MIDIMessageType.CONTROL_CHANGE, channel=0, controller=7, ) msg = MIDIMessage( msg_type=MIDIMessageType.CONTROL_CHANGE, channel=0, controller=10, value=100, ) assert not m.matches(msg) def test_device_filter(self): m = MIDIMapping( msg_type=MIDIMessageType.CONTROL_CHANGE, channel=0, controller=7, source_device="X-Touch", ) msg_match = MIDIMessage( msg_type=MIDIMessageType.CONTROL_CHANGE, channel=0, controller=7, value=100, source_device="X-Touch", ) msg_no_match = MIDIMessage( msg_type=MIDIMessageType.CONTROL_CHANGE, channel=0, controller=7, value=100, source_device="MIDImix", ) assert m.matches(msg_match) assert not m.matches(msg_no_match) def test_nrpn_mapping_match(self): m = MIDIMapping( msg_type=MIDIMessageType.CONTROL_CHANGE, channel=0, is_nrpn=True, nrpn_number=258, # (2 << 7) | 2 ) msg = MIDIMessage( msg_type=MIDIMessageType.CONTROL_CHANGE, channel=0, is_nrpn=True, nrpn_msb=2, nrpn_lsb=2, value=8192, ) assert m.matches(msg) def test_scale_value_linear(self): m = MIDIMapping(midi_min=0, midi_max=127, param_min=0.0, param_max=1.0) assert m.scale_value(0) == pytest.approx(0.0) assert m.scale_value(127) == pytest.approx(1.0) assert m.scale_value(63) == pytest.approx(0.5, abs=0.02) def test_scale_value_invert(self): m = MIDIMapping(midi_min=0, midi_max=127, param_min=0.0, param_max=1.0, invert=True) assert m.scale_value(0) == pytest.approx(1.0) assert m.scale_value(127) == pytest.approx(0.0) def test_scale_value_db_range(self): m = MIDIMapping(midi_min=0, midi_max=127, param_min=-60.0, param_max=12.0) assert m.scale_value(0) == pytest.approx(-60.0) assert m.scale_value(127) == pytest.approx(12.0) def test_scale_value_exponential(self): m = MIDIMapping(midi_min=0, midi_max=127, param_min=0.0, param_max=1.0, curve="exponential") assert m.scale_value(0) == pytest.approx(0.0) assert m.scale_value(127) == pytest.approx(1.0) # Should be non-linear (value at 50% is < 50%) assert m.scale_value(63) < 0.5 def test_disabled_mapping(self): m = MIDIMapping(controller=7, enabled=False) msg = MIDIMessage(MIDIMessageType.CONTROL_CHANGE, 0, 7, 100) # The engine skips disabled mappings; matches() still works but caller checks enabled assert m.matches(msg) assert not m.enabled