Refactor radar system

This commit is contained in:
Ayzen
2026-10-05 16:46:28 +03:00
parent 515049d2ed
commit 8f097dfa2e
179 changed files with 8212 additions and 6676 deletions
+149 -2
View File
@@ -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: