"""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