Initializare dupa curatare folder
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user