Files
radar_system/python_app/tests/test_bscan_replay.py
T
2026-10-06 12:32:26 +03:00

248 lines
11 KiB
Python

"""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("<I6QfII", RESULT_MAGIC, sweep.collection_id,
sweep.monotonic_ns, 0, 7, sweep.capture_start_ns,
sweep.capture_end_ns, 0., 0, 0))
self.writer.push = Mock(side_effect=process)
self.history.preprocessed.extend(_sweep(cid) for cid in range(1, 7))
self.assertTrue(self.replay.run(
window=6, revision=7, ring=self.ring, reader=reader,
continue_replay=lambda: True, pump_events=lambda _pause: None,
))
self.assertEqual([item.collection_id for item in self.history.replay_results], list(range(1, 7)))
self.assertEqual(self.warnings, [])
def test_replays_full_history_in_capture_order_despite_independent_ring_drops(self) -> 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()