Files
radar_system/python_app/tests/test_bscan_history_window.py
T
2026-10-05 16:46:28 +03:00

230 lines
10 KiB
Python

"""Configurable B-scan display window.
The B-scan used to be pinned to the C++ replay window (~50 sweeps) by two separate
mechanisms: the render-side history limit and a monotonically rising
`collection_id` floor. Widening only the first would have changed nothing, because
the floor kept filtering older collections out for good.
The floor is gone: selection is positional, which is the only criterion that holds
when ids are sparse (the results ring drops) or restart from 1 (a new C++ run).
These tests pin the observable consequences — how many columns end up on screen —
rather than any internal counter.
"""
from __future__ import annotations
import os
import unittest
import numpy as np
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtWidgets import QApplication # noqa: E402
from python_app.gui.app_window import AppWindow # noqa: E402
from python_app.tests.gui_test_support import isolated_window
from collections import deque # noqa: E402
from python_app.gui.runtime.history import record_result_history # noqa: E402
from python_app.models.dataset_model import ( # noqa: E402
ComboKey,
ResultBlock,
ResultCollection,
ResultPayload,
)
_app: QApplication | None = None
_window: AppWindow | None = None
def setUpModule() -> None:
global _app, _window
_app = QApplication.instance() or QApplication([])
_window = isolated_window(unittest.addModuleCleanup)
def tearDownModule() -> None:
if _window is not None:
_window.close()
def _bscan_result(collection_id: int) -> ResultCollection:
payload = ResultPayload(
processing_name="bscan",
kind=1,
frequency_hz=np.array([0.5, 1.0], dtype=np.float32),
trace=np.array([collection_id + 0j, collection_id + 0j], dtype=np.complex64),
)
block = ResultBlock(combo=ComboKey(input=0, output=0), payloads=[payload])
return ResultCollection(collection_id=collection_id, monotonic_ns=collection_id, blocks=[block])
class BscanDisplayWindowTest(unittest.TestCase):
def setUp(self) -> None:
self.w = _window
self._original_is_running = self.w._supervisor.is_running
self.w._result_history.clear()
self.w._bscan_render_signature = None
def tearDown(self) -> None:
self.w._supervisor.is_running = self._original_is_running
self.w._result_history.clear()
self.w._bscan_render_signature = None
def _set_running(self, running: bool) -> None:
self.w._supervisor.is_running = lambda: running
def _fill_history(self, count: int, *, id_step: int = 1) -> None:
for index in range(count):
self.w._result_history.append(_bscan_result(1 + index * id_step))
def test_running_acquisition_ignores_the_user_window(self) -> None:
# A live rebuild runs on every incoming result, so the live path stays pinned
# to the replay window no matter what the operator typed for stopped review.
self._set_running(True)
self.w._bscan_history_window.setValue(300)
self.assertEqual(self.w._bscan_display_window_scans(), self.w._bscan_cpp_replay_window)
def test_stopped_acquisition_uses_the_user_window(self) -> None:
self._set_running(False)
self.w._bscan_history_window.setValue(300)
self.assertEqual(self.w._bscan_display_window_scans(), 300)
def test_widening_the_window_brings_older_frames_back(self) -> None:
# The regression this whole change exists for: a live run renders 50 columns,
# and widening the window after Stop must reach back over the retained history
# rather than stay pinned to whatever the live path last drew.
self._fill_history(300)
self._set_running(True)
self.w._sync_bscan_history_from_results()
self.assertEqual(
len(self.w._bscan_history_by_combo[(0, 0)]), self.w._bscan_cpp_replay_window
)
self._set_running(False)
self.w._bscan_history_window.setValue(300)
self.w._sync_bscan_history_from_results()
self.assertEqual(len(self.w._bscan_history_by_combo[(0, 0)]), 300)
def test_restarted_collection_ids_do_not_hide_the_fresh_run(self) -> None:
# Regression: Start after a stopped review made the image melt to 0 columns and
# then snap back to 50. A new C++ run numbers from 1, so entries that are newest
# by position carry the smallest ids; any id-based cut-off derived from the old
# run rejected exactly them.
self._fill_history(300)
self._set_running(False)
self.w._bscan_history_window.setValue(300)
self.w._sync_bscan_history_from_results()
self._set_running(True)
window = self.w._bscan_cpp_replay_window
for fresh in range(1, window + 1):
self.w._result_history.append(_bscan_result(fresh))
self.w._sync_bscan_history_from_results()
self.assertEqual(len(self.w._bscan_history_by_combo[(0, 0)]), window)
def test_window_counts_entries_not_collection_ids(self) -> None:
# The results ring overwrites unread slots when the producer outruns the GUI
# poll loop, so retained collection ids are sparse. Counting in ids rather than
# in entries is exactly the case where asking for 150 sweeps rendered only 87.
self._fill_history(300, id_step=3)
self._set_running(False)
self.w._bscan_history_window.setValue(150)
self.w._sync_bscan_history_from_results()
self.assertEqual(len(self.w._bscan_history_by_combo[(0, 0)]), 150)
def test_reprocessed_results_replace_rather_than_duplicate_columns(self) -> None:
# The processor's recompute is triggered by feeding sweeps back through it, and
# it republishes them under their ORIGINAL ids. Two independent mechanisms keep
# that from doubling every column, and neither may be confused with the removed
# `collection_id` floor: a threshold cannot tell a duplicate from its original,
# since they share the id. Deduplication is by key equality.
self._set_running(False)
self.w._bscan_history_window.setValue(300)
self._fill_history(200)
self.w._sync_bscan_history_from_results()
self.assertEqual(len(self.w._bscan_history_by_combo[(0, 0)]), 200)
# Intake: a recomputed collection replaces the entry holding the same key.
for collection in list(self.w._result_history):
record_result_history(self.w._result_history, _bscan_result(collection.collection_id))
self.assertEqual(len(self.w._result_history), 200)
# Render: a duplicate that reached the deque by another path is still collapsed,
# keeping the newer of the two.
self.w._result_history.append(_bscan_result(200))
self.w._bscan_render_signature = None
self.w._sync_bscan_history_from_results()
self.assertEqual(len(self.w._bscan_history_by_combo[(0, 0)]), 200)
def test_rebuild_renders_the_full_widened_window(self) -> None:
self._fill_history(300)
self._set_running(False)
self.w._bscan_history_window.setValue(300)
self.w._sync_bscan_history_from_results()
self.assertEqual(len(self.w._bscan_history_by_combo[(0, 0)]), 300)
self.w._bscan_history_window.setValue(50)
self.w._sync_bscan_history_from_results()
self.assertEqual(len(self.w._bscan_history_by_combo[(0, 0)]), 50)
class ResultHistoryOrderTest(unittest.TestCase):
"""The result history stays in acquisition order whatever order results arrive in.
The processor reprocessing its retained sweeps also delivers sweeps whose first
result the GUI missed (the results ring overwrites unread slots). Appended, such a
sweep showed up as the newest B-scan column, and was recorded to disk once more.
"""
@staticmethod
def _ids(history: deque[ResultCollection]) -> list[int]:
return [int(collection.collection_id) for collection in history]
def test_a_new_sweep_is_appended(self) -> None:
history = deque(_bscan_result(cid) for cid in (1, 2))
self.assertTrue(record_result_history(history, _bscan_result(3)))
self.assertEqual(self._ids(history), [1, 2, 3])
def test_a_late_result_takes_its_place_in_acquisition_order(self) -> None:
history = deque(_bscan_result(cid) for cid in (1, 2, 4, 5))
self.assertFalse(record_result_history(history, _bscan_result(3)))
self.assertEqual(self._ids(history), [1, 2, 3, 4, 5])
def test_a_reprocessed_result_replaces_its_entry(self) -> None:
history = deque(_bscan_result(cid) for cid in (1, 2, 3))
reprocessed = _bscan_result(2)
self.assertFalse(record_result_history(history, reprocessed))
self.assertEqual(self._ids(history), [1, 2, 3])
self.assertIs(history[1], reprocessed)
def test_a_full_history_drops_its_oldest_entry(self) -> None:
history = deque((_bscan_result(cid) for cid in (1, 2, 4, 5)), maxlen=4)
record_result_history(history, _bscan_result(3))
self.assertEqual(self._ids(history), [2, 3, 4, 5])
record_result_history(history, _bscan_result(1)) # older than all it keeps
self.assertEqual(self._ids(history), [2, 3, 4, 5])
record_result_history(history, _bscan_result(6))
self.assertEqual(self._ids(history), [3, 4, 5, 6])
def test_only_new_sweeps_are_recorded_to_disk(self) -> None:
window = _window
arrivals = [_bscan_result(cid) for cid in (2, 3, 2, 1)] # 2 reprocessed, 1 late
reader = type("Reader", (), {"pop_result_collection": lambda _self: arrivals.pop(0) if arrivals else None})()
recorded: list[int] = []
window._result_reader = reader
self.addCleanup(setattr, window, "_result_reader", None)
window._record_collection = lambda stage, collection: recorded.append(int(collection.collection_id))
self.addCleanup(delattr, window, "_record_collection")
window._result_history.clear()
self.addCleanup(window._result_history.clear)
window._read_all_results()
self.assertEqual(recorded, [2, 3])
self.assertEqual(self._ids(window._result_history), [1, 2, 3])
if __name__ == "__main__":
unittest.main()