Files
rdm-card-reader/tests/test_led.py
T

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()