307 lines
8.5 KiB
Python
307 lines
8.5 KiB
Python
"""Tests for the status LED.
|
|
|
|
Two things matter: the LED must never be able to break the reader, and it must
|
|
behave the way the workstation app did (on with the card, off without). Real
|
|
GPIO is unavailable here, so a fake pin is injected to test the logic.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import threading
|
|
import time
|
|
|
|
SRC = os.path.join(
|
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"
|
|
)
|
|
if SRC not in sys.path:
|
|
sys.path.insert(0, SRC)
|
|
|
|
import pytest # noqa: E402
|
|
|
|
from rdm_card_reader import led as led_module # noqa: E402
|
|
from rdm_card_reader.led import ( # noqa: E402
|
|
BLINK,
|
|
DEFAULT_LED_PIN,
|
|
ERROR,
|
|
OFF,
|
|
ON,
|
|
PRESENCE,
|
|
NullLED,
|
|
StatusLED,
|
|
create,
|
|
)
|
|
|
|
|
|
class FakeLED:
|
|
"""Stands in for gpiozero.LED, recording what it was told to do."""
|
|
|
|
def __init__(self, pin, active_high=True):
|
|
self.pin = pin
|
|
self.active_high = active_high
|
|
self.state = False
|
|
self.transitions = []
|
|
self.closed = False
|
|
|
|
def on(self):
|
|
self.state = True
|
|
self.transitions.append("on")
|
|
|
|
def off(self):
|
|
self.state = False
|
|
self.transitions.append("off")
|
|
|
|
def close(self):
|
|
self.closed = True
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_gpio(monkeypatch):
|
|
"""Make StatusLED build a FakeLED instead of touching GPIO."""
|
|
created = {}
|
|
|
|
class FakeGPIOLED(led_module.GPIOLED):
|
|
def __init__(self, pin, active_high=True):
|
|
self.pin = pin
|
|
self._led = FakeLED(pin, active_high)
|
|
self._is_on = False
|
|
created["led"] = self._led
|
|
|
|
def on(self):
|
|
self._led.on()
|
|
self._is_on = True
|
|
|
|
def off(self):
|
|
self._led.off()
|
|
self._is_on = False
|
|
|
|
def close(self):
|
|
self._led.close()
|
|
|
|
monkeypatch.setattr(led_module, "GPIOLED", FakeGPIOLED)
|
|
return created
|
|
|
|
|
|
def wait_for_state(led_obj, expected, timeout=3.0):
|
|
"""Wait for the worker thread to apply a command."""
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
if led_obj.state is expected:
|
|
return True
|
|
time.sleep(0.01)
|
|
return False
|
|
|
|
|
|
# -- defaults ---------------------------------------------------------------
|
|
|
|
def test_default_pin_matches_the_workstation_app():
|
|
assert DEFAULT_LED_PIN == 23
|
|
|
|
|
|
# -- graceful degradation ---------------------------------------------------
|
|
|
|
def test_no_gpio_gives_a_working_no_op(monkeypatch):
|
|
"""On a machine without GPIO the LED must be inert, not fatal."""
|
|
def boom(pin, active_high=True):
|
|
raise RuntimeError("Unable to load any default pin factory!")
|
|
|
|
monkeypatch.setattr(led_module, "GPIOLED", boom)
|
|
status = StatusLED(pin=23)
|
|
assert status.status.enabled is False
|
|
assert status.status.backend == "null"
|
|
# Every hook must still be safe to call.
|
|
status.insert()
|
|
status.remove()
|
|
status.error()
|
|
status.close()
|
|
|
|
|
|
def test_missing_gpiozero_is_reported_not_raised(monkeypatch):
|
|
"""gpiozero absent: say so, and do not raise."""
|
|
import builtins
|
|
|
|
real_import = builtins.__import__
|
|
|
|
def blocked(name, *args, **kwargs):
|
|
if name == "gpiozero":
|
|
raise ImportError("No module named 'gpiozero'")
|
|
return real_import(name, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(builtins, "__import__", blocked)
|
|
status = StatusLED(pin=23)
|
|
assert status.status.enabled is False
|
|
assert "not installed" in status.status.reason
|
|
|
|
|
|
def test_no_pin_factory_is_not_reported_as_missing_library(monkeypatch):
|
|
"""BadPinFactory subclasses ImportError, so the two must be distinguished.
|
|
|
|
Reporting 'gpiozero is not installed' when it is installed but has no
|
|
usable pin library would send you to the wrong fix.
|
|
"""
|
|
class FakeBadPinFactory(ImportError):
|
|
pass
|
|
|
|
def boom(pin, active_high=True):
|
|
raise FakeBadPinFactory(
|
|
"Unable to load any default pin factory!"
|
|
)
|
|
|
|
monkeypatch.setattr(led_module, "GPIOLED", boom)
|
|
status = StatusLED(pin=23)
|
|
assert status.status.enabled is False
|
|
assert "not installed" not in status.status.reason
|
|
assert "pin factory" in status.status.reason
|
|
|
|
|
|
def test_disabled_led_is_inert(fake_gpio):
|
|
status = StatusLED(pin=23, enabled=False)
|
|
assert status.status.enabled is False
|
|
assert isinstance(status.led, NullLED)
|
|
status.insert()
|
|
time.sleep(0.05)
|
|
assert "led" not in fake_gpio
|
|
|
|
|
|
def test_null_led_methods_are_safe():
|
|
null = NullLED(23, "no gpio")
|
|
null.on()
|
|
null.off()
|
|
null.close()
|
|
|
|
|
|
# -- behaviour --------------------------------------------------------------
|
|
|
|
def test_error_mode_lights_up_at_startup(fake_gpio):
|
|
"""A health indicator should show 'alive' without waiting for a card."""
|
|
status = StatusLED(pin=23, mode=ERROR)
|
|
assert fake_gpio["led"].state is True
|
|
status.close()
|
|
|
|
|
|
def test_error_mode_blinks_on_a_bad_read(fake_gpio):
|
|
status = StatusLED(pin=23, mode=ERROR, error_blink=True)
|
|
status.error()
|
|
time.sleep(0.25)
|
|
assert len(fake_gpio["led"].transitions) >= 2
|
|
status.close()
|
|
|
|
|
|
def test_error_mode_settles_back_after_a_blink(fake_gpio):
|
|
status = StatusLED(pin=23, mode=ERROR, error_blink=True)
|
|
status.error()
|
|
time.sleep(0.25)
|
|
status.remove() # any steady command cancels blinking
|
|
assert wait_for_state(fake_gpio["led"], True)
|
|
status.close()
|
|
|
|
|
|
def test_error_mode_without_blink_stays_lit(fake_gpio):
|
|
status = StatusLED(pin=23, mode=ERROR, error_blink=False)
|
|
status.error()
|
|
assert wait_for_state(fake_gpio["led"], True)
|
|
status.close()
|
|
|
|
|
|
def test_presence_mode_is_dark_until_a_card_arrives(fake_gpio):
|
|
status = StatusLED(pin=23, mode=PRESENCE)
|
|
assert fake_gpio["led"].state is False
|
|
status.insert()
|
|
assert wait_for_state(fake_gpio["led"], True)
|
|
status.remove()
|
|
assert wait_for_state(fake_gpio["led"], False)
|
|
status.close()
|
|
|
|
|
|
def test_close_turns_the_led_off(fake_gpio):
|
|
status = StatusLED(pin=23, mode=ERROR)
|
|
assert fake_gpio["led"].state is True
|
|
status.close()
|
|
assert fake_gpio["led"].closed is True
|
|
assert fake_gpio["led"].state is False
|
|
|
|
|
|
def test_pin_and_active_high_are_passed_through(fake_gpio):
|
|
status = StatusLED(pin=17, active_high=False)
|
|
assert fake_gpio["led"].pin == 17
|
|
assert fake_gpio["led"].active_high is False
|
|
status.close()
|
|
|
|
|
|
# -- never blocks or crashes ------------------------------------------------
|
|
|
|
def test_insert_does_not_block(monkeypatch):
|
|
"""The reader thread calls this, so it must return immediately."""
|
|
class SlowLED(led_module.GPIOLED):
|
|
def __init__(self, pin, active_high=True):
|
|
self.pin = pin
|
|
self._is_on = False
|
|
|
|
def on(self):
|
|
time.sleep(0.5) # deliberately slow hardware
|
|
|
|
def off(self):
|
|
pass
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
monkeypatch.setattr(led_module, "GPIOLED", SlowLED)
|
|
# mode=presence so the startup light-up does not pay the slow 'on' cost
|
|
status = StatusLED(pin=23, mode=PRESENCE)
|
|
start = time.time()
|
|
for _ in range(20):
|
|
status.insert()
|
|
elapsed = time.time() - start
|
|
assert elapsed < 0.2, "insert() blocked for {:.2f}s".format(elapsed)
|
|
status.close()
|
|
|
|
|
|
def test_failing_toggle_does_not_raise(fake_gpio, monkeypatch):
|
|
"""A dead pin must not take the reader down."""
|
|
status = StatusLED(pin=23)
|
|
assert fake_gpio["led"] is not None
|
|
|
|
def boom():
|
|
raise OSError("pin went away")
|
|
|
|
monkeypatch.setattr(status.led, "on", boom)
|
|
status.insert()
|
|
time.sleep(0.15) # worker swallows the error
|
|
status.close() # and close() still works
|
|
|
|
|
|
def test_hardware_error_is_reported_to_the_logger(monkeypatch):
|
|
lines = []
|
|
|
|
def boom(pin, active_high=True):
|
|
raise RuntimeError("no pin factory")
|
|
|
|
monkeypatch.setattr(led_module, "GPIOLED", boom)
|
|
StatusLED(pin=23, logger=lines.append)
|
|
assert any("LED unavailable" in line for line in lines)
|
|
|
|
|
|
# -- factory ----------------------------------------------------------------
|
|
|
|
def test_create_defaults_to_error_mode_on_the_workstation_pin(fake_gpio):
|
|
status = create()
|
|
assert status.status.enabled is True
|
|
assert status.status.pin == DEFAULT_LED_PIN
|
|
assert status.status.mode == ERROR
|
|
status.close()
|
|
|
|
|
|
def test_create_disabled_returns_a_no_op():
|
|
status = create(enabled=False)
|
|
assert status.status.enabled is False
|
|
status.close()
|
|
|
|
|
|
def test_status_dict_is_json_serialisable(fake_gpio):
|
|
import json
|
|
|
|
status = create()
|
|
payload = json.dumps(status.status.to_dict())
|
|
assert "gpiozero" in payload
|
|
status.close()
|