"""End-to-end test over a pseudo-terminal, with no RFID hardware. The test owns the master side of a PTY, points the CLI at the slave side and writes real RDM6300 frames. This exercises the same code path a physical reader would: serial open -> byte stream -> parse -> callback -> stdout. """ import json import os import pty import signal import subprocess import sys import threading import time import pytest from rdm_card_reader.card import CONFIG_CARD_VALUE from .test_protocol import encode_stream, encode_frame # noqa: F401 ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) SRC = os.path.join(ROOT, "src") TIMEOUT = 15.0 class ReaderProcess: """Run the CLI against a PTY and collect its stdout in the background.""" def __init__(self, extra_args=()): self.master, slave = pty.openpty() self.port = os.ttyname(slave) env = dict(os.environ) env["PYTHONPATH"] = SRC + os.pathsep + env.get("PYTHONPATH", "") self.proc = subprocess.Popen( [sys.executable, "-u", "-m", "rdm_card_reader", "--port", self.port, "--heartbeat", "0.3", *extra_args], cwd=ROOT, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, start_new_session=True, ) self.lines = [] self._lock = threading.Lock() self._thread = threading.Thread(target=self._pump, daemon=True) self._thread.start() def _pump(self): for line in self.proc.stdout: with self._lock: self.lines.append(line.rstrip()) def output(self): with self._lock: return "\n".join(self.lines) def send(self, frame): os.write(self.master, b"\x02" + frame + b"\x03") def wait_for(self, needle, timeout=TIMEOUT): deadline = time.time() + timeout while time.time() < deadline: if needle in self.output(): return True time.sleep(0.05) return False def __enter__(self): self.wait_for("waiting for cards") return self def __exit__(self, *exc): try: os.killpg(os.getpgid(self.proc.pid), signal.SIGINT) except Exception: pass try: self.proc.wait(timeout=5) except Exception: self.proc.kill() self._thread.join(timeout=3) for fd in (self.master, self.proc.stdout): try: fd.close() except Exception: pass return False @pytest.mark.skipif(sys.platform != "linux", reason="PTY test is Linux only") def test_card_insert_and_remove_are_reported(): card_id = 0x00C4A0B5 frame = encode_frame(card_id) with ReaderProcess() as rp: deadline = time.time() + TIMEOUT while time.time() < deadline and not rp.wait_for("CARD INSERTED", 0.2): rp.send(frame) assert rp.wait_for("CARD INSERTED"), rp.output() assert rp.wait_for("CARD REMOVED") text = rp.output() assert rp.port in text assert str(card_id) in text assert "0x00C4A0B5" in text assert "222" in text # checksum = 0xDE assert "0x0F" in text # type assert "True" in text # valid @pytest.mark.skipif(sys.platform != "linux", reason="PTY test is Linux only") def test_corrupt_checksum_is_reported_as_invalid(): good = bytearray(encode_frame(0x1A2B3C4D)) good[-1] = ord("0") # break the checksum bad = bytes(good) with ReaderProcess() as rp: deadline = time.time() + TIMEOUT while time.time() < deadline and not rp.wait_for("INVALID CARD", 0.2): rp.send(bad) assert "INVALID CARD" in rp.output() assert "checksum mismatch" in rp.output() @pytest.mark.skipif(sys.platform != "linux", reason="PTY test is Linux only") def test_json_mode_emits_machine_readable_events(): with ReaderProcess(extra_args=("--json",)) as rp: deadline = time.time() + TIMEOUT while time.time() < deadline and not rp.wait_for('"event"', 0.2): rp.send(encode_frame(0x00C4A0B5)) payloads = [ json.loads(line) for line in rp.output().splitlines() if line.strip().startswith("{") ] assert payloads, rp.output() assert any(p["event"] == "card_inserted" for p in payloads) assert any(p["id"] == 0x00C4A0B5 for p in payloads) assert all(p["port"] == rp.port for p in payloads) @pytest.mark.skipif(sys.platform != "linux", reason="PTY test is Linux only") def test_config_card_is_annotated(): with ReaderProcess() as rp: deadline = time.time() + TIMEOUT while time.time() < deadline and not rp.wait_for("config card", 0.2): rp.send(encode_frame(CONFIG_CARD_VALUE)) assert "config card" in rp.output() @pytest.mark.skipif(sys.platform != "linux", reason="PTY test is Linux only") def test_removal_after_heartbeat_expires(): with ReaderProcess() as rp: deadline = time.time() + TIMEOUT while time.time() < deadline and not rp.wait_for("CARD INSERTED", 0.2): rp.send(encode_frame(0x00C4A0B5)) # stop sending and let the 0.3 s heartbeat lapse assert rp.wait_for("CARD REMOVED", timeout=5) assert "CARD REMOVED" in rp.output()