"""IPC/SHM tests: encode/decode round-trips, corruption handling, ring semantics. Pins the agreed contract: * encode -> decode is lossless for raw/preprocessed traces and result payloads; * a corrupt/truncated frame raises a single, catchable ValueError (never a bare struct.error / UnicodeDecodeError); * the ring is latest-wins: on overflow the oldest slot is overwritten and a slow reader keeps the freshest frames; * peek_latest returns the newest frame without consuming it; * opening a missing/incompatible ring fails fast. """ from __future__ import annotations import struct import unittest from contextlib import suppress from pathlib import Path import numpy as np from python_app.models.dataset_model import ( ComboKey, ResultBlock, ResultCollection, ResultPayload, SweepCollection, TraceData, ) from python_app.orchestration.shm.decoder import ( PREPROC_MAGIC, RAW_MAGIC, RESULT_MAGIC, decode_result_collection, decode_trace_collection, ) from python_app.orchestration.shm.ring_reader import ShmRingReader from python_app.orchestration.shm.ring_writer import ShmRingWriter from python_app.storage.npz.serialize import serialize_result_collection, serialize_trace_collection def _trace(in_pos: int, out_pos: int, n: int) -> TraceData: """Build a trace with float32-exact data so round-trips compare exactly.""" freq = np.arange(n, dtype=np.float32) + 1.0 s11 = (np.arange(n, dtype=np.float32) + 0.5j * np.arange(n, dtype=np.float32)).astype(np.complex64) s21 = (-np.arange(n, dtype=np.float32) + 2.0j * np.arange(n, dtype=np.float32)).astype(np.complex64) return TraceData(combo=ComboKey(input=in_pos, output=out_pos), frequency_hz=freq, s11=s11, s21=s21) class TraceCollectionRoundTripTest(unittest.TestCase): def _assert_round_trips(self, magic: int) -> None: collection = SweepCollection( collection_id=7, monotonic_ns=123, traces=[_trace(0, 0, 4), _trace(3, 1, 2)], capture_start_ns=10, capture_end_ns=20, ) decoded = decode_trace_collection(serialize_trace_collection(collection, magic), magic) self.assertEqual(decoded.collection_id, 7) self.assertEqual(decoded.monotonic_ns, 123) self.assertEqual((decoded.capture_start_ns, decoded.capture_end_ns), (10, 20)) self.assertEqual(len(decoded.traces), 2) for original, got in zip(collection.traces, decoded.traces): self.assertEqual((got.combo.input, got.combo.output), (original.combo.input, original.combo.output)) self.assertTrue(np.array_equal(got.frequency_hz, original.frequency_hz)) self.assertTrue(np.array_equal(got.s11, original.s11)) self.assertTrue(np.array_equal(got.s21, original.s21)) def test_raw_round_trips(self) -> None: self._assert_round_trips(RAW_MAGIC) def test_preprocessed_round_trips(self) -> None: self._assert_round_trips(PREPROC_MAGIC) def test_empty_traces_round_trip(self) -> None: collection = SweepCollection(collection_id=1, monotonic_ns=2, traces=[]) decoded = decode_trace_collection(serialize_trace_collection(collection, RAW_MAGIC), RAW_MAGIC) self.assertEqual(decoded.traces, []) class ResultCollectionRoundTripTest(unittest.TestCase): def test_all_payload_kinds_round_trip(self) -> None: image = np.arange(6, dtype=np.float32).reshape((2, 3)) table = np.array([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], dtype=np.float32) collection = ResultCollection( collection_id=9, monotonic_ns=42, processing_duration_ns=1000, collection_payloads=[ ResultPayload( processing_name="gpr_accumulator", kind=3, image_x_axis=np.array([0.0, 1.0, 2.0], dtype=np.float32), image_y_axis=np.array([0.0, 1.0], dtype=np.float32), image=image, ), ResultPayload(processing_name="gpr_points", kind=4, table=table), ], blocks=[ ResultBlock(combo=ComboKey(input=1, output=0), payloads=[ ResultPayload( processing_name="bscan", kind=1, frequency_hz=np.array([1.0, 2.0], dtype=np.float32), trace=np.array([1 + 1j, 2 - 2j], dtype=np.complex64), ), ResultPayload(processing_name="snr", kind=2, scalar_value=2.5), ]), ], ) decoded = decode_result_collection(serialize_result_collection(collection)) self.assertEqual((decoded.collection_id, decoded.monotonic_ns, decoded.processing_duration_ns), (9, 42, 1000)) acc, points = decoded.collection_payloads self.assertEqual(acc.processing_name, "gpr_accumulator") self.assertTrue(np.array_equal(acc.image, image)) self.assertTrue(np.array_equal(points.table, table)) block = decoded.blocks[0] self.assertEqual((block.combo.input, block.combo.output), (1, 0)) self.assertTrue(np.array_equal(block.payloads[0].trace, np.array([1 + 1j, 2 - 2j], dtype=np.complex64))) self.assertAlmostEqual(block.payloads[1].scalar_value, 2.5) class CorruptFrameTest(unittest.TestCase): """Any corruption surfaces as ValueError (the single catchable contract).""" def _valid_trace_bytes(self) -> bytes: return serialize_trace_collection( SweepCollection(collection_id=1, monotonic_ns=1, traces=[_trace(0, 0, 3)]), RAW_MAGIC ) def test_bad_magic(self) -> None: corrupt = b"\x00\x00\x00\x00" + self._valid_trace_bytes()[4:] with self.assertRaises(ValueError): decode_trace_collection(corrupt, RAW_MAGIC) def test_truncated_buffer(self) -> None: with self.assertRaises(ValueError): decode_trace_collection(self._valid_trace_bytes()[:18], RAW_MAGIC) def test_absurd_trace_count(self) -> None: # Claims 1e6 traces but supplies none -> the first trace read runs off the end. corrupt = struct.pack(" None: corrupt = struct.pack(" None: corrupt = struct.pack(" str: return f"/radar_test_{self._testMethodName}{suffix}" def _writer(self, name: str, capacity: int, slot_size: int) -> ShmRingWriter: with suppress(OSError): (Path("/dev/shm") / name[1:]).unlink() # drop a leftover from a crashed run writer = ShmRingWriter(name, capacity, slot_size) self.addCleanup(self._cleanup, name, writer) return writer def _reader(self, name: str, **kw: object) -> ShmRingReader: reader = ShmRingReader(name, **kw) self.addCleanup(self._safe_close, reader) return reader @staticmethod def _safe_close(obj: object) -> None: with suppress(Exception): obj.close() # type: ignore[attr-defined] @staticmethod def _cleanup(name: str, writer: ShmRingWriter) -> None: with suppress(Exception): writer.close() with suppress(OSError): (Path("/dev/shm") / name[1:]).unlink() class RingRoundTripTest(_RingTestCase): def test_fifo_round_trip(self) -> None: name = self._ring_name() writer = self._writer(name, capacity=8, slot_size=64) reader = self._reader(name) payloads = [f"frame{i}".encode() for i in range(5)] for p in payloads: self.assertTrue(writer.push(p)) self.assertEqual([reader.pop_payload() for _ in payloads], payloads) self.assertIsNone(reader.pop_payload()) # empty afterwards def test_oversized_payload_rejected(self) -> None: writer = self._writer(self._ring_name(), capacity=4, slot_size=8) self.assertFalse(writer.push(b"x" * 9)) # larger than the slot class RingOverflowTest(_RingTestCase): def test_latest_wins_drops_oldest(self) -> None: name = self._ring_name() writer = self._writer(name, capacity=4, slot_size=64) reader = self._reader(name) for i in range(7): # 3 more than capacity self.assertTrue(writer.push(f"f{i}".encode())) # The 4 newest survive; the 3 oldest were overwritten. survivors = [] while (item := reader.pop_payload()) is not None: survivors.append(item) self.assertEqual(survivors, [b"f3", b"f4", b"f5", b"f6"]) class PeekLatestTest(_RingTestCase): def test_peek_is_latest_and_non_consuming(self) -> None: name = self._ring_name() writer = self._writer(name, capacity=8, slot_size=64) reader = self._reader(name) self.assertIsNone(reader.peek_latest_payload()) # nothing published yet for i in range(3): writer.push(f"f{i}".encode()) self.assertEqual(reader.peek_latest_payload(), b"f2") # newest self.assertEqual(reader.peek_latest_payload(), b"f2") # stable, not consumed self.assertEqual(reader.pop_payload(), b"f0") # consumer cursor untouched writer.push(b"f3") self.assertEqual(reader.peek_latest_payload(), b"f3") # follows the newest class RingOpenTest(_RingTestCase): def test_missing_ring_fails_fast(self) -> None: with self.assertRaises(FileNotFoundError): ShmRingReader("/radar_test_definitely_missing", open_timeout_s=0.1, open_poll_s=0.02) def test_incompatible_header_rejected(self) -> None: name = "/radar_test_bad_header" path = Path("/dev/shm") / name[1:] path.write_bytes(b"\x00" * 128) # right size, wrong magic self.addCleanup(lambda: path.unlink(missing_ok=True)) with self.assertRaises(RuntimeError): ShmRingReader(name) if __name__ == "__main__": unittest.main()