import time from pygui.backend.models.frame import Frame from pygui.serialcomm.stream_reader import StreamReader, _crc16 class FakeTransport: """Yield canned data then blocks (returns b'').""" def __init__(self, lines): self._data = b"".join(lines) self._pos = 0 self.closed = False def read(self, n=1): time.sleep(0.001) if self._pos >= len(self._data): return b"" res = self._data[self._pos:self._pos+n] self._pos += n return res def readline(self): time.sleep(0.001) if self._pos >= len(self._data): return b"" idx = self._data.find(b"\n", self._pos) if idx == -1: res = self._data[self._pos:] self._pos = len(self._data) return res res = self._data[self._pos:idx+1] self._pos = idx + 1 return res def close(self): self.closed = True def parse_line(raw: str, seq: int) -> Frame: parts = [float(x) for x in raw.split(",")] return Frame(seq = seq, time = parts[0], values = parts[1:]) def test_reads_frames_and_counts_errors(): got, errors = [], [] transport = FakeTransport([b"0.0,149,148\n", b"garbage\n", b"0.5,150,149\n"]) r = StreamReader(transport, parse_line, on_frame = got.append, on_error = lambda e: errors.append(e)) r.start() time.sleep(0.1) r.stop() assert [f.values for f in got] == [[149.0, 148.0], [150.0, 149.0]] assert r.error_count == 1 assert transport.closed class WedgedTransport: """Simulates a worker thread stuck in a blocking read() that ignores the stop event — read() never returns on its own. stop() must close the port to unblock it rather than relying on the read loop to notice.""" def __init__(self): self.closed = False def read(self, n=1): while not self.closed: time.sleep(0.001) raise OSError("port closed") def close(self): self.closed = True def test_stop_closes_transport_even_if_worker_thread_is_wedged(): r = StreamReader(WedgedTransport(), parse_line, on_frame=lambda f: None, on_error=lambda e: None) r.start() time.sleep(0.05) # let the thread enter the blocking read r.stop() assert r._transport.closed, ( "stop() must close the transport itself — otherwise a wedged " "worker thread keeps the port open and a subsequent startStream() " "opens a second competing handle to the same port" ) def make_packet(payload: bytes, seq: int = 1) -> bytes: """Build a wire packet exactly as the device does: AA 88, msg type, seq (LE), payload len (LE), payload, CRC16/MODBUS (LE) over the rest.""" body = (b"\xaa\x88\x01" + seq.to_bytes(2, "little") + len(payload).to_bytes(2, "little") + payload) return body + _crc16(body).to_bytes(2, "little") def parse_payload(payload: bytes, seq: int) -> Frame: return Frame(seq=seq, time=0.0, values=[float(b) for b in payload]) def test_crc16_known_vector(): # CRC16/MODBUS check value for "123456789" is 0x4B37. assert _crc16(b"123456789") == 0x4B37 def test_binary_stream_parses_crc_valid_packets(): got = [] data = make_packet(b"\x01\x02", seq=1) + make_packet(b"\x03\x04", seq=2) r = StreamReader(FakeTransport([data]), parse_payload, on_frame=got.append, on_error=lambda e: None) r.start() time.sleep(0.2) r.stop() assert [f.seq for f in got] == [1, 2] assert r.error_count == 0 def test_corrupt_crc_packet_dropped_and_stream_continues(): got = [] bad = bytearray(make_packet(b"\x09\x09", seq=1)) bad[-1] ^= 0xFF # break the CRC data = bytes(bad) + make_packet(b"\x01\x02", seq=2) r = StreamReader(FakeTransport([data]), parse_payload, on_frame=got.append, on_error=lambda e: None) r.start() time.sleep(0.2) r.stop() assert [f.seq for f in got] == [2] assert r.error_count == 1 def test_bogus_giant_payload_len_does_not_stall_the_stream(): """A false 0xAA 0x88 inside data yields a garbage payload_len (up to 64KB). Without a sanity cap the reader waits forever accumulating a packet that doesn't exist — the 'frames stop, no error' symptom.""" got = [] # False sync + header claiming a 0xFFFF-byte payload, then a real packet. bogus = b"\xaa\x88\x01\x00\x00\xff\xff" data = bogus + make_packet(b"\x01\x02", seq=7) r = StreamReader(FakeTransport([data]), parse_payload, on_frame=got.append, on_error=lambda e: None) r.start() time.sleep(0.2) r.stop() assert [f.seq for f in got] == [7] def test_quiet_port_between_scans_is_not_a_resync_failure(): """Device pauses between measurement scans. Empty reads must not count toward the resync give-up cap (C#'s findMessageStart waits forever) — the old code died with TimeoutError after ~50 empty reads.""" errors = [] got = [] r = StreamReader(FakeTransport([make_packet(b"\x01\x02", seq=1)]), parse_payload, on_frame=got.append, on_error=errors.append) r.start() time.sleep(0.5) # ~100 empty reads after the single packet drains r.stop() assert [f.seq for f in got] == [1] assert errors == [] def test_stop_does_not_report_the_close_it_caused_as_an_error(): """stop()'s own transport.close() unblocks the worker thread's read() by making it raise — that's expected teardown, not a stream fault, and must not reach on_error (Activity Log would otherwise show a fake "Live stream error" on every ordinary Stop).""" errors = [] r = StreamReader(WedgedTransport(), parse_line, on_frame=lambda f: None, on_error=errors.append) r.start() time.sleep(0.05) r.stop() time.sleep(0.05) # let the worker thread's except-handler run assert errors == []