Files
radar_system/python_app/tests/test_gui_action_lifecycle.py
T

718 lines
39 KiB
Python

"""Regression coverage for saves, full history drains and interruptible GUI actions."""
from __future__ import annotations
from contextlib import ExitStack
from collections import deque
import itertools
import json
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, QCheckBox, QComboBox, QDoubleSpinBox, QLineEdit, QSpinBox # noqa: E402
from PyQt6.QtCore import QSignalBlocker, QTimer # noqa: E402
from python_app.gui.app_window import AppWindow # noqa: E402
from python_app.gui.controllers.pipeline_controller import PipelineController
from python_app.orchestration.process_supervisor import ProcessSupervisor
from functools import partial
from python_app.gui.runtime.history import build_processor_run_signature, build_run_history_signature
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.runtime.pipeline_pause import PipelinePause
from python_app.models.dataset_model import ResultCollection, SweepCollection # noqa: E402
from python_app.models.gui_profile_schema import GuiPreprocessDialogStateModel, GuiProfileModel, GuiStateModel # noqa: E402
from python_app.models.run_config_schema import ComboModel, GprRxGeometryModel, GprTxGeometryModel, RunConfigModel # noqa: E402
from python_app.orchestration.live_processing_config import ProcessingLiveConfig, ProcessingLiveConfigWriter
from python_app.models.setting_fields import GUI_FIELDS
from python_app.workflows.neutral_preprocess import build_neutral_s21_sets # noqa: E402
from python_app.workflows.sequential_capture_workflow import SequentialCaptureState
def setUpModule() -> None:
global _app
_app = QApplication.instance() or QApplication([])
def _mock(value: object) -> mock.Mock:
assert isinstance(value, mock.Mock)
return value
def _reader(method: str, collections: list) -> mock.Mock:
pending = deque(collections)
return mock.Mock(**{method: mock.Mock(side_effect=lambda: pending.popleft() if pending else None)})
def _window(case: unittest.TestCase) -> AppWindow:
supervisor = mock.Mock(spec=ProcessSupervisor)
supervisor.is_running.return_value = supervisor.is_processor_running.return_value = False
supervisor.collect_exit_reports.return_value, supervisor.pids.return_value = [], {}
with mock.patch("python_app.gui.controllers.pipeline_controller.ProcessSupervisor", return_value=supervisor):
window = isolated_window(case.addCleanup)
window._pipeline.timer.stop()
return window
def _pipeline(case: unittest.TestCase) -> PipelineController:
pipeline, supervisor = pipeline_fixture(case)
supervisor.is_processor_running.return_value = True
pipeline.max_pop_per_poll = pipeline.max_pop_per_snapshot_drain = 1
pipeline.recording.record = mock.Mock()
pipeline.recording.finish = mock.Mock()
pipeline.pump_events = mock.Mock()
pipeline._build_config = mock.Mock(return_value=RunConfigModel())
return pipeline
class PersistentHistoryDeletionTest(unittest.TestCase):
def setUp(self) -> None:
cleanup = ExitStack()
self.addCleanup(cleanup.close)
folder = cleanup.enter_context(tempfile.TemporaryDirectory())
self.path = Path(folder) / "processing_live.json"
self.writer = _window(self)
self.writer._live.writer = ProcessingLiveConfigWriter(self.path)
self.first = dict(collection_id=1, capture_start_ns=100)
self.second = dict(collection_id=2, capture_start_ns=200)
self.remove(self.first)
def remove(self, target: dict[str, int]) -> None:
self.writer._history.results.append(ResultCollection(target["collection_id"], 0, capture_start_ns=target["capture_start_ns"]))
self.writer._history.delete(remove_last=True)
self.writer._live.publish()
def test_delete_publishes_before_plot_cleanup_and_keeps_tombstone_on_failure(self) -> None:
for boundary in ("live", "plot"):
with self.subTest(boundary=boundary):
w = self.writer
removed = ResultCollection(2, 210, capture_start_ns=200)
w._history.results.append(removed)
release = mock.Mock()
pause = PipelinePause(lambda: True, release)
events = []
def action(name: str) -> None:
events.append(name)
if name == boundary:
raise OSError(name)
with mock.patch.object(w._plots, "clear", side_effect=lambda: action("plot")), \
mock.patch.object(w._pipeline, "pause", return_value=pause), \
mock.patch.object(w._pipeline, "drop_pending", return_value=(0, 0, 0)), \
mock.patch.object(w._pipeline, "publish_live", side_effect=lambda: action("live")), \
mock.patch.object(w._pipeline, "settle_deleted_results"), \
mock.patch.object(w._snapshots, "_error") as error:
w._snapshots.delete_history(remove_last=True)
self.assertEqual(events, ["live", "plot"])
self.assertFalse(w._history.results)
self.assertFalse(w._history.record_result(removed))
error.assert_called_once()
release.assert_called_once_with(True)
def test_ordinary_update_and_second_removal_preserve_all_pending_targets(self) -> None:
self.writer._live.publish()
self.assertEqual(json.loads(self.path.read_text())["history_removed"], [self.first])
self.remove(self.second)
saved = json.loads(self.path.read_text())
self.assertEqual(saved["history_removed"], [self.first, self.second])
self.assertEqual(saved["history_clear_before_ns"], 0)
self.assertEqual(self.writer._live.processing_settings[saved["settings_revision"]], saved)
def test_clear_replaces_removals_with_a_cutoff_that_survives_later_updates(self) -> None:
self.remove(self.second)
with mock.patch("python_app.gui.controllers.history_controller.time.monotonic_ns", return_value=500):
self.writer._history.delete(remove_last=False)
self.writer._live.publish()
self.writer._live.publish()
saved = json.loads(self.path.read_text())
self.assertEqual(saved["history_removed"], [])
self.assertEqual(saved["history_clear_before_ns"], 500)
def test_failed_write_retains_deletion_state_for_the_next_ordinary_update(self) -> None:
for command in ("remove_last", "clear_all"):
with self.subTest(command=command):
before = self.path.read_bytes()
revisions = dict(self.writer._live.processing_settings)
with mock.patch("python_app.file_io.os.replace", side_effect=OSError("disk full")), mock.patch(
"python_app.gui.controllers.history_controller.time.monotonic_ns", return_value=500,
), self.assertRaisesRegex(OSError, "disk full"):
self.writer._history.results.append(ResultCollection(2, 0, capture_start_ns=200))
self.writer._history.delete(remove_last=command == "remove_last")
self.writer._live.publish()
self.assertEqual(self.path.read_bytes(), before)
self.assertEqual(self.writer._live.processing_settings, revisions)
expected_targets = [self.first, self.second] if command == "remove_last" else []
expected_cutoff = 0 if command == "remove_last" else 500
self.assertEqual(self.writer._history.removed, expected_targets)
self.assertEqual(self.writer._history.clear_before_ns, expected_cutoff)
self.writer._live.publish()
saved = json.loads(self.path.read_text())
self.assertEqual(saved["history_removed"], expected_targets)
self.assertEqual(saved["history_clear_before_ns"], expected_cutoff)
def test_late_deleted_results_never_reach_history_or_recording_but_next_acquisition_does(self) -> None:
def result(ns: int, start: int, end: int) -> ResultCollection:
return ResultCollection(1, ns, capture_start_ns=start, capture_end_ns=end)
for cutoff, target, late, fresh in (
(0, dict(collection_id=1, capture_start_ns=100), result(600, 100, 200), result(700, 201, 300)),
(200, None, result(600, 100, 200), result(700, 201, 300)),
):
with self.subTest(cutoff=cutoff, target=target):
pipeline = _pipeline(self)
pipeline.history.clear_before_ns = cutoff
pipeline.history.removed[:] = [target] if target is not None else []
pipeline._result_reader = _reader("pop_result_collection", [late, fresh])
self.assertIsNone(pipeline.read_results())
self.assertEqual(list(pipeline.history.results), [])
_mock(pipeline.recording.record).assert_not_called()
self.assertIs(pipeline.read_results(), fresh)
self.assertEqual(list(pipeline.history.results), [fresh])
_mock(pipeline.recording.record).assert_called_once_with("results", fresh)
class NeutralSetCreationTest(unittest.TestCase):
def test_failed_second_set_preserves_gui_selection_and_previous_data(self) -> None:
w = _window(self)
config = RunConfigModel.from_dict({"radar": {
"model": "librevna", "driver_mode": "mock", "sweep": {"points": 7},
}})
w._preprocess._build_config = lambda: config
w._preprocess.ensure_dialog = mock.Mock(return_value=SimpleNamespace(set_name=lambda: "new_neutral"))
w._preprocess.selected_sets = {"s21_calibration": "old_cal", "s21_reference": "old_ref"}
w._preprocess.radar_key = "previous_radar"
w._pipeline._processor_signature = ("processor_signature",)
w._history.run_signature = ("history_signature",)
w._preprocess.refresh_sets = mock.Mock()
w._pipeline.reset_history = mock.Mock()
root = w._profile._project_root / "python_app/data"
calibration, reference = build_neutral_s21_sets(config)
w._snapshots.store.save_set("s21_calibration", config.radar_key(), "old_cal", calibration)
w._snapshots.store.save_set("s21_reference", config.radar_key(), "old_ref", reference)
before = {path: path.read_bytes() for path in root.rglob("*") if path.is_file()}
save = w._snapshots.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._snapshots.store, "save_set", side_effect=save_set), w._log.capture_errors() as errors:
w._preprocess.create_neutral_sets()
self.assertIn("reference write failed", errors[0])
self.assertEqual({path: path.read_bytes() for path in root.rglob("*") if path.is_file()}, before)
self.assertEqual(w._preprocess.selected_sets, {"s21_calibration": "old_cal", "s21_reference": "old_ref"})
self.assertEqual(w._preprocess.radar_key, "previous_radar")
self.assertEqual((w._pipeline._processor_signature, w._history.run_signature), (("processor_signature",), ("history_signature",)))
_mock(w._preprocess.refresh_sets).assert_not_called()
_mock(w._pipeline.reset_history).assert_not_called()
class SnapshotDrainTest(unittest.TestCase):
def test_single_capture_without_raw_tap_uses_first_fresh_result(self) -> None:
pipeline = _pipeline(self)
pipeline.single_capture_active, pipeline._single_capture_start_ns = True, 10
stale = ResultCollection(1, 20, capture_start_ns=9, capture_end_ns=9)
fresh = ResultCollection(1, 30, capture_start_ns=11, capture_end_ns=18)
later = ResultCollection(2, 40, capture_start_ns=21, capture_end_ns=28)
pipeline._result_reader = _reader("pop_result_collection", [stale, fresh, later])
with mock.patch.object(pipeline.plots, "has_output", return_value=True), \
mock.patch.object(pipeline.plots, "draw") as draw, \
mock.patch("python_app.gui.controllers.pipeline_controller.time.monotonic_ns", return_value=30):
pipeline.poll()
self.assertTrue(pipeline.single_capture_active)
draw.assert_not_called()
pipeline.poll()
self.assertFalse(pipeline.single_capture_active)
draw.assert_called_once_with(fresh)
def test_save_drain_preserves_recording_and_single_capture_tracking(self) -> None:
pipeline = _pipeline(self)
pipeline.single_capture_active, pipeline._single_capture_start_ns = True, 10
raw = SweepCollection(collection_id=5, monotonic_ns=20, traces=[], capture_start_ns=11, capture_end_ns=18)
preprocessed = SweepCollection(collection_id=5, monotonic_ns=21, traces=[], capture_start_ns=11, capture_end_ns=18)
result = ResultCollection(collection_id=5, monotonic_ns=22, blocks=[], capture_start_ns=11, capture_end_ns=18)
pipeline._raw_reader = _reader("pop_raw_collection", [raw])
pipeline._pre_reader = _reader("pop_preprocessed_collection", [preprocessed])
pipeline._result_reader = _reader("pop_result_collection", [result, result])
with self.assertNoLogs("python_app.gui.controllers.pipeline_controller", level="WARNING"):
pipeline.drain_snapshot()
self.assertEqual(_mock(pipeline.recording.record).call_args_list, [
mock.call("raw", raw), mock.call("preprocessed", preprocessed), mock.call("results", result),
])
self.assertTrue(pipeline.single_capture_active)
self.assertEqual(list(pipeline.history.results), [result])
def test_continuous_producer_cannot_prevent_snapshot_timeout(self) -> None:
pipeline = _pipeline(self)
collection = SweepCollection(collection_id=1, monotonic_ns=20, traces=[], capture_start_ns=11, capture_end_ns=18)
pop = mock.Mock(return_value=collection)
pipeline._raw_reader = mock.Mock(pop_raw_collection=pop)
ticks = itertools.count(0.0, 0.1)
with mock.patch("python_app.gui.controllers.pipeline_controller.time.monotonic", side_effect=lambda: next(ticks)), \
self.assertNoLogs("python_app.gui.controllers.pipeline_controller", level="WARNING"):
pipeline.drain_snapshot()
self.assertGreater(pop.call_count, 1)
self.assertLess(pop.call_count, 15)
def test_stop_drain_reads_the_whole_tail_when_history_is_full(self) -> None:
pipeline = _pipeline(self)
pipeline.history.raw = deque([SweepCollection(collection_id=0, monotonic_ns=2, traces=[], capture_start_ns=1, capture_end_ns=2)], maxlen=1)
tail = [SweepCollection(collection_id=i, monotonic_ns=i + 20, traces=[], capture_start_ns=i + 10, capture_end_ns=i + 11) for i in range(1, 11)]
pipeline._raw_reader = _reader("pop_raw_collection", tail)
clock = [0.0]
_mock(pipeline.pump_events).side_effect = lambda pause: clock.__setitem__(0, clock[0] + pause)
with mock.patch("python_app.gui.controllers.pipeline_controller.time.monotonic", side_effect=lambda: clock[0]):
pipeline.drain_rings(timeout_s=1.0, poll_s=0.01)
self.assertEqual([call.args[1].collection_id for call in _mock(pipeline.recording.record).call_args_list], list(range(1, 11)))
self.assertEqual(pipeline.history.raw[-1].collection_id, 10)
class ShutdownReentryTest(unittest.TestCase):
def test_reader_failure_on_stop_is_reported_and_cancels_automatic_resume(self) -> None:
pipeline = _pipeline(self)
_mock(pipeline.supervisor.is_running).return_value = True
pipeline.drain_rings = mock.Mock(side_effect=ValueError("corrupt ring payload"))
raw = pipeline._raw_reader = mock.Mock(close=mock.Mock())
preprocessed = pipeline._pre_reader = mock.Mock(close=mock.Mock())
result = pipeline._result_reader = mock.Mock(close=mock.Mock())
result.configure_mock(name="/results")
expected_stop_seq = pipeline.stop_generation + 1
with mock.patch("python_app.gui.controllers.pipeline_controller.ShmRingReader") as reopen:
pipeline.stop()
_mock(pipeline.supervisor.stop).assert_called_once()
_mock(pipeline.supervisor.stop_all).assert_not_called()
_mock(pipeline.recording.finish).assert_called_once()
for reader in (raw, preprocessed, result):
reader.close.assert_called_once()
self.assertEqual(pipeline.readers, (None, None, reopen.return_value))
reopen.assert_called_once_with("/results")
_mock(pipeline._show_exception).assert_called_once()
self.assertIn("corrupt ring", str(_mock(pipeline._show_exception).call_args.args[1]))
self.assertFalse(pipeline.stopping)
self.assertFalse(pipeline.single_capture_active)
_mock(pipeline._status).assert_called_with("Status: error")
self.assertFalse(pipeline.can_resume(expected_stop_seq))
def test_unsupported_rph_radar_mock_fails_before_starting_any_process(self) -> None:
pipeline = _pipeline(self)
config = RunConfigModel()
config.radar.model = "rph_radar"
config.apply_device_model_constraints()
_mock(pipeline._build_config).return_value = config
pipeline.stop_all = mock.Mock()
pipeline.start(single_capture=True)
_mock(pipeline.supervisor.start).assert_not_called()
_mock(pipeline._show_exception).assert_called_once()
self.assertIn("native", str(_mock(pipeline._show_exception).call_args.args[1]))
def test_stop_blocks_nested_start_poll_and_duplicate_stop(self) -> None:
pipeline = _pipeline(self)
_mock(pipeline.supervisor.is_running).return_value = True
def reenter(**_kwargs):
pipeline.start()
pipeline.poll()
pipeline.stop()
pipeline.drain_rings = mock.Mock(side_effect=reenter)
pipeline.stop()
_mock(pipeline.supervisor.stop_acquisition).assert_called_once()
_mock(pipeline.supervisor.stop_preprocessor).assert_called_once()
_mock(pipeline.recording.finish).assert_called_once()
_mock(pipeline._build_config).assert_not_called()
_mock(pipeline._poll_ui).assert_not_called()
self.assertFalse(pipeline.stopping)
self.assertFalse(pipeline.should_run)
def test_close_during_stop_waits_until_tail_is_finalized(self) -> None:
pipeline = _pipeline(self)
_mock(pipeline.supervisor.is_running).return_value = True
ordering = []
def close_during_drain(**_kwargs):
self.assertFalse(pipeline.begin_close())
pipeline.drain_rings = mock.Mock(side_effect=close_during_drain)
_mock(pipeline.recording.finish).side_effect = lambda **_kwargs: ordering.append("finalize")
_mock(pipeline._deferred_close).side_effect = lambda: ordering.append("close")
pipeline.stop()
_mock(pipeline.supervisor.stop_all).assert_called_once()
self.assertEqual(ordering, ["finalize", "close"])
def test_explicit_stop_during_apply_pause_cancels_apply_and_restart(self) -> None:
pipeline = _pipeline(self)
_mock(pipeline.supervisor.is_running).return_value = True
pipeline.start = mock.Mock()
pipeline.drain_rings = mock.Mock(side_effect=lambda **_kwargs: pipeline.stop())
pipeline.apply_radar()
_mock(pipeline._build_config).assert_called_once()
_mock(pipeline.start).assert_not_called()
class CaptureBatchControlTest(unittest.TestCase):
def test_stop_between_combos_keeps_captures_and_allows_later_resume(self) -> None:
w = _window(self)
captured = []
session = mock.Mock(
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._preprocess.capture_session = session
dialog = mock.Mock()
w._preprocess.ensure_dialog = lambda: dialog
w._preprocess._record_capture = mock.Mock()
w._pipeline.pump_events = mock.Mock(side_effect=lambda pause: PipelineController.pump_events(w._pipeline, pause))
QTimer.singleShot(0, w._pipeline.stop)
with mock.patch("python_app.gui.controllers.preprocess_controller.QTimer.singleShot", side_effect=lambda _delay, fn: fn()):
w._preprocess.capture_all()
self.assertEqual(captured, [0])
self.assertIs(w._preprocess.capture_session, session)
self.assertFalse(w._preprocess.capture_busy)
self.assertIn("paused", dialog.set_status.call_args.args[0])
w._preprocess.capture_all()
self.assertEqual(captured, [0, 1, 2])
self.assertEqual(_mock(w._preprocess._record_capture).call_count, 3)
class PipelinePauseTest(unittest.TestCase):
def runtime(self):
pipeline = _pipeline(self)
pipeline.start = mock.Mock()
pipeline.drain_rings = mock.Mock()
return pipeline
def test_backoff_intent_is_suspended_until_operation_finishes(self) -> None:
pipeline = self.runtime()
pipeline.should_run = True
pause = pipeline.pause()
self.assertFalse(pipeline.should_run)
pipeline.poll()
_mock(pipeline.supervisor.start).assert_not_called()
_mock(pipeline.start).assert_not_called()
pause.finish()
pause.finish()
self.assertFalse(pipeline.paused)
_mock(pipeline.start).assert_called_once_with()
def test_reentrant_pause_is_refused_before_hardware_shutdown_repeats(self) -> None:
pipeline = self.runtime()
pipeline.should_run = True
_mock(pipeline.supervisor.is_running).return_value = True
failures = []
def reenter(**_kwargs):
with self.assertRaisesRegex(RuntimeError, "current pipeline operation"):
pipeline.pause()
failures.append(True)
_mock(pipeline.drain_rings).side_effect = reenter
with pipeline.pause():
self.assertTrue(failures)
_mock(pipeline.start).assert_not_called()
_mock(pipeline.supervisor.stop_acquisition).assert_called_once()
_mock(pipeline.supervisor.stop_preprocessor).assert_called_once()
_mock(pipeline.start).assert_called_once()
def test_explicit_stop_during_pause_cancels_resumption(self) -> None:
pipeline = self.runtime()
pipeline.should_run = True
with pipeline.pause() as pause:
pipeline.stop()
self.assertFalse(pause.valid)
self.assertFalse(pipeline.paused)
_mock(pipeline.start).assert_not_called()
def test_qt_stop_or_close_during_pause_drain_cancels_before_operation(self) -> None:
for close in (False, True):
with self.subTest(close=close):
w = _window(self)
pipeline = w._pipeline
_mock(pipeline.supervisor.is_running).return_value = True
pipeline.pump_events = lambda pause_s: PipelineController.pump_events(pipeline, pause_s)
pipeline._deferred_close = w.close
dispatched = []
def interrupt() -> None:
dispatched.append(pipeline.stopping)
(w.close if close else pipeline.stop)()
QTimer.singleShot(0, interrupt)
with self.assertRaises(InterruptedError):
pipeline.pause()
self.assertEqual(dispatched, [True])
self.assertFalse(pipeline.paused)
self.assertFalse(pipeline.should_run)
_mock(pipeline.supervisor.start).assert_not_called()
if close:
self.assertTrue(w._closed)
def test_rejected_sequence_cannot_release_another_operations_pause(self) -> None:
w = _window(self)
w._pipeline.should_run = True
with mock.patch.object(w._pipeline, "start") as start:
pause = w._preprocess._capture_pause = w._pipeline.pause()
with w._log.capture_errors() as errors:
w._preprocess.start_sequence("s21_reference")
self.assertEqual(len(errors), 1)
self.assertTrue(pause.valid)
start.assert_not_called()
w._preprocess._release_pause()
start.assert_called_once()
def test_invalid_apply_preserves_the_current_pipeline(self) -> None:
pipeline = self.runtime()
_mock(pipeline.supervisor.is_running).return_value = True
_mock(pipeline._build_config).side_effect = ValueError("incomplete sweep")
pipeline.apply_radar()
_mock(pipeline.supervisor.stop_acquisition).assert_not_called()
_mock(pipeline.supervisor.stop_all).assert_not_called()
_mock(pipeline.start).assert_not_called()
_mock(pipeline._show_exception).assert_called_once()
def test_multiclick_capture_owns_pause_until_abort_even_during_backoff(self) -> None:
w = _window(self)
w._pipeline.should_run = True
dialog = w._preprocess.ensure_dialog()
dialog.set_set_name("reference")
session = mock.Mock(kind="s21_reference")
session.state.return_value = SequentialCaptureState("s21_reference", "reference", 0, 1,
ComboModel(0, 0), False, False)
with mock.patch("python_app.gui.controllers.preprocess_controller.SequentialCaptureSession", return_value=session), \
mock.patch.object(w._pipeline, "start") as start, w._log.capture_errors() as errors:
w._preprocess.start_sequence("s21_reference")
self.assertEqual(errors, [])
session.open.assert_called_once()
self.assertTrue(w._pipeline.paused)
self.assertFalse(w._pipeline.should_run)
start.assert_not_called()
w._preprocess.abort()
session.close.assert_called_once()
self.assertFalse(w._pipeline.paused)
start.assert_called_once()
class GuiProfileWriteTest(unittest.TestCase):
def test_rejected_web_batch_restores_empty_combo_and_original_error(self) -> None:
window = _window(self)
self.assertEqual(window._view.switches.display_combo.currentIndex(), -1)
mode, gain = window._view.processing.mode.currentText(), widget(window._view.processing, "_bscan_gain", QDoubleSpinBox).value()
error = ValueError("Incompatible processing settings")
with self.assertRaises(ValueError) as raised:
window._config.apply_web_fields(
{"processor_mode": "bscan", "bscan_gain": gain + 1},
mock.Mock(side_effect=error),
)
self.assertIs(raised.exception, error)
self.assertEqual(window._view.processing.mode.currentText(), mode)
self.assertEqual(widget(window._view.processing, "_bscan_gain", QDoubleSpinBox).value(), gain)
self.assertEqual(window._view.switches.display_combo.count(), 0)
self.assertEqual(window._view.switches.display_combo.currentIndex(), -1)
def test_all_processing_pages_and_lazy_dialog_round_trip_nondefault_values(self) -> None:
window = _window(self)
profile = window._profile.build_profile()
expected = profile.gui
assert expected is not None
for binding in GUI_FIELDS:
if not binding.spec.widget or binding.path == ("switches", "combos_text"):
continue
widget = window._config.fields[binding.spec.widget]
if isinstance(widget, QCheckBox):
value = not widget.isChecked()
elif isinstance(widget, QComboBox):
value = widget.itemText((widget.currentIndex() + 1) % widget.count())
elif isinstance(widget, (QSpinBox, QDoubleSpinBox)):
value = widget.value() + widget.singleStep()
if value > widget.maximum():
value = widget.value() - widget.singleStep()
value = int(value) if isinstance(widget, QSpinBox) else round(value, widget.decimals())
elif isinstance(widget, QLineEdit):
value = "0:0" if binding.spec.web == "combo_filter" else "0" if "positions" in binding.path[-1] else "1"
else:
self.fail(f"Uncovered settings widget: {binding.path}")
binding.write(expected, value)
expected.processing.selected_mode = "pass_through"
expected.preprocess_dialog = GuiPreprocessDialogStateModel("roundtrip", str(window._profile._project_root), True, 7)
window._profile.apply(profile, window._profile.state.path)
saved = window._profile.write(window._profile._project_root / "roundtrip.json")
self.assertEqual(saved.gui, expected)
self.assertEqual(GuiProfileModel.load_from_path(window._profile._project_root / "roundtrip.json").gui, expected)
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())
assert pending.gui is not None
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.model = "librevna_multi"
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.notch.enabled = True
active.preprocess.notch.bands_hz = [(2e9, 2.1e9)]
writer = _window(self)
writer._profile.build_profile = pending.clone
writer._pipeline.active_config = active
dataset_path, settings_path = Path(folder) / "dataset.json", Path(folder) / "settings.json"
saved = writer._profile.write(dataset_path, dataset=True)
writer._profile.write(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_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 = _window(self)
writer._pipeline.active_config = active
_mock(writer._pipeline.supervisor.is_running).return_value = running
with QSignalBlocker(writer._view.processing.geometries[0]), QSignalBlocker(writer._view.processing.geometries[1]):
writer._view.processing.geometries[0].set_records([dict(output_pos=0, x_m=1., y_m=2., z_m=3.)])
writer._view.processing.geometries[1].set_records([dict(input_pos=0, x_m=-1., y_m=-2., z_m=-3.)])
writer._live.processing_settings.clear()
with mock.patch.object(writer._live.writer, "write", side_effect=OSError("disk full") if failed_write else None) as write:
if failed_write:
with self.assertRaises(OSError):
writer._live.publish()
else:
writer._live.publish()
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)
if failed_write:
self.assertEqual(writer._live.processing_settings, {})
else:
self.assertEqual(list(writer._live.processing_settings.values()), [write.call_args.args[0].to_dict()])
def test_dataset_profile_without_active_run_preserves_ui_selection(self) -> None:
writer = _window(self)
pending = GuiProfileModel()
pending.run_config.preprocess.s21.object_calibration.set_name = "selected"
writer._profile.build_profile = pending.clone
writer._pipeline.active_config = None
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / "dataset.json"
writer._profile.write(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 = _window(self)
writer._profile.build_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._profile.write(path)
self.assertEqual(path.read_bytes(), previous)
self.assertEqual(list(path.parent.iterdir()), [path])
class PreprocessHistoryRestartTest(unittest.TestCase):
def runtime(self):
pipeline = _pipeline(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 = "through"
config.preprocess.s21.reference.set_name = "empty"
_mock(pipeline._build_config).return_value = config
pipeline._processor_signature = build_processor_run_signature(config)
pipeline.history.run_signature = build_run_history_signature(config)
pipeline._config_writer = mock.Mock()
_mock(pipeline._config_writer.write).return_value = Path("/tmp/runtime-test.json")
pipeline._validate_assets = mock.Mock()
return pipeline, config
def test_preprocess_change_controls_history_for_both_start_modes(self) -> None:
for changed, single_capture in itertools.product((False, True), repeat=2):
with self.subTest(changed=changed, single_capture=single_capture):
pipeline, config = self.runtime()
retained = ResultCollection(1, 20, capture_start_ns=10)
pipeline.history.results.append(retained)
if changed:
config.preprocess.s21.object_calibration.set_name = "sphere"
events = []
_mock(pipeline.publish_live).side_effect = lambda: events.append("clear")
_mock(pipeline.supervisor.start).side_effect = lambda *_args, **_kwargs: events.append("start")
with mock.patch("python_app.gui.controllers.pipeline_controller.ShmRingReader"):
pipeline.start(single_capture=single_capture)
_mock(pipeline._show_exception).assert_not_called()
self.assertEqual(events, ["clear", "start"] if changed else ["start"])
self.assertEqual(list(pipeline.history.results), [] if changed else [retained])
self.assertEqual(pipeline.history.clear_before_ns > 0, changed)
_mock(pipeline.supervisor.stop_all).assert_not_called()
def test_capture_override_is_cloned_and_skips_display_mode_constraints(self) -> None:
pipeline, config = self.runtime()
pause = pipeline.pause()
with mock.patch("python_app.gui.controllers.pipeline_controller.ShmRingReader"), \
mock.patch("python_app.gui.controllers.pipeline_controller.validate_processing_mode_constraints") as validate:
pipeline.start(config_override=config, pause=pause)
_mock(pipeline._show_exception).assert_not_called()
_mock(pipeline._build_config).assert_not_called()
validate.assert_not_called()
exported = _mock(pipeline._config_writer.write).call_args.args[0]
self.assertIsNot(exported, config)
self.assertFalse(config.runtime.continuous)
self.assertTrue(exported.runtime.continuous)
pause.finish(resume=False)
def test_hardware_actions_wait_for_object_capture_before_pipeline_start(self) -> None:
w = _window(self)
w._preprocess.object_calibration.capture = mock.Mock()
actions = (w._pipeline.start, w._preprocess.capture_tmp_reference,
w._preprocess.create_neutral_sets, lambda: w._preprocess.start_sequence("s21_reference"),
w._pipeline.apply_radar, w._snapshots.save, partial(w._snapshots.save, json_export=True),
partial(w._snapshots.delete_history, remove_last=True), partial(w._snapshots.delete_history, remove_last=False),
w._profile._ready_to_load, partial(w._snapshots.start_recording, 1))
for action in actions:
with self.subTest(action=action), w._log.capture_errors() as errors:
action()
self.assertEqual(len(errors), 1)
self.assertIn("object", errors[0].lower())
_mock(w._pipeline.supervisor.start).assert_not_called()
if __name__ == "__main__":
unittest.main()