Files
radar_system/python_app/tests/test_gui_action_lifecycle.py
T
2026-10-05 16:46:28 +03:00

298 lines
15 KiB
Python

"""Regression coverage for saves, full history drains and interruptible GUI actions."""
from __future__ import annotations
from collections import deque
import itertools
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 # noqa: E402
from PyQt6.QtCore import QTimer # noqa: E402
from python_app.gui.app_window import AppWindow # noqa: E402
from python_app.gui.controllers.app_window_config.profile_io_mixin import AppWindowConfigProfileIOMixin # noqa: E402
from python_app.gui.controllers.app_window_pipeline_mixin import AppWindowPipelineMixin # noqa: E402
from python_app.gui.controllers.app_window_preprocess_mixin import AppWindowPreprocessMixin # noqa: E402
from python_app.gui.controllers.app_window_snapshot_mixin import AppWindowSnapshotMixin # noqa: E402
from python_app.models.dataset_model import ResultCollection, SweepCollection # noqa: E402
from python_app.models.gui_profile_model import GuiProfileModel # noqa: E402
from python_app.models.run_config_model import RunConfigModel # noqa: E402
from python_app.storage.npz_store import NpzStore # noqa: E402
from python_app.workflows.kamil_adc_neutral_preprocess import build_neutral_s21_sets # noqa: E402
def setUpModule() -> None:
global _app
_app = QApplication.instance() or QApplication([])
def _reader(method: str, collections: list):
pending = deque(collections)
return SimpleNamespace(**{method: lambda: pending.popleft() if pending else None, "close": mock.Mock()})
class _Runtime(AppWindowPipelineMixin, AppWindowSnapshotMixin, AppWindowPreprocessMixin):
def __init__(self) -> None:
self._raw_reader = self._pre_reader = self._result_reader = None
self._raw_history = deque(maxlen=2)
self._pre_history = deque(maxlen=2)
self._result_history = deque(maxlen=2)
self._max_pop_per_poll = self._max_pop_per_snapshot_drain = 1
self._single_capture_active = True
self._single_capture_start_ns = 10
self._single_capture_seen_raw = False
self._single_capture_target_collection_id = None
self._capture_session = None
self._preprocess_capture_busy = self._closing = False
self._resume_pipeline_after_capture = True
self._stop_request_seq = 0
self._pipeline_metrics = mock.Mock()
self._record_collection = mock.Mock()
self._supervisor = mock.Mock()
self._supervisor.is_running.return_value = False
self._supervisor.is_processor_running.return_value = True
self._status_label = mock.Mock()
for name in ("_log", "_log_debug", "_log_exception", "_show_error", "_show_exception",
"_update_history_indicator", "_finalize_recording_on_stop", "_web_update_snapshot",
"_build_config", "_update_capture_dialog_state", "close"):
setattr(self, name, mock.Mock())
self._pump_events_during_drain = mock.Mock()
class NeutralSetCreationTest(unittest.TestCase):
def test_failed_second_set_preserves_gui_selection_and_previous_data(self) -> None:
w = _Runtime()
config = RunConfigModel.from_dict({"radar": {
"model": "librevna", "driver_mode": "mock", "sweep": {"points": 7},
}})
w._build_config.return_value = config
w._ensure_preprocess_dialog = mock.Mock(return_value=SimpleNamespace(set_name=lambda: "new_neutral"))
w._radar_key = mock.Mock(return_value="radar")
w._selected_preprocess_sets = {"s21_calibration": "old_cal", "s21_reference": "old_ref"}
w._selected_preprocess_radar_key = "previous_radar"
w._processor_run_signature = "processor_signature"
w._history_run_signature = "history_signature"
w._refresh_sets = mock.Mock()
w._reset_runtime_history = mock.Mock()
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
w._store = NpzStore(root)
calibration, reference = build_neutral_s21_sets(config)
w._store.save_set("s21_calibration", "radar", "old_cal", calibration)
w._store.save_set("s21_reference", "radar", "old_ref", reference)
before = {path: path.read_bytes() for path in root.rglob("*") if path.is_file()}
save = w._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._store, "save_set", side_effect=save_set):
w._create_neutral_sets()
self.assertEqual({path: path.read_bytes() for path in root.rglob("*") if path.is_file()}, before)
self.assertEqual(w._selected_preprocess_sets, {"s21_calibration": "old_cal", "s21_reference": "old_ref"})
self.assertEqual(w._selected_preprocess_radar_key, "previous_radar")
self.assertEqual((w._processor_run_signature, w._history_run_signature), ("processor_signature", "history_signature"))
w._show_exception.assert_called_once()
w._refresh_sets.assert_not_called()
w._reset_runtime_history.assert_not_called()
class SnapshotDrainTest(unittest.TestCase):
def test_save_drain_preserves_recording_and_single_capture_tracking(self) -> None:
w = _Runtime()
raw = SweepCollection(collection_id=5, monotonic_ns=20, traces=[], capture_start_ns=1, capture_end_ns=8)
preprocessed = SweepCollection(collection_id=5, monotonic_ns=20, traces=[])
result = ResultCollection(collection_id=5, monotonic_ns=20, blocks=[])
w._raw_reader = _reader("pop_raw_collection", [raw])
w._pre_reader = _reader("pop_preprocessed_collection", [preprocessed])
w._result_reader = _reader("pop_result_collection", [result, result])
w._drain_runtime_rings_for_snapshot()
self.assertEqual(w._record_collection.call_args_list, [
mock.call("raw", raw), mock.call("preprocessed", preprocessed), mock.call("results", result),
])
self.assertTrue(w._single_capture_seen_raw)
self.assertEqual(w._single_capture_target_collection_id, 5)
w._pipeline_metrics.record.assert_any_call("acquisition", 7)
self.assertEqual(list(w._result_history), [result])
w._log_exception.assert_not_called()
def test_continuous_producer_cannot_prevent_snapshot_timeout(self) -> None:
w = _Runtime()
collection = SweepCollection(collection_id=1, monotonic_ns=20, traces=[])
pop = mock.Mock(return_value=collection)
w._raw_reader = SimpleNamespace(pop_raw_collection=pop)
ticks = itertools.count(0.0, 0.1)
with mock.patch("python_app.gui.controllers.app_window_snapshot_mixin.time.monotonic", side_effect=lambda: next(ticks)):
w._drain_runtime_rings_for_snapshot()
self.assertGreater(pop.call_count, 1)
self.assertLess(pop.call_count, 15)
w._log_exception.assert_not_called()
def test_stop_drain_reads_the_whole_tail_when_history_is_full(self) -> None:
w = _Runtime()
w._raw_history = deque([SweepCollection(collection_id=0, monotonic_ns=0, traces=[])], maxlen=1)
tail = [SweepCollection(collection_id=i, monotonic_ns=i, traces=[]) for i in range(1, 11)]
w._raw_reader = _reader("pop_raw_collection", tail)
clock = [0.0]
w._pump_events_during_drain.side_effect = lambda pause: clock.__setitem__(0, clock[0] + pause)
with mock.patch("python_app.gui.controllers.app_window_pipeline_mixin.time.monotonic", side_effect=lambda: clock[0]):
w._drain_rings_until_quiet(timeout_s=1.0, poll_s=0.01)
self.assertEqual([call.args[1].collection_id for call in w._record_collection.call_args_list], list(range(1, 11)))
self.assertEqual(w._raw_history[-1].collection_id, 10)
class ShutdownReentryTest(unittest.TestCase):
def test_reader_failure_on_stop_is_reported_and_cancels_automatic_resume(self) -> None:
w = _Runtime()
w._supervisor.is_running.return_value = True
w._drain_rings_until_quiet = mock.Mock(side_effect=ValueError("corrupt ring payload"))
raw = w._raw_reader = SimpleNamespace(close=mock.Mock())
preprocessed = w._pre_reader = SimpleNamespace(close=mock.Mock())
result = w._result_reader = SimpleNamespace(close=mock.Mock(), _ring_name="/results")
expected_stop_seq = w._stop_request_seq + 1
with mock.patch("python_app.gui.controllers.app_window_pipeline_mixin.ShmRingReader") as reopen:
w._stop_run()
w._supervisor.stop.assert_called_once()
w._supervisor.stop_all.assert_not_called()
w._finalize_recording_on_stop.assert_called_once()
for reader in (raw, preprocessed, result):
reader.close.assert_called_once()
self.assertIsNone(w._raw_reader)
self.assertIsNone(w._pre_reader)
self.assertIs(w._result_reader, reopen.return_value)
reopen.assert_called_once_with("/results")
w._show_exception.assert_called_once()
self.assertIn("corrupt ring", str(w._show_exception.call_args.args[1]))
self.assertFalse(w._stopping_run)
self.assertFalse(w._single_capture_active)
self.assertEqual(w._status_label.setText.call_args.args[0], "Status: error")
self.assertFalse(w._pipeline_resume_allowed(expected_stop_seq))
def test_unsupported_kamil_adc_mock_fails_before_starting_any_process(self) -> None:
w = _Runtime()
config = RunConfigModel()
config.radar.model = "kamil_adc"
config.apply_device_model_constraints()
w._build_config.return_value = config
w._stop_all_processes = mock.Mock()
w._start_run(single_capture=True)
w._supervisor.start.assert_not_called()
w._show_exception.assert_called_once()
self.assertIn("native", str(w._show_exception.call_args.args[1]))
def test_stop_blocks_nested_start_poll_and_duplicate_stop(self) -> None:
w = _Runtime()
w._supervisor.is_running.return_value = True
def reenter(**_kwargs):
w._start_run()
w._poll_rings()
w._stop_run()
w._drain_rings_until_quiet = mock.Mock(side_effect=reenter)
w._stop_run()
w._supervisor.stop_orchestrator.assert_called_once()
w._supervisor.stop_preprocessor.assert_called_once()
w._finalize_recording_on_stop.assert_called_once()
w._build_config.assert_not_called()
w._web_update_snapshot.assert_not_called()
self.assertFalse(w._stopping_run)
self.assertFalse(w._resume_pipeline_after_capture)
def test_close_during_stop_waits_until_tail_is_finalized(self) -> None:
w = _Runtime()
w._supervisor.is_running.return_value = True
event = mock.Mock()
ordering = []
def close_during_drain(**_kwargs):
AppWindow.closeEvent(w, event)
w._drain_rings_until_quiet = mock.Mock(side_effect=close_during_drain)
w._finalize_recording_on_stop.side_effect = lambda: ordering.append("finalize")
w.close.side_effect = lambda: ordering.append("close")
w._stop_run()
event.ignore.assert_called()
w._supervisor.stop_all.assert_called_once()
self.assertEqual(ordering, ["finalize", "close"])
self.assertFalse(w._close_after_stop)
def test_explicit_stop_during_apply_pause_cancels_apply_and_restart(self) -> None:
w = _Runtime()
w._supervisor.is_running.return_value = True
w._start_run = mock.Mock()
w._drain_rings_until_quiet = mock.Mock(side_effect=lambda **_kwargs: w._stop_run())
w._apply_radar_settings()
w._build_config.assert_not_called()
w._start_run.assert_not_called()
class CaptureBatchControlTest(unittest.TestCase):
def test_stop_between_combos_keeps_captures_and_allows_later_resume(self) -> None:
w = _Runtime()
captured = []
session = SimpleNamespace(
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._capture_session = session
dialog = mock.Mock()
w._ensure_preprocess_dialog = lambda: dialog
w._record_preprocess_capture = mock.Mock()
w._pump_events_during_drain = lambda pause: AppWindowPipelineMixin._pump_events_during_drain(w, pause)
QTimer.singleShot(0, w._stop_run)
with mock.patch("python_app.gui.controllers.app_window_preprocess_mixin.QTimer.singleShot", side_effect=lambda _delay, fn: fn()):
w._capture_all_remaining()
self.assertEqual(captured, [0])
self.assertIs(w._capture_session, session)
self.assertFalse(w._preprocess_capture_busy)
self.assertIn("paused", dialog.set_status.call_args.args[0])
w._capture_all_remaining()
self.assertEqual(captured, [0, 1, 2])
self.assertEqual(w._record_preprocess_capture.call_count, 3)
class GuiProfileWriteTest(unittest.TestCase):
def test_failed_profile_replace_preserves_existing_profile_and_cleans_temporary(self) -> None:
writer = AppWindowConfigProfileIOMixin()
writer._build_gui_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._write_gui_profile_to_path(path)
self.assertEqual(path.read_bytes(), previous)
self.assertEqual(list(path.parent.iterdir()), [path])
class HistoryCommandRestoreTest(unittest.TestCase):
def test_malformed_or_exhausted_counter_cannot_prevent_gui_start(self) -> None:
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / "live.json"
for content in ('[]', 'null', 'invalid', '{"history_command_seq": NaN}',
'{"history_command_seq": Infinity}', '{"history_command_seq": true}',
'{"history_command_seq": 1.5}', '{"history_command_seq": -1}',
'{"history_command_seq": 18446744073709551615}'):
path.write_text(content)
with self.subTest(content=content):
self.assertEqual(AppWindow._load_history_command_seq(path), 0)
path.write_text('{"history_command_seq": 9876543210}')
self.assertEqual(AppWindow._load_history_command_seq(path), 9876543210)
if __name__ == "__main__":
unittest.main()