Files
shawn ce06a4360d Build: hardware UI layer — footswitch debounce, RGB LEDs, OLED display
- 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)
2026-06-07 23:26:44 -04:00

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