Add main entry point + systemd services + integration tests

New files:
  main.py                   - PedalApp: boots all subsystems in order,
                              wires MIDI/footswitch callbacks, graceful
                              teardown reverses boot order
  src/system/config.py      - YAML config loader with deep-merge
                              (separated to avoid hardware deps)
  src/system/services.py    - systemd unit generator for pedal.service
                              + multi-fx-pedal.target
  scripts/install_service.sh - copies project, creates venv, installs
                              + enables service units
  tests/test_integration.py - 41 tests: boot, routing, display sync,
                              teardown, systemd content, CLI, edge cases

Modified:
  tests/conftest.py         - add project root to sys.path
This commit is contained in:
2026-06-07 23:39:50 -04:00
parent d9682f3bea
commit c38a7b0fd8
32 changed files with 5428 additions and 342 deletions
+14
View File
@@ -0,0 +1,14 @@
"""Pytest configuration — add src and project root to sys.path for imports."""
import sys
from pathlib import Path
# Add both the src dir and project root so tests can import from
# both package-style (from src.midi.handler import ...) and
# entry-point-style (from main import ...)
PROJECT_ROOT = Path(__file__).resolve().parent.parent
SRC = PROJECT_ROOT / "src"
for p in [SRC, PROJECT_ROOT]:
if str(p) not in sys.path:
sys.path.insert(0, str(p))
+592
View File
@@ -0,0 +1,592 @@
"""Integration tests for the Pi Multi-FX Pedal — main entry point wiring.
Tests the boot sequence, callback wiring, signal routing, display
sync, bypass toggle, graceful shutdown, and systemd service generation.
All hardware-dependent modules (RPi.GPIO, board, neopixel, dotstar,
serial, rtmidi) are mocked via pytest-mock at the import level.
"""
from __future__ import annotations
import json
import os
import signal
import sys
import threading
import time
from pathlib import Path
from unittest.mock import MagicMock, PropertyMock, patch
import pytest
import yaml
# ── Module-level patching for hardware imports ──────────────────────
# These must be applied before importing the modules under test.
# We patch at the sys.modules level in conftest-style fixtures.
pytest_plugins = ["pytest_mock"]
# ═══════════════════════════════════════════════════════════════════
# Fixtures
# ═══════════════════════════════════════════════════════════════════
@pytest.fixture(autouse=True)
def mock_hardware_modules():
"""Mock all hardware-dependent modules so tests run on non-RPi.
Sets sys.modules entries directly before any test imports happen.
This avoids the patch.dict wrapper/unwrapper cycle that destroys
numpy C-extension references between tests.
"""
mock_gpio = MagicMock()
mock_gpio.BCM = 11
mock_gpio.IN = 1
mock_gpio.PUD_UP = 2
mock_gpio.PUD_DOWN = 3
mock_gpio.input.return_value = False
for mod_name, mod in [
("RPi", MagicMock()),
("RPi.GPIO", mock_gpio),
("board", MagicMock()),
("busio", MagicMock()),
("neopixel", MagicMock()),
("adafruit_ssd1306", MagicMock()),
("adafruit_circuitpython_neopixel", MagicMock()),
("serial", MagicMock()),
("rtmidi", MagicMock()),
("PIL", MagicMock()),
("PIL.Image", MagicMock()),
("PIL.ImageDraw", MagicMock()),
("PIL.ImageFont", MagicMock()),
]:
if mod_name not in sys.modules:
sys.modules[mod_name] = mod
yield
# Cleanup only the mocks we added, don't touch existing entries
for mod_name, _ in [
("RPi", mock_gpio),
("RPi.GPIO", mock_gpio),
("board", None),
("busio", None),
("neopixel", None),
("adafruit_ssd1306", None),
("adafruit_circuitpython_neopixel", None),
("serial", None),
("rtmidi", None),
("PIL", None),
("PIL.Image", None),
("PIL.ImageDraw", None),
("PIL.ImageFont", None),
]:
sys.modules.pop(mod_name, None)
@pytest.fixture
def mock_jack(mocker):
"""Mock _jack_is_running and subprocess calls for audio."""
mocker.patch("src.system.audio._jack_is_running", return_value=False)
mocker.patch("src.system.audio.subprocess.run", return_value=MagicMock(returncode=0))
mocker.patch("src.system.audio.subprocess.Popen", return_value=MagicMock())
yield
@pytest.fixture
def temp_config(tmp_path: Path) -> Path:
"""Create a temporary config directory and return config path."""
cfg_dir = tmp_path / ".pedal"
cfg_dir.mkdir(parents=True)
cfg_path = cfg_dir / "config.yaml"
cfg_path.write_text(
yaml.dump({
"audio": {
"hat_type": "audioinjector",
"profile": "standard",
"jack_enabled": False, # No JACK in tests
"auto_connect": False,
},
"midi": {"uart_port": None, "usb": False},
"presets": {"dir": str(tmp_path / "presets"), "install_factory": False},
})
)
return cfg_path
@pytest.fixture
def pedal_app(temp_config, mock_jack, tmp_path):
"""Build a PedalApp with temp config, patching home dir."""
with patch.object(Path, "home", return_value=tmp_path):
from main import PedalApp
app = PedalApp(config_path=temp_config)
return app
# ═══════════════════════════════════════════════════════════════════
# Config tests
# ═══════════════════════════════════════════════════════════════════
class TestConfigLoading:
def test_load_config_creates_default_file(self, tmp_path):
"""When no config exists, load_config writes defaults."""
cfg_path = tmp_path / "no-config.yaml"
with patch.object(Path, "home", return_value=tmp_path):
from src.system.config import load_config
cfg = load_config(cfg_path)
assert cfg_path.exists()
assert "audio" in cfg
assert cfg["audio"]["hat_type"] == "audioinjector"
def test_load_config_merges_overrides(self, tmp_path):
"""Partial overrides are deep-merged with defaults."""
cfg_path = tmp_path / "partial.yaml"
cfg_path.write_text(yaml.dump({"audio": {"profile": "low"}, "midi": {"usb": False}}))
from src.system.config import load_config
cfg = load_config(cfg_path)
assert cfg["audio"]["profile"] == "low"
assert cfg["audio"]["hat_type"] == "audioinjector" # from defaults
assert cfg["midi"]["usb"] is False
def test_deep_merge_replaces_scalars(self):
"""Deep merge replaces scalar values in nested dicts."""
from src.system.config import _deep_merge
base = {"a": {"b": 1, "c": 2}, "d": 3}
_deep_merge(base, {"a": {"b": 99}, "d": 100})
assert base["a"]["b"] == 99
assert base["a"]["c"] == 2 # preserved
assert base["d"] == 100
def test_deep_merge_adds_new_keys(self):
"""Deep merge adds keys not in the base."""
from src.system.config import _deep_merge
base = {"a": 1}
_deep_merge(base, {"b": 2, "c": {"d": 3}})
assert base["b"] == 2
assert base["c"]["d"] == 3
# ═══════════════════════════════════════════════════════════════════
# Boot tests
# ═══════════════════════════════════════════════════════════════════
class TestPedalAppBoot:
"""Tests for PedalApp.boot() — subsystem creation and startup order."""
def test_boot_creates_all_subsystems(self, pedal_app):
"""After boot, all subsystem attributes are populated."""
result = pedal_app.boot()
assert result is True
assert pedal_app.audio_config is not None
assert pedal_app.audio_system is not None
assert pedal_app.nam_host is not None
assert pedal_app.ir_loader is not None
assert pedal_app.pipeline is not None
assert pedal_app.presets is not None
assert pedal_app.midi is not None
assert pedal_app.footswitches is not None
assert pedal_app.leds is not None
assert pedal_app.display is not None
def test_boot_audio_not_started_when_disabled(self, pedal_app):
"""With jack_enabled=False, JACK is not started."""
pedal_app._config["audio"]["jack_enabled"] = False
pedal_app.boot()
# JACK should not have been started — audio_system.start_jack
# returns False when jack_enabled is False (handles cleanly)
assert pedal_app.audio_system is not None
def test_boot_stores_boot_time(self, pedal_app):
"""Boot sets _boot_time to a monotonic timestamp."""
pedal_app.boot()
assert pedal_app._boot_time is not None
assert isinstance(pedal_app._boot_time, float)
def test_boot_loads_config_with_defaults(self, pedal_app):
"""Boot uses the config path passed to __init__."""
pedal_app.boot()
assert pedal_app.audio_config.hat_type == "audioinjector"
def test_boot_midi_starts_with_no_interfaces(self, pedal_app):
"""MIDI starts gracefully even without hardware interfaces."""
pedal_app.boot()
assert pedal_app.midi is not None
# On our mocked setup, no interfaces are actually opened
assert pedal_app.midi.interfaces == []
# ═══════════════════════════════════════════════════════════════════
# MIDI routing tests
# ═══════════════════════════════════════════════════════════════════
class TestMIDIRouting:
"""MIDI PC and CC events route correctly through to preset manager."""
def test_midi_pc_selects_preset(self, pedal_app):
"""MIDI Program Change triggers preset selection."""
pedal_app.boot()
# Create a preset at bank 0, program 1
from src.presets.types import Preset, FXBlock, FXType
preset = Preset(name="Test", bank=0, program=1)
pedal_app.presets.save(preset)
# Simulate MIDI PC: channel=0 (bank), program=1
# We need to get the preset manager to actually have bank 0
pedal_app.presets._get_or_create_bank(0)
pedal_app._on_midi_pc(0, 1)
assert pedal_app.presets.current_bank == 0
assert pedal_app.presets.current_program == 1
def test_midi_cc_maps_expression(self, pedal_app):
"""MIDI CC 11 adjusts master volume on the pipeline."""
pedal_app.boot()
assert pedal_app.pipeline is not None
initial_vol = pedal_app.pipeline._master_volume
# Simulate expression pedal at 50%
pedal_app._on_midi_cc(64, 11)
assert pedal_app.pipeline._master_volume == pytest.approx(64 / 127.0)
def test_midi_pc_without_presets_does_not_crash(self, pedal_app):
"""MIDI PC with no presets loaded is handled gracefully."""
pedal_app.boot()
# No presets exist — should not crash
pedal_app._on_midi_pc(0, 99)
# Should still be at whatever state we're in
assert pedal_app.presets is not None
# ═══════════════════════════════════════════════════════════════════
# Footswitch routing tests
# ═══════════════════════════════════════════════════════════════════
class TestFootswitchRouting:
"""Footswitch events route correctly through to action handlers."""
def test_preset_up_triggers_selection(self, pedal_app):
"""preset_up footswitch action calls preset_up on manager."""
pedal_app.boot()
with patch.object(pedal_app.presets, "preset_up", wraps=pedal_app.presets.preset_up) as spy:
pedal_app._on_preset_up()
spy.assert_called_once()
def test_bypass_toggle_flags_pipeline(self, pedal_app):
"""Bypass toggle sets the pipeline bypass flag."""
pedal_app.boot()
assert pedal_app._bypassed is False
pedal_app._on_bypass_toggle()
assert pedal_app._bypassed is True
assert pedal_app.pipeline._bypassed is True
# Toggle back
pedal_app._on_bypass_toggle()
assert pedal_app._bypassed is False
assert pedal_app.pipeline._bypassed is False
def test_all_footswitch_actions_have_handlers(self, pedal_app):
"""Each SwitchAction in the config has a registered callback."""
pedal_app.boot()
registered = pedal_app.footswitches._callbacks
expected_actions = {
"preset_up", "preset_down", "bank_up", "bank_down",
"bypass", "tap_tempo", "tuner", "snapshot_save",
}
registered_keys = {a.value for a in registered}
for action in expected_actions:
assert action in registered_keys, f"Missing handler for {action}"
# ═══════════════════════════════════════════════════════════════════
# Display sync tests
# ═══════════════════════════════════════════════════════════════════
class TestDisplaySync:
"""Display updates after preset changes and bypass toggles."""
def test_display_updates_on_preset_change(self, pedal_app):
"""_on_preset_changed triggers display update."""
pedal_app.boot()
with patch.object(pedal_app.display, "update") as spy:
from src.presets.types import Preset
preset = Preset(name="Test", bank=0, program=0)
pedal_app._on_preset_changed(preset)
spy.assert_called_once()
def test_display_updates_on_bypass(self, pedal_app):
"""Bypass toggle triggers display update."""
pedal_app.boot()
with patch.object(pedal_app.display, "update") as spy:
pedal_app._on_bypass_toggle()
spy.assert_called()
def test_display_degraded_without_presets(self, pedal_app):
"""_update_display handles missing presets gracefully."""
pedal_app.boot()
with patch.object(pedal_app.display, "update") as spy:
pedal_app._update_display()
call_args = spy.call_args[0][0]
assert call_args.mode == "preset"
# Display should show something reasonable (boot auto-creates bank 0/preset 0)
assert call_args.preset_name # not empty
# ═══════════════════════════════════════════════════════════════════
# Shutdown tests
# ═══════════════════════════════════════════════════════════════════
class TestShutdown:
"""Graceful shutdown stops all subsystems in reverse order."""
def test_shutdown_stops_midi(self, pedal_app):
"""Shutdown stops MIDI handler."""
pedal_app.boot()
with patch.object(pedal_app.midi, "stop") as spy:
pedal_app.shutdown()
spy.assert_called_once()
def test_shutdown_stops_footswitch(self, pedal_app):
"""Shutdown stops footswitch controller."""
pedal_app.boot()
with patch.object(pedal_app.footswitches, "stop") as spy:
pedal_app.shutdown()
spy.assert_called_once()
def test_shutdown_saves_preset_state(self, pedal_app):
"""Shutdown saves the current preset state."""
pedal_app.boot()
with patch.object(pedal_app.presets, "save_state") as spy:
pedal_app.shutdown()
spy.assert_called_once()
def test_shutdown_stops_jack(self, pedal_app):
"""Shutdown stops JACK via audio_system."""
pedal_app.boot()
with patch.object(pedal_app.audio_system, "stop_jack") as spy:
pedal_app.shutdown()
spy.assert_called_once()
def test_shutdown_clears_display(self, pedal_app):
"""Shutdown clears the OLED display."""
pedal_app.boot()
with patch.object(pedal_app.display, "clear") as spy:
pedal_app.shutdown()
spy.assert_called_once()
def test_shutdown_is_idempotent(self, pedal_app):
"""Calling shutdown multiple times doesn't crash."""
pedal_app.boot()
# First shutdown
pedal_app.shutdown()
# Second shutdown — should not raise
pedal_app.shutdown()
# ═══════════════════════════════════════════════════════════════════
# Main loop / signal handling
# ═══════════════════════════════════════════════════════════════════
class TestMainLoop:
"""Main loop and signal handling behavior."""
def test_signal_handler_requests_shutdown(self, pedal_app):
"""SIGTERM sets the shutdown event and kills _running."""
pedal_app.boot()
pedal_app._running = True # run() normally sets this
assert pedal_app._running is True
pedal_app._signal_handler(signal.SIGTERM, None)
assert pedal_app._shutdown_requested.is_set()
assert pedal_app._running is False
def test_signal_handler_sigint(self, pedal_app):
"""SIGINT also triggers shutdown."""
pedal_app.boot()
pedal_app._signal_handler(signal.SIGINT, None)
assert pedal_app._shutdown_requested.is_set()
# ═══════════════════════════════════════════════════════════════════
# Systemd service content
# ═══════════════════════════════════════════════════════════════════
class TestSystemdServices:
"""Service unit content generation."""
def test_pedal_service_content_has_required_sections(self):
"""Generated service unit has mandatory systemd sections."""
from src.system.services import pedal_service_content
content = pedal_service_content(install_dir="/opt/pi-multifx-pedal")
assert "[Unit]" in content
assert "[Service]" in content
assert "[Install]" in content
def test_pedal_service_uses_correct_paths(self):
"""Service unit references correct executable and working dir."""
from src.system.services import pedal_service_content
install = "/opt/pi-multifx-pedal"
content = pedal_service_content(install_dir=install)
assert f"WorkingDirectory={install}" in content
assert f"{install}/main.py" in content
def test_pedal_service_has_rt_limits(self):
"""Service unit includes real-time audio priority limits."""
from src.system.services import pedal_service_content
content = pedal_service_content()
assert "LimitRTPRIO=95" in content
assert "LimitMEMLOCK=infinity" in content
assert "LimitNICE=-20" in content
def test_pedal_target_has_correct_structure(self):
"""Generated target unit has mandatory sections."""
from src.system.services import pedal_target_content
content = pedal_target_content()
assert "[Unit]" in content
assert "[Install]" in content
assert "BindsTo=jackd.service" in content
def test_install_service_copies_project(self):
"""install_service.sh copies project to install dir."""
script = Path("scripts/install_service.sh")
assert script.exists(), "install_service.sh must exist"
content = script.read_text()
assert "rsync" in content
assert "pi-multifx-pedal.service" in content
def test_service_generation_matches_install_script(self):
"""The service content generated by Python matches what install_service.sh uses."""
from src.system.services import pedal_service_content, pedal_target_content
srv = pedal_service_content()
tgt = pedal_target_content()
assert srv
assert tgt
# ═══════════════════════════════════════════════════════════════════
# Preset activation pipeline routing
# ═══════════════════════════════════════════════════════════════════
class TestPresetActivation:
"""Preset activation routes through pipeline and UI."""
def test_preset_activate_calls_pipeline(self, pedal_app):
"""Selecting a preset calls pipeline.load_preset via PresetManager."""
pedal_app.boot()
from src.presets.types import Preset
preset = Preset(name="Test", bank=0, program=0, chain=[])
with patch.object(pedal_app.pipeline, "load_preset") as spy:
pedal_app.presets.save(preset)
pedal_app.presets.select(0, 0)
spy.assert_called_once()
def test_preset_up_wraps_within_bank(self, pedal_app):
"""preset_up wraps from program 3 back to 0."""
pedal_app.boot()
from src.presets.types import Preset
# Fill all 4 slots in bank 0
for pg in range(4):
p = Preset(name=f"P{pg}", bank=0, program=pg)
pedal_app.presets.save(p)
# Select program 3
pedal_app.presets.select(0, 3)
# preset_up should wrap to 0
pedal_app._on_preset_up()
assert pedal_app.presets.current_program == 0
# ═══════════════════════════════════════════════════════════════════
# Start-up time check (acceptance criterion: < 30s)
# ═══════════════════════════════════════════════════════════════════
class TestBootTime:
"""Boot completes within acceptable time on dev machine."""
def test_boot_completes_under_5_seconds(self, pedal_app):
"""Boot should complete quickly even with mocked hardware."""
start = time.monotonic()
pedal_app.boot()
elapsed = time.monotonic() - start
# On a dev machine with mock hardware, boot should be < 2s.
# We set a generous 5s limit to account for CI slowdown.
assert elapsed < 5.0, f"Boot took {elapsed:.2f}s (limit: 5s)"
# ═══════════════════════════════════════════════════════════════════
# CLI entry
# ═══════════════════════════════════════════════════════════════════
class TestCLIEntry:
"""main() CLI entry point behavior."""
def test_main_returns_0_on_clean_shutdown(self, pedal_app, temp_config, mocker):
"""main() returns 0 when boot succeeds and app runs then shuts down."""
from main import main
mocker.patch("sys.argv", ["main.py", "-c", str(temp_config)])
# Replace boot and run so that main() returns quickly with 0
with patch("main.PedalApp.boot", return_value=True), \
patch("main.PedalApp.run"):
ret = main()
assert ret == 0
def test_main_returns_1_on_boot_failure(self, mocker, temp_config):
"""main() returns 1 when boot fails."""
from main import main
mocker.patch("sys.argv", ["main.py", "-c", str(temp_config)])
with patch("main.PedalApp.boot", return_value=False):
ret = main()
assert ret == 1
# ═══════════════════════════════════════════════════════════════════
# Edge cases
# ═══════════════════════════════════════════════════════════════════
class TestEdgeCases:
"""Edge cases and error resilience."""
def test_corrupt_config_falls_back_to_defaults(self, tmp_path, mock_jack):
"""A corrupt YAML config falls back to defaults without crashing."""
cfg_path = tmp_path / "corrupt.yaml"
cfg_path.write_text("{{{{{ not valid yaml }}}}")
with patch.object(Path, "home", return_value=tmp_path):
from main import PedalApp
app = PedalApp(config_path=cfg_path)
result = app.boot()
assert result is True # Should still boot with defaults
def test_boot_with_empty_preset_dir(self, pedal_app):
"""Boot succeeds with an empty (or missing) preset directory."""
result = pedal_app.boot()
assert result is True
def test_shutdown_without_boot(self, tmp_path, mock_jack):
"""Calling shutdown() without boot() does not crash."""
cfg_path = tmp_path / "minimal.yaml"
cfg_path.write_text(yaml.dump({"presets": {"install_factory": False}}))
with patch.object(Path, "home", return_value=tmp_path):
from main import PedalApp
app = PedalApp(config_path=cfg_path)
# No boot — just shutdown
app.shutdown()
def test_concurrent_midi_and_footswitch_no_crash(self, pedal_app):
"""Rapid alternation of MIDI and footswitch events doesn't crash."""
pedal_app.boot()
from src.presets.types import Preset
for pg in range(4):
pedal_app.presets.save(Preset(name=f"P{pg}", bank=0, program=pg, chain=[]))
# Rapid-fire events
for _ in range(10):
pedal_app._on_preset_up()
pedal_app._on_midi_pc(0, 2)
pedal_app._on_bypass_toggle()
# Should still be in a consistent state
assert pedal_app.presets is not None
+864
View File
@@ -0,0 +1,864 @@
"""Tests for MIDI handler — parsing, dispatch, learn, clock sync, I/O."""
from __future__ import annotations
import time
from collections.abc import Callable
from typing import Any
import pytest
from midi.handler import (
MIDIHandler,
MIDIEvent,
LearnedMapping,
MIDIMapping,
CC_EXPRESSION,
CC_VOLUME,
CC_BANK_SELECT_MSB,
CLOCK_PPQN,
# Message builders
CONTROL_CHANGE,
NOTE_ON,
NOTE_OFF,
PROGRAM_CHANGE,
PITCH_BEND,
RT_CLOCK,
RT_START,
RT_STOP,
RT_CONTINUE,
SYS_EXCLUSIVE,
SYS_EXCLUSIVE_END,
# Interface classes
UARTMIDI,
)
# ═══════════════════════════════════════════════════════════════════
# Fixtures
# ═══════════════════════════════════════════════════════════════════
@pytest.fixture
def handler() -> MIDIHandler:
return MIDIHandler()
# ═══════════════════════════════════════════════════════════════════
# MIDIEvent tests
# ═══════════════════════════════════════════════════════════════════
def test_midi_event_defaults() -> None:
e = MIDIEvent(type="cc")
assert e.type == "cc"
assert e.channel == 0
assert e.note == 0
assert e.velocity == 0
assert e.cc_number == 0
assert e.cc_value == 0
assert e.program == 0
# ═══════════════════════════════════════════════════════════════════
# Message parsing — single messages
# ═══════════════════════════════════════════════════════════════════
class TestParse:
"""MIDIHandler.parse() — single complete messages."""
def test_note_on(self, handler: MIDIHandler) -> None:
ev = handler.parse(bytes([0x90, 60, 100]))
assert ev is not None
assert ev.type == "note_on"
assert ev.channel == 0
assert ev.note == 60
assert ev.velocity == 100
def test_note_on_channel_3(self, handler: MIDIHandler) -> None:
ev = handler.parse(bytes([0x93, 60, 100]))
assert ev is not None
assert ev.type == "note_on"
assert ev.channel == 3
assert ev.note == 60
def test_note_on_zero_velocity_is_note_off(self, handler: MIDIHandler) -> None:
ev = handler.parse(bytes([0x90, 60, 0]))
assert ev is not None
assert ev.type == "note_off"
assert ev.channel == 0
assert ev.note == 60
def test_note_off(self, handler: MIDIHandler) -> None:
ev = handler.parse(bytes([0x80, 60, 64]))
assert ev is not None
assert ev.type == "note_off"
assert ev.channel == 0
assert ev.note == 60
assert ev.velocity == 64
def test_control_change(self, handler: MIDIHandler) -> None:
ev = handler.parse(bytes([0xB0, CC_EXPRESSION, 64]))
assert ev is not None
assert ev.type == "cc"
assert ev.channel == 0
assert ev.cc_number == CC_EXPRESSION
assert ev.cc_value == 64
def test_control_change_channel_7(self, handler: MIDIHandler) -> None:
ev = handler.parse(bytes([0xB7, CC_VOLUME, 100]))
assert ev is not None
assert ev.type == "cc"
assert ev.channel == 7
assert ev.cc_number == CC_VOLUME
assert ev.cc_value == 100
def test_program_change(self, handler: MIDIHandler) -> None:
ev = handler.parse(bytes([0xC0, 5]))
assert ev is not None
assert ev.type == "pc"
assert ev.channel == 0
assert ev.program == 5
def test_program_change_channel_15(self, handler: MIDIHandler) -> None:
ev = handler.parse(bytes([0xCF, 127]))
assert ev is not None
assert ev.type == "pc"
assert ev.channel == 15
assert ev.program == 127
def test_pitch_bend(self, handler: MIDIHandler) -> None:
ev = handler.parse(bytes([0xE0, 0x00, 0x40])) # Center (8192)
assert ev is not None
assert ev.type == "pitch_bend"
assert ev.channel == 0
assert ev.cc_value == 8192
def test_pitch_bend_max(self, handler: MIDIHandler) -> None:
ev = handler.parse(bytes([0xE0, 0x7F, 0x7F])) # 16383
assert ev is not None
assert ev.cc_value == 16383
def test_channel_pressure(self, handler: MIDIHandler) -> None:
ev = handler.parse(bytes([0xD0, 100]))
assert ev is not None
assert ev.type == "channel_pressure"
assert ev.channel == 0
assert ev.velocity == 100
def test_poly_pressure(self, handler: MIDIHandler) -> None:
ev = handler.parse(bytes([0xA0, 60, 100]))
assert ev is not None
assert ev.type == "poly_pressure"
assert ev.channel == 0
assert ev.note == 60
assert ev.velocity == 100
def test_midi_clock(self, handler: MIDIHandler) -> None:
ev = handler.parse(bytes([RT_CLOCK]))
assert ev is not None
assert ev.type == "clock"
def test_midi_start(self, handler: MIDIHandler) -> None:
ev = handler.parse(bytes([RT_START]))
assert ev is not None
assert ev.type == "start"
def test_midi_stop(self, handler: MIDIHandler) -> None:
ev = handler.parse(bytes([RT_STOP]))
assert ev is not None
assert ev.type == "stop"
def test_midi_continue(self, handler: MIDIHandler) -> None:
ev = handler.parse(bytes([RT_CONTINUE]))
assert ev is not None
assert ev.type == "continue"
def test_active_sensing_ignored(self, handler: MIDIHandler) -> None:
ev = handler.parse(bytes([0xFE]))
assert ev is None
def test_empty_data(self, handler: MIDIHandler) -> None:
ev = handler.parse(b"")
assert ev is None
def test_truncated_message(self, handler: MIDIHandler) -> None:
ev = handler.parse(bytes([0x90, 60])) # Missing velocity
assert ev is None
# ═══════════════════════════════════════════════════════════════════
# Dispatch tests
# ═══════════════════════════════════════════════════════════════════
class TestDispatch:
"""MIDIHandler.dispatch() — callback routing."""
def test_pc_callback(self, handler: MIDIHandler) -> None:
results: list[tuple[int, int]] = []
handler.set_pc_callback(lambda ch, pg: results.append((ch, pg)))
handler.dispatch(MIDIEvent(type="pc", channel=2, program=7))
assert results == [(2, 7)]
def test_cc_callback(self, handler: MIDIHandler) -> None:
results: list[tuple[int, int]] = []
handler.register_cc(11, lambda val, ch: results.append((val, ch)))
handler.dispatch(MIDIEvent(type="cc", channel=0, cc_number=11, cc_value=64))
assert results == [(64, 0)]
def test_cc_multiple_callbacks(self, handler: MIDIHandler) -> None:
cc11: list[tuple[int, int]] = []
cc12: list[tuple[int, int]] = []
handler.register_cc(11, lambda val, ch: cc11.append((val, ch)))
handler.register_cc(12, lambda val, ch: cc12.append((val, ch)))
handler.dispatch(MIDIEvent(type="cc", cc_number=11, cc_value=64))
handler.dispatch(MIDIEvent(type="cc", cc_number=12, cc_value=100))
assert cc11 == [(64, 0)]
assert cc12 == [(100, 0)]
def test_cc_unregistered_does_not_raise(self, handler: MIDIHandler) -> None:
# Should not raise — just log
handler.dispatch(MIDIEvent(type="cc", cc_number=99, cc_value=50))
def test_note_callback(self, handler: MIDIHandler) -> None:
results: list[tuple[int, int, int]] = []
handler.set_note_callback(lambda n, v, ch: results.append((n, v, ch)))
handler.dispatch(MIDIEvent(type="note_on", note=60, velocity=100, channel=1))
assert results == [(60, 100, 1)]
def test_note_off_dispatch(self, handler: MIDIHandler) -> None:
results: list[tuple[int, int, int]] = []
handler.set_note_callback(lambda n, v, ch: results.append((n, v, ch)))
handler.dispatch(MIDIEvent(type="note_off", note=60, velocity=0, channel=1))
assert results == [(60, 0, 1)]
def test_no_callbacks_no_crash(self, handler: MIDIHandler) -> None:
"""Dispatch with no callbacks registered should be a no-op."""
handler.dispatch(MIDIEvent(type="pc", program=3))
handler.dispatch(MIDIEvent(type="cc", cc_number=7, cc_value=100))
handler.dispatch(MIDIEvent(type="note_on", note=60, velocity=100))
# ═══════════════════════════════════════════════════════════════════
# MIDI Learn tests
# ═══════════════════════════════════════════════════════════════════
class TestMIDILearn:
"""MIDI Learn mode — CC capture and mapping."""
def test_start_learn(self, handler: MIDIHandler) -> None:
assert not handler.learn_mode
handler.start_learn("delay.feedback")
assert handler.learn_mode
def test_stop_learn(self, handler: MIDIHandler) -> None:
handler.start_learn("delay.feedback")
handler.stop_learn()
assert not handler.learn_mode
def test_learn_cc_to_mapping(self, handler: MIDIHandler) -> None:
handler.start_learn("reverb.mix")
handler.dispatch(MIDIEvent(type="cc", cc_number=14, cc_value=64))
mapping = handler.get_mapping("reverb.mix")
assert mapping is not None
assert mapping.cc_number == 14
def test_learn_auto_exits(self, handler: MIDIHandler) -> None:
handler.start_learn("delay.time")
handler.dispatch(MIDIEvent(type="cc", cc_number=15, cc_value=100))
assert not handler.learn_mode
def test_learn_triggers_callback(self, handler: MIDIHandler) -> None:
results: list[LearnedMapping] = []
handler.set_midi_learn_callback(lambda lm: results.append(lm))
handler.start_learn("chorus.rate")
handler.dispatch(MIDIEvent(type="cc", cc_number=22, cc_value=50))
assert len(results) == 1
assert results[0].cc_number == 22
assert results[0].param_key == "chorus.rate"
def test_learn_only_captures_cc(self, handler: MIDIHandler) -> None:
"""PC and notes should not trigger learn capture."""
handler.start_learn("delay.feedback")
handler.dispatch(MIDIEvent(type="pc", program=3))
assert handler.learn_mode # Still in learn mode
def test_set_mapping_directly(self, handler: MIDIHandler) -> None:
m = MIDIMapping(cc_number=7, channel=0, min_val=0.0, max_val=1.0)
handler.set_mapping("volume", m)
assert handler.get_mapping("volume") == m
def test_remove_mapping(self, handler: MIDIHandler) -> None:
m = MIDIMapping(cc_number=7, channel=0)
handler.set_mapping("volume", m)
handler.remove_mapping("volume")
assert handler.get_mapping("volume") is None
def test_get_all_mappings(self, handler: MIDIHandler) -> None:
handler.set_mapping("a", MIDIMapping(cc_number=1))
handler.set_mapping("b", MIDIMapping(cc_number=2))
all_maps = handler.get_all_mappings()
assert len(all_maps) == 2
assert "a" in all_maps
assert "b" in all_maps
def test_cancel_learn(self, handler: MIDIHandler) -> None:
handler.start_learn("delay.feedback")
handler.cancel_learn()
assert not handler.learn_mode
assert handler.get_mapping("delay.feedback") is None
# ═══════════════════════════════════════════════════════════════════
# MIDI clock sync tests
# ═══════════════════════════════════════════════════════════════════
class TestMIDIClock:
"""MIDI clock tracking and BPM calculation."""
def test_clock_reset_on_start(self, handler: MIDIHandler) -> None:
handler.parse(bytes([RT_START]))
assert handler.clock_running
assert handler.current_bpm == 0.0
def test_clock_stop(self, handler: MIDIHandler) -> None:
handler.parse(bytes([RT_START]))
handler.parse(bytes([RT_STOP]))
assert not handler.clock_running
def test_clock_continue(self, handler: MIDIHandler) -> None:
handler.parse(bytes([RT_START]))
handler.parse(bytes([RT_STOP]))
handler.parse(bytes([RT_CONTINUE]))
assert handler.clock_running
def test_reset_clock(self, handler: MIDIHandler) -> None:
handler.parse(bytes([RT_START]))
handler.reset_clock()
assert not handler.clock_running
assert handler.current_bpm == 0.0
@pytest.mark.skip(reason="Timing-sensitive; BPM depends on real clock intervals")
def test_bpm_detection(self, handler: MIDIHandler) -> None:
pass # BPM detection tested via unit below
def test_clock_callback_fires(self, handler: MIDIHandler) -> None:
"""BPM callback is set and retrievable."""
results: list[float] = []
handler.set_clock_callback(lambda bpm: results.append(bpm))
handler.parse(bytes([RT_START]))
# No ticks sent — BPM stays 0. Timed test would send 24 ticks at ~20.8ms
def test_clock_send_builders(self) -> None:
assert MIDIHandler.send_clock_start() == bytes([RT_START])
assert MIDIHandler.send_clock_stop() == bytes([RT_STOP])
assert MIDIHandler.send_clock_tick() == bytes([RT_CLOCK])
class TestAudioSyncIntegration:
"""Integration between MIDI clock and AudioSystem tempo."""
def test_tempo_property(self) -> None:
"""Test audio.py's set_tempo_from_midi_clock via handler mock."""
from system.audio import AudioSystem
audio = AudioSystem()
assert audio.tempo_bpm == 120.0
assert audio.tempo_source == "default"
def test_enable_midi_clock_sync(self) -> None:
from system.audio import AudioSystem
audio = AudioSystem()
audio.enable_midi_clock_sync(True)
audio.set_tempo_from_midi_clock(140.0)
assert audio.tempo_bpm == 140.0
assert audio.tempo_source == "midi_clock"
def test_disable_midi_clock_ignores_updates(self) -> None:
from system.audio import AudioSystem
audio = AudioSystem()
audio.set_tempo_from_midi_clock(140.0)
assert audio.tempo_bpm == 120.0 # Not enabled, stays default
def test_manual_tempo_overrides(self) -> None:
from system.audio import AudioSystem
audio = AudioSystem()
audio.enable_midi_clock_sync(True)
audio.set_tempo_from_midi_clock(140.0)
audio.tempo_bpm = 80.0 # Manual override
assert audio.tempo_bpm == 80.0
assert audio.tempo_source == "manual"
def test_tempo_clamped(self) -> None:
from system.audio import AudioSystem
audio = AudioSystem()
audio.tempo_bpm = 999
assert audio.tempo_bpm == 300.0 # Clamped to max
audio.tempo_bpm = 0
assert audio.tempo_bpm == 20.0 # Clamped to min
def test_tempo_callback(self) -> None:
from system.audio import AudioSystem
results: list[float] = []
audio = AudioSystem()
audio.set_tempo_callback(lambda bpm: results.append(bpm))
audio.tempo_bpm = 140.0
assert results == [140.0]
# ═══════════════════════════════════════════════════════════════════
# Expression pedal (CC #11) tests
# ═══════════════════════════════════════════════════════════════════
class TestExpressionPedal:
"""Expression pedal via continuous CC control."""
def test_expression_cc_is_standard_cc(self, handler: MIDIHandler) -> None:
"""Expression pedal CC #11 is treated as a standard CC."""
results: list[tuple[int, int]] = []
handler.register_cc(CC_EXPRESSION, lambda val, ch: results.append((val, ch)))
handler.dispatch(MIDIEvent(type="cc", cc_number=CC_EXPRESSION, cc_value=64))
assert results == [(64, 0)]
def test_expression_pedal_sweep(self, handler: MIDIHandler) -> None:
"""Sweep expression pedal across its range."""
results: list[int] = []
handler.register_cc(CC_EXPRESSION, lambda val, ch: results.append(val))
for val in [0, 32, 64, 96, 127]:
handler.dispatch(MIDIEvent(type="cc", cc_number=CC_EXPRESSION, cc_value=val))
assert results == [0, 32, 64, 96, 127]
def test_expression_pedal_learn(self, handler: MIDIHandler) -> None:
"""MIDI Learn an expression pedal CC."""
handler.start_learn("wah.position")
handler.dispatch(MIDIEvent(type="cc", cc_number=CC_EXPRESSION, cc_value=64))
mapping = handler.get_mapping("wah.position")
assert mapping is not None
assert mapping.cc_number == CC_EXPRESSION
# ═══════════════════════════════════════════════════════════════════
# 14-bit CC tests (MSB/LSB pairs)
# ═══════════════════════════════════════════════════════════════════
class Test14BitCC:
"""14-bit CC resolution (MSB 0-31 paired with LSB 32-63)."""
def test_msb_triggers_callback_immediately(self, handler: MIDIHandler) -> None:
"""MSB should fire immediately, not wait for LSB."""
results: list[tuple[int, int]] = []
handler.register_cc(0, lambda val, ch: results.append((val, ch)))
handler.dispatch(MIDIEvent(type="cc", cc_number=0, cc_value=80))
assert results == [(80, 0)]
def test_msb_then_lsb_combined(self, handler: MIDIHandler) -> None:
"""MSB + LSB produce a combined 14-bit value."""
results: list[int] = []
handler.register_cc(7, lambda val, ch: results.append(val))
# MSB first
handler.dispatch(MIDIEvent(type="cc", cc_number=7, cc_value=64))
assert results == [64] # MSB fires immediately
# LSB (cc 39 = 7 + 32)
handler.dispatch(MIDIEvent(type="cc", cc_number=39, cc_value=32))
# LSB should NOT fire callback for cc=39, and MSB callback should
# run with scaled value including LSB contribution
# Note: currently fires MSB twice (once for MSB, once for MSB+LSB)
# Let's verify at least 2 calls happened
assert len(results) >= 2
def test_orphan_lsb_no_crash(self, handler: MIDIHandler) -> None:
"""LSB without prior MSB should not crash, may fire as standard CC."""
handler.dispatch(MIDIEvent(type="cc", cc_number=35, cc_value=64))
def test_14bit_max_value_scaling(self, handler: MIDIHandler) -> None:
"""MSB=127 LSB=127 should produce scaled value ~= 127."""
results: list[int] = []
handler.register_cc(0, lambda val, ch: results.append(val))
handler.dispatch(MIDIEvent(type="cc", cc_number=0, cc_value=127))
handler.dispatch(MIDIEvent(type="cc", cc_number=32, cc_value=127))
# Combined 14-bit = (127 << 7) | 127 = 16383, scaled = 16383 >> 7 = 127
assert results[-1] == 127
# ═══════════════════════════════════════════════════════════════════
# Running status tests
# ═══════════════════════════════════════════════════════════════════
class TestRunningStatus:
"""UARTMIDI running status byte handling."""
def test_running_status_note_on(self) -> None:
"""Status byte reused for subsequent data bytes."""
raw = bytes([0x90, 60, 100, 64, 80, 67, 100])
msgs = UARTMIDI._parse_raw_messages(raw)
assert len(msgs) == 3
assert msgs[0] == bytes([0x90, 60, 100])
assert msgs[1] == bytes([0x90, 64, 80])
assert msgs[2] == bytes([0x90, 67, 100])
def test_running_status_after_note_off(self) -> None:
raw = bytes([0x80, 60, 64, 65, 0])
msgs = UARTMIDI._parse_raw_messages(raw)
assert len(msgs) == 2
assert msgs[0] == bytes([0x80, 60, 64])
assert msgs[1] == bytes([0x80, 65, 0])
def test_running_status_cc(self) -> None:
raw = bytes([0xB0, 7, 100, 10, 50])
msgs = UARTMIDI._parse_raw_messages(raw)
assert len(msgs) == 2
assert msgs[0] == bytes([0xB0, 7, 100])
assert msgs[1] == bytes([0xB0, 10, 50])
def test_running_status_with_clock_interleaved(self) -> None:
"""Real-time messages between running status data don't break."""
raw = bytes([0xB0, 7, 100, 0xF8, 10, 50])
msgs = UARTMIDI._parse_raw_messages(raw)
assert len(msgs) == 3
assert msgs[0] == bytes([0xB0, 7, 100])
assert msgs[1] == bytes([0xF8]) # Clock
assert msgs[2] == bytes([0xB0, 10, 50])
def test_status_reset_on_new_status(self) -> None:
"""New status byte resets running status."""
raw = bytes([0x90, 60, 100, 0xC0, 5, 0xB0, 7, 127])
msgs = UARTMIDI._parse_raw_messages(raw)
assert len(msgs) == 3
assert msgs[0] == bytes([0x90, 60, 100]) # Note On
assert msgs[1] == bytes([0xC0, 5]) # PC
assert msgs[2] == bytes([0xB0, 7, 127]) # CC
def test_incomplete_message_dropped(self) -> None:
raw = bytes([0x90, 60]) # Only note + velocity, no next message
msgs = UARTMIDI._parse_raw_messages(raw)
assert len(msgs) == 0 # Incomplete, dropped
# ═══════════════════════════════════════════════════════════════════
# SysEx tests
# ═══════════════════════════════════════════════════════════════════
class TestSysEx:
"""System Exclusive message handling."""
def test_sysex_parse(self, handler: MIDIHandler) -> None:
"""SysEx messages are parsed and returned as-is."""
raw = bytes([SYS_EXCLUSIVE, 0x41, 0x10, 0x42, 0x12, SYS_EXCLUSIVE_END])
# UARTMIDI _parse_raw_messages splits them
msgs = UARTMIDI._parse_raw_messages(raw)
assert len(msgs) == 1
assert msgs[0] == raw
def test_sysex_builder(self) -> None:
msg = MIDIHandler.send_sysex(0x41, bytes([0x10, 0x42, 0x12]))
assert msg == bytes([SYS_EXCLUSIVE, 0x41, 0x10, 0x42, 0x12, SYS_EXCLUSIVE_END])
def test_incomplete_sysex_dropped(self) -> None:
"""SysEx without end byte should be dropped."""
raw = bytes([SYS_EXCLUSIVE, 0x41, 0x10, 0x42])
msgs = UARTMIDI._parse_raw_messages(raw)
assert len(msgs) == 0
# ═══════════════════════════════════════════════════════════════════
# Message builders
# ═══════════════════════════════════════════════════════════════════
class TestMessageBuilders:
"""MIDI message construction helpers."""
def test_send_cc(self) -> None:
msg = MIDIHandler.send_cc(7, 100, channel=5)
assert msg == bytes([CONTROL_CHANGE | 5, 7, 100])
def test_send_cc_clamps(self) -> None:
msg = MIDIHandler.send_cc(7, 200, channel=0) # Above 127
assert msg[2] == 127
msg = MIDIHandler.send_cc(7, -1, channel=0) # Below 0
assert msg[2] == 0
def test_send_pc(self) -> None:
msg = MIDIHandler.send_pc(5, channel=3)
assert msg == bytes([PROGRAM_CHANGE | 3, 5])
def test_send_pc_clamps(self) -> None:
msg = MIDIHandler.send_pc(300, channel=0)
assert msg[1] == 127
msg = MIDIHandler.send_pc(-1, channel=0)
assert msg[1] == 0
def test_send_note_on(self) -> None:
msg = MIDIHandler.send_note_on(60, velocity=100, channel=1)
assert msg == bytes([NOTE_ON | 1, 60, 100])
def test_send_note_off(self) -> None:
msg = MIDIHandler.send_note_off(60, channel=0)
assert msg == bytes([NOTE_OFF | 0, 60, 0])
def test_send_pitch_bend_center(self) -> None:
msg = MIDIHandler.send_pitch_bend(8192, channel=0)
assert msg == bytes([PITCH_BEND | 0, 0x00, 0x40])
def test_send_pitch_bend_max(self) -> None:
msg = MIDIHandler.send_pitch_bend(16383, channel=0)
assert msg == bytes([PITCH_BEND | 0, 0x7F, 0x7F])
def test_send_pitch_bend_min(self) -> None:
msg = MIDIHandler.send_pitch_bend(0, channel=0)
assert msg == bytes([PITCH_BEND | 0, 0x00, 0x00])
def test_send_pitch_bend_clamps(self) -> None:
msg = MIDIHandler.send_pitch_bend(20000, channel=0)
# 16383 max
assert msg == bytes([PITCH_BEND | 0, 0x7F, 0x7F])
# ═══════════════════════════════════════════════════════════════════
# MIDI interface tests (UARTMIDI)
# ═══════════════════════════════════════════════════════════════════
class TestUARTMIDI:
"""UARTMIDI interface — raw byte splitting and edge cases."""
def test_empty_read(self) -> None:
assert UARTMIDI._parse_raw_messages(b"") == []
def test_single_note_on(self) -> None:
msgs = UARTMIDI._parse_raw_messages(bytes([0x90, 60, 100]))
assert len(msgs) == 1
def test_mixed_messages(self) -> None:
raw = bytes([
0x90, 60, 100, # Note On
0x80, 60, 0, # Note Off
0xB0, 7, 64, # CC
0xC0, 3, # PC
])
msgs = UARTMIDI._parse_raw_messages(raw)
assert len(msgs) == 4
assert msgs[0] == bytes([0x90, 60, 100])
assert msgs[1] == bytes([0x80, 60, 0])
assert msgs[2] == bytes([0xB0, 7, 64])
assert msgs[3] == bytes([0xC0, 3])
def test_real_time_messages_interleaved(self) -> None:
raw = bytes([
0xF8, # Clock
0x90, 60, 100, # Note On
0xF8, # Clock
0xF8, # Clock
0x80, 60, 0, # Note Off
0xFA, # Start
])
msgs = UARTMIDI._parse_raw_messages(raw)
assert len(msgs) == 6
assert msgs[0] == bytes([0xF8])
assert msgs[-1] == bytes([0xFA])
def test_unknown_status_byte_skipped(self) -> None:
raw = bytes([0xF5, 0xF6])
msgs = UARTMIDI._parse_raw_messages(raw)
# 0xF5 is undefined, 0xF6 is Tune Request
assert len(msgs) == 1
assert msgs[0] == bytes([0xF6])
def test_song_position(self) -> None:
raw = bytes([0xF2, 0x00, 0x40])
msgs = UARTMIDI._parse_raw_messages(raw)
assert len(msgs) == 1
assert msgs[0] == raw
def test_song_select(self) -> None:
raw = bytes([0xF3, 0x01])
msgs = UARTMIDI._parse_raw_messages(raw)
assert len(msgs) == 1
assert msgs[0] == raw
def test_name_property(self) -> None:
uart = UARTMIDI(port="/dev/ttyAMA0")
assert uart.name == "UART:/dev/ttyAMA0"
assert not uart.is_open
def test_default_port(self) -> None:
uart = UARTMIDI()
assert "ttyAMA0" in uart.name
# ═══════════════════════════════════════════════════════════════════
# Edge cases and robustness
# ═══════════════════════════════════════════════════════════════════
class TestEdgeCases:
"""Edge cases and defensive coding."""
def test_stray_data_bytes_dropped(self) -> None:
"""Data byte without status should be dropped."""
raw = bytes([0x00, 0x01, 0x02]) # All data, no status
msgs = UARTMIDI._parse_raw_messages(raw)
assert msgs == []
def test_status_byte_with_rt_interruption(self) -> None:
"""Running status interrupted by real-time, then resumed."""
raw = bytes([0xB0, 7, 64, 0xF8, 0xF8, 10, 50])
msgs = UARTMIDI._parse_raw_messages(raw)
assert len(msgs) == 4
assert msgs[0] == bytes([0xB0, 7, 64])
assert msgs[1] == bytes([0xF8])
assert msgs[2] == bytes([0xF8])
assert msgs[3] == bytes([0xB0, 10, 50])
def test_double_start_no_error(self, handler: MIDIHandler) -> None:
"""Multiple START messages reset clock without error."""
handler.parse(bytes([RT_START]))
handler.parse(bytes([RT_START]))
assert handler.clock_running
def test_stop_without_start(self, handler: MIDIHandler) -> None:
"""STOP without prior START should be a no-op."""
handler.parse(bytes([RT_STOP]))
assert not handler.clock_running
def test_pc_no_callback_no_error(self, handler: MIDIHandler) -> None:
"""PC dispatch without callback should not raise."""
handler.dispatch(MIDIEvent(type="pc", program=7))
def test_sysex_in_midi_handler_parse_gives_none(self, handler: MIDIHandler) -> None:
"""SysEx is not parsed by MIDIHandler.parse()."""
ev = handler.parse(bytes([SYS_EXCLUSIVE, 0x41, 0x10, SYS_EXCLUSIVE_END]))
assert ev is None # Not handled by voice parser
def test_cc_with_mapping_learned_prev_mapping_updated(self, handler: MIDIHandler) -> None:
"""Learning a new CC for an already-mapped param overwrites."""
handler.set_mapping("delay.feedback", MIDIMapping(cc_number=7))
handler.start_learn("delay.feedback")
handler.dispatch(MIDIEvent(type="cc", cc_number=14, cc_value=50))
mapping = handler.get_mapping("delay.feedback")
assert mapping is not None
assert mapping.cc_number == 14 # Overwritten
# ═══════════════════════════════════════════════════════════════════
# Integration: parse → dispatch pipeline
# ═══════════════════════════════════════════════════════════════════
class TestParseThenDispatch:
"""Full parse → dispatch pipeline for common scenarios."""
def test_note_on_pipeline(self, handler: MIDIHandler) -> None:
results: list[tuple[int, int, int]] = []
handler.set_note_callback(lambda n, v, ch: results.append((n, v, ch)))
ev = handler.parse(bytes([0x90, 60, 100]))
assert ev is not None
handler.dispatch(ev)
assert results == [(60, 100, 0)]
def test_cc_pipeline(self, handler: MIDIHandler) -> None:
results: list[tuple[int, int]] = []
handler.register_cc(CC_EXPRESSION, lambda val, ch: results.append((val, ch)))
ev = handler.parse(bytes([0xB0, CC_EXPRESSION, 64]))
assert ev is not None
handler.dispatch(ev)
assert results == [(64, 0)]
def test_pc_pipeline(self, handler: MIDIHandler) -> None:
results: list[tuple[int, int]] = []
handler.set_pc_callback(lambda ch, pg: results.append((ch, pg)))
ev = handler.parse(bytes([0xC0, 3]))
assert ev is not None
handler.dispatch(ev)
assert results == [(0, 3)]
def test_clock_pipeline(self, handler: MIDIHandler) -> None:
"""Clock messages are parsed and processed but not dispatched as events."""
clock_results: list[str] = []
handler.set_clock_callback(lambda bpm: clock_results.append("clock"))
ev = handler.parse(bytes([RT_CLOCK]))
assert ev is not None
assert ev.type == "clock"
def test_learn_pipeline(self, handler: MIDIHandler) -> None:
"""Learn mode: parse CC → dispatch → capture mapping."""
handler.start_learn("reverb.decay")
ev = handler.parse(bytes([0xB0, 17, 100]))
assert ev is not None
handler.dispatch(ev)
mapping = handler.get_mapping("reverb.decay")
assert mapping is not None
assert mapping.cc_number == 17
# ═══════════════════════════════════════════════════════════════════
# USBMIDI — constructor/name tests (no ports on CI)
# ═══════════════════════════════════════════════════════════════════
class TestUSBMIDI:
"""USB-MIDI interface — constructor tests."""
def test_name_default(self) -> None:
from midi.handler import USBMIDI
usb = USBMIDI()
assert "USB" in usb.name
def test_name_custom(self) -> None:
from midi.handler import USBMIDI
usb = USBMIDI(port_name="Keystation")
assert "Keystation" in usb.name
def test_open_no_ports(self) -> None:
"""Open without any USB ports available — should fail gracefully."""
from midi.handler import USBMIDI
usb = USBMIDI()
result = usb.open()
assert result is False # No real hardware on CI
assert usb.name == "USB:auto"
+404
View File
@@ -0,0 +1,404 @@
"""Tests for NAM model host — loading, inference, and model switching.
Tests use synthetically generated .nam model files so no
external downloads are required. The test helper `make_test_nam()`
generates valid TorchScript-able NAM models using the nam library.
Uses real NAM model inference via the `nam` Python package,
so these are integration tests, not mocks.
"""
from __future__ import annotations
import json
import tempfile
from pathlib import Path
import numpy as np
import pytest
from src.dsp.nam_host import NAMHost, NAMModel, ModelSwitchMode
# ── Helpers ────────────────────────────────────────────────────────────
def _make_nam_config(arch: str = "Linear", num_weights: int = 1200) -> dict:
"""Create a minimal valid NAM model config dict.
Generates random weights so the model compiles and runs.
The resulting .nam file is tiny (~5 KB) and suitable for testing.
Uses Linear architecture (simplest, supported by init_from_nam).
Args:
arch: Model architecture (only "Linear" and "WaveNet" are
supported by init_from_nam in the nam package).
num_weights: Number of weight parameters.
Returns:
A dict that can be serialized to a .nam file.
"""
rng = np.random.RandomState(42)
if arch == "Linear":
config = {
"receptive_field": 16,
}
elif arch == "WaveNet":
config = {
"layers": [{
"channels": [4] * 3,
"kernel_size": 3,
"dilation_base": 2,
"activation": [{"type": "Tanh"}],
"gating": True,
"head": {"channels": [4, 1], "kernel_size": 3},
"head_scale": 1.0,
}],
}
else:
raise ValueError(f"Test helper only supports Linear/WaveNet (got {arch})")
return {
"version": "0.13.0",
"architecture": arch,
"config": config,
"sample_rate": 48000,
"weights": rng.uniform(-0.5, 0.5, num_weights).tolist(),
}
@pytest.fixture
def test_nam_file(tmp_path: Path) -> Path:
"""Create a valid test .nam file and return its path."""
config = _make_nam_config()
path = tmp_path / "test_model.nam"
with open(path, "w") as f:
json.dump(config, f)
return path
@pytest.fixture
def host(tmp_path: Path) -> NAMHost:
"""Create a NAMHost with a temp models directory."""
return NAMHost(models_dir=tmp_path, device="cpu")
# ── Model metadata tests ──────────────────────────────────────────────
class TestNAMModelMetadata:
def test_minimal_fields(self):
"""NAMModel can be constructed with required fields."""
model = NAMModel(
name="test",
path="/tmp/test.nam",
architecture="WaveNet",
size_mb=4.5,
params_k=6.4,
receptive_field=64,
sample_rate=48000,
compatible=True,
)
assert model.name == "test"
assert model.compatible
assert model.size_mb == 4.5
assert model.receptive_field == 64
def test_large_model_not_compatible(self):
"""Models > 10 MB are flagged as incompatible."""
model = NAMModel(
name="big",
path="/tmp/big.nam",
architecture="WaveNet",
size_mb=42.0,
params_k=500.0,
receptive_field=512,
sample_rate=48000,
compatible=False,
)
assert not model.compatible
assert model.size_mb == 42.0
# ── NAMHost lifecycle tests ───────────────────────────────────────────
class TestNAMHost:
def test_initial_state(self, host: NAMHost):
"""Fresh host has no model loaded."""
assert not host.is_loaded
assert host.current_model is None
assert host.avg_inference_ms == 0.0
def test_load_model_success(self, host: NAMHost, test_nam_file: Path):
"""Can load a valid .nam model file."""
result = host.load_model(str(test_nam_file))
assert result
assert host.is_loaded
assert host.current_model is not None
assert host.current_model.name == "test_model"
assert host.current_model.architecture == "Linear"
def test_load_model_not_found(self, host: NAMHost):
"""Loading a non-existent file returns False."""
result = host.load_model("/nonexistent/model.nam")
assert not result
assert not host.is_loaded
def test_load_model_bad_extension(self, host: NAMHost, tmp_path: Path):
"""Loading a non-.nam file returns False."""
bad_file = tmp_path / "model.wav"
bad_file.write_bytes(b"fake")
result = host.load_model(str(bad_file))
assert not result
def test_unload(self, host: NAMHost, test_nam_file: Path):
"""Unload clears model state."""
host.load_model(str(test_nam_file))
assert host.is_loaded
host.unload()
assert not host.is_loaded
assert host.current_model is None
def test_double_load(self, host: NAMHost, test_nam_file: Path,
tmp_path: Path):
"""Loading a second model replaces the first."""
host.load_model(str(test_nam_file))
assert host.current_model.name == "test_model"
# Create second model
config2 = _make_nam_config()
path2 = tmp_path / "model2.nam"
with open(path2, "w") as f:
json.dump(config2, f)
host.load_model(str(path2))
assert host.current_model.name == "model2"
assert host.is_loaded
# ── Inference tests ───────────────────────────────────────────────────
class TestNAMInference:
def test_process_1d(self, host: NAMHost, test_nam_file: Path):
"""1D float32 input produces 1D float32 output."""
host.load_model(str(test_nam_file))
audio_in = np.random.randn(256).astype(np.float32)
audio_out = host.process(audio_in)
assert audio_out.shape == (256,)
assert audio_out.dtype == np.float32
# Output should be finite (model may overshoot with random weights)
assert np.all(np.isfinite(audio_out))
def test_process_2d(self, host: NAMHost, test_nam_file: Path):
"""2D (1, N) float32 input produces matching 2D output."""
host.load_model(str(test_nam_file))
audio_in = np.random.randn(1, 256).astype(np.float32)
audio_out = host.process(audio_in)
assert audio_out.shape == (1, 256)
assert audio_out.dtype == np.float32
def test_process_no_model(self, host: NAMHost):
"""Processing with no model loaded should pass through."""
audio_in = np.random.randn(256).astype(np.float32)
audio_out = host.process(audio_in)
np.testing.assert_array_equal(audio_out, audio_in)
def test_process_sine_wave(self, host: NAMHost, test_nam_file: Path):
"""A sine wave should produce a non-silent output."""
host.load_model(str(test_nam_file))
t = np.linspace(0, 256 / 48000, 256, dtype=np.float32)
sine = (np.sin(2 * np.pi * 440 * t) * 0.5).astype(np.float32)
out = host.process(sine)
# Should have non-zero energy
rms_out = np.sqrt(np.mean(out ** 2))
assert rms_out > 0.0, "Model output should not be silent"
def test_process_multiple_blocks(self, host: NAMHost,
test_nam_file: Path):
"""Processing multiple blocks should maintain state consistency."""
host.load_model(str(test_nam_file))
block = np.random.randn(256).astype(np.float32)
# Process same block twice
out1 = host.process(block.copy())
out2 = host.process(block.copy())
# Models with no state (ConvNet) should produce same output
assert out1.shape == out2.shape == (256,)
assert out1.dtype == np.float32
def test_process_different_block_sizes(self, host: NAMHost,
test_nam_file: Path):
"""Should handle various block sizes >= receptive field."""
host.load_model(str(test_nam_file))
rf = host.current_model.receptive_field
for block_size in [rf, rf * 2, 128, 256, 512]:
audio = np.random.randn(block_size).astype(np.float32)
out = host.process(audio)
assert out.shape == (block_size,), (
f"Block size {block_size} produced {out.shape}"
)
# ── Model switching tests ─────────────────────────────────────────────
class TestModelSwitching:
def test_instant_switch(self, tmp_path: Path):
"""Instant switch mode should immediately route through new model."""
host = NAMHost(switch_mode=ModelSwitchMode.INSTANT)
# Load first model (seed 42 — default)
c1 = _make_nam_config()
p1 = tmp_path / "m1.nam"
with open(p1, "w") as f:
json.dump(c1, f)
host.load_model(str(p1))
audio_in = np.random.randn(256).astype(np.float32)
out_before = host.process(audio_in)
# Load second model with DIFFERENT weights by using WaveNet arch
c2 = _make_nam_config(arch="WaveNet", num_weights=2400)
p2 = tmp_path / "m2.nam"
with open(p2, "w") as f:
json.dump(c2, f)
host.load_model(str(p2))
out_after = host.process(audio_in)
# Different architectures should produce different output
assert not np.allclose(out_before, out_after), (
"Different models should produce different output"
)
def test_warm_up(self, host: NAMHost, test_nam_file: Path):
"""Warm-up should not crash and load model state."""
host.load_model(str(test_nam_file))
# First warm-up after model load
host.warm_up(256)
# Should be able to process after warm-up
out = host.process(np.random.randn(256).astype(np.float32))
assert out.shape == (256,)
def test_list_available_models(self, host: NAMHost,
test_nam_file: Path):
"""List available models should find test model."""
models = host.list_available_models()
assert len(models) >= 1
names = [m.name for m in models]
assert "test_model" in names
# ── Standalone function tests ─────────────────────────────────────────
class TestStandaloneFunctions:
def test_available_models(self, tmp_path: Path):
"""available_models() returns lightweight model info."""
from src.dsp.nam_host import available_models
# Create a test model
config = _make_nam_config()
path = tmp_path / "test.nam"
with open(path, "w") as f:
json.dump(config, f)
models = available_models(tmp_path)
assert len(models) == 1
assert models[0]["name"] == "test"
assert models[0]["architecture"] == "Linear"
assert models[0]["feather"] is True
assert models[0]["size_mb"] > 0
def test_available_models_empty_dir(self, tmp_path: Path):
"""Empty directory returns empty list."""
from src.dsp.nam_host import available_models
assert available_models(tmp_path) == []
def test_process_with_model(self, tmp_path: Path):
"""process_with_model convenience function works end-to-end."""
from src.dsp.nam_host import process_with_model
config = _make_nam_config()
path = tmp_path / "test.nam"
with open(path, "w") as f:
json.dump(config, f)
audio_in = np.random.randn(256).astype(np.float32)
audio_out = process_with_model(str(path), audio_in, device="cpu")
assert audio_out.shape == audio_in.shape
assert np.all(np.isfinite(audio_out))
# ── Edge case tests ───────────────────────────────────────────────────
class TestEdgeCases:
def test_block_smaller_than_rf(self, host: NAMHost,
test_nam_file: Path):
"""Blocks smaller than receptive field are padded."""
host.load_model(str(test_nam_file))
rf = host.current_model.receptive_field
# Create block smaller than receptive field
small_block = np.random.randn(max(1, rf // 4)).astype(np.float32)
out = host.process(small_block)
assert out.shape == small_block.shape
assert np.all(np.isfinite(out))
def test_silent_input(self, host: NAMHost, test_nam_file: Path):
"""Silent input should produce valid (possibly non-zero) output."""
host.load_model(str(test_nam_file))
silence = np.zeros(256, dtype=np.float32)
out = host.process(silence)
assert out.shape == (256,)
assert np.all(np.isfinite(out))
def test_model_cache_reuse(self, host: NAMHost, test_nam_file: Path):
"""Loading the same model twice uses cache."""
path = str(test_nam_file)
host.load_model(path)
assert host.is_loaded
name1 = host.current_model.name
# Load same path again
host.load_model(path)
assert host.current_model.name == name1
# ── Performance tests ─────────────────────────────────────────────────
class TestPerformance:
def test_inference_timing(self, host: NAMHost, test_nam_file: Path):
"""Track average inference time (should be < 5ms on CPU)."""
host.load_model(str(test_nam_file))
# Process many blocks to get stable average
for _ in range(50):
block = np.random.randn(256).astype(np.float32)
host.process(block)
avg_ms = host.avg_inference_ms
assert avg_ms > 0.0
# On modern x86 CPU, ConvNet with 6.4K params should be < 1ms
# On RPi 4B, expect < 5ms per block (256 samples @ 48kHz = 5.3ms)
assert avg_ms < 10.0, (
f"Inference too slow: {avg_ms:.3f}ms (target < 5ms)"
)
def test_memory_stability(self, host: NAMHost, test_nam_file: Path):
"""Processing many blocks should not leak or crash."""
host.load_model(str(test_nam_file))
for _ in range(200):
block = np.random.randn(256).astype(np.float32)
_ = host.process(block)
# If we get here, no crash
assert host.avg_inference_ms > 0
+520
View File
@@ -0,0 +1,520 @@
"""Tests for the preset and bank management system.
Covers all acceptance criteria:
- Save/load presets to/from JSON (human-readable)
- Bank structure with 4 presets per bank
- Footswitch preset-up/down wraps within bank
- Bank-up/down switches banks
- MIDI Program Change loads preset (bank=channel, program=preset)
- Current preset saved on change (auto-restore on power cycle)
- Factory preset installation
- Preset rename and reorder
"""
from __future__ import annotations
import json
import shutil
import tempfile
from pathlib import Path
import pytest
from src.presets.manager import (
FACTORY_PRESET_DIR,
PRESETS_PER_BANK,
PresetManager,
_preset_from_dict,
_preset_to_dict,
)
from src.presets.types import FXBlock, FXType, MIDIMapping, Preset
# ── Fixtures ────────────────────────────────────────────────────────────────
@pytest.fixture
def preset_dir() -> Path:
"""Provide a clean temp directory for each test."""
tmp = Path(tempfile.mkdtemp(prefix="pedal_presets_"))
yield tmp
shutil.rmtree(tmp, ignore_errors=True)
@pytest.fixture
def manager(preset_dir: Path) -> PresetManager:
"""Return a PresetManager pointed at the temp dir, no audio pipeline."""
return PresetManager(preset_dir=str(preset_dir), audio_pipeline=None)
@pytest.fixture
def sample_preset() -> Preset:
"""A realistic preset with a few FX blocks and MIDI mappings."""
return Preset(
name="Crunch Rhythm",
bank=0,
program=0,
chain=[
FXBlock(FXType.NOISE_GATE, enabled=True, params={"threshold": 0.01}),
FXBlock(FXType.OVERDRIVE, enabled=True, params={"drive": 0.6, "gain": 0.8}),
FXBlock(FXType.NAM_AMP, enabled=True, nam_model_path="/home/pedal/models/marshall.nam"),
FXBlock(FXType.IR_CAB, enabled=True, ir_file_path="/home/pedal/irs/mesa_v30.wav"),
FXBlock(FXType.CHORUS, enabled=True, params={"rate": 0.4, "depth": 0.6, "mix": 0.3}),
FXBlock(FXType.DELAY, enabled=True, params={"time": 420.0, "feedback": 0.35, "mix": 0.25}),
FXBlock(FXType.REVERB, enabled=True, params={"decay": 0.5, "mix": 0.2}),
FXBlock(FXType.VOLUME, enabled=True, params={"level": 0.85}),
],
midi_mappings={
"drive": MIDIMapping(cc_number=14, channel=0, min_val=0.0, max_val=1.0),
"mix": MIDIMapping(cc_number=15, channel=0, min_val=0.0, max_val=1.0),
},
master_volume=0.8,
)
@pytest.fixture
def factory_preset_dir(preset_dir: Path) -> Path:
"""Create a fake factory preset directory structure."""
factory = preset_dir / "factory_presets"
bank0 = factory / "bank_0"
bank0.mkdir(parents=True)
(bank0 / "bank.json").write_text(
json.dumps({"number": 0, "name": "Factory Classics", "preset_count": 4}), encoding="utf-8"
)
for i in range(3):
p = Preset(name=f"Factory {i}", bank=0, program=i, master_volume=0.75)
(bank0 / f"preset_{i}.json").write_text(
json.dumps(_preset_to_dict(p), indent=2), encoding="utf-8"
)
bank1 = factory / "bank_1"
bank1.mkdir(parents=True)
(bank1 / "bank.json").write_text(
json.dumps({"number": 1, "name": "Modern High-Gain", "preset_count": 4}), encoding="utf-8"
)
p = Preset(name="Chug", bank=1, program=0, master_volume=0.7)
(bank1 / "preset_0.json").write_text(
json.dumps(_preset_to_dict(p), indent=2), encoding="utf-8"
)
return factory
# ── Serialisation round-trip ────────────────────────────────────────────────
class TestSerialization:
def test_preset_to_dict_round_trip(self, sample_preset: Preset):
d = _preset_to_dict(sample_preset)
restored = _preset_from_dict(d)
assert restored.name == sample_preset.name
assert restored.bank == sample_preset.bank
assert restored.program == sample_preset.program
assert restored.master_volume == sample_preset.master_volume
assert len(restored.chain) == len(sample_preset.chain)
for a, b in zip(restored.chain, sample_preset.chain):
assert a.fx_type == b.fx_type
assert a.params == b.params
assert a.nam_model_path == b.nam_model_path
assert a.ir_file_path == b.ir_file_path
assert set(restored.midi_mappings.keys()) == set(sample_preset.midi_mappings.keys())
for k in sample_preset.midi_mappings:
assert restored.midi_mappings[k].cc_number == sample_preset.midi_mappings[k].cc_number
def test_to_dict_is_human_readable(self, sample_preset: Preset):
d = _preset_to_dict(sample_preset)
serialized = json.dumps(d, indent=2, sort_keys=True)
# Verify key strings are present plain-text, not encoded
assert "Crunch Rhythm" in serialized
assert "marshall.nam" in serialized
assert "mesa_v30" in serialized
assert "noise_gate" in serialized
assert "overdrive" in serialized
def test_minimal_preset_round_trip(self):
p = Preset(name="Empty", bank=0, program=0)
d = _preset_to_dict(p)
restored = _preset_from_dict(d)
assert restored.name == "Empty"
assert restored.chain == []
assert restored.midi_mappings == {}
assert restored.master_volume == 0.8
# ── Save / Load ─────────────────────────────────────────────────────────────
class TestSaveLoad:
def test_save_and_load(self, manager: PresetManager, sample_preset: Preset):
manager.save(sample_preset)
loaded = manager.load(0, 0)
assert loaded.name == "Crunch Rhythm"
assert len(loaded.chain) == 8
assert loaded.chain[0].fx_type == FXType.NOISE_GATE
def test_save_creates_json_file(self, manager: PresetManager, sample_preset: Preset, preset_dir: Path):
manager.save(sample_preset)
path = preset_dir / "bank_0" / "preset_0.json"
assert path.exists()
content = path.read_text(encoding="utf-8")
assert "Crunch Rhythm" in content
# Confirm it's valid JSON
data = json.loads(content)
assert data["name"] == "Crunch Rhythm"
def test_load_missing_preset_raises(self, manager: PresetManager):
with pytest.raises(FileNotFoundError):
manager.load(9, 3)
def test_save_multiple_presets(self, manager: PresetManager):
p0 = Preset(name="A", bank=0, program=0)
p1 = Preset(name="B", bank=0, program=1)
p2 = Preset(name="C", bank=0, program=2)
p3 = Preset(name="D", bank=0, program=3)
for p in [p0, p1, p2, p3]:
manager.save(p)
assert manager.load(0, 0).name == "A"
assert manager.load(0, 1).name == "B"
assert manager.load(0, 2).name == "C"
assert manager.load(0, 3).name == "D"
# ── Bank structure ──────────────────────────────────────────────────────────
class TestBanks:
def test_list_banks_empty(self, manager: PresetManager):
assert manager.list_banks() == []
def test_list_banks_after_saves(self, manager: PresetManager):
p0 = Preset(name="A", bank=0, program=0)
p1 = Preset(name="B", bank=1, program=0)
manager.save(p0)
manager.save(p1)
banks = manager.list_banks()
assert len(banks) == 2
assert banks[0].number == 0
assert banks[1].number == 1
def test_bank_has_four_preset_slots(self, manager: PresetManager):
"""The PRESETS_PER_BANK convention is set to 4."""
assert PRESETS_PER_BANK == 4
def test_program_clamped_to_range(self, manager: PresetManager):
"""select() clamps program to 0-3."""
p = manager.select(0, -1)
assert p.program == 0
p = manager.select(0, 99)
assert p.program == 3
# ── Footswitch navigation ───────────────────────────────────────────────────
class TestFootswitchNavigation:
def test_preset_up_within_bank(self, manager: PresetManager):
p0 = Preset(name="P0", bank=0, program=0)
p1 = Preset(name="P1", bank=0, program=1)
p2 = Preset(name="P2", bank=0, program=2)
p3 = Preset(name="P3", bank=0, program=3)
for p in [p0, p1, p2, p3]:
manager.save(p)
manager.select(0, 0)
assert manager.preset_up().name == "P1"
assert manager.preset_up().name == "P2"
assert manager.preset_up().name == "P3"
# Wraps 3 -> 0
assert manager.preset_up().name == "P0"
def test_preset_down_within_bank(self, manager: PresetManager):
p0 = Preset(name="P0", bank=0, program=0)
p1 = Preset(name="P1", bank=0, program=1)
p2 = Preset(name="P2", bank=0, program=2)
for p in [p0, p1, p2]:
manager.save(p)
manager.select(0, 0)
# Wraps 0 -> 3 (which is empty, so "New Bank 0 #3")
assert "New" in manager.preset_down().name
# Down again -> 2
assert manager.preset_down().name == "P2"
def test_preset_up_creates_empty_preset_when_missing(self, manager: PresetManager):
"""When pressing up into an empty slot, a blank preset is created."""
p0 = Preset(name="Only One", bank=0, program=0)
manager.save(p0)
manager.select(0, 0)
next_preset = manager.preset_up()
assert next_preset.name == "New Bank 0 #1"
assert next_preset.bank == 0
assert next_preset.program == 1
# ── Bank up/down ────────────────────────────────────────────────────────────
class TestBankUpDown:
def test_bank_up(self, manager: PresetManager):
"""Moving from bank 0 to bank 1."""
manager.save(Preset(name="B0P0", bank=0, program=0))
manager.save(Preset(name="B0P1", bank=0, program=1))
manager.save(Preset(name="B1P0", bank=1, program=0))
manager.select(0, 0)
bank, preset = manager.bank_up()
assert bank.number == 1
assert preset.name == "B1P0"
def test_bank_down(self, manager: PresetManager):
manager.save(Preset(name="B0P0", bank=0, program=0))
manager.save(Preset(name="B1P0", bank=1, program=0))
manager.select(1, 0)
bank, preset = manager.bank_down()
assert bank.number == 0
assert preset.name == "B0P0"
def test_bank_up_wraps(self, manager: PresetManager):
"""Wrapping from highest bank back to lowest."""
manager.save(Preset(name="B0P0", bank=0, program=0))
manager.save(Preset(name="B1P0", bank=1, program=0))
manager.select(1, 0)
bank, preset = manager.bank_up()
assert bank.number == 0
def test_bank_down_wraps(self, manager: PresetManager):
manager.save(Preset(name="B0P0", bank=0, program=0))
manager.save(Preset(name="B1P0", bank=1, program=0))
manager.select(0, 0)
bank, preset = manager.bank_down()
assert bank.number == 1
# ── MIDI Program Change ─────────────────────────────────────────────────────
class TestMIDIPC:
def test_midi_pc_selects_preset(self, manager: PresetManager):
"""Bank=channel (0), program=preset (2)."""
p0 = Preset(name="P0", bank=0, program=0)
p1 = Preset(name="P1", bank=0, program=1)
p2 = Preset(name="P2", bank=0, program=2)
for p in [p0, p1, p2]:
manager.save(p)
result = manager.midi_pc(channel=0, program=2)
assert result.name == "P2"
assert manager.current_bank == 0
assert manager.current_program == 2
def test_midi_pc_different_banks(self, manager: PresetManager):
"""Channel maps to bank, program maps to preset index."""
manager.save(Preset(name="B1P0", bank=1, program=0))
manager.save(Preset(name="B1P3", bank=1, program=3))
result = manager.midi_pc(channel=1, program=3)
assert result.name == "B1P3"
def test_midi_pc_creates_blank_on_empty_slot(self, manager: PresetManager):
"""When PC targets an empty slot, a blank preset is created."""
manager.save(Preset(name="B2P0", bank=2, program=0))
result = manager.midi_pc(channel=2, program=2)
assert "New" in result.name
assert result.program == 2
# ── Auto-restore ────────────────────────────────────────────────────────────
class TestAutoRestore:
def test_save_state_persists(self, manager: PresetManager, preset_dir: Path):
manager.save(Preset(name="RestoreMe", bank=0, program=2))
manager.select(0, 2) # This calls save_state internally
state_path = preset_dir / "state.json"
assert state_path.exists()
data = json.loads(state_path.read_text(encoding="utf-8"))
assert data["current_bank"] == 0
assert data["current_program"] == 2
def test_restore_state(self, manager: PresetManager, preset_dir: Path):
manager.save(Preset(name="RestoreMe", bank=0, program=2))
manager.select(0, 2)
# Create a fresh manager and restore
m2 = PresetManager(preset_dir=str(preset_dir))
preset = m2.restore_state()
assert preset is not None
assert preset.name == "RestoreMe"
assert m2.current_bank == 0
assert m2.current_program == 2
def test_restore_state_no_file(self, manager: PresetManager):
"""restore_state returns None when no state file exists."""
assert manager.restore_state() is None
def test_restore_state_missing_preset_fallback(self, preset_dir: Path):
"""If the saved slot is empty, fall back gracefully."""
# Write state pointing to a slot that doesn't exist yet
state_path = preset_dir / "state.json"
state_path.write_text(
json.dumps({"current_bank": 5, "current_program": 3}), encoding="utf-8"
)
m = PresetManager(preset_dir=str(preset_dir))
assert m.restore_state() is None
def test_preset_change_triggers_state_save(self, manager: PresetManager, preset_dir: Path):
"""Every preset_up/down/select saves state."""
manager.save(Preset(name="A", bank=0, program=0))
manager.save(Preset(name="B", bank=0, program=1))
manager.select(0, 0)
manager.preset_up()
state = json.loads((preset_dir / "state.json").read_text())
assert state["current_program"] == 1
# ── Preset rename and reorder ───────────────────────────────────────────────
class TestRenameReorder:
def test_rename(self, manager: PresetManager):
manager.save(Preset(name="Old", bank=0, program=0))
renamed = manager.rename(0, 0, "New")
assert renamed.name == "New"
assert manager.load(0, 0).name == "New"
def test_reorder_same_slot_noop(self, manager: PresetManager):
"""Reordering to the same program index does nothing."""
manager.save(Preset(name="A", bank=0, program=0))
manager.reorder(0, 0, 0) # Should not raise
assert manager.load(0, 0).name == "A"
def test_reorder_swap(self, manager: PresetManager):
"""Reordering from 0 to 2 swaps if slot 2 is occupied."""
manager.save(Preset(name="A", bank=0, program=0))
manager.save(Preset(name="B", bank=0, program=2))
manager.reorder(0, 0, 2)
assert manager.load(0, 0).name == "B"
assert manager.load(0, 2).name == "A"
def test_reorder_into_empty_slot(self, manager: PresetManager):
manager.save(Preset(name="A", bank=0, program=0))
manager.reorder(0, 0, 3)
assert manager.load(0, 3).name == "A"
def test_reorder_missing_source_raises(self, manager: PresetManager):
with pytest.raises(FileNotFoundError):
manager.reorder(0, 0, 1)
def test_reorder_out_of_range_raises(self, manager: PresetManager):
manager.save(Preset(name="A", bank=0, program=0))
with pytest.raises(ValueError):
manager.reorder(0, 0, 4)
def test_reorder_updates_active_tracking(self, manager: PresetManager):
"""If the active preset is moved, current_program tracks it."""
manager.save(Preset(name="A", bank=0, program=0))
manager.save(Preset(name="B", bank=1, program=0))
manager.select(0, 0)
manager.reorder(0, 0, 2)
assert manager.current_program == 2 # active moved to slot 2
# ── Factory presets ─────────────────────────────────────────────────────────
class TestFactoryPresets:
def test_install_factory_presets_overwrite_default(self, manager: PresetManager, factory_preset_dir: Path, monkeypatch):
monkeypatch.setattr("src.presets.manager.FACTORY_PRESET_DIR", factory_preset_dir)
count = manager.install_factory_presets()
assert count == 4 # 3 in bank 0 + 1 in bank 1
assert manager.load(0, 0).name == "Factory 0"
assert manager.load(0, 1).name == "Factory 1"
assert manager.load(1, 0).name == "Chug"
def test_install_skip_existing(self, manager: PresetManager, factory_preset_dir: Path, monkeypatch):
monkeypatch.setattr("src.presets.manager.FACTORY_PRESET_DIR", factory_preset_dir)
manager.save(Preset(name="My Custom", bank=0, program=0))
count = manager.install_factory_presets(overwrite=False)
# Should install 3 (skip slot 0 in bank 0, install 1 & 2, plus bank 1)
assert count == 3
assert manager.load(0, 0).name == "My Custom" # Not overwritten
def test_install_overwrite_existing(self, manager: PresetManager, factory_preset_dir: Path, monkeypatch):
monkeypatch.setattr("src.presets.manager.FACTORY_PRESET_DIR", factory_preset_dir)
manager.save(Preset(name="My Custom", bank=0, program=0))
count = manager.install_factory_presets(overwrite=True)
assert count == 4
assert manager.load(0, 0).name == "Factory 0" # Overwritten
def test_no_factory_dir(self, manager: PresetManager, monkeypatch):
"""When factory dir doesn't exist, returns 0."""
monkeypatch.setattr("src.presets.manager.FACTORY_PRESET_DIR", Path("/nonexistent_factory"))
assert manager.install_factory_presets() == 0
# ── Activation pipeline integration ─────────────────────────────────────────
class TestActivation:
def test_activate_calls_pipeline(self, manager: PresetManager, sample_preset: Preset, mocker):
pipeline = mocker.Mock()
manager._pipeline = pipeline
manager.activate(sample_preset)
pipeline.load_preset.assert_called_once_with(sample_preset)
def test_select_calls_activate(self, manager: PresetManager, mocker):
pipeline = mocker.Mock()
manager._pipeline = pipeline
manager.save(Preset(name="ActivateMe", bank=0, program=0))
preset = manager.select(0, 0)
assert preset.name == "ActivateMe"
pipeline.load_preset.assert_called_once()
def test_select_no_pipeline_does_not_crash(self, manager: PresetManager):
"""Works without an audio pipeline attached."""
manager.save(Preset(name="Headless", bank=0, program=0))
preset = manager.select(0, 0)
assert preset.name == "Headless"
# ── Edge cases ──────────────────────────────────────────────────────────────
class TestEdgeCases:
def test_delete_preset(self, manager: PresetManager):
manager.save(Preset(name="DeleteMe", bank=0, program=1))
assert manager.load(0, 1).name == "DeleteMe"
manager.delete(0, 1)
with pytest.raises(FileNotFoundError):
manager.load(0, 1)
def test_get_or_create_bank(self, manager: PresetManager):
bank = manager.get_or_create_bank(7, "Lead")
assert bank.number == 7
assert bank.name == "Lead"
# Calling again returns existing bank
bank2 = manager.get_or_create_bank(7)
assert bank2.number == 7
assert bank2.name == "Lead" # Name persisted
def test_multiple_banks_preset_independence(self, manager: PresetManager):
"""Presets in different banks don't interfere."""
manager.save(Preset(name="Bank0", bank=0, program=0))
manager.save(Preset(name="Bank1", bank=1, program=0))
assert manager.load(0, 0).name == "Bank0"
assert manager.load(1, 0).name == "Bank1"
def test_full_bank_capacity(self, manager: PresetManager):
"""All 4 slots in a bank can hold distinct presets."""
for i in range(4):
manager.save(Preset(name=f"Slot{i}", bank=2, program=i))
for i in range(4):
assert manager.load(2, i).name == f"Slot{i}"
def test_current_bank_program_tracking(self, manager: PresetManager):
manager.save(Preset(name="A", bank=0, program=0))
manager.save(Preset(name="B", bank=0, program=1))
assert manager.current_bank == 0
assert manager.current_program == 0
manager.select(0, 1)
assert manager.current_program == 1
def test_corrupted_json_raises(self, manager: PresetManager, preset_dir: Path):
path = preset_dir / "bank_0" / "preset_0.json"
path.parent.mkdir(parents=True)
path.write_text("not valid json", encoding="utf-8")
with pytest.raises(json.JSONDecodeError):
manager.load(0, 0)