438 lines
20 KiB
Python
438 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 — a full client queue drops its oldest frame.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
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 fastapi import HTTPException, WebSocketDisconnect # noqa: E402
|
|
|
|
from python_app.gui.controllers.app_window_web_mixin import ( # noqa: E402
|
|
AppWindowWebController,
|
|
AppWindowWebMixin,
|
|
_WebActionCall,
|
|
)
|
|
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,
|
|
_normalize_channel,
|
|
build_vna_history_payload,
|
|
)
|
|
from python_app.webui.controller import WebActionError # noqa: E402
|
|
from python_app.webui.routes import post_live_settings, ws # 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),
|
|
s11=np.array([1 + 1j, 2 + 2j], dtype=np.complex64),
|
|
s21=np.array([3 + 3j, 4 - 4j], dtype=np.complex64),
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# NPZ set persistence
|
|
# --------------------------------------------------------------------------- #
|
|
class NpzStoreRoundTripTest(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self._dir = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self._dir.cleanup)
|
|
self.store = NpzStore(Path(self._dir.name))
|
|
|
|
def test_save_then_load_round_trips_traces_and_metadata(self) -> None:
|
|
col = SweepCollection(collection_id=7, monotonic_ns=11, traces=[_trace(0, 0), _trace(1, 0)],
|
|
capture_start_ns=100, capture_end_ns=200)
|
|
self.store.save_set("calibration", "radar1", "set1", col)
|
|
loaded = self.store.load_set("calibration", "radar1", "set1")
|
|
|
|
self.assertEqual(loaded.collection_id, 7)
|
|
self.assertEqual((loaded.capture_start_ns, loaded.capture_end_ns), (100, 200))
|
|
self.assertEqual(len(loaded.traces), 2)
|
|
by_combo = {(t.combo.input, t.combo.output): t for t in loaded.traces}
|
|
self.assertTrue(np.array_equal(by_combo[(0, 0)].s21, _trace(0, 0).s21))
|
|
self.assertTrue(np.array_equal(by_combo[(1, 0)].frequency_hz, _trace(1, 0).frequency_hz))
|
|
|
|
def test_set_appears_in_listing_and_leaves_no_tmp(self) -> None:
|
|
self.store.save_set("calibration", "radar1", "set1", SweepCollection(collection_id=1, monotonic_ns=1,
|
|
traces=[_trace(0, 0)]))
|
|
self.assertIn("set1", self.store.list_sets("calibration", "radar1"))
|
|
leftover = list(Path(self._dir.name).rglob("*.tmp"))
|
|
self.assertEqual(leftover, [])
|
|
|
|
def test_load_missing_set_raises(self) -> None:
|
|
with self.assertRaises(Exception):
|
|
self.store.load_set("calibration", "radar1", "absent")
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# 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_normalize_channel(self) -> None:
|
|
self.assertEqual(_normalize_channel("S21"), "s21")
|
|
self.assertEqual(_normalize_channel(" s11 "), "s11")
|
|
with self.assertRaises(ValueError):
|
|
_normalize_channel("s99")
|
|
|
|
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._cfg_dir = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self._cfg_dir.cleanup)
|
|
self.configs_dir = Path(self._cfg_dir.name)
|
|
(self.configs_dir / "alpha.json").write_text("{}", encoding="utf-8")
|
|
(self.configs_dir / "beta.json").write_text("{}", encoding="utf-8")
|
|
self.controller = AppWindowWebController(self.configs_dir)
|
|
self.addCleanup(self.controller.deleteLater)
|
|
|
|
def test_rejects_unknown_field(self) -> None:
|
|
with self.assertRaisesRegex(ValueError, "Unknown live-settings"):
|
|
self.controller.apply_live_settings({"definitely_not_a_field": 1})
|
|
|
|
def test_known_field_emits_and_returns_snapshot(self) -> None:
|
|
received: list[dict] = []
|
|
updated = [{"name": "gpr_object_min_frac", "value": 0.5}]
|
|
|
|
def apply(fields, call):
|
|
received.append(fields)
|
|
self.controller.update_snapshot(status={}, live_settings=updated, frame=None)
|
|
call.done.set()
|
|
|
|
self.controller.apply_settings_requested.connect(apply)
|
|
out = self.controller.apply_live_settings({"gpr_object_min_frac": 0.5})
|
|
self.assertEqual(received, [{"gpr_object_min_frac": 0.5}])
|
|
self.assertEqual(out, updated)
|
|
|
|
def test_live_settings_error_is_raised_to_the_caller(self) -> None:
|
|
self.controller.apply_settings_requested.connect(
|
|
lambda fields, call: (setattr(call, "error", "Duplicate antenna position: 0"), call.done.set())
|
|
)
|
|
with self.assertRaisesRegex(WebActionError, "Duplicate antenna position"):
|
|
self.controller.apply_live_settings({"tx_geometry": []})
|
|
|
|
def test_snapshot_is_replaced_and_returned_as_copy(self) -> None:
|
|
self.controller.update_snapshot(status={"running": True}, live_settings=[{"name": "x"}], frame={"seq": 1})
|
|
status = self.controller.status()
|
|
self.assertEqual(status, {"running": True})
|
|
status["running"] = False # mutating the copy must not affect the controller
|
|
self.assertTrue(self.controller.status()["running"])
|
|
self.assertEqual(self.controller.peek_frame(), {"seq": 1})
|
|
|
|
def test_frame_only_updates_when_present(self) -> None:
|
|
self.controller.update_snapshot(status={}, live_settings=[], frame={"seq": 1})
|
|
self.controller.update_snapshot(status={}, live_settings=[], frame=None) # no new grab
|
|
self.assertEqual(self.controller.peek_frame(), {"seq": 1}) # keeps the last frame
|
|
|
|
def test_controls_emit_signals(self) -> None:
|
|
# Controls are synchronous now: each emits a call the GUI slot must finalize.
|
|
# In-thread, emit() runs the slot directly, so finalizing it returns at once.
|
|
fired: list[str] = []
|
|
|
|
def handler(label):
|
|
def slot(call):
|
|
fired.append(label)
|
|
call.done.set()
|
|
return slot
|
|
|
|
self.controller.start_requested.connect(handler("start"))
|
|
self.controller.stop_requested.connect(handler("stop"))
|
|
self.controller.single_capture_requested.connect(handler("single"))
|
|
self.controller.capture_requested.connect(handler("capture"))
|
|
self.controller.start()
|
|
self.controller.stop()
|
|
self.controller.single_capture()
|
|
self.controller.capture_tmp_reference()
|
|
self.assertEqual(fired, ["start", "stop", "single", "capture"])
|
|
|
|
def test_action_error_is_raised_to_the_caller(self) -> None:
|
|
# An error the GUI slot records on the call surfaces as WebActionError (HTTP 400).
|
|
self.controller.start_requested.connect(
|
|
lambda call: (setattr(call, "error", "destination already exists"), call.done.set())
|
|
)
|
|
with self.assertRaisesRegex(WebActionError, "destination already exists"):
|
|
self.controller.start()
|
|
|
|
def test_start_recording_forwards_path_name_count(self) -> None:
|
|
received: list[tuple[str, str, int]] = []
|
|
self.controller.start_recording_requested.connect(
|
|
lambda p, n, c, call: (received.append((p, n, c)), call.done.set())
|
|
)
|
|
self.controller.start_recording("/tmp/out", "run1", 250)
|
|
self.assertEqual(received, [("/tmp/out", "run1", 250)])
|
|
|
|
def test_lists_configs_sorted_and_load_emits_signal(self) -> None:
|
|
self.assertEqual(self.controller.list_configs(), ["alpha.json", "beta.json"])
|
|
requested: list[str] = []
|
|
self.controller.load_config_requested.connect(
|
|
lambda name, call: (requested.append(name), call.done.set())
|
|
)
|
|
self.controller.load_config("beta.json")
|
|
self.assertEqual(requested, ["beta.json"])
|
|
|
|
def test_load_config_rejects_unknown_or_unsafe_name(self) -> None:
|
|
# Missing file, traversal, sub-path, and non-json are all refused (HTTP 400 upstream).
|
|
for bad in ("ghost.json", "../escape.json", "sub/alpha.json", "alpha.txt"):
|
|
with self.assertRaisesRegex(ValueError, "Unknown run config"):
|
|
self.controller.load_config(bad)
|
|
|
|
def test_save_dataset_forwards_path_and_name(self) -> None:
|
|
received: list[tuple[str, str]] = []
|
|
self.controller.save_dataset_requested.connect(
|
|
lambda p, n, call: (received.append((p, n)), call.done.set())
|
|
)
|
|
self.controller.save_dataset("/tmp/out", "run1")
|
|
self.assertEqual(received, [("/tmp/out", "run1")])
|
|
|
|
def test_combo_controls_forward_selection_and_next_to_gui(self) -> None:
|
|
received: list = []
|
|
self.controller.select_combo_requested.connect(
|
|
lambda inp, out, call: (received.append((inp, out)), call.done.set())
|
|
)
|
|
self.controller.next_combo_requested.connect(
|
|
lambda call: (received.append("next"), call.done.set())
|
|
)
|
|
|
|
self.controller.select_combo(0, 1)
|
|
self.controller.next_combo()
|
|
|
|
self.assertEqual(received, [(0, 1), "next"])
|
|
|
|
def test_invalid_recording_count_is_rejected_without_dispatch(self) -> None:
|
|
fired = mock.Mock()
|
|
self.controller.start_recording_requested.connect(fired)
|
|
for count in (0, -1, True, 1.5, "2"):
|
|
with self.subTest(count=count), self.assertRaisesRegex(ValueError, "positive integer"):
|
|
self.controller.start_recording("/tmp", "run", count)
|
|
fired.assert_not_called()
|
|
|
|
def test_timed_out_queued_action_cannot_run_later(self) -> None:
|
|
queued = []
|
|
self.controller.start_requested.connect(queued.append)
|
|
with mock.patch("python_app.gui.controllers.app_window_web_mixin._WEB_ACTION_TIMEOUT_S", 0):
|
|
with self.assertRaisesRegex(WebActionError, "cancelled"):
|
|
self.controller.start()
|
|
w = AppWindowWebMixin()
|
|
action = mock.Mock()
|
|
w._run_web_action(queued[0], action)
|
|
action.assert_not_called()
|
|
self.assertFalse(self.controller._pending_calls)
|
|
|
|
def test_timeout_reports_an_already_running_action_without_repeating_it(self) -> None:
|
|
started = []
|
|
self.controller.start_requested.connect(lambda call: (call.begin(), started.append(call)))
|
|
with mock.patch("python_app.gui.controllers.app_window_web_mixin._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_waiting_requests_and_rejects_new_controls(self) -> None:
|
|
queued = []
|
|
self.controller.start_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()
|
|
|
|
|
|
class WebActionLifecycleTest(unittest.TestCase):
|
|
def test_closed_window_does_not_run_queued_control(self) -> None:
|
|
w = AppWindowWebMixin()
|
|
w._closing = True
|
|
call = _WebActionCall()
|
|
action = mock.Mock()
|
|
w._run_web_action(call, action)
|
|
action.assert_not_called()
|
|
self.assertTrue(call.done.is_set())
|
|
self.assertEqual(call.error, "The application is closing")
|
|
|
|
def test_out_of_range_recording_count_does_not_change_destination_or_arm(self) -> None:
|
|
w = AppWindowWebMixin()
|
|
w._record_count = SimpleNamespace(minimum=lambda: 1, maximum=lambda: 100, setValue=mock.Mock())
|
|
w._save_path_input = SimpleNamespace(setText=mock.Mock())
|
|
w._save_name_input = SimpleNamespace(setText=mock.Mock())
|
|
w._start_run_with_recording = mock.Mock()
|
|
with self.assertRaisesRegex(ValueError, "from 1 to 100"):
|
|
w._start_web_recording("/tmp/new", "run", 101)
|
|
w._save_path_input.setText.assert_not_called()
|
|
w._save_name_input.setText.assert_not_called()
|
|
w._record_count.setValue.assert_not_called()
|
|
w._start_run_with_recording.assert_not_called()
|
|
|
|
|
|
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 AppWindowWebMixin._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:
|
|
self.controller = mock.Mock()
|
|
self.request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(controller=self.controller)))
|
|
self.enterContext(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 WebSocketInitialFrameTest(unittest.TestCase):
|
|
def test_new_client_receives_cached_plot_even_when_no_new_frame_is_published(self) -> None:
|
|
self.enterContext(mock.patch(
|
|
"python_app.webui.routes.asyncio.to_thread",
|
|
new=mock.AsyncMock(side_effect=lambda func, *args: func(*args)),
|
|
))
|
|
for frame in ({"type": "frame", "seq": 7, "png_b64": "cached"}, None):
|
|
with self.subTest(frame=frame):
|
|
controller = SimpleNamespace(peek_frame=lambda: frame)
|
|
broadcaster = mock.Mock()
|
|
status = {"type": "status", "running": False}
|
|
broadcaster.register.side_effect = lambda queue: queue.put_nowait(status)
|
|
socket = SimpleNamespace(
|
|
app=SimpleNamespace(state=SimpleNamespace(controller=controller, broadcaster=broadcaster)),
|
|
accept=mock.AsyncMock(), close=mock.AsyncMock(),
|
|
send_json=mock.AsyncMock(side_effect=WebSocketDisconnect),
|
|
)
|
|
asyncio.run(ws(socket))
|
|
socket.send_json.assert_awaited_once_with(frame if frame is not None else status)
|
|
broadcaster.unregister.assert_called_once()
|
|
socket.close.assert_awaited_once()
|
|
|
|
|
|
class RingBroadcasterFanOutTest(unittest.TestCase):
|
|
def test_full_client_queue_drops_oldest(self) -> None:
|
|
async def scenario() -> None:
|
|
broadcaster = RingBroadcaster(controller=object())
|
|
queue: asyncio.Queue = asyncio.Queue(maxsize=1)
|
|
broadcaster.register(queue)
|
|
broadcaster._publish({"type": "frame", "seq": 1})
|
|
broadcaster._publish({"type": "frame", "seq": 2}) # evicts seq 1
|
|
self.assertEqual(queue.qsize(), 1)
|
|
self.assertEqual(queue.get_nowait(), {"type": "frame", "seq": 2}) # newest survives
|
|
|
|
asyncio.run(scenario())
|
|
|
|
def test_unregister_stops_delivery(self) -> None:
|
|
async def scenario() -> None:
|
|
broadcaster = RingBroadcaster(controller=object())
|
|
queue: asyncio.Queue = asyncio.Queue(maxsize=4)
|
|
broadcaster.register(queue)
|
|
broadcaster.unregister(queue)
|
|
broadcaster.unregister(queue) # idempotent
|
|
broadcaster._publish({"type": "frame", "seq": 1})
|
|
self.assertEqual(queue.qsize(), 0)
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|