From 83c8dd5546203b8d2c57e6fbdc0ea9c152a54084 Mon Sep 17 00:00:00 2001 From: michael Date: Thu, 19 Mar 2026 12:34:10 -0700 Subject: [PATCH] test: comprehensive SniffSession live streaming tests 10 tests covering streaming flags, trace entry parsing, status transitions, button-toggle lifecycle, BREAK_LOOP, entry accumulation across pause/resume cycles, and full end-to-end workflow. --- tests/test_sniff_session.py | 327 ++++++++++++++++++++++++++++++++++++ 1 file changed, 327 insertions(+) create mode 100644 tests/test_sniff_session.py diff --git a/tests/test_sniff_session.py b/tests/test_sniff_session.py new file mode 100644 index 0000000..1d1b4e9 --- /dev/null +++ b/tests/test_sniff_session.py @@ -0,0 +1,327 @@ +"""Tests for SniffSession live streaming mode.""" +import struct +import time + +import pytest + +from pm3py.core.protocol import ( + Cmd, + SNIFF_FLAG_STREAMING, SNIFF_FLAG_BUTTON_TOGGLE, + SNIFF_STATE_STARTED, SNIFF_STATE_PAUSED, + SNIFF_STATE_RESUMED, SNIFF_STATE_STOPPED, + RESP_PREAMBLE_MAGIC, RESP_POSTAMBLE_NOCRC, +) +from pm3py.core.transport import encode_ng_frame +from pm3py.sniff.session import SniffSession + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +class FakeSerial: + """Mock serial port that feeds pre-built response frames.""" + + def __init__(self, frames: list[bytes] | None = None): + self._buf = bytearray() + if frames: + for f in frames: + self._buf.extend(f) + self._pos = 0 + self._closed = False + self._written: list[bytes] = [] + + @property + def in_waiting(self): + return len(self._buf) - self._pos + + def read(self, size=1): + if self._closed or self._pos >= len(self._buf): + time.sleep(0.01) + return b"" + end = min(self._pos + size, len(self._buf)) + data = bytes(self._buf[self._pos:end]) + self._pos = end + return data + + def write(self, data): + self._written.append(bytes(data)) + return len(data) + + def reset_input_buffer(self): + pass + + def reset_output_buffer(self): + pass + + def close(self): + self._closed = True + + def feed(self, data: bytes): + """Append more data to the read buffer.""" + self._buf.extend(data) + + +def _make_resp_frame(cmd: int, status: int, data: bytes) -> bytes: + """Build a raw NG response frame (USB, no CRC).""" + payload_len = len(data) + preamble = struct.pack("= 1 + frame = ser._written[0] + + # Decode the command frame: magic(4) + length_ng(2) + cmd(2) + payload + postamble(2) + magic, length_ng, cmd = struct.unpack_from("= 1 + frame = ser._written[0] + payload = frame[8:-2] + idle_timeout, flags = struct.unpack_from(" stream -> pause -> resume -> more -> stopped.""" + frames = [ + _make_status_frame(SNIFF_STATE_STARTED), + # Reader command + _make_sniff_stream_frame(0, 0, 100, 1000, b"\x26\x01\x00"), + # Tag response + _make_sniff_stream_frame(0, 1, 200, 1500, b"\x00\x0F\xAA\xBB"), + # Another reader command + _make_sniff_stream_frame(0, 0, 100, 3000, b"\x20\x01"), + _make_status_frame(SNIFF_STATE_PAUSED, count=3), + _make_status_frame(SNIFF_STATE_RESUMED), + # More frames after resume + _make_sniff_stream_frame(0, 0, 100, 5000, b"\x26\x01\x00"), + _make_sniff_stream_frame(0, 1, 200, 5500, b"\x00\x0F\xCC\xDD"), + _make_status_frame(SNIFF_STATE_STOPPED, count=5), + ] + ser = FakeSerial(frames) + session = SniffSession(ser) + + session._formatter = None + session._active = True + session._state = "starting" + session._reader_thread = __import__("threading").Thread( + target=session._stream_reader, daemon=True) + session._reader_thread.start() + + time.sleep(0.2) + session._reader_thread.join(timeout=1.0) + + entries = session.entries + assert len(entries) == 5 + + # Verify timestamps are monotonically increasing + timestamps = [e["timestamp"] for e in entries] + assert timestamps == sorted(timestamps) + assert timestamps == [1000, 1500, 3000, 5000, 5500] + + # Verify directions alternate for first pair + assert entries[0]["direction"] == 0 # reader + assert entries[1]["direction"] == 1 # tag + + # Final state + assert session.state == "stopped" + assert session._active is False + + def test_close_cleans_up(self): + """close() stops session and closes serial.""" + ser = FakeSerial() + session = SniffSession(ser) + session._active = True + session._reader_thread = None + + session.close() + + assert session._active is False + assert session.state == "stopped" + assert ser._closed is True + + def test_entries_returns_copy(self): + """entries property returns a copy, not the internal list.""" + ser = FakeSerial() + session = SniffSession(ser) + session._entries.append({"direction": 0, "data": b"\x26"}) + + entries = session.entries + entries.append({"direction": 1, "data": b"\x00"}) + assert len(session.entries) == 1 # internal list unchanged