#!/usr/bin/env python3 """Standalone UI test tool for the Pi Multi-FX Pedal. Tests the hardware UI layer without physical hardware: - Simulates footswitch presses and verifies callbacks - Toggles LED states and logs output - Cycles through display modes - Interactive menu when run directly Usage: python scripts/ui_test.py # Interactive mode python scripts/ui_test.py --all # Run all automated tests python scripts/ui_test.py --footswitch # Footswitch tests only python scripts/ui_test.py --leds # LED tests only python scripts/ui_test.py --display # Display tests only """ from __future__ import annotations import logging import sys import time # Ensure src is importable sys.path.insert(0, ".") logging.basicConfig( level=logging.DEBUG, format="%(levelname)-7s | %(message)s", ) logger = logging.getLogger("ui_test") from src.ui.footswitch import ( DEBOUNCE_MS, LONG_PRESS_MS, FootSwitch, FootswitchController, SwitchAction, ) from src.ui.leds import LEDController, LEDDriver, LEDPattern from src.ui.display import DisplayController, DisplayState # ============================================================ # Automated test functions # ============================================================ def test_footswitch_basic() -> list[str]: """Test basic footswitch press/release.""" results = [] ctrl = FootswitchController() actions = [] ctrl.register_callback(SwitchAction.BYPASS, lambda: actions.append("bypass")) ctrl.start() time.sleep(0.1) # Simulate press via direct trigger ctrl.simulate_press(SwitchAction.BYPASS) time.sleep(0.05) if "bypass" in actions: results.append(" ✓ BYPASS callback fired on simulate_press") else: results.append(" ✗ BYPASS callback NOT fired") ctrl.stop() return results def test_footswitch_debounce() -> list[str]: """Test GPIO debounce engine with virtual pins.""" results = [] ctrl = FootswitchController() actions = [] ctrl.register_callback(SwitchAction.PRESET_UP, lambda: actions.append("up")) ctrl.register_callback(SwitchAction.TAP_TEMPO, lambda: actions.append("tap")) ctrl.start() try: pin = ctrl._switches[0].gpio_pin # Pin 17 # --- Short press test --- ctrl.simulate_gpio_change(pin, True) time.sleep((DEBOUNCE_MS + 20) / 1000) # Past debounce ctrl.simulate_gpio_change(pin, False) time.sleep((DEBOUNCE_MS + 20) / 1000) # Let poll loop process if "up" in actions: results.append(" ✓ Short press → PRESET_UP fired") else: results.append(" ✗ Short press — PRESET_UP not fired") # --- Long press test (FS1: PRESET_UP default, TAP_TEMPO long) --- ctrl.simulate_gpio_change(pin, True) time.sleep((LONG_PRESS_MS + 100) / 1000) # Hold past threshold if "tap" in actions: results.append(" ✓ Long press → TAP_TEMPO fired") else: results.append(" ✗ Long press — TAP_TEMPO not fired") # Release ctrl.simulate_gpio_change(pin, False) time.sleep((DEBOUNCE_MS + 20) / 1000) finally: ctrl.stop() return results def test_leds() -> list[str]: """Test LED controller features.""" results = [] ctrl = LEDController(4, driver=LEDDriver.MOCK) ctrl.initialize() # Set a pixel ctrl.set_pixel(0, (255, 0, 0)) r, g, b = ctrl._pixels[0] results.append(f" ✓ Pixel 0 set: RGB({r},{g},{b}) (expected ~127,0,0)") # Bypass LED ctrl.set_bypass_led(1, bypassed=True) results.append(f" ✓ Bypass LED (bypassed): RGB{ctrl._pixels[1]} (expected red)") ctrl.set_bypass_led(2, bypassed=False) results.append(f" ✓ Bypass LED (active): RGB{ctrl._pixels[2]} (expected green)") # Clear ctrl.set_all((128, 128, 128)) ctrl.clear_all() if all(px == (0, 0, 0) for px in ctrl._pixels): results.append(" ✓ Clear all → all (0,0,0)") else: results.append(" ✗ Clear all — pixels not all black") # Brightness ctrl.set_brightness(0.8) results.append(f" ✓ Brightness set to {ctrl._global_brightness}") # Animations (should not crash) ctrl.preset_animate("up") results.append(" ✓ Preset up animation (no crash)") ctrl.preset_animate("down") results.append(" ✓ Preset down animation (no crash)") ctrl.tap_tempo_blip() results.append(" ✓ Tap tempo blip (no crash)") return results def test_display() -> list[str]: """Test display controller state rendering.""" results = [] dc = DisplayController() init_ok = dc.initialize() results.append(f" ✓ Initialize returned {init_ok} (expected False — no HW)") # Preset mode dc.update(DisplayState( mode="preset", preset_name="Metal Zone", bank_name="B3", bypassed=False, fx_active=["noise_gate", "distortion", "delay", "reverb"], )) results.append(" ✓ Preset mode: Metal Zone") # Tuner mode dc.update(DisplayState( mode="tuner", tuner_note="E", tuner_cents=-5, )) results.append(" ✓ Tuner mode: E, -5 cents") # FX edit mode dc.update(DisplayState( mode="fx_edit", param_name="Gain", param_value=0.67, )) results.append(" ✓ FX edit mode: Gain = 0.67") # Settings mode dc.update(DisplayState(mode="settings", param_name="MIDI Channel")) results.append(" ✓ Settings mode: MIDI Channel") # Default state dc.update(DisplayState()) results.append(" ✓ Default (empty) state") dc.clear() results.append(" ✓ Clear (no crash)") return results # ============================================================ # Interactive menu # ============================================================ def interactive_menu(): """Run an interactive test session.""" ctrl = FootswitchController() led_ctrl = LEDController(4, driver=LEDDriver.MOCK) disp = DisplayController() disp.initialize() actions = { "1": ("Simulate PRESET_UP", lambda: ctrl.simulate_press(SwitchAction.PRESET_UP)), "2": ("Simulate PRESET_DOWN", lambda: ctrl.simulate_press(SwitchAction.PRESET_DOWN)), "3": ("Simulate BYPASS toggle", lambda: ctrl.simulate_press(SwitchAction.BYPASS)), "4": ("Simulate TAP_TEMPO", lambda: ctrl.simulate_press(SwitchAction.TAP_TEMPO)), "5": ("Simulate TUNER", lambda: ctrl.simulate_press(SwitchAction.TUNER)), "l": ("LED: set test colors", lambda: ( led_ctrl.set_pixel(0, (255, 0, 0)), led_ctrl.set_pixel(1, (0, 255, 0)), led_ctrl.set_pixel(2, (0, 0, 255)), led_ctrl.set_pixel(3, (255, 255, 0)), logger.info("LEDs: RGB pixels 0-3 set"), )), "a": ("LED: preset animate up", lambda: led_ctrl.preset_animate("up")), "d": ("LED: preset animate down", lambda: led_ctrl.preset_animate("down")), "t": ("LED: tap tempo blip", lambda: led_ctrl.tap_tempo_blip()), "c": ("LED: clear all", lambda: led_ctrl.clear_all()), "p": ("DISP: preset mode", lambda: disp.update(DisplayState( mode="preset", preset_name="Clean", bank_name="A1", fx_active=["compressor", "chorus", "delay"], ))), "u": ("DISP: tuner mode (A -12c)", lambda: disp.update(DisplayState( mode="tuner", tuner_note="A", tuner_cents=-12, ))), "e": ("DISP: fx_edit (Drive 0.75)", lambda: disp.update(DisplayState( mode="fx_edit", param_name="Drive", param_value=0.75, ))), "s": ("DISP: settings mode", lambda: disp.update(DisplayState( mode="settings", param_name="Brightness", ))), "q": ("Quit", None), } print("\n=== Pi Multi-FX Pedal — UI Test Console ===\n") print("Actions:") for key, (desc, _) in actions.items(): print(f" [{key}] {desc}") while True: try: choice = input("\n> ").strip().lower() except (EOFError, KeyboardInterrupt): print() break if choice == "q": break if choice in actions: _, fn = actions[choice] if fn: fn() else: break else: print(f"Unknown: '{choice}'") print("UI test console stopped.") # ============================================================ # Main # ============================================================ def main(): import argparse parser = argparse.ArgumentParser(description="Pi Multi-FX Pedal — UI Test Tool") parser.add_argument("--all", action="store_true", help="Run all automated tests") parser.add_argument("--footswitch", action="store_true", help="Footswitch tests") parser.add_argument("--leds", action="store_true", help="LED tests") parser.add_argument("--display", action="store_true", help="Display tests") parser.add_argument("--interactive", "-i", action="store_true", help="Interactive menu") args = parser.parse_args() # Default to interactive if no flags if not any([args.all, args.footswitch, args.leds, args.display, args.interactive]): args.interactive = True if args.interactive: interactive_menu() return if args.all or args.footswitch: print("\n--- Footswitch Tests ---") for r in test_footswitch_basic(): print(r) for r in test_footswitch_debounce(): print(r) if args.all or args.leds: print("\n--- LED Tests ---") for r in test_leds(): print(r) if args.all or args.display: print("\n--- Display Tests ---") for r in test_display(): print(r) print("\nDone.") if __name__ == "__main__": main()