Rework radar system

This commit is contained in:
Ayzen
2026-10-06 12:32:26 +03:00
parent 552cdc688a
commit 5bb86b7165
299 changed files with 13537 additions and 27763 deletions
+213 -241
View File
@@ -5,40 +5,43 @@ Pins the agreed semantics:
* 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.
* 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 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 PyQt6.QtCore import QTimer # noqa: E402
from fastapi import FastAPI, HTTPException, Request # noqa: E402
from python_app.gui.controllers.app_window_web_mixin import ( # noqa: E402
AppWindowWebController,
AppWindowWebMixin,
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,
_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.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
@@ -51,45 +54,10 @@ 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
# --------------------------------------------------------------------------- #
@@ -135,12 +103,6 @@ class VnaHistoryTest(unittest.TestCase):
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]])
@@ -150,159 +112,155 @@ class VnaHistoryTest(unittest.TestCase):
# --------------------------------------------------------------------------- #
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)
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_rejects_unknown_field(self) -> None:
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_known_field_emits_and_returns_snapshot(self) -> None:
received: list[dict] = []
updated = [{"name": "gpr_object_min_frac", "value": 0.5}]
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 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"):
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_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_reported_errors_keep_nested_actions_separate(self) -> None:
inner = _WebActionCall(lambda: self.window._log.show_error("inner failed"))
def test_lists_configs_sorted_and_load_emits_signal(self) -> None:
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"])
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"])
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_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_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_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_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 = []
self.controller.start_requested.connect(queued.append)
with mock.patch("python_app.gui.controllers.app_window_web_mixin._WEB_ACTION_TIMEOUT_S", 0):
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()
w = AppWindowWebMixin()
action = mock.Mock()
w._run_web_action(queued[0], action)
action.assert_not_called()
self.controller._run_action(queued[0])
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):
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_waiting_requests_and_rejects_new_controls(self) -> None:
queued = []
self.controller.start_requested.connect(lambda call: (queued.append(call), self.controller.shutdown()))
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())
@@ -310,30 +268,37 @@ class WebControllerTest(unittest.TestCase):
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()
def test_closing_pipeline_does_not_run_queued_control(self) -> None:
action = mock.Mock()
w._run_web_action(call, action)
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_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()
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):
@@ -342,7 +307,7 @@ class WebPortTest(unittest.TestCase):
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()
return web_ui_port()
def test_default_when_unset_or_invalid(self) -> None:
self.assertEqual(self._port_with_env(None), 8080)
@@ -356,9 +321,13 @@ class WebPortTest(unittest.TestCase):
class WebLiveSettingsRouteTest(unittest.TestCase):
def setUp(self) -> None:
cleanup = ExitStack()
self.addCleanup(cleanup.close)
self.controller = mock.Mock()
self.request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(controller=self.controller)))
self.enterContext(mock.patch(
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)),
))
@@ -384,51 +353,54 @@ class WebLiveSettingsRouteTest(unittest.TestCase):
# --------------------------------------------------------------------------- #
# 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:
def test_capture_enabled_only_between_first_connect_and_last_disconnect(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
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_unregister_stops_delivery(self) -> None:
def test_idle_skips_capture_and_new_frame_survives_status_heartbeat(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)
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())