"""Full-history replay uses exact acquisition keys and owns its ring lifecycle.""" from __future__ import annotations from dataclasses import replace import os import struct from pathlib import Path import unittest from unittest.mock import Mock, patch import numpy as np os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") from PyQt6.QtWidgets import QApplication from python_app.gui.controllers.bscan_replay import BscanReplay from python_app.gui.controllers.history_controller import HistoryController from python_app.models.collection_identity import collection_key from python_app.models.dataset_model import ComboKey, ResultCollection, SweepCollection, TraceData from python_app.models.run_config_schema import RingEndpointModel from python_app.orchestration.shm.decoder import PREPROC_MAGIC, RESULT_MAGIC, 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_trace_collection _app: QApplication | None = None def setUpModule() -> None: global _app instance = QApplication.instance() _app = instance if isinstance(instance, QApplication) else QApplication([]) def _sweep(collection_id: int, *, combos: int = 3, points: int = 64) -> SweepCollection: traces = [TraceData( combo=ComboKey(input=index, output=index + 1), frequency_hz=np.linspace(1e9, 8e9, points, dtype=np.float32), s21=(np.arange(points) * 2 + index).astype(np.complex64) - 0.25j, capture_start_ns=collection_id * 10 + index, capture_end_ns=collection_id * 10 + index + 1, ) for index in range(combos)] return SweepCollection(collection_id, collection_id * 1_000_000 + 7, traces, capture_start_ns=collection_id * 10, capture_end_ns=collection_id * 10 + 5) def _result_of(sweep: SweepCollection) -> ResultCollection: return ResultCollection(sweep.collection_id, sweep.monotonic_ns + 100, capture_start_ns=sweep.capture_start_ns, capture_end_ns=sweep.capture_end_ns, settings_revision=7) class _ProcessorReader(ShmRingReader): """Boundary stand-in: decode pushed wire bytes and queue processor results.""" def __init__(self) -> None: self.published: list[ResultCollection] = [] self.received: list[SweepCollection] = [] self.wait_once = False @property def capacity(self) -> int: return 50 def push(self, payload: bytes) -> bool: sweep = decode_trace_collection(payload, PREPROC_MAGIC) self.received.append(sweep) self.published.append(_result_of(sweep)) return True def pop_result_collection(self) -> ResultCollection | None: if self.wait_once: self.wait_once = False return None return self.published.pop(0) if self.published else None class ReplayTest(unittest.TestCase): def setUp(self) -> None: self.history = HistoryController() self.warnings: list[str] = [] self.replay = BscanReplay(self.history, lambda: None, self.warning, lambda _text: None) self.addCleanup(self.replay.close) self.ring = RingEndpointModel("/unused_test_replay", 50, 1 << 20) self.processor = _ProcessorReader() self.writer = Mock(spec=ShmRingWriter, push=self.processor.push, slot_size_bytes=1 << 20) writer_patch = patch( "python_app.gui.controllers.bscan_replay.ShmRingWriter", return_value=self.writer, ) self.writer_factory = writer_patch.start() self.addCleanup(writer_patch.stop) def warning(self, text: str, *, details: str | None = None, once_key: str | None = None) -> None: self.warnings.append(text) def run_replay(self) -> bool: return self.replay.run(window=300, revision=7, ring=self.ring, reader=self.processor, continue_replay=lambda: True, pump_events=lambda _pause: None) def test_result_ring_capacity_bounds_each_replay_burst(self) -> None: name = f"/radar_replay_small_result_{os.getpid()}" self.addCleanup((Path("/dev/shm") / name[1:]).unlink, missing_ok=True) results = ShmRingWriter(name, 2, 256) self.addCleanup(results.close) reader = ShmRingReader(name) self.addCleanup(reader.close) def process(payload: bytes) -> bool: sweep = decode_trace_collection(payload, PREPROC_MAGIC) return results.push(struct.pack(" None: sweeps = [_sweep(cid) for cid in range(1, 301)] self.history.preprocessed.extend(sweeps) self.history.results.extend(_result_of(_sweep(cid)) for cid in range(300, 450)) # Old processor replay arrives before the GUI's own results, including a # restarted collection id whose capture key differs from the requested one. unrelated = _result_of(replace(_sweep(1), capture_start_ns=9000, capture_end_ns=9005)) stale_tail = replace(_result_of(sweeps[-1]), settings_revision=6) self.processor.published = [stale_tail, *[_result_of(sweep) for sweep in sweeps[6:10]], unrelated] self.assertTrue(self.run_replay()) source = self.history.bscan_source() self.assertEqual([collection_key(item) for item in source], [collection_key(item) for item in sweeps]) self.assertEqual(len(self.processor.received), 300) self.assertTrue(all(item.settings_revision == 7 for item in source)) self.assertIsNot(source[-1], stale_tail) self.assertIn(unrelated, self.history.results) self.writer_factory.assert_called_once_with(self.ring.name, self.ring.capacity, self.ring.slot_size_bytes) def test_refuses_without_reader_or_active_ring_geometry(self) -> None: self.history.preprocessed.append(_sweep(1)) self.assertFalse(self.replay.run(window=1, revision=7, ring=self.ring, reader=None, continue_replay=lambda: True, pump_events=lambda _pause: None)) self.assertFalse(self.replay.run(window=1, revision=7, ring=None, reader=self.processor, continue_replay=lambda: True, pump_events=lambda _pause: None)) self.writer_factory.assert_not_called() self.assertIn("geometry is unknown", self.warnings[-1]) def test_nested_replay_is_refused(self) -> None: self.history.preprocessed.append(_sweep(1)) self.processor.wait_once = True nested: list[bool] = [] def pump(_pause: float) -> None: nested.append(self.run_replay()) self.assertTrue(self.replay.run(window=1, revision=7, ring=self.ring, reader=self.processor, continue_replay=lambda: True, pump_events=pump)) self.assertEqual(nested, [False]) self.assertFalse(self.replay.active) def test_close_discard_or_settings_edit_during_pump_cancels_current_replay(self) -> None: self.history.preprocessed.extend(_sweep(cid) for cid in range(1, 25)) for action in ("close", "discard", "schedule"): with self.subTest(action=action): self.processor.published.clear() self.processor.received.clear() self.processor.wait_once = True def pump(_pause: float) -> None: if action == "schedule": self.replay.schedule(True) elif action == "close": self.replay.close() else: self.replay.discard() self.assertFalse(self.run_replay()) self.assertFalse(self.replay.run( window=24, revision=7, ring=self.ring, reader=self.processor, continue_replay=lambda: True, pump_events=pump, )) self.assertLess(len(self.processor.received), 24) self.assertEqual(list(self.history.results), []) self.assertEqual(self.history.replay_results, []) self.assertFalse(self.replay.active) self.assertEqual(self.replay.timer.isActive(), action == "schedule") def test_runtime_guard_is_rechecked_before_io_and_after_event_pumping(self) -> None: self.history.preprocessed.extend(_sweep(cid) for cid in range(1, 25)) for stop_at in ("push", "pop", "pump"): with self.subTest(stop_at=stop_at): self.processor.published.clear() self.processor.received.clear() self.processor.wait_once = stop_at == "pump" running = True def guard() -> bool: count = len(self.processor.received) return running and not (stop_at == "push" and count >= 1 or stop_at == "pop" and count >= 12) def pump(_pause: float) -> None: nonlocal running running = False self.assertFalse(self.replay.run( window=24, revision=7, ring=self.ring, reader=self.processor, continue_replay=guard, pump_events=pump, )) self.assertEqual(len(self.processor.received), 1 if stop_at == "push" else 12) self.assertEqual(list(self.history.results), []) self.assertEqual(self.history.replay_results, []) self.assertFalse(self.replay.active) def test_small_ring_is_drained_before_another_burst(self) -> None: self.ring.capacity = 2 self.history.preprocessed.extend(_sweep(cid) for cid in range(1, 16)) def push(payload: bytes) -> bool: self.assertLess(len(self.processor.published), self.ring.capacity) return self.processor.push(payload) self.writer.push = push self.assertTrue(self.run_replay()) self.assertEqual(len(self.history.replay_results), 15) def test_discard_and_close_release_replay_source_timer_and_writer(self) -> None: self.history.preprocessed.append(_sweep(1)) self.assertTrue(self.run_replay()) self.replay.discard() self.assertEqual(self.history.bscan_source(), list(self.history.results)) self.replay.schedule(False) self.assertFalse(self.replay.timer.isActive()) self.replay.schedule(True) self.assertTrue(self.replay.timer.isActive()) self.replay.close() self.replay.close() self.assertFalse(self.replay.timer.isActive()) self.writer.close.assert_called_once() if __name__ == "__main__": unittest.main()