"""Regression coverage for saves, full history drains and interruptible GUI actions.""" from __future__ import annotations from collections import deque import itertools import os from pathlib import Path import tempfile from types import SimpleNamespace import unittest from unittest import mock os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") from PyQt6.QtWidgets import QApplication # noqa: E402 from PyQt6.QtCore import QTimer # noqa: E402 from python_app.gui.app_window import AppWindow # noqa: E402 from python_app.gui.controllers.app_window_config.profile_io_mixin import AppWindowConfigProfileIOMixin # noqa: E402 from python_app.gui.controllers.app_window_config.live_processing_mixin import AppWindowLiveProcessingMixin # noqa: E402 from python_app.gui.controllers.app_window_pipeline_mixin import AppWindowPipelineMixin # noqa: E402 from python_app.gui.controllers.app_window_preprocess_mixin import AppWindowPreprocessMixin # noqa: E402 from python_app.gui.controllers.app_window_snapshot_mixin import AppWindowSnapshotMixin # noqa: E402 from python_app.gui.controllers.app_window_recording_mixin import AppWindowRecordingMixin # noqa: E402 from python_app.models.dataset_model import ResultCollection, SweepCollection # noqa: E402 from python_app.models.gui_profile_model import GuiProfileModel, GuiStateModel # noqa: E402 from python_app.models.run_config_model import ComboModel, GprRxGeometryModel, GprTxGeometryModel, RunConfigModel # noqa: E402 from python_app.storage.npz_store import NpzStore # noqa: E402 from python_app.workflows.kamil_adc_neutral_preprocess import build_neutral_s21_sets # noqa: E402 def setUpModule() -> None: global _app _app = QApplication.instance() or QApplication([]) def _reader(method: str, collections: list): pending = deque(collections) return SimpleNamespace(**{method: lambda: pending.popleft() if pending else None, "close": mock.Mock()}) class _Runtime(AppWindowPipelineMixin, AppWindowSnapshotMixin, AppWindowPreprocessMixin): def __init__(self) -> None: self._raw_reader = self._pre_reader = self._result_reader = None self._raw_history = deque(maxlen=2) self._pre_history = deque(maxlen=2) self._result_history = deque(maxlen=2) self._max_pop_per_poll = self._max_pop_per_snapshot_drain = 1 self._single_capture_active = True self._single_capture_start_ns = 10 self._single_capture_seen_raw = False self._single_capture_target_collection_id = None self._capture_session = None self._preprocess_capture_busy = self._closing = False self._resume_pipeline_after_capture = True self._stop_request_seq = 0 self._pipeline_metrics = mock.Mock() self._record_collection = mock.Mock() self._supervisor = mock.Mock() self._supervisor.is_running.return_value = False self._supervisor.is_processor_running.return_value = True self._status_label = mock.Mock() for name in ("_log", "_log_debug", "_log_exception", "_show_error", "_show_exception", "_update_history_indicator", "_finalize_recording_on_stop", "_web_update_snapshot", "_build_config", "_update_capture_dialog_state", "close"): setattr(self, name, mock.Mock()) self._pump_events_during_drain = mock.Mock() class NeutralSetCreationTest(unittest.TestCase): def test_failed_second_set_preserves_gui_selection_and_previous_data(self) -> None: w = _Runtime() config = RunConfigModel.from_dict({"radar": { "model": "librevna", "driver_mode": "mock", "sweep": {"points": 7}, }}) w._build_config.return_value = config w._ensure_preprocess_dialog = mock.Mock(return_value=SimpleNamespace(set_name=lambda: "new_neutral")) w._radar_key = mock.Mock(return_value="radar") w._selected_preprocess_sets = {"s21_calibration": "old_cal", "s21_reference": "old_ref"} w._selected_preprocess_radar_key = "previous_radar" w._processor_run_signature = "processor_signature" w._history_run_signature = "history_signature" w._refresh_sets = mock.Mock() w._reset_runtime_history = mock.Mock() with tempfile.TemporaryDirectory() as directory: root = Path(directory) w._store = NpzStore(root) calibration, reference = build_neutral_s21_sets(config) w._store.save_set("s21_calibration", "radar", "old_cal", calibration) w._store.save_set("s21_reference", "radar", "old_ref", reference) before = {path: path.read_bytes() for path in root.rglob("*") if path.is_file()} save = w._store.save_set def save_set(kind, *args): if kind == "s21_reference": raise OSError("reference write failed") return save(kind, *args) with mock.patch.object(w._store, "save_set", side_effect=save_set): w._create_neutral_sets() self.assertEqual({path: path.read_bytes() for path in root.rglob("*") if path.is_file()}, before) self.assertEqual(w._selected_preprocess_sets, {"s21_calibration": "old_cal", "s21_reference": "old_ref"}) self.assertEqual(w._selected_preprocess_radar_key, "previous_radar") self.assertEqual((w._processor_run_signature, w._history_run_signature), ("processor_signature", "history_signature")) w._show_exception.assert_called_once() w._refresh_sets.assert_not_called() w._reset_runtime_history.assert_not_called() class SnapshotDrainTest(unittest.TestCase): def test_save_drain_preserves_recording_and_single_capture_tracking(self) -> None: w = _Runtime() raw = SweepCollection(collection_id=5, monotonic_ns=20, traces=[], capture_start_ns=1, capture_end_ns=8) preprocessed = SweepCollection(collection_id=5, monotonic_ns=20, traces=[]) result = ResultCollection(collection_id=5, monotonic_ns=20, blocks=[]) w._raw_reader = _reader("pop_raw_collection", [raw]) w._pre_reader = _reader("pop_preprocessed_collection", [preprocessed]) w._result_reader = _reader("pop_result_collection", [result, result]) w._drain_runtime_rings_for_snapshot() self.assertEqual(w._record_collection.call_args_list, [ mock.call("raw", raw), mock.call("preprocessed", preprocessed), mock.call("results", result), ]) self.assertTrue(w._single_capture_seen_raw) self.assertEqual(w._single_capture_target_collection_id, 5) w._pipeline_metrics.record.assert_any_call("acquisition", 7) self.assertEqual(list(w._result_history), [result]) w._log_exception.assert_not_called() def test_continuous_producer_cannot_prevent_snapshot_timeout(self) -> None: w = _Runtime() collection = SweepCollection(collection_id=1, monotonic_ns=20, traces=[]) pop = mock.Mock(return_value=collection) w._raw_reader = SimpleNamespace(pop_raw_collection=pop) ticks = itertools.count(0.0, 0.1) with mock.patch("python_app.gui.controllers.app_window_snapshot_mixin.time.monotonic", side_effect=lambda: next(ticks)): w._drain_runtime_rings_for_snapshot() self.assertGreater(pop.call_count, 1) self.assertLess(pop.call_count, 15) w._log_exception.assert_not_called() def test_stop_drain_reads_the_whole_tail_when_history_is_full(self) -> None: w = _Runtime() w._raw_history = deque([SweepCollection(collection_id=0, monotonic_ns=0, traces=[])], maxlen=1) tail = [SweepCollection(collection_id=i, monotonic_ns=i, traces=[]) for i in range(1, 11)] w._raw_reader = _reader("pop_raw_collection", tail) clock = [0.0] w._pump_events_during_drain.side_effect = lambda pause: clock.__setitem__(0, clock[0] + pause) with mock.patch("python_app.gui.controllers.app_window_pipeline_mixin.time.monotonic", side_effect=lambda: clock[0]): w._drain_rings_until_quiet(timeout_s=1.0, poll_s=0.01) self.assertEqual([call.args[1].collection_id for call in w._record_collection.call_args_list], list(range(1, 11))) self.assertEqual(w._raw_history[-1].collection_id, 10) class ShutdownReentryTest(unittest.TestCase): def test_reader_failure_on_stop_is_reported_and_cancels_automatic_resume(self) -> None: w = _Runtime() w._supervisor.is_running.return_value = True w._drain_rings_until_quiet = mock.Mock(side_effect=ValueError("corrupt ring payload")) raw = w._raw_reader = SimpleNamespace(close=mock.Mock()) preprocessed = w._pre_reader = SimpleNamespace(close=mock.Mock()) result = w._result_reader = SimpleNamespace(close=mock.Mock(), _ring_name="/results") expected_stop_seq = w._stop_request_seq + 1 with mock.patch("python_app.gui.controllers.app_window_pipeline_mixin.ShmRingReader") as reopen: w._stop_run() w._supervisor.stop.assert_called_once() w._supervisor.stop_all.assert_not_called() w._finalize_recording_on_stop.assert_called_once() for reader in (raw, preprocessed, result): reader.close.assert_called_once() self.assertIsNone(w._raw_reader) self.assertIsNone(w._pre_reader) self.assertIs(w._result_reader, reopen.return_value) reopen.assert_called_once_with("/results") w._show_exception.assert_called_once() self.assertIn("corrupt ring", str(w._show_exception.call_args.args[1])) self.assertFalse(w._stopping_run) self.assertFalse(w._single_capture_active) self.assertEqual(w._status_label.setText.call_args.args[0], "Status: error") self.assertFalse(w._pipeline_resume_allowed(expected_stop_seq)) def test_unsupported_kamil_adc_mock_fails_before_starting_any_process(self) -> None: w = _Runtime() config = RunConfigModel() config.radar.model = "kamil_adc" config.apply_device_model_constraints() w._build_config.return_value = config w._stop_all_processes = mock.Mock() w._start_run(single_capture=True) w._supervisor.start.assert_not_called() w._show_exception.assert_called_once() self.assertIn("native", str(w._show_exception.call_args.args[1])) def test_stop_blocks_nested_start_poll_and_duplicate_stop(self) -> None: w = _Runtime() w._supervisor.is_running.return_value = True def reenter(**_kwargs): w._start_run() w._poll_rings() w._stop_run() w._drain_rings_until_quiet = mock.Mock(side_effect=reenter) w._stop_run() w._supervisor.stop_orchestrator.assert_called_once() w._supervisor.stop_preprocessor.assert_called_once() w._finalize_recording_on_stop.assert_called_once() w._build_config.assert_not_called() w._web_update_snapshot.assert_not_called() self.assertFalse(w._stopping_run) self.assertFalse(w._resume_pipeline_after_capture) def test_close_during_stop_waits_until_tail_is_finalized(self) -> None: w = _Runtime() w._supervisor.is_running.return_value = True event = mock.Mock() ordering = [] def close_during_drain(**_kwargs): AppWindow.closeEvent(w, event) w._drain_rings_until_quiet = mock.Mock(side_effect=close_during_drain) w._finalize_recording_on_stop.side_effect = lambda: ordering.append("finalize") w.close.side_effect = lambda: ordering.append("close") w._stop_run() event.ignore.assert_called() w._supervisor.stop_all.assert_called_once() self.assertEqual(ordering, ["finalize", "close"]) self.assertFalse(w._close_after_stop) def test_explicit_stop_during_apply_pause_cancels_apply_and_restart(self) -> None: w = _Runtime() w._supervisor.is_running.return_value = True w._start_run = mock.Mock() w._drain_rings_until_quiet = mock.Mock(side_effect=lambda **_kwargs: w._stop_run()) w._apply_radar_settings() w._build_config.assert_not_called() w._start_run.assert_not_called() class CaptureBatchControlTest(unittest.TestCase): def test_stop_between_combos_keeps_captures_and_allows_later_resume(self) -> None: w = _Runtime() captured = [] session = SimpleNamespace( kind="s21_reference", is_complete=lambda: len(captured) == 3, state=lambda: SimpleNamespace(total_count=3, captured_count=len(captured)), capture_current_combo=lambda: captured.append(len(captured)), ) w._capture_session = session dialog = mock.Mock() w._ensure_preprocess_dialog = lambda: dialog w._record_preprocess_capture = mock.Mock() w._pump_events_during_drain = lambda pause: AppWindowPipelineMixin._pump_events_during_drain(w, pause) QTimer.singleShot(0, w._stop_run) with mock.patch("python_app.gui.controllers.app_window_preprocess_mixin.QTimer.singleShot", side_effect=lambda _delay, fn: fn()): w._capture_all_remaining() self.assertEqual(captured, [0]) self.assertIs(w._capture_session, session) self.assertFalse(w._preprocess_capture_busy) self.assertIn("paused", dialog.set_status.call_args.args[0]) w._capture_all_remaining() self.assertEqual(captured, [0, 1, 2]) self.assertEqual(w._record_preprocess_capture.call_count, 3) class GuiProfileWriteTest(unittest.TestCase): def test_dataset_profile_uses_actual_preprocessing_and_preserves_current_gui(self) -> None: for applied, selected in (("", "next_run"), ("applied", "")): with self.subTest(applied=applied), tempfile.TemporaryDirectory() as folder: pending = GuiProfileModel(gui=GuiStateModel()) pending.gui.processing.bscan.max_depth_m = 7.5 pending.run_config.gpr.tx_geometry = [GprTxGeometryModel(output_pos=0, x_m=.4, y_m=.2, z_m=.1)] pending.run_config.preprocess.s21.object_calibration.set_name = selected active = pending.run_config.clone() active.gpr.tx_geometry[0].x_m = -.4 active.radar.serial = "acquired_A" active.radar.sweep.points = 129 active.input_switch.positions = 2 active.output_switch.positions = 2 active.combos = [ComboModel(input=1, output=1)] pending.run_config.radar.serial = "pending_B" pending.run_config.radar.sweep.points = 257 active.preprocess.s21.object_calibration.set_name = applied active.preprocess.s21.object_calibration.bundle_path = "/bundles/object" if applied else "" active.preprocess.s21.calibration.set_name = "actual_through" active.preprocess.s21.calibration.bundle_path = "/bundles/through" active.preprocess.s21.reference.set_name = "actual_reference" active.preprocess.s21.reference.bundle_path = "/bundles/reference" active.preprocess.s11.calibration.open.set_name = "actual_open" active.preprocess.s11.calibration.open.bundle_path = "/bundles/open" active.preprocess.s11.reference.set_name = "actual_s11_reference" active.preprocess.notch.enabled = True active.preprocess.notch.bands_hz = [(2e9, 2.1e9)] writer = AppWindowConfigProfileIOMixin() writer._build_gui_profile = pending.clone writer._active_run_config = active dataset_path, settings_path = Path(folder) / "dataset.json", Path(folder) / "settings.json" saved = writer._write_gui_profile_to_path(dataset_path, dataset=True) writer._write_gui_profile_to_path(settings_path) dataset = GuiProfileModel.load_from_path(dataset_path).to_dict() expected = pending.to_dict() acquired = active.to_dict() for key in ("radar", "switches", "run", "preprocess"): expected[key] = acquired[key] expected["gpr"]["tx_geometry"] = acquired["gpr"]["tx_geometry"] expected["gpr"]["rx_geometry"] = acquired["gpr"]["rx_geometry"] self.assertEqual(dataset, expected) self.assertEqual(GuiProfileModel.load_from_path(settings_path).to_dict(), pending.to_dict()) saved.run_config.preprocess.s21.calibration.set_name = "mutated_return_value" self.assertEqual(active.preprocess.s21.calibration.set_name, "actual_through") def test_dataset_profile_retains_original_device_and_geometry_after_loading_another_profile(self) -> None: acquired = GuiProfileModel() acquired.run_config.ensure_combos() acquired.run_config.radar.serial = "device_A" acquired.run_config.gpr.tx_geometry = [GprTxGeometryModel(output_pos=0, x_m=-.4)] acquired.run_config.gpr.rx_geometry = [GprRxGeometryModel(input_pos=0, x_m=.4)] pending = acquired.clone() pending.run_config.radar.model = "librevna_multi" pending.run_config.radar.serial = "device_B" pending.run_config.gpr.tx_geometry[0].x_m = 2. pending.run_config.gpr.rx_geometry[0].x_m = -2. writer = AppWindowConfigProfileIOMixin() writer._build_gui_profile = pending.clone writer._active_run_config = acquired.run_config with tempfile.TemporaryDirectory() as folder: path = Path(folder) / "dataset.json" writer._write_gui_profile_to_path(path, dataset=True) saved = GuiProfileModel.load_from_path(path) self.assertEqual(saved.run_config.radar, acquired.run_config.radar) self.assertEqual(saved.run_config.gpr, acquired.run_config.gpr) self.assertEqual(saved.run_config.combos, acquired.run_config.combos) def test_applied_geometry_tracks_successful_live_writes_only_while_acquiring(self) -> None: for running, failed_write in ((True, False), (False, False), (True, True)): with self.subTest(running=running, failed_write=failed_write): active = RunConfigModel() active.gpr.tx_geometry = [GprTxGeometryModel(output_pos=0, x_m=-.4)] active.gpr.rx_geometry = [GprRxGeometryModel(input_pos=0, x_m=.4)] original = active.clone() writer = SimpleNamespace( _active_run_config=active, _supervisor=SimpleNamespace(is_running=lambda: running), _live_config_writer=SimpleNamespace(write=mock.Mock(side_effect=OSError("disk full") if failed_write else None)), _live_processing_config=mock.Mock(return_value=object()), _can_reprocess_history=lambda: False, _gpr_tx_geometry_input=SimpleNamespace(records=lambda: [dict(output_pos=0, x_m=1., y_m=2., z_m=3.)]), _gpr_rx_geometry_input=SimpleNamespace(records=lambda: [dict(input_pos=0, x_m=-1., y_m=-2., z_m=-3.)]), ) if failed_write: with self.assertRaises(OSError): AppWindowLiveProcessingMixin._write_live_processing_config(writer) else: AppWindowLiveProcessingMixin._write_live_processing_config(writer) if running and not failed_write: self.assertEqual(active.gpr.tx_geometry, [GprTxGeometryModel(0, 1., 2., 3.)]) self.assertEqual(active.gpr.rx_geometry, [GprRxGeometryModel(0, -1., -2., -3.)]) else: self.assertEqual(active.gpr, original.gpr) def test_dataset_profile_without_active_run_preserves_ui_selection(self) -> None: writer = AppWindowConfigProfileIOMixin() pending = GuiProfileModel() pending.run_config.preprocess.s21.object_calibration.set_name = "selected" writer._build_gui_profile = pending.clone writer._active_run_config = None with tempfile.TemporaryDirectory() as folder: path = Path(folder) / "dataset.json" writer._write_gui_profile_to_path(path, dataset=True) self.assertEqual(GuiProfileModel.load_from_path(path).to_dict(), pending.to_dict()) def test_failed_profile_replace_preserves_existing_profile_and_cleans_temporary(self) -> None: writer = AppWindowConfigProfileIOMixin() writer._build_gui_profile = lambda: GuiProfileModel() with tempfile.TemporaryDirectory() as folder: path = Path(folder) / "profile.json" previous = b'{"previous": "profile"}' path.write_bytes(previous) with mock.patch("python_app.file_io.os.replace", side_effect=OSError("disk failure")): with self.assertRaisesRegex(OSError, "disk failure"): writer._write_gui_profile_to_path(path) self.assertEqual(path.read_bytes(), previous) self.assertEqual(list(path.parent.iterdir()), [path]) class HistoryCommandRestoreTest(unittest.TestCase): def test_malformed_or_exhausted_counter_cannot_prevent_gui_start(self) -> None: with tempfile.TemporaryDirectory() as folder: path = Path(folder) / "live.json" for content in ('[]', 'null', 'invalid', '{"history_command_seq": NaN}', '{"history_command_seq": Infinity}', '{"history_command_seq": true}', '{"history_command_seq": 1.5}', '{"history_command_seq": -1}', '{"history_command_seq": 18446744073709551615}'): path.write_text(content) with self.subTest(content=content): self.assertEqual(AppWindow._load_history_command_seq(path), 0) path.write_text('{"history_command_seq": 9876543210}') self.assertEqual(AppWindow._load_history_command_seq(path), 9876543210) class PreprocessHistoryRestartTest(unittest.TestCase): def _runtime(self): w = _Runtime() config = RunConfigModel.from_dict({"radar": {"model": "librevna", "driver_mode": "mock"}}) config.input_switch.driver = config.output_switch.driver = "h7992" config.input_switch.radar_port, config.output_switch.radar_port = 1, 2 config.preprocess.s21.calibration.set_name = "through" config.preprocess.s21.reference.set_name = "empty" w._build_config.return_value = config w._processor_run_signature = w._build_processor_run_signature(config) w._history_run_signature = w._build_run_history_signature(config) w._project_root = Path("/tmp/object-calibration-test") w._store = mock.Mock() w._store.has_combo_coverage.return_value = True w._config_writer = mock.Mock() w._config_writer.write.return_value = w._project_root / "runtime.json" w._radar_key = mock.Mock(return_value="radar") w._supervisor.pids.return_value = {} w._bscan_reprocess_timer = mock.Mock() for name in ("_validate_processing_mode_constraints", "_prepare_radar_for_native_acquisition", "_close_replay_ring_writer", "_discard_bscan_replay_results", "_close_readers", "_drop_pending_ring_payloads", "_reset_runtime_history", "_stop_all_processes", "_write_live_processing_config"): setattr(w, name, mock.Mock()) return w, config def test_changed_preprocess_clears_native_history_before_both_start_modes(self) -> None: for single_capture in (False, True): with self.subTest(single_capture=single_capture): w, config = self._runtime() config.preprocess.s21.object_calibration.set_name = "sphere" events = [] w._write_live_processing_config.side_effect = lambda **_kwargs: events.append("clear") w._supervisor.start.side_effect = lambda *_args, **_kwargs: events.append("start") with mock.patch("python_app.gui.controllers.app_window_pipeline_mixin.ShmRingReader"): w._start_run(single_capture=single_capture) w._show_exception.assert_not_called() w._write_live_processing_config.assert_called_once_with( history_command="clear_all", bump_history_seq=True) self.assertEqual(events, ["clear", "start"]) w._reset_runtime_history.assert_called_once() self.assertEqual(w._history_run_signature, w._build_run_history_signature(config)) def test_unchanged_preprocess_retains_history_and_existing_processor(self) -> None: w, config = self._runtime() with mock.patch("python_app.gui.controllers.app_window_pipeline_mixin.ShmRingReader"): w._start_run(single_capture=True) w._show_exception.assert_not_called() w._write_live_processing_config.assert_not_called() w._reset_runtime_history.assert_not_called() w._stop_all_processes.assert_not_called() def test_capture_override_is_cloned_and_skips_display_mode_constraints(self) -> None: w, config = self._runtime() w._object_capture_job = object() with mock.patch("python_app.gui.controllers.app_window_pipeline_mixin.ShmRingReader"): w._start_run(config_override=config) w._show_exception.assert_not_called() w._build_config.assert_not_called() w._validate_processing_mode_constraints.assert_not_called() exported = w._config_writer.prepare_preprocess_bundles.call_args.args[2] self.assertIsNot(exported, config) self.assertFalse(config.runtime.continuous) self.assertTrue(exported.runtime.continuous) def test_regular_start_and_other_hardware_actions_wait_for_object_capture(self) -> None: for action, args in (("_start_run", ()), ("_capture_tmp_reference", ()), ("_create_neutral_sets", ()), ("_start_capture_sequence", ("s21_reference",)), ("_apply_radar_settings", ()), ("_save_snapshot", ()), ("_save_vna_history_json", ()), ("_remove_last_runtime_history", ()), ("_clear_all_runtime_history", ())): with self.subTest(action=action): w, _ = self._runtime() w._object_capture_job = object() getattr(w, action)(*args) w._build_config.assert_not_called() w._show_error.assert_called_once() def test_profile_load_and_recording_wait_for_object_capture_even_before_pipeline_start(self) -> None: for action in (AppWindowConfigProfileIOMixin._ensure_ready_to_load_config, AppWindowRecordingMixin._start_run_with_recording): with self.subTest(action=action.__name__): w, _ = self._runtime() w._object_capture_job = object() self.assertFalse(action(w)) w._build_config.assert_not_called() w._supervisor.is_running.assert_not_called() w._show_error.assert_called_once() if __name__ == "__main__": unittest.main()