"""Object capture owns a pause; calculation saves against captured provenance.""" from __future__ import annotations from contextlib import ExitStack from pathlib import Path import tempfile import unittest from unittest import mock import numpy as np from PyQt6 import sip from PyQt6.QtWidgets import QApplication, QWidget from python_app.gui.controllers.history_controller import HistoryController from python_app.gui.controllers.object_calibration_capture import ObjectSweepCapture from python_app.gui.controllers.pipeline_controller import PipelineController from python_app.gui.controllers.plot_controller import PlotController from python_app.gui.controllers.preprocess_controller import PreprocessController from python_app.gui.controllers.recording_controller import RecordingController from python_app.gui.runtime.pipeline_pause import PipelinePause from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData from python_app.models.gui_profile_schema import GuiPreprocessDialogStateModel from python_app.models.run_config_schema import ComboModel, GprRxGeometryModel, GprTxGeometryModel, RunConfigModel from python_app.orchestration.config_writer import ConfigWriter from python_app.orchestration.live_processing_config import ProcessingLiveConfig from python_app.orchestration.process_supervisor import ProcessSupervisor from python_app.storage.npz.store import NpzStore from python_app.workflows.object_calibration import capture_provenance from python_app.workflows.sequential_capture_workflow import CaptureBatch, SequentialCaptureSession, SequentialCaptureState def setUpModule() -> None: global app app = QApplication.instance() or QApplication([]) def _config() -> RunConfigModel: config = RunConfigModel() config.input_switch.positions, config.output_switch.positions = 3, 4 config.combos = [ComboModel(0, 1), ComboModel(2, 3)] config.gpr.tx_geometry = [GprTxGeometryModel(1, -.4), GprTxGeometryModel(3, .4)] config.gpr.rx_geometry = [GprRxGeometryModel(0, -.2), GprRxGeometryModel(2, .2)] config.preprocess.s21.calibration.set_name = "through" config.preprocess.s21.reference.set_name = "empty" config.rings.preprocessed_tap.name = "/object_capture_test_preprocessed_tap" return config def _through() -> SweepCollection: return SweepCollection(1, 10, [ TraceData(ComboKey(i, o), np.array([1e8, 2e8, 3e8], np.float32), np.ones(3, np.complex64)) for o in (1, 3) for i in (0, 2) ]) class ObjectCaptureLifecycleTest(unittest.TestCase): def setUp(self) -> None: cleanup = ExitStack() self.addCleanup(cleanup.close) temporary = tempfile.TemporaryDirectory() self.addCleanup(temporary.cleanup) self.root = Path(temporary.name) self.config = _config() self.store = NpzStore(self.root / "sets") self.store.save_set("s21_calibration", self.config.radar_key(), "through", _through()) self.parent = QWidget() self.addCleanup(self.parent.close) self.running = False self.supervisor = mock.create_autospec(ProcessSupervisor, instance=True) self.supervisor.is_running.side_effect = lambda: self.running self.supervisor.is_processor_running.return_value = False self.supervisor.pids.side_effect = lambda: {"acquisition": 1, "data_preprocessor": 2} if self.running else {} self.history = HistoryController() self.plots = mock.create_autospec(PlotController, instance=True) self.recording = RecordingController(self.store, {}, mock.Mock(), mock.Mock(), mock.Mock()) self.error = mock.Mock() self.pipeline = PipelineController( self.root, self.store, ConfigWriter(self.root / "runtime"), self.history, self.plots, self.recording, build_config=lambda: self.config.clone(), live_config=ProcessingLiveConfig, publish_live=mock.Mock(), capture_guard=lambda: self.preprocess.busy_reason(), refresh_radar_limits=lambda: True, status=mock.Mock(), history_changed=mock.Mock(), poll_ui=mock.Mock(), show_exception=self.error, deferred_close=mock.Mock(), ) self.pipeline.supervisor = self.supervisor self.start = cleanup.enter_context(mock.patch.object(self.pipeline, "start", side_effect=self._start)) self.stop = cleanup.enter_context(mock.patch.object(self.pipeline, "stop_pipeline", side_effect=self._stop)) self.preprocess = PreprocessController( self.parent, self.config, GuiPreprocessDialogStateModel(median_sweep_count=2), self.store, self.pipeline, self.plots, build_config=lambda: self.config.clone(), current_radar_key=lambda: self.config.radar_key(), geometry=lambda: self.config.gpr, summary=mock.Mock(), error=self.error, ) self.addCleanup(self.preprocess.shutdown) self.controller = self.preprocess.object_calibration self.controller.open_creation("object") assert self.controller.dialog is not None self.dialog = self.controller.dialog self.add_capture = cleanup.enter_context(mock.patch.object(self.dialog, "add_capture", wraps=self.dialog.add_capture)) cleanup.enter_context(mock.patch("python_app.gui.controllers.object_calibration_capture.QTimer.singleShot")) def _stop(self, *, keep_processor: bool) -> None: self.running, self.pipeline.should_run = False, False def _start(self, *, config_override: RunConfigModel | None = None, pause: PipelinePause | None = None) -> bool: if pause is not None: if not pause.valid: return False elif self.preprocess.busy_reason() is not None: raise RuntimeError("busy job was not cleared before resume") self.running, self.pipeline.should_run = True, pause is None return True def _capture(self) -> ObjectSweepCapture: self.controller.capture_data() capture = self.controller.capture assert capture is not None capture._start() return capture def test_missing_tap_reports_error_before_pausing(self) -> None: self.config = _config() self.config.rings.preprocessed_tap.name = "" self.controller.capture_data() self.assertIsNone(self.controller.capture) self.assertFalse(self.dialog._capture_pending) self.stop.assert_not_called() self.start.assert_not_called() self.error.assert_called_once() def test_preprocess_refresh_signals_report_invalid_pending_config(self) -> None: dialog = self.preprocess.ensure_dialog() with mock.patch.object(self.preprocess, "_current_radar_key", side_effect=ValueError("incomplete sweep")): dialog.refresh_requested.emit() dialog.radar_config_dir_changed.emit() dialog.multi_radar_option_changed.emit() self.assertEqual(self.error.call_count, 3) self.assertEqual(self.error.call_args.args[0], "Failed to refresh preprocessing sets") def test_queued_capture_update_ignores_dialog_deleted_after_close(self) -> None: dialog = self.preprocess.ensure_dialog() session = mock.create_autospec(SequentialCaptureSession, instance=True) session.is_complete.return_value = False session.state.return_value = SequentialCaptureState("s21_reference", "reference", 1, 2, ComboModel(0, 1), True, False) session.kind = "s21_reference" session.capture_current_combo.return_value = CaptureBatch(ComboModel(0, 0), (_through().traces[0],)) self.preprocess.capture_session = session with mock.patch.object(self.preprocess, "_record_capture"), \ mock.patch("python_app.gui.controllers.preprocess_controller.QTimer.singleShot") as scheduled: self.preprocess.capture_next() self.assertEqual(scheduled.call_count, 1) callback = scheduled.call_args.args[1] self.pipeline.begin_close() self.preprocess.shutdown() sip.delete(dialog) try: self.assertTrue(sip.isdeleted(dialog)) callback() finally: self.preprocess.dialog = None def test_captures_fresh_exact_keys_and_clears_busy_job_before_resume(self) -> None: self.config.preprocess.s21.object_calibration.set_name = "existing" self.running = self.pipeline.should_run = True capture = self._capture() override: RunConfigModel = self.start.call_args.kwargs["config_override"] self.assertEqual(override.preprocess.s21.object_calibration.set_name, "") self.assertEqual(len(override.combos), 4) self.assertEqual(self.config.preprocess.s21.object_calibration.set_name, "existing") stale, first, second = [SweepCollection(1, capture._start_ns + 100, capture_start_ns=capture._start_ns + offset, capture_end_ns=capture._start_ns + offset + 1) for offset in (-1, 1, 2)] self.history.preprocessed.extend([stale, first, first, second]) capture._poll() self.add_capture.assert_called_once() self.assertEqual(self.add_capture.call_args.args[0], [first, second]) self.assertEqual(self.add_capture.call_args.args[2], capture_provenance(override.to_dict(), override.radar_key(), _through())) self.assertEqual(self.start.call_count, 2) self.assertIsNone(self.preprocess.object_capture) self.assertFalse(self.pipeline.paused) capture.cancel() self.assertEqual(self.stop.call_count, 2) self.assertEqual(self.pipeline.stop_generation, 0) def test_stop_cancels_restart_intent_but_capture_cancel_restores_backoff(self) -> None: for backoff, stop in ((False, True), (True, True), (True, False)): with self.subTest(backoff=backoff, stop=stop): self.running, self.pipeline.should_run = not backoff, backoff self.start.reset_mock() capture = self._capture() self.assertTrue(self.pipeline.paused) self.assertFalse(self.pipeline.should_run) capture._poll() self.assertEqual(self.start.call_count, 1) if stop: self.pipeline.stop() capture._poll() else: capture.cancel() self.add_capture.assert_not_called() self.assertEqual(self.start.call_count, 1 if stop else 2) self.assertEqual(self.running, not stop) self.assertEqual(self.pipeline.should_run, not stop) self.assertFalse(self.pipeline.paused) self.assertIsNone(self.preprocess.object_capture) def test_missing_producer_finishes_without_waiting_for_timeout(self) -> None: capture = self._capture() self.supervisor.pids.side_effect = None self.supervisor.pids.return_value = {"data_preprocessor": 2} capture._poll() self.assertIsNone(self.preprocess.object_capture) self.assertFalse(self.pipeline.paused) self.assertIn("stopped before all sweeps", self.dialog._status_label.text()) self.add_capture.assert_not_called() def test_cancel_during_start_keeps_lease_until_start_returns(self) -> None: self.running = self.pipeline.should_run = True def start(*, config_override: RunConfigModel | None = None, pause: PipelinePause | None = None) -> bool: if pause is not None: capture = self.preprocess.object_capture assert capture is not None capture.cancel() self.assertIs(self.preprocess.object_capture, capture) self.assertTrue(pause.valid) return self._start(config_override=config_override, pause=pause) self.start.side_effect = start self._capture() self.assertEqual(self.start.call_count, 2) self.assertEqual(self.stop.call_count, 2) self.assertIsNone(self.preprocess.object_capture) self.assertFalse(self.pipeline.paused) self.assertTrue(self.running) self.add_capture.assert_not_called() def test_stop_or_close_before_deferred_capture_start_does_not_touch_pipeline(self) -> None: for closing in (False, True): with self.subTest(closing=closing): self.controller.capture_data() capture = self.controller.capture assert capture is not None if closing: self.pipeline.begin_close() else: self.pipeline.stop_generation += 1 capture._start() self.start.assert_not_called() self.stop.assert_not_called() self.assertIsNone(self.preprocess.object_capture) def test_calculation_preserves_captures_and_saves_only_success_with_original_provenance(self) -> None: captured = _config() provenance = capture_provenance(captured.to_dict(), captured.radar_key(), _through()) metadata = {"provenance": provenance, "targets": [], "diagnostics": [], "tx_output_pos": [1, 3], "rx_input_pos": [0, 2]} self.dialog.add_capture([_through()], captured.to_dict(), provenance) for name, error in (("matching", None), ("changed", None), ("cancelled", InterruptedError("cancelled")), ("failed", ValueError("bad input"))): with self.subTest(outcome=name): self.config = captured.clone() if name == "changed": self.config.radar.sweep.points += 1 self.dialog._set_name_input.setText(name) with mock.patch.object(self.dialog, "calculation_inputs", return_value=([], captured.to_dict(), {}, provenance)), \ mock.patch("python_app.workflows.mimo_calibration.compute_from_collections", return_value=(_through(), metadata), side_effect=error): self.controller._start_calculation() worker = self.controller._worker assert worker is not None worker.join(timeout=2) self.assertFalse(worker.is_alive()) self.controller._poll_calculation() self.assertEqual(self.dialog._table.rowCount(), 1) self.assertFalse(self.dialog._calculating) self.assertEqual(self.preprocess.selected_sets["s21_object_calibration"], "matching") if error is not None: self.assertNotIn(name, self.store.list_sets("s21_object_calibration", captured.radar_key())) else: saved, saved_meta = self.store.load_set_with_metadata("s21_object_calibration", captured.radar_key(), name) self.assertEqual(saved_meta["provenance"], provenance) self.assertEqual(len(saved.traces), 4) expected = {"matching": "Applies on the next Start", "changed": "Not selected", "cancelled": "cancelled", "failed": "failed"} self.assertIn(expected[name], self.dialog._status_label.text()) self.error.assert_called_once()