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

410 lines
20 KiB
Python

"""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()