"""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_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.models.dataset_model import ResultCollection, SweepCollection # noqa: E402 from python_app.models.gui_profile_model import GuiProfileModel # noqa: E402 from python_app.models.run_config_model import 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_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) if __name__ == "__main__": unittest.main()