410 lines
20 KiB
Python
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()
|