"""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