"""User-visible failure contracts from the final lifecycle review.""" from functools import partial import unittest from unittest import mock import numpy as np from PyQt6.QtCore import QSignalBlocker from PyQt6.QtGui import QValidator from PyQt6.QtWidgets import QLineEdit from python_app.models.dataset_model import ResultCollection, ResultPayload, SweepCollection from python_app.models.run_config_schema import RunConfigModel from python_app.tests.gui_test_support import isolated_window, widget from python_app.tests.test_reader_recovery import pipeline_fixture from python_app.gui.controllers.web_controller import _WebActionCall class ReviewGuiRegressions(unittest.TestCase): def test_refused_actions_report_shutdown_sequence_and_refresh_errors(self) -> None: pipeline_fixture(self) window = isolated_window(self.addCleanup) pipeline = window._pipeline pipeline.timer.stop() pipeline._stopping = True actions = (window._preprocess.capture_tmp_reference, window._preprocess.create_neutral_sets, partial(window._preprocess.start_sequence, "s21_reference"), window._snapshots.save, partial(window._snapshots.delete_history, remove_last=True), partial(window._snapshots.start_recording, 10), partial(window._profile.load, window._profile.state.path)) try: for action in actions: with self.subTest(action=action): call = _WebActionCall(action) window._web._run_action(call) self.assertIn("shutdown", call.error or "") finally: pipeline._stopping = False with pipeline.pause(), mock.patch.object(pipeline, "_capture_guard", return_value="Finish the active capture sequence first"): call = _WebActionCall(pipeline.start) window._web._run_action(call) self.assertIn("capture sequence", call.error or "") window._preprocess.ensure_dialog() with mock.patch.object(window._preprocess, "_current_radar_key", return_value="changed"), \ mock.patch.object(window._preprocess, "_build_config", side_effect=ValueError("invalid sweep")), \ window._log.capture_errors() as errors: window._preprocess.reset_selection() self.assertEqual(len(errors), 1) self.assertIn("invalid sweep", errors[0]) def test_refused_start_preserves_processor_history_and_continuous_run(self) -> None: pipeline, supervisor = pipeline_fixture(self) config = RunConfigModel.from_dict({"radar": {"model": "librevna", "driver_mode": "mock"}}) config.input_switch.radar_port, config.output_switch.radar_port = 1, 2 config.preprocess.s21.calibration.set_name = "missing" config.preprocess.s21.reference.set_name = "missing" supervisor.is_processor_running.return_value = True original = ResultCollection(1, 2) pipeline.history.results.append(original) for running in (False, True): with self.subTest(running=running): supervisor.is_running.return_value = running pipeline.should_run = running self.assertFalse(pipeline.start(single_capture=running, config_override=config)) supervisor.stop_all.assert_not_called() supervisor.stop_acquisition.assert_not_called() self.assertEqual(list(pipeline.history.results), [original]) self.assertEqual(pipeline.should_run, running) def test_pause_during_single_capture_does_not_start_continuous_run(self) -> None: pipeline, supervisor = pipeline_fixture(self) supervisor.is_running.return_value = True pipeline.single_capture_active = True with mock.patch.object(pipeline, "drain_rings"), mock.patch.object(pipeline, "start") as start: operation = pipeline.pause() operation.finish() start.assert_not_called() def test_capture_failure_releases_pause_before_presenting_error(self) -> None: pipeline_fixture(self) window = isolated_window(self.addCleanup) window._pipeline.timer.stop() paused_when_reported = [] with mock.patch("python_app.gui.controllers.preprocess_controller.capture_reference_set", side_effect=OSError("USB lost")), \ mock.patch.object(window._preprocess, "_error", side_effect=lambda *_: paused_when_reported.append(window._pipeline.paused)): window._preprocess.capture_tmp_reference() self.assertEqual(paused_when_reported, [False]) for action in ("finalize", "abort", "drain"): with self.subTest(action=action): paused_when_reported.clear() pre, pipeline = window._preprocess, window._pipeline dialog = pre.ensure_dialog() if action != "drain": pre._capture_pause = pipeline.pause() pre.capture_session = mock.Mock(kind="s21_reference", set_name="test", is_complete=lambda: True) pre.capture_session.finalize.return_value = ("key", SweepCollection(1, 2)) with mock.patch.object(pre, "_error", side_effect=lambda *_: paused_when_reported.append(pipeline.paused)), \ mock.patch.object(pipeline, "_show_exception", side_effect=lambda *_: paused_when_reported.append(pipeline.paused)): if action == "finalize": with mock.patch.object(pre, "refresh_sets", side_effect=OSError("refresh failed")): pre.finalize() elif action == "abort": with mock.patch.object(dialog, "set_status", side_effect=OSError("status failed")): pre.abort() else: with mock.patch.object(pipeline.supervisor, "is_running", return_value=True), \ mock.patch.object(pipeline, "drain_rings", side_effect=OSError("drain failed")): pipeline.apply_radar() self.assertEqual(paused_when_reported, [False]) def test_failed_resume_retains_restart_intent_until_explicit_stop(self) -> None: pipeline, supervisor = pipeline_fixture(self) pipeline.should_run = True pipeline.active_config = RunConfigModel() pipeline.active_config_path = pipeline._project_root / "runtime.json" with mock.patch.object(pipeline, "start", return_value=False), \ mock.patch.object(pipeline, "_open_readers"), mock.patch.object(pipeline, "drop_pending"): pipeline.pause().finish() self.assertTrue(pipeline.should_run) pipeline._last_restart_s = 0 pipeline.poll() supervisor.start.assert_called_once_with(pipeline.active_config_path, allow_clean_acquisition_exit=False) pipeline.stop() supervisor.start.reset_mock() pipeline.poll() supervisor.start.assert_not_called() def test_display_failure_keeps_acquisition_and_rejects_malformed_position_input(self) -> None: pipeline, supervisor = pipeline_fixture(self) pipeline.should_run = True supervisor.pids.return_value = dict.fromkeys(("acquisition", "data_preprocessor", "data_processor"), 1) with mock.patch.object(pipeline, "read_results", return_value=ResultCollection(1, 2)), \ mock.patch.object(pipeline.plots, "draw", side_effect=ValueError("bad display")), \ mock.patch.object(pipeline, "stop_all") as stop, \ self.assertLogs("python_app.gui.controllers.pipeline_controller", "ERROR") as logs: pipeline.poll() pipeline.poll() stop.assert_not_called() self.assertTrue(pipeline.should_run) self.assertEqual(len(logs.records), 1) for owner, method in ((pipeline.recording, "poll"), (pipeline, "_finish_single_capture_if_ready")): with self.subTest(method=method), mock.patch.object(owner, method, side_effect=ValueError(method)), \ mock.patch.object(pipeline, "stop_all") as stop, \ mock.patch.object(pipeline, "_check_single_capture_deadline") as deadline: pipeline.single_capture_active = owner is pipeline pipeline.poll() stop.assert_not_called() self.assertTrue(pipeline.should_run) if pipeline.single_capture_active: deadline.assert_called_once() window = isolated_window(self.addCleanup) window._pipeline.timer.stop() for mode in ("gpr", "sar3d"): field = widget(window._view.processing, f"_{mode}_input_positions_input", QLineEdit) validator = field.validator() self.assertIsNotNone(validator) assert validator is not None self.assertEqual(validator.validate("0,1;", 4)[0], QValidator.State.Invalid) with QSignalBlocker(field): field.setText("0, 1,") # A trailing comma during editing is harmless to the shared CSV parser. for table in (np.empty((0, 0)), np.array([[99.]])): result = ResultCollection(1, 2, collection_payloads=[ResultPayload(processing_name="sar3d_status", kind=4, table=table)]) self.assertFalse(window._plots.sar3d.draw(result, window._config.processing_settings().sar3d, window._config.geometry())) self.assertIn("error", window._plots.sar3d.status_label.text().lower()) def test_stop_or_close_cancels_paused_actions_without_error_dialog(self) -> None: pipeline_fixture(self) window = isolated_window(self.addCleanup) window._pipeline.timer.stop() actions = (window._pipeline.apply_radar, window._preprocess.capture_tmp_reference, window._preprocess.create_neutral_sets, partial(window._preprocess.start_sequence, "s21_reference"), partial(window._snapshots.delete_history, remove_last=False)) with mock.patch.object(window._pipeline, "pause", side_effect=InterruptedError("Pipeline operation cancelled")), \ mock.patch.object(window._pipeline, "_show_exception") as pipeline_error, \ mock.patch.object(window._preprocess, "_error") as preprocess_error, \ mock.patch.object(window._snapshots, "_error") as snapshot_error: for action in actions: with self.subTest(action=action): action() pipeline_error.assert_not_called() preprocess_error.assert_not_called() snapshot_error.assert_not_called()