"""Storage + embedded WebUI tests. Pins the agreed semantics: * NPZ set persistence round-trips traces + metadata (float32 wire precision); * vna_history export fails loud (ValueError) when no matching traces / bad stage; * the web bridge rejects unknown live-settings fields (HTTP 400), serves immutable snapshot copies, and forwards controls as Qt signals; * the web fan-out is latest-wins — each client retains the latest message per type. """ from __future__ import annotations from contextlib import ExitStack import asyncio import os import tempfile import threading import time import unittest from pathlib import Path from unittest import mock import numpy as np os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") # before any Qt import from PyQt6.QtWidgets import QApplication # noqa: E402 from PyQt6.QtCore import QTimer # noqa: E402 from fastapi import FastAPI, HTTPException, Request # noqa: E402 from python_app.gui.controllers.web_controller import ( # noqa: E402 _WebActionCall, web_ui_port, ) from python_app.tests.gui_test_support import isolated_window # noqa: E402 from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData # noqa: E402 from python_app.storage.npz.paths import radar_config_filename_prefix # noqa: E402 from python_app.storage.npz.store import NpzStore # noqa: E402 from python_app.storage.npz.vna_history_json import ( # noqa: E402 _complex_to_points, build_vna_history_payload, ) from python_app.webui.controller import WebActionError, WebController # noqa: E402 from python_app.webui.routes import post_live_settings # noqa: E402 from python_app.webui.streaming import RingBroadcaster # noqa: E402 def setUpModule() -> None: global _app _app = QApplication.instance() or QApplication([]) def _trace(in_pos: int, out_pos: int) -> TraceData: return TraceData( combo=ComboKey(input=in_pos, output=out_pos), frequency_hz=np.array([1.0, 2.0], dtype=np.float32), s21=np.array([3 + 3j, 4 - 4j], dtype=np.complex64), ) # --------------------------------------------------------------------------- # # Radar-config filename prefix # --------------------------------------------------------------------------- # class RadarConfigPrefixTest(unittest.TestCase): def test_encodes_model_span_points_power(self) -> None: self.assertEqual( radar_config_filename_prefix("librevna", 1e9, 6e9, 201, -10.0), "librevna_1-6GHz_201pts_-10dBm", ) def test_megahertz_span_keeps_fractional_values(self) -> None: self.assertEqual( radar_config_filename_prefix("librevna", 1.5e6, 6e6, 51, -3.5), "librevna_1.5-6MHz_51pts_-3.5dBm", ) def test_adc_model_reports_adc_point_count(self) -> None: self.assertEqual( radar_config_filename_prefix("kamil_adc", 1e9, 6e9, 1, 0.0), "kamil_adc_1-6GHz_adc_0dBm", ) # --------------------------------------------------------------------------- # # vna_history export # --------------------------------------------------------------------------- # class VnaHistoryTest(unittest.TestCase): def _sweeps(self, *combos) -> list[SweepCollection]: return [SweepCollection(collection_id=1, monotonic_ns=1, traces=[_trace(i, o) for i, o in combos])] def test_builds_payload_for_matching_combo(self) -> None: payload = build_vna_history_payload([], self._sweeps((0, 0)), [], input_index=0, output_index=0) self.assertEqual(payload["input_index"], 0) self.assertEqual(payload["channel"], "s21") self.assertEqual(payload["preprocessed_record_count"], 1) self.assertTrue(payload["sweep_history"]) def test_no_matching_traces_raises(self) -> None: with self.assertRaises(ValueError): build_vna_history_payload([], self._sweeps((0, 0)), [], input_index=9, output_index=9) def test_invalid_primary_stage_raises(self) -> None: with self.assertRaisesRegex(ValueError, "primary_stage"): build_vna_history_payload([], self._sweeps((0, 0)), [], input_index=0, output_index=0, primary_stage="x") def test_complex_to_points(self) -> None: self.assertEqual(_complex_to_points(np.array([1 + 2j, 3 - 4j])), [[1.0, 2.0], [3.0, -4.0]]) # --------------------------------------------------------------------------- # # Web bridge controller # --------------------------------------------------------------------------- # class WebControllerTest(unittest.TestCase): def setUp(self) -> None: self.window = isolated_window(self.addCleanup) self.controller = self.window._web self.configs_dir = self.controller._run_configs_dir self.configs_dir.mkdir(exist_ok=True) for name in ("alpha", "beta"): (self.configs_dir / f"{name}.json").write_text("{}", encoding="utf-8") def test_valid_live_fields_return_updated_schema_and_errors_reach_caller(self) -> None: updated = [{"name": "gpr_object_min_frac", "value": 0.5}] with mock.patch.object(self.window._live, "apply_web") as apply, \ mock.patch.object(self.window._config, "web_schema", return_value=updated): self.assertEqual(self.controller.apply_live_settings({"gpr_object_min_frac": 0.5}), updated) apply.assert_called_once_with({"gpr_object_min_frac": 0.5}) apply.side_effect = ValueError("Duplicate antenna position: 0") with self.assertRaisesRegex(WebActionError, "Duplicate antenna position"): self.controller.apply_live_settings({"tx_geometry": []}) with self.assertRaisesRegex(ValueError, "Unknown live-settings"): self.controller.apply_live_settings({"definitely_not_a_field": 1}) def test_snapshots_return_copies_and_keep_frame_until_a_new_grab(self) -> None: with mock.patch.object(self.controller, "_grab_frame_if_due", side_effect=[{"seq": 1}, None]): self.controller.refresh_snapshot() original = self.controller.status() modified = self.controller.status() modified["running"] = not original["running"] self.assertEqual(self.controller.status(), original) settings = self.controller.current_live_settings() settings.clear() self.assertTrue(self.controller.current_live_settings()) self.controller.refresh_snapshot() self.assertEqual(self.controller.peek_frame(), {"seq": 1}) def test_controls_call_the_shared_runtime_actions(self) -> None: with mock.patch.object(self.window._pipeline, "start") as start, \ mock.patch.object(self.window._pipeline, "stop") as stop, \ mock.patch.object(self.window._preprocess, "capture_tmp_reference") as capture, \ mock.patch.object(self.window._snapshots, "delete_history") as remove: self.controller.start() self.controller.stop() self.controller.single_capture() self.controller.capture_tmp_reference() self.controller.remove_last_measurement() self.controller.clear_history() self.assertEqual(start.call_args_list, [mock.call(), mock.call(single_capture=True)]) stop.assert_called_once() capture.assert_called_once() self.assertEqual(remove.call_args_list, [mock.call(remove_last=True), mock.call(remove_last=False)]) def test_reported_errors_keep_nested_actions_separate(self) -> None: inner = _WebActionCall(lambda: self.window._log.show_error("inner failed")) def outer() -> None: self.window._log.show_error("outer failed") self.controller._run_action(inner) self.window._log.show_error("second outer error") with mock.patch.object(self.window._pipeline, "start", side_effect=outer): with self.assertRaisesRegex(WebActionError, "outer failed"): self.controller.start() self.assertEqual(inner.error, "inner failed") self.assertIsNone(self.window._log._capture) def test_desktop_error_during_web_event_pump_is_not_returned_to_web(self) -> None: call = _WebActionCall(lambda: self.window._pipeline.pump_events(0.02)) QTimer.singleShot(0, lambda: self.window._log.show_error("Desktop action failed")) with mock.patch.dict(os.environ, {"RADAR_SYSTEM_HEADLESS": "0"}), \ mock.patch("python_app.gui.controllers.log_controller.QMessageBox") as dialog: self.controller._run_action(call) self.assertIsNone(call.error) dialog.return_value.setText.assert_called_once_with("Desktop action failed") dialog.return_value.exec.assert_called_once() self.assertIsNone(self.window._log._capture) def test_recording_checks_count_before_changing_destination(self) -> None: count = self.controller._record_count count.setMaximum(100) before = (self.controller._save_path.text(), self.controller._save_name.text(), count.value()) with mock.patch.object(self.window._snapshots, "start_recording") as record: call = mock.Mock(wraps=self.controller.start_recording) for invalid in (0, -1, True, 1.5, "2"): with self.subTest(count=invalid), self.assertRaisesRegex(ValueError, "positive integer"): call("/tmp/new", "run", invalid) with self.assertRaisesRegex(WebActionError, "from 1 to 100"): self.controller.start_recording("/tmp/new", "run", 101) record.assert_not_called() self.assertEqual((self.controller._save_path.text(), self.controller._save_name.text(), count.value()), before) self.controller.start_recording("/tmp/new", "run", 25) record.assert_called_once_with(25) self.assertEqual((self.controller._save_path.text(), self.controller._save_name.text(), count.value()), ("/tmp/new", "run", 25)) def test_config_filename_is_validated_on_gui_thread(self) -> None: self.assertEqual(self.controller.list_configs(), ["alpha.json", "beta.json"]) with mock.patch.object(self.window._profile, "load") as load: self.controller.load_config("beta.json") load.assert_called_once_with(self.configs_dir / "beta.json") for bad in ("ghost.json", "../escape.json", "sub/alpha.json", "alpha.txt"): with self.assertRaisesRegex(WebActionError, "Unknown run config"): self.controller.load_config(bad) self.controller._action_requested.disconnect() self.controller._action_requested.connect( lambda call: ((self.configs_dir / "alpha.json").unlink(), self.controller._run_action(call))) with self.assertRaisesRegex(WebActionError, "Unknown run config"): self.controller.load_config("alpha.json") self.assertEqual(load.call_count, 1) def test_save_updates_shared_destination_and_blank_path_keeps_previous(self) -> None: with mock.patch.object(self.window._snapshots, "save") as save: self.controller.save_dataset("/tmp/out", "run1") self.assertEqual((self.controller._save_path.text(), self.controller._save_name.text()), ("/tmp/out", "run1")) self.controller.save_dataset("", "") self.assertEqual((self.controller._save_path.text(), self.controller._save_name.text()), ("/tmp/out", "")) self.assertEqual(save.call_count, 2) def test_combo_controls_forward_selection_and_next(self) -> None: with mock.patch.object(self.window._plots, "select_combo") as select, \ mock.patch.object(self.window._plots, "select_next_combo") as next_combo: self.controller.select_combo(0, 1) self.controller.next_combo() select.assert_called_once_with(0, 1) next_combo.assert_called_once() def test_timed_out_queued_action_cannot_run_later(self) -> None: queued: list[_WebActionCall] = [] self.controller._action_requested.disconnect() self.controller._action_requested.connect(queued.append) with mock.patch("python_app.gui.controllers.web_controller._WEB_ACTION_TIMEOUT_S", 0), \ mock.patch.object(self.window._pipeline, "start") as action: with self.assertRaisesRegex(WebActionError, "cancelled"): self.controller.start() self.controller._run_action(queued[0]) action.assert_not_called() self.assertFalse(self.controller._pending_calls) def test_timeout_reports_running_action_without_repeating_it(self) -> None: started: list[_WebActionCall] = [] self.controller._action_requested.disconnect() self.controller._action_requested.connect(lambda call: (call.begin(), started.append(call))) with mock.patch("python_app.gui.controllers.web_controller._WEB_ACTION_TIMEOUT_S", 0): with self.assertRaisesRegex(WebActionError, "still running"): self.controller.start() self.assertFalse(started[0].done.is_set()) started[0].finish() self.assertTrue(started[0].done.is_set()) def test_shutdown_releases_waiters_and_rejects_new_controls(self) -> None: queued: list[_WebActionCall] = [] self.controller._action_requested.disconnect() self.controller._action_requested.connect(lambda call: (queued.append(call), self.controller.shutdown())) with self.assertRaisesRegex(WebActionError, "closing"): self.controller.start() self.assertTrue(queued[0].done.is_set()) self.assertFalse(queued[0].begin()) with self.assertRaisesRegex(WebActionError, "closing"): self.controller.next_combo() def test_closing_pipeline_does_not_run_queued_control(self) -> None: action = mock.Mock() call = _WebActionCall(action) self.window._pipeline.begin_close() self.controller._run_action(call) action.assert_not_called() self.assertTrue(call.done.is_set()) self.assertEqual(call.error, "The application is closing") def test_worker_thread_action_runs_on_gui_thread(self) -> None: threads: list[int] = [] errors: list[Exception] = [] def request() -> None: try: self.controller.start() except Exception as exc: errors.append(exc) with mock.patch.object(self.window._pipeline, "start", side_effect=lambda: threads.append(threading.get_ident())): worker = threading.Thread(target=request) worker.start() deadline = time.monotonic() + 2 while worker.is_alive() and time.monotonic() < deadline: _app.processEvents() time.sleep(0.001) self.controller.shutdown() worker.join(timeout=1) self.assertFalse(worker.is_alive()) self.assertEqual(errors, []) self.assertEqual(threads, [threading.get_ident()]) class WebPortTest(unittest.TestCase): def _port_with_env(self, value: str | None) -> int: env = {} if value is None else {"RADAR_SYSTEM_WEBUI_PORT": value} with mock.patch.dict(os.environ, env, clear=False): if value is None: os.environ.pop("RADAR_SYSTEM_WEBUI_PORT", None) return web_ui_port() def test_default_when_unset_or_invalid(self) -> None: self.assertEqual(self._port_with_env(None), 8080) self.assertEqual(self._port_with_env("abc"), 8080) self.assertEqual(self._port_with_env("99999"), 8080) # out of range self.assertEqual(self._port_with_env("0"), 8080) def test_valid_port(self) -> None: self.assertEqual(self._port_with_env("9000"), 9000) class WebLiveSettingsRouteTest(unittest.TestCase): def setUp(self) -> None: cleanup = ExitStack() self.addCleanup(cleanup.close) self.controller = mock.Mock() app = FastAPI() app.state.controller = self.controller self.request = Request({"type": "http", "app": app}) cleanup.enter_context(mock.patch( "python_app.webui.routes.asyncio.to_thread", new=mock.AsyncMock(side_effect=lambda func, *args: func(*args)), )) def test_response_contains_schema_after_the_action_finishes(self) -> None: updated = [{"name": "bscan_gain", "value": 2.0}] self.controller.apply_live_settings.return_value = updated response = asyncio.run(post_live_settings(self.request, {"bscan_gain": 2.0})) self.assertEqual(response, updated) self.controller.apply_live_settings.assert_called_once_with({"bscan_gain": 2.0}) def test_gui_failure_is_returned_as_http_error(self) -> None: self.controller.apply_live_settings.side_effect = WebActionError("Invalid antenna geometry") with self.assertRaises(HTTPException) as caught: asyncio.run(post_live_settings(self.request, {"tx_geometry": []})) self.assertEqual(caught.exception.status_code, 400) self.assertEqual(caught.exception.detail, "Invalid antenna geometry") # --------------------------------------------------------------------------- # # Latest-wins fan-out # --------------------------------------------------------------------------- # class RingBroadcasterFanOutTest(unittest.TestCase): def test_capture_enabled_only_between_first_connect_and_last_disconnect(self) -> None: async def scenario() -> None: controller = mock.Mock(spec=WebController) broadcaster = RingBroadcaster(controller=controller) controller.peek_frame.return_value = None controller.status.return_value = {} controller.current_live_settings.return_value = [] queue = broadcaster.register() second = broadcaster.register() controller.set_frame_streaming.assert_called_once_with(True) broadcaster.unregister(queue) broadcaster.unregister(queue) # idempotent controller.set_frame_streaming.assert_called_once_with(True) broadcaster.unregister(second) self.assertEqual(controller.set_frame_streaming.call_args_list, [mock.call(True), mock.call(False)]) asyncio.run(scenario()) def test_idle_skips_capture_and_new_frame_survives_status_heartbeat(self) -> None: async def scenario() -> None: controller = mock.Mock(spec=WebController) controller.status.return_value = {"running": False} controller.current_live_settings.return_value = [] frame = {"type": "frame", "seq": 1, "png_b64": "plot"} controller.peek_frame.return_value = frame broadcaster = RingBroadcaster(controller) published = asyncio.Event() publish = broadcaster._publish def record(message: dict) -> None: publish(message) if message["type"] == "frame": published.set() broadcaster._publish = record broadcaster.start() try: await asyncio.sleep(0) # first polling iteration, with no clients controller.peek_frame.assert_not_called() queue = broadcaster.register() await asyncio.wait_for(published.wait(), timeout=1) messages = [await queue.get() for _ in range(3)] self.assertIn(frame, messages) self.assertEqual({message["type"] for message in messages}, {"frame", "status", "settings"}) finally: await broadcaster.stop() controller.set_frame_streaming.assert_called_with(False) asyncio.run(scenario()) if __name__ == "__main__": unittest.main()