ce06a4360d
- footswitch.py: GPIO debounce engine (20ms window), long-press detection (500ms), threaded poll loop with virtual pin support for testing - leds.py: WS2812B/APA102 RGB LED controller with bypass indicator (red/green), preset navigation scan animation, tap tempo blip, configurable brightness (0.0-1.0) - display.py: SSD1306 128x64 OLED renderer with preset mode (bank + preset name + FX chain), tuner mode (note + cents bar), FX edit (parameter bar), settings mode, boot splash - __init__.py: Public API re-exports for all 3 modules - tests/test_ui.py: 37 tests — debounce engine, GPIO simulation, short/long press boundary, LED pixel control, bypass colors, display mode rendering (preset/tuner/fx_edit/settings) - scripts/ui_test.py: standalone test tool (python scripts/ui_test.py --all or --interactive)
362 lines
11 KiB
Python
362 lines
11 KiB
Python
"""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()
|
|
state = DisplayState(preset_name="Clean", bank_name="A1", mode="preset")
|
|
dc.update(state)
|
|
assert "Clean" in caplog.text
|
|
# bank_name is stored in state but not in log line when preset_name is set
|
|
assert state.bank_name == "A1"
|
|
|
|
|
|
# ============================================================
|
|
# Integration helpers
|
|
# ============================================================
|
|
|
|
|
|
def test_switch_action_values():
|
|
"""All SwitchAction values are unique."""
|
|
vals = [a.value for a in SwitchAction]
|
|
assert len(vals) == len(set(vals)), "Duplicate SwitchAction values"
|
|
|
|
|
|
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 |