8e84b0217d
- 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
591 lines
26 KiB
Python
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 |