Refactor radar system
This commit is contained in:
@@ -15,16 +15,19 @@ 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
|
||||
@@ -35,6 +38,7 @@ from python_app.storage.npz.vna_history_json import ( # noqa: E402
|
||||
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
|
||||
|
||||
|
||||
@@ -160,10 +164,24 @@ class WebControllerTest(unittest.TestCase):
|
||||
|
||||
def test_known_field_emits_and_returns_snapshot(self) -> None:
|
||||
received: list[dict] = []
|
||||
self.controller.apply_settings_requested.connect(received.append)
|
||||
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.assertIsInstance(out, list)
|
||||
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})
|
||||
@@ -238,6 +256,85 @@ class WebControllerTest(unittest.TestCase):
|
||||
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:
|
||||
@@ -257,9 +354,59 @@ class WebPortTest(unittest.TestCase):
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user