Files
radar_system/python_app/tests/test_review_gui_regressions.py
T
2026-10-06 12:32:26 +03:00

185 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)
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", "legacy_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))
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()