187 lines
11 KiB
Python
187 lines
11 KiB
Python
"""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)
|
|
results = iter([ResultCollection(1, 2), None, ResultCollection(2, 3)])
|
|
pipeline._result_reader = mock.Mock(pop_result_collection=lambda: next(results, None))
|
|
with 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()
|