718 lines
39 KiB
Python
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_kamil_adc_mock_fails_before_starting_any_process(self) -> None:
|
|
pipeline = _pipeline(self)
|
|
config = RunConfigModel()
|
|
config.radar.model = "kamil_adc"
|
|
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()
|