"""Single-capture completion logic. The single capture starts a one-shot pipeline and must finish on exactly the result that belongs to its sweep. Because collection ids restart per acquisition run, a stale result from a previous run can carry the SAME id; the start timestamp disambiguates them. These tests pin that boundary (fresh vs stale same-id) and the readiness state machine — the path that previously hung on the simulator when start_ns was recorded too late. """ from __future__ import annotations import os import unittest from unittest import mock import numpy as np os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") from PyQt6.QtWidgets import QApplication, QComboBox # noqa: E402 from python_app.gui.app_window import AppWindow # noqa: E402 from python_app.tests.gui_test_support import isolated_window 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 instance = QApplication.instance() _app = instance if isinstance(instance, QApplication) else QApplication([]) _window = isolated_window(unittest.addModuleCleanup) def _result(collection_id: int, capture_start_ns: int) -> ResultCollection: payload = ResultPayload( processing_name="pass_through", kind=1, frequency_hz=np.array([1.0, 2.0], dtype=np.float32), trace=np.array([1 + 1j, 2 - 2j], dtype=np.complex64), ) block = ResultBlock(combo=ComboKey(input=0, output=0), payloads=[payload]) return ResultCollection(collection_id=collection_id, monotonic_ns=capture_start_ns + 100, blocks=[block], capture_start_ns=capture_start_ns, capture_end_ns=capture_start_ns + 1) class SingleCaptureBoundaryTest(unittest.TestCase): def setUp(self) -> None: assert _window is not None self.w = _window self.pipeline = self.w._pipeline self.pipeline.timer.stop() self.w._view.processing.mode.setCurrentText("pass_through") self.pipeline.single_capture_active = True self.pipeline._single_capture_start_ns = 1000 self.pipeline._single_capture_target_key = (5, 1500) self.w._history.preprocessed.clear() self.w._history.results.clear() def test_picks_fresh_result_not_stale_same_id(self) -> None: stale, fresh = _result(5, 500), _result(5, 1500) self.w._history.results.extend((stale, fresh)) with mock.patch.object(self.w._plots, "draw", wraps=self.w._plots.draw) as draw: self.assertTrue(self.pipeline._finish_single_capture_if_ready()) draw.assert_called_once_with(fresh) def test_stale_same_id_result_is_not_matched(self) -> None: self.w._history.results.append(_result(5, 500)) self.assertFalse(self.pipeline._finish_single_capture_if_ready()) def test_wrong_collection_id_is_not_matched(self) -> None: self.w._history.results.append(_result(9, 1500)) self.assertFalse(self.pipeline._finish_single_capture_if_ready()) def test_not_ready_until_a_fresh_raw_is_seen(self) -> None: # The readiness guard that the timing fix makes reachable: with no raw recognized # as fresh yet, the capture must not complete (it must keep waiting, not hang). self.pipeline._single_capture_target_key = None self.w._history.results.append(_result(5, 1500)) self.assertFalse(self.pipeline._finish_single_capture_if_ready()) self.assertTrue(self.pipeline.single_capture_active) def test_completes_and_stops_on_the_fresh_result(self) -> None: self.w._history.results.append(_result(5, 1500)) self.assertTrue(self.pipeline._finish_single_capture_if_ready()) self.assertFalse(self.pipeline.single_capture_active) def test_empty_processed_trace_completes_capture_instead_of_waiting_for_timeout(self) -> None: result = _result(5, 1500) payload = result.blocks[0].payloads[0] payload.frequency_hz = np.empty(0, dtype=np.float32) payload.trace = np.empty(0, dtype=np.complex64) self.w._history.results.append(result) self.assertTrue(self.pipeline._finish_single_capture_if_ready()) self.assertFalse(self.pipeline.single_capture_active) if __name__ == "__main__": unittest.main()