Files
shawn 8e84b0217d Phase 7: Stability test + fix MIDI test signature
- tests/test_stability.py — 1-hour continuous DSP pipeline stability test
  covering all 46 FX types with CPU/memory/NaN monitoring
- tests/test_integration.py — fixed test_midi_cc_maps_expression to use
  correct _on_midi_cc(value, cc_number, channel) signature matching
  the Phase 6 MIDI fix
2026-06-12 13:29:55 -04:00

591 lines
26 KiB
Python

"""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
# Simulate expression pedal at 50% (value=64, CC=11, channel=0)
pedal_app._on_midi_cc(64, 11, 0)
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