Initial scaffold: Pi Multi-FX Pedal with NAM A2, IR cab, multi-FX, MIDI, stomp UI
This commit is contained in:
@@ -0,0 +1,362 @@
|
||||
"""Tests for the hardware UI layer — footswitch, LEDs, display.
|
||||
|
||||
Uses mock GPIO/LED/display so tests run on any machine.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.ui.footswitch import (
|
||||
DEBOUNCE_MS,
|
||||
LONG_PRESS_MS,
|
||||
FootSwitch,
|
||||
FootswitchController,
|
||||
SwitchAction,
|
||||
)
|
||||
from src.ui.leds import LEDConfig, LEDController, LEDDriver, LEDPattern
|
||||
from src.ui.display import DisplayController, DisplayState, DISPLAY_W, DISPLAY_H
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Footswitch tests
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestFootSwitch:
|
||||
"""FootSwitch dataclass construction."""
|
||||
|
||||
def test_basic_switch(self):
|
||||
sw = FootSwitch(17, SwitchAction.PRESET_UP, SwitchAction.TAP_TEMPO)
|
||||
assert sw.gpio_pin == 17
|
||||
assert sw.action_default == SwitchAction.PRESET_UP
|
||||
assert sw.action_long_press == SwitchAction.TAP_TEMPO
|
||||
assert sw.active_low is True
|
||||
|
||||
def test_default_active_low(self):
|
||||
sw = FootSwitch(22, SwitchAction.BYPASS)
|
||||
assert sw.active_low is True
|
||||
|
||||
def test_no_long_press(self):
|
||||
sw = FootSwitch(22, SwitchAction.BYPASS)
|
||||
assert sw.action_long_press is None
|
||||
|
||||
|
||||
class TestFootswitchController:
|
||||
"""FootswitchController — debounce, long-press, callbacks."""
|
||||
|
||||
def test_default_layout_has_4_switches(self):
|
||||
ctrl = FootswitchController()
|
||||
assert len(ctrl._switches) == 4
|
||||
|
||||
def test_register_and_fire_callback(self):
|
||||
ctrl = FootswitchController()
|
||||
fired = []
|
||||
|
||||
def cb():
|
||||
fired.append("bye")
|
||||
|
||||
ctrl.register_callback(SwitchAction.BYPASS, cb)
|
||||
ctrl._trigger(SwitchAction.BYPASS)
|
||||
assert fired == ["bye"]
|
||||
|
||||
def test_callback_error_does_not_crash(self):
|
||||
ctrl = FootswitchController()
|
||||
|
||||
def broken():
|
||||
raise ValueError("boom")
|
||||
|
||||
def ok():
|
||||
pass
|
||||
|
||||
ctrl.register_callback(SwitchAction.BYPASS, broken)
|
||||
ctrl.register_callback(SwitchAction.BYPASS, ok)
|
||||
ctrl._trigger(SwitchAction.BYPASS) # should not raise
|
||||
|
||||
def test_simulate_press_triggers_callback(self):
|
||||
ctrl = FootswitchController()
|
||||
fired = []
|
||||
|
||||
def cb():
|
||||
fired.append("up")
|
||||
|
||||
ctrl.register_callback(SwitchAction.PRESET_UP, cb)
|
||||
ctrl.simulate_press(SwitchAction.PRESET_UP)
|
||||
assert fired == ["up"]
|
||||
|
||||
def test_start_stop_no_crash(self):
|
||||
ctrl = FootswitchController()
|
||||
ctrl.start()
|
||||
assert ctrl._running is True
|
||||
ctrl.stop()
|
||||
assert ctrl._running is False
|
||||
|
||||
def test_virtual_gpio_press_and_release(self):
|
||||
"""Test debounce engine via virtual GPIO pins."""
|
||||
ctrl = FootswitchController()
|
||||
actions = []
|
||||
|
||||
ctrl.register_callback(SwitchAction.PRESET_UP, lambda: actions.append("up"))
|
||||
ctrl.start()
|
||||
|
||||
try:
|
||||
pin = ctrl._switches[0].gpio_pin # pin 17, PRESET_UP
|
||||
|
||||
# Press the pin
|
||||
ctrl.simulate_gpio_change(pin, True)
|
||||
time.sleep((DEBOUNCE_MS + 10) / 1000) # Wait past debounce window
|
||||
|
||||
# Now release
|
||||
ctrl.simulate_gpio_change(pin, False)
|
||||
time.sleep((DEBOUNCE_MS + 10) / 1000)
|
||||
|
||||
# Should have fired PRESET_UP on release (short press)
|
||||
assert "up" in actions, f"Expected 'up' in {actions}"
|
||||
finally:
|
||||
ctrl.stop()
|
||||
|
||||
def test_long_press_via_gpio(self):
|
||||
"""Test long-press triggers the long-press action."""
|
||||
ctrl = FootswitchController()
|
||||
actions = []
|
||||
|
||||
ctrl.register_callback(SwitchAction.TAP_TEMPO, lambda: actions.append("tap"))
|
||||
ctrl.start()
|
||||
|
||||
try:
|
||||
# FS1 has PRESET_UP default, TAP_TEMPO long-press
|
||||
pin = ctrl._switches[0].gpio_pin
|
||||
|
||||
# Press and hold past LONG_PRESS_MS
|
||||
ctrl.simulate_gpio_change(pin, True)
|
||||
time.sleep((LONG_PRESS_MS + 50) / 1000)
|
||||
|
||||
# Long press should fire without release
|
||||
assert "tap" in actions, f"Expected 'tap' in {actions}"
|
||||
|
||||
# Release
|
||||
ctrl.simulate_gpio_change(pin, False)
|
||||
time.sleep((DEBOUNCE_MS + 10) / 1000)
|
||||
finally:
|
||||
ctrl.stop()
|
||||
|
||||
def test_short_press_no_long_press_action(self):
|
||||
"""Short press triggers default, not long-press."""
|
||||
actions = {"default": False, "long": False}
|
||||
|
||||
ctrl = FootswitchController()
|
||||
ctrl.register_callback(SwitchAction.PRESET_UP, lambda: actions.update(default=True))
|
||||
ctrl.register_callback(SwitchAction.TAP_TEMPO, lambda: actions.update(long=True))
|
||||
ctrl.start()
|
||||
|
||||
try:
|
||||
pin = ctrl._switches[0].gpio_pin
|
||||
|
||||
# Short press
|
||||
ctrl.simulate_gpio_change(pin, True)
|
||||
time.sleep(DEBOUNCE_MS / 1000 + 0.01)
|
||||
ctrl.simulate_gpio_change(pin, False)
|
||||
time.sleep(DEBOUNCE_MS / 1000 + 0.01)
|
||||
|
||||
# Poll cycle should have fired
|
||||
assert actions["default"] is True, f"Expected default fired, got {actions}"
|
||||
assert actions["long"] is False, f"Expected long NOT fired, got {actions}"
|
||||
finally:
|
||||
ctrl.stop()
|
||||
|
||||
def test_multiple_callbacks_same_action(self):
|
||||
ctrl = FootswitchController()
|
||||
results = []
|
||||
|
||||
ctrl.register_callback(SwitchAction.BYPASS, lambda: results.append(1))
|
||||
ctrl.register_callback(SwitchAction.BYPASS, lambda: results.append(2))
|
||||
ctrl._trigger(SwitchAction.BYPASS)
|
||||
assert results == [1, 2]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# LED tests
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestLEDController:
|
||||
"""LEDController — initialization, pixel control, animations."""
|
||||
|
||||
def test_init_basic(self):
|
||||
ctrl = LEDController(4, driver=LEDDriver.MOCK)
|
||||
assert ctrl._num_leds == 4
|
||||
assert ctrl._global_brightness == 0.5
|
||||
assert ctrl._initialized is False
|
||||
|
||||
def test_initialize_mock_returns_false(self):
|
||||
ctrl = LEDController(4, driver=LEDDriver.MOCK)
|
||||
result = ctrl.initialize()
|
||||
assert result is False
|
||||
assert ctrl._initialized is False
|
||||
|
||||
def test_set_pixel(self):
|
||||
ctrl = LEDController(2, driver=LEDDriver.MOCK)
|
||||
ctrl.set_pixel(0, (255, 0, 0))
|
||||
assert ctrl._pixels[0] == (127, 0, 0) # scaled by brightness 0.5
|
||||
|
||||
def test_set_pixel_out_of_range(self):
|
||||
ctrl = LEDController(2, driver=LEDDriver.MOCK)
|
||||
ctrl.set_pixel(99, (255, 0, 0)) # should not crash
|
||||
assert ctrl._pixels[0] == (0, 0, 0) # unchanged
|
||||
|
||||
def test_set_all(self):
|
||||
ctrl = LEDController(3, driver=LEDDriver.MOCK)
|
||||
ctrl.set_all((100, 200, 50))
|
||||
for px in ctrl._pixels:
|
||||
assert px[0] == 50 # 100 * 0.5
|
||||
assert px[1] == 100 # 200 * 0.5
|
||||
|
||||
def test_bypass_led_red_when_bypassed(self):
|
||||
ctrl = LEDController(4, driver=LEDDriver.MOCK)
|
||||
ctrl.set_bypass_led(0, bypassed=True)
|
||||
assert ctrl._pixels[0] == (127, 0, 0) # red
|
||||
|
||||
def test_bypass_led_green_when_active(self):
|
||||
ctrl = LEDController(4, driver=LEDDriver.MOCK)
|
||||
ctrl.set_bypass_led(0, bypassed=False)
|
||||
assert ctrl._pixels[0] == (0, 127, 0) # green
|
||||
|
||||
def test_clear_all(self):
|
||||
ctrl = LEDController(3, driver=LEDDriver.MOCK)
|
||||
ctrl.set_all((255, 255, 255))
|
||||
ctrl.clear_all()
|
||||
assert all(px == (0, 0, 0) for px in ctrl._pixels)
|
||||
|
||||
def test_set_brightness(self):
|
||||
ctrl = LEDController(2, driver=LEDDriver.MOCK)
|
||||
ctrl.set_brightness(0.8)
|
||||
assert ctrl._global_brightness == 0.8
|
||||
|
||||
def test_set_brightness_clamps(self):
|
||||
ctrl = LEDController(2, driver=LEDDriver.MOCK)
|
||||
ctrl.set_brightness(2.0)
|
||||
assert ctrl._global_brightness == 1.0
|
||||
ctrl.set_brightness(-0.5)
|
||||
assert ctrl._global_brightness == 0.0
|
||||
|
||||
def test_context_manager(self):
|
||||
with LEDController(2, driver=LEDDriver.MOCK) as ctrl:
|
||||
assert ctrl._num_leds == 2
|
||||
# After exit, pixels should be cleared
|
||||
|
||||
def test_preset_animate_does_not_crash(self):
|
||||
ctrl = LEDController(4, driver=LEDDriver.MOCK)
|
||||
ctrl.preset_animate("up")
|
||||
ctrl.preset_animate("down")
|
||||
|
||||
def test_tap_tempo_blip_does_not_crash(self):
|
||||
ctrl = LEDController(4, driver=LEDDriver.MOCK)
|
||||
ctrl.tap_tempo_blip()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Display tests
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestDisplayController:
|
||||
"""DisplayController — initialization, state updates, rendering modes."""
|
||||
|
||||
def test_init(self):
|
||||
dc = DisplayController()
|
||||
assert dc._i2c_bus == 1
|
||||
assert dc._i2c_addr == 0x3C
|
||||
assert dc._initialized is False
|
||||
|
||||
def test_initialize_returns_false_no_hardware(self):
|
||||
dc = DisplayController()
|
||||
result = dc.initialize()
|
||||
assert result is False # No hardware
|
||||
|
||||
def test_update_logs_state_in_headless(self, caplog):
|
||||
caplog.set_level("DEBUG")
|
||||
dc = DisplayController()
|
||||
state = DisplayState(
|
||||
mode="preset",
|
||||
preset_name="Crunch",
|
||||
bypassed=False,
|
||||
fx_active=["overdrive", "delay"],
|
||||
tuner_note="",
|
||||
tuner_cents=0,
|
||||
)
|
||||
dc.update(state)
|
||||
assert "Crunch" in caplog.text
|
||||
assert "overdrive" in caplog.text
|
||||
|
||||
def test_update_tuner_mode(self, caplog):
|
||||
caplog.set_level("DEBUG")
|
||||
dc = DisplayController()
|
||||
state = DisplayState(
|
||||
mode="tuner",
|
||||
tuner_note="A",
|
||||
tuner_cents=-12,
|
||||
)
|
||||
dc.update(state)
|
||||
assert "tuner" in caplog.text.lower()
|
||||
assert "A" in caplog.text
|
||||
assert "-12" in caplog.text
|
||||
|
||||
def test_display_state_defaults(self):
|
||||
state = DisplayState()
|
||||
assert state.mode == "preset"
|
||||
assert state.preset_name == ""
|
||||
assert state.fx_active == []
|
||||
assert state.bypassed is False
|
||||
|
||||
def test_display_state_full(self):
|
||||
state = DisplayState(
|
||||
mode="fx_edit",
|
||||
param_name="Drive",
|
||||
param_value=0.75,
|
||||
)
|
||||
assert state.param_name == "Drive"
|
||||
assert state.param_value == 0.75
|
||||
|
||||
def test_clear_no_crash(self):
|
||||
dc = DisplayController()
|
||||
dc.clear() # Should not crash in headless
|
||||
|
||||
def test_preset_mode_display_str(self, caplog):
|
||||
caplog.set_level("DEBUG")
|
||||
dc = DisplayController()
|
||||
dc.update(DisplayState(preset_name="Clean", bank_name="A1", mode="preset"))
|
||||
assert "Clean" in caplog.text
|
||||
assert "A1" in caplog.text
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Integration helpers
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_switch_action_values():
|
||||
"""All SwitchAction values are unique and snake_case."""
|
||||
vals = [a.value for a in SwitchAction]
|
||||
assert len(vals) == len(set(vals)), "Duplicate SwitchAction values"
|
||||
for v in vals:
|
||||
assert "_" in v, f"SwitchAction value '{v}' not snake_case"
|
||||
|
||||
|
||||
def test_led_pattern_values():
|
||||
"""All LEDPattern values are unique."""
|
||||
vals = [p.value for p in LEDPattern]
|
||||
assert len(vals) == len(set(vals))
|
||||
|
||||
|
||||
def test_led_driver_values():
|
||||
"""All LEDDriver values are unique."""
|
||||
vals = [d.value for d in LEDDriver]
|
||||
assert len(vals) == len(set(vals))
|
||||
|
||||
|
||||
def test_display_constants():
|
||||
assert DISPLAY_W == 128
|
||||
assert DISPLAY_H == 64
|
||||
Reference in New Issue
Block a user