577 lines
19 KiB
Python
577 lines
19 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import asyncio
|
||
|
|
import logging
|
||
|
|
from collections import deque
|
||
|
|
from types import SimpleNamespace
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
import cmvr_edge_ai.connectors.cmvr_es.camera as camera_module
|
||
|
|
from cmvr_edge_ai.connectors.cmvr_es import (
|
||
|
|
CmvrCameraRgbStreamSource,
|
||
|
|
CmvrServiceError,
|
||
|
|
)
|
||
|
|
from cmvr_edge_ai.contracts import ImageFrame
|
||
|
|
from cmvr_edge_ai.core import Emission
|
||
|
|
|
||
|
|
|
||
|
|
class _FakeCall:
|
||
|
|
def __init__(self, *events: Any) -> None:
|
||
|
|
self._events = deque(events)
|
||
|
|
self.cancel_count = 0
|
||
|
|
|
||
|
|
def __aiter__(self) -> _FakeCall:
|
||
|
|
return self
|
||
|
|
|
||
|
|
async def __anext__(self) -> Any:
|
||
|
|
if not self._events:
|
||
|
|
raise StopAsyncIteration
|
||
|
|
event = self._events.popleft()
|
||
|
|
if isinstance(event, BaseException):
|
||
|
|
raise event
|
||
|
|
return event
|
||
|
|
|
||
|
|
def cancel(self) -> bool:
|
||
|
|
self.cancel_count += 1
|
||
|
|
return True
|
||
|
|
|
||
|
|
|
||
|
|
class _BlockingCall:
|
||
|
|
def __init__(self) -> None:
|
||
|
|
self.entered = asyncio.Event()
|
||
|
|
self._released = asyncio.Event()
|
||
|
|
self.cancel_count = 0
|
||
|
|
|
||
|
|
def __aiter__(self) -> _BlockingCall:
|
||
|
|
return self
|
||
|
|
|
||
|
|
async def __anext__(self) -> Any:
|
||
|
|
self.entered.set()
|
||
|
|
await self._released.wait()
|
||
|
|
raise asyncio.CancelledError
|
||
|
|
|
||
|
|
def cancel(self) -> bool:
|
||
|
|
self.cancel_count += 1
|
||
|
|
self._released.set()
|
||
|
|
return True
|
||
|
|
|
||
|
|
|
||
|
|
class _ImmediateRetryEvent(asyncio.Event):
|
||
|
|
"""Exercise reconnect paths without waiting for wall-clock backoff."""
|
||
|
|
|
||
|
|
async def wait(self) -> bool:
|
||
|
|
if self.is_set():
|
||
|
|
return True
|
||
|
|
raise TimeoutError
|
||
|
|
|
||
|
|
|
||
|
|
class _OneProgressTickEvent:
|
||
|
|
"""Cause exactly one heartbeat tick, then terminate its logging loop."""
|
||
|
|
|
||
|
|
def __init__(self) -> None:
|
||
|
|
self._done = False
|
||
|
|
|
||
|
|
def is_set(self) -> bool:
|
||
|
|
return self._done
|
||
|
|
|
||
|
|
async def wait(self) -> bool:
|
||
|
|
self._done = True
|
||
|
|
raise TimeoutError
|
||
|
|
|
||
|
|
|
||
|
|
class _FakeStub:
|
||
|
|
def __init__(
|
||
|
|
self,
|
||
|
|
*calls: Any,
|
||
|
|
start_feedbacks: tuple[Any, ...] = (),
|
||
|
|
) -> None:
|
||
|
|
self._calls = deque(calls)
|
||
|
|
self._start_feedbacks = deque(start_feedbacks)
|
||
|
|
self.events: list[str] = []
|
||
|
|
self.start_requests: list[Any] = []
|
||
|
|
self.start_timeouts: list[float | None] = []
|
||
|
|
self.start_count = 0
|
||
|
|
self.open_count = 0
|
||
|
|
|
||
|
|
async def StartCamera(
|
||
|
|
self,
|
||
|
|
request: Any,
|
||
|
|
*,
|
||
|
|
timeout: float | None = None,
|
||
|
|
) -> Any:
|
||
|
|
self.events.append("start")
|
||
|
|
self.start_requests.append(request)
|
||
|
|
self.start_timeouts.append(timeout)
|
||
|
|
self.start_count += 1
|
||
|
|
if not self._start_feedbacks:
|
||
|
|
return _start_feedback()
|
||
|
|
feedback = self._start_feedbacks.popleft()
|
||
|
|
if isinstance(feedback, BaseException):
|
||
|
|
raise feedback
|
||
|
|
return feedback
|
||
|
|
|
||
|
|
def GetRGBImageStream(self, request_stream: Any) -> Any:
|
||
|
|
# The connector owns request-stream lifetime. This fake only exercises
|
||
|
|
# response and reconnect behavior, so protobuf request objects are not
|
||
|
|
# imported or inspected.
|
||
|
|
del request_stream
|
||
|
|
self.events.append("stream")
|
||
|
|
self.open_count += 1
|
||
|
|
if not self._calls:
|
||
|
|
raise AssertionError("camera connector opened an unexpected stream")
|
||
|
|
return self._calls.popleft()
|
||
|
|
|
||
|
|
|
||
|
|
def _start_feedback(
|
||
|
|
*,
|
||
|
|
success: bool = True,
|
||
|
|
error_message: str = "",
|
||
|
|
) -> Any:
|
||
|
|
return SimpleNamespace(
|
||
|
|
header=SimpleNamespace(
|
||
|
|
success=success,
|
||
|
|
error_message=error_message,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _response(
|
||
|
|
remote_sequence: int,
|
||
|
|
*,
|
||
|
|
data: bytes = b"encoded-frame",
|
||
|
|
codec: str = "H264",
|
||
|
|
key_frame: bool = False,
|
||
|
|
width: int = 640,
|
||
|
|
height: int = 480,
|
||
|
|
seconds: int = 0,
|
||
|
|
nanos: int = 0,
|
||
|
|
success: bool = True,
|
||
|
|
error_message: str = "",
|
||
|
|
) -> Any:
|
||
|
|
return SimpleNamespace(
|
||
|
|
header=SimpleNamespace(
|
||
|
|
success=success,
|
||
|
|
error_message=error_message,
|
||
|
|
timestamp=SimpleNamespace(seconds=seconds, nanos=nanos),
|
||
|
|
),
|
||
|
|
color_frame=SimpleNamespace(
|
||
|
|
data=data,
|
||
|
|
width=width,
|
||
|
|
height=height,
|
||
|
|
codec=codec,
|
||
|
|
is_key_frame=key_frame,
|
||
|
|
),
|
||
|
|
intrinsics=SimpleNamespace(
|
||
|
|
fx=100.0,
|
||
|
|
fy=101.0,
|
||
|
|
cx=50.0,
|
||
|
|
cy=51.0,
|
||
|
|
coeffs=(0.1, 0.2, 0.3, 0.4, 0.5),
|
||
|
|
),
|
||
|
|
seq_no=remote_sequence,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _source(stub: Any, **params: Any) -> CmvrCameraRgbStreamSource:
|
||
|
|
configured = {
|
||
|
|
"endpoint": "cmvr_es",
|
||
|
|
"device_id": "camera-1",
|
||
|
|
"pixel_format": "BGR8",
|
||
|
|
"reconnect_initial_s": 0.001,
|
||
|
|
"reconnect_max_s": 0.002,
|
||
|
|
**params,
|
||
|
|
}
|
||
|
|
source = CmvrCameraRgbStreamSource("camera", configured)
|
||
|
|
# setup() is deliberately bypassed: these unit tests exercise stream state
|
||
|
|
# without importing generated protobuf bindings or constructing gRPC objects.
|
||
|
|
source._stub = stub # type: ignore[attr-defined]
|
||
|
|
source._start_request = object() # type: ignore[attr-defined]
|
||
|
|
source._stream_request = object() # type: ignore[attr-defined]
|
||
|
|
source._shutdown_event = asyncio.Event() # type: ignore[attr-defined]
|
||
|
|
source._rpc_timeout_s = 2.5 # type: ignore[attr-defined]
|
||
|
|
return source
|
||
|
|
|
||
|
|
|
||
|
|
def test_reconnect_uses_local_monotonic_sequence_and_new_session_id(
|
||
|
|
monkeypatch: pytest.MonkeyPatch,
|
||
|
|
) -> None:
|
||
|
|
session_ids = iter(("session-a", "session-b"))
|
||
|
|
monkeypatch.setattr(
|
||
|
|
camera_module,
|
||
|
|
"uuid4",
|
||
|
|
lambda: SimpleNamespace(hex=next(session_ids)),
|
||
|
|
)
|
||
|
|
first_call = _FakeCall(
|
||
|
|
_response(
|
||
|
|
900,
|
||
|
|
data=b"first",
|
||
|
|
key_frame=True,
|
||
|
|
seconds=12,
|
||
|
|
nanos=34,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
second_call = _FakeCall(_response(3, data=b"second"))
|
||
|
|
|
||
|
|
stub = _FakeStub(first_call, second_call)
|
||
|
|
|
||
|
|
async def exercise(): # type: ignore[no-untyped-def]
|
||
|
|
source = _source(stub)
|
||
|
|
stream = source.messages()
|
||
|
|
first = await asyncio.wait_for(anext(stream), timeout=1)
|
||
|
|
second = await asyncio.wait_for(anext(stream), timeout=1)
|
||
|
|
await stream.aclose()
|
||
|
|
return first, second
|
||
|
|
|
||
|
|
first, second = asyncio.run(exercise())
|
||
|
|
|
||
|
|
assert isinstance(first, Emission)
|
||
|
|
assert isinstance(second, Emission)
|
||
|
|
assert first.port == second.port == "frames"
|
||
|
|
assert [first.envelope.sequence, second.envelope.sequence] == [0, 1]
|
||
|
|
assert [first.envelope.session_id, second.envelope.session_id] == [
|
||
|
|
"session-a",
|
||
|
|
"session-b",
|
||
|
|
]
|
||
|
|
assert [
|
||
|
|
first.envelope.attributes["remote_sequence"],
|
||
|
|
second.envelope.attributes["remote_sequence"],
|
||
|
|
] == [900, 3]
|
||
|
|
assert first.envelope.attributes["stream_session_id"] == "session-a"
|
||
|
|
assert second.envelope.attributes["stream_session_id"] == "session-b"
|
||
|
|
assert first.envelope.source_id == second.envelope.source_id == "camera-1"
|
||
|
|
assert first.envelope.captured_at_ns == 12_000_000_034
|
||
|
|
assert second.envelope.captured_at_ns is None
|
||
|
|
assert isinstance(first.envelope.payload, ImageFrame)
|
||
|
|
assert first.envelope.payload.data == b"first"
|
||
|
|
assert first.envelope.payload.codec == "H264"
|
||
|
|
assert first.envelope.payload.is_key_frame is True
|
||
|
|
assert first.envelope.payload.pixel_format == "BGR8"
|
||
|
|
assert first.envelope.payload.intrinsics is not None
|
||
|
|
assert first.envelope.payload.intrinsics.coefficients == (
|
||
|
|
0.1,
|
||
|
|
0.2,
|
||
|
|
0.3,
|
||
|
|
0.4,
|
||
|
|
0.5,
|
||
|
|
)
|
||
|
|
assert first_call.cancel_count == 1
|
||
|
|
assert second_call.cancel_count == 1
|
||
|
|
assert stub.events == ["start", "stream", "start", "stream"]
|
||
|
|
assert stub.start_count == stub.open_count == 2
|
||
|
|
assert stub.start_timeouts == [2.5, 2.5]
|
||
|
|
assert all(request is not None for request in stub.start_requests)
|
||
|
|
|
||
|
|
|
||
|
|
def test_transport_failure_reconnects_to_a_new_call() -> None:
|
||
|
|
disconnected = _FakeCall(ConnectionError("temporary disconnect"))
|
||
|
|
recovered = _FakeCall(_response(77, data=b"recovered", key_frame=True))
|
||
|
|
stub = _FakeStub(disconnected, recovered)
|
||
|
|
|
||
|
|
async def exercise() -> Emission:
|
||
|
|
source = _source(stub)
|
||
|
|
stream = source.messages()
|
||
|
|
result = await asyncio.wait_for(anext(stream), timeout=1)
|
||
|
|
await stream.aclose()
|
||
|
|
assert isinstance(result, Emission)
|
||
|
|
return result
|
||
|
|
|
||
|
|
emission = asyncio.run(exercise())
|
||
|
|
|
||
|
|
assert stub.open_count == 2
|
||
|
|
assert stub.start_count == 2
|
||
|
|
assert stub.events == ["start", "stream", "start", "stream"]
|
||
|
|
assert disconnected.cancel_count == 1
|
||
|
|
assert recovered.cancel_count == 1
|
||
|
|
assert emission.envelope.sequence == 0
|
||
|
|
assert emission.envelope.attributes["remote_sequence"] == 77
|
||
|
|
assert emission.envelope.payload.data == b"recovered"
|
||
|
|
|
||
|
|
|
||
|
|
def test_start_camera_rejection_prevents_stream_open() -> None:
|
||
|
|
stub = _FakeStub(
|
||
|
|
_FakeCall(_response(1)),
|
||
|
|
start_feedbacks=(
|
||
|
|
_start_feedback(
|
||
|
|
success=False,
|
||
|
|
error_message="camera is unavailable",
|
||
|
|
),
|
||
|
|
),
|
||
|
|
)
|
||
|
|
|
||
|
|
async def exercise() -> None:
|
||
|
|
source = _source(stub, reconnect=False)
|
||
|
|
with pytest.raises(CmvrServiceError, match="camera is unavailable"):
|
||
|
|
await asyncio.wait_for(anext(source.messages()), timeout=1)
|
||
|
|
|
||
|
|
asyncio.run(exercise())
|
||
|
|
|
||
|
|
assert stub.events == ["start"]
|
||
|
|
assert stub.start_count == 1
|
||
|
|
assert stub.start_timeouts == [2.5]
|
||
|
|
assert stub.open_count == 0
|
||
|
|
|
||
|
|
|
||
|
|
def test_start_camera_transport_failure_is_retried_before_stream_open() -> None:
|
||
|
|
recovered = _FakeCall(_response(5, data=b"after-start-retry"))
|
||
|
|
stub = _FakeStub(
|
||
|
|
recovered,
|
||
|
|
start_feedbacks=(
|
||
|
|
ConnectionError("StartCamera unavailable"),
|
||
|
|
_start_feedback(),
|
||
|
|
),
|
||
|
|
)
|
||
|
|
|
||
|
|
async def exercise() -> Emission:
|
||
|
|
source = _source(stub)
|
||
|
|
emission = await asyncio.wait_for(anext(source.messages()), timeout=1)
|
||
|
|
await source.stop()
|
||
|
|
assert isinstance(emission, Emission)
|
||
|
|
return emission
|
||
|
|
|
||
|
|
emission = asyncio.run(exercise())
|
||
|
|
|
||
|
|
assert stub.events == ["start", "start", "stream"]
|
||
|
|
assert stub.start_count == 2
|
||
|
|
assert stub.start_timeouts == [2.5, 2.5]
|
||
|
|
assert stub.open_count == 1
|
||
|
|
assert emission.envelope.payload.data == b"after-start-retry"
|
||
|
|
|
||
|
|
|
||
|
|
def test_camera_stream_logs_the_complete_lifecycle(
|
||
|
|
caplog: pytest.LogCaptureFixture,
|
||
|
|
monkeypatch: pytest.MonkeyPatch,
|
||
|
|
) -> None:
|
||
|
|
now_ns = 0
|
||
|
|
|
||
|
|
def advancing_clock() -> int:
|
||
|
|
nonlocal now_ns
|
||
|
|
now_ns += 2_000_000_000
|
||
|
|
return now_ns
|
||
|
|
|
||
|
|
elapsed_s = 0.0
|
||
|
|
|
||
|
|
def advancing_timer() -> float:
|
||
|
|
nonlocal elapsed_s
|
||
|
|
elapsed_s += 0.05
|
||
|
|
return elapsed_s
|
||
|
|
|
||
|
|
monkeypatch.setattr(camera_module, "monotonic_ns", advancing_clock)
|
||
|
|
monkeypatch.setattr(camera_module, "perf_counter", advancing_timer)
|
||
|
|
first_session = _FakeCall(
|
||
|
|
_response(10, data=b"first", key_frame=True),
|
||
|
|
_response(11, data=b"second"),
|
||
|
|
ConnectionError("stream interrupted"),
|
||
|
|
)
|
||
|
|
recovered_session = _FakeCall(_response(12, data=b"recovered"))
|
||
|
|
stub = _FakeStub(first_session, recovered_session)
|
||
|
|
|
||
|
|
async def exercise() -> None:
|
||
|
|
source = _source(stub, stream_log_interval_s=1)
|
||
|
|
source._shutdown_event = _ImmediateRetryEvent() # type: ignore[attr-defined]
|
||
|
|
stream = source.messages()
|
||
|
|
await anext(stream)
|
||
|
|
await anext(stream)
|
||
|
|
await anext(stream)
|
||
|
|
await source.stop()
|
||
|
|
await stream.aclose()
|
||
|
|
|
||
|
|
with caplog.at_level(logging.INFO, logger=camera_module.__name__):
|
||
|
|
asyncio.run(exercise())
|
||
|
|
|
||
|
|
messages = [record.getMessage() for record in caplog.records]
|
||
|
|
expected_phrases = (
|
||
|
|
"camera start requested",
|
||
|
|
"camera start succeeded",
|
||
|
|
"camera stream opening",
|
||
|
|
"camera stream first frame",
|
||
|
|
"camera stream disconnected",
|
||
|
|
"camera source stopping",
|
||
|
|
"camera source stopped",
|
||
|
|
)
|
||
|
|
for phrase in expected_phrases:
|
||
|
|
assert any(phrase in message for message in messages), messages
|
||
|
|
|
||
|
|
assert sum("camera start requested" in message for message in messages) == 2
|
||
|
|
assert sum("camera start succeeded" in message for message in messages) == 2
|
||
|
|
assert sum("camera stream opening" in message for message in messages) == 2
|
||
|
|
first_frame_messages = [
|
||
|
|
message for message in messages if "camera stream first frame" in message
|
||
|
|
]
|
||
|
|
assert len(first_frame_messages) == 2
|
||
|
|
assert "remote_sequence=10" in first_frame_messages[0]
|
||
|
|
assert "remote_sequence=12" in first_frame_messages[1]
|
||
|
|
|
||
|
|
|
||
|
|
def test_camera_stream_progress_log_reports_window_statistics(
|
||
|
|
caplog: pytest.LogCaptureFixture,
|
||
|
|
monkeypatch: pytest.MonkeyPatch,
|
||
|
|
) -> None:
|
||
|
|
monkeypatch.setattr(camera_module, "monotonic_ns", lambda: 3_000_000_000)
|
||
|
|
stats = camera_module._StreamStats(
|
||
|
|
session_id="session-progress",
|
||
|
|
opened_ns=0,
|
||
|
|
window_started_ns=1_000_000_000,
|
||
|
|
frames=3,
|
||
|
|
received_bytes=600,
|
||
|
|
key_frames=1,
|
||
|
|
window_frames=2,
|
||
|
|
window_bytes=500,
|
||
|
|
window_key_frames=1,
|
||
|
|
last_frame_ns=2_500_000_000,
|
||
|
|
last_remote_sequence=42,
|
||
|
|
codec="H264",
|
||
|
|
width=640,
|
||
|
|
height=480,
|
||
|
|
)
|
||
|
|
source = _source(_FakeStub(), stream_log_interval_s=1)
|
||
|
|
|
||
|
|
async def exercise() -> None:
|
||
|
|
await source._log_stream_progress( # type: ignore[attr-defined]
|
||
|
|
stats,
|
||
|
|
_OneProgressTickEvent(), # type: ignore[arg-type]
|
||
|
|
)
|
||
|
|
|
||
|
|
with caplog.at_level(logging.INFO, logger=camera_module.__name__):
|
||
|
|
asyncio.run(exercise())
|
||
|
|
|
||
|
|
messages = [
|
||
|
|
record.getMessage()
|
||
|
|
for record in caplog.records
|
||
|
|
if "camera stream progress" in record.getMessage()
|
||
|
|
]
|
||
|
|
assert len(messages) == 1
|
||
|
|
message = messages[0]
|
||
|
|
assert "session=session-progress" in message
|
||
|
|
assert "total_frames=3" in message
|
||
|
|
assert "window_frames=2" in message
|
||
|
|
assert "window_bytes=500" in message
|
||
|
|
assert "last_remote_sequence=42" in message
|
||
|
|
assert "codec=H264" in message
|
||
|
|
assert "width=640" in message
|
||
|
|
assert "height=480" in message
|
||
|
|
|
||
|
|
|
||
|
|
def test_reconnect_false_propagates_the_disconnect() -> None:
|
||
|
|
failed_call = _FakeCall(ConnectionError("camera cable disconnected"))
|
||
|
|
stub = _FakeStub(failed_call)
|
||
|
|
|
||
|
|
async def exercise() -> None:
|
||
|
|
source = _source(stub, reconnect=False)
|
||
|
|
with pytest.raises(ConnectionError, match="camera cable disconnected"):
|
||
|
|
await asyncio.wait_for(anext(source.messages()), timeout=1)
|
||
|
|
|
||
|
|
asyncio.run(exercise())
|
||
|
|
|
||
|
|
assert stub.open_count == 1
|
||
|
|
assert stub.start_count == 1
|
||
|
|
assert stub.events == ["start", "stream"]
|
||
|
|
assert failed_call.cancel_count == 1
|
||
|
|
|
||
|
|
|
||
|
|
def test_max_reconnect_attempts_raises_after_the_allowed_retries() -> None:
|
||
|
|
first_failure = _FakeCall(ConnectionError("first disconnect"))
|
||
|
|
second_failure = _FakeCall(ConnectionError("second disconnect"))
|
||
|
|
stub = _FakeStub(first_failure, second_failure)
|
||
|
|
|
||
|
|
async def exercise() -> None:
|
||
|
|
source = _source(stub, max_reconnect_attempts=1)
|
||
|
|
with pytest.raises(
|
||
|
|
CmvrServiceError,
|
||
|
|
match="exceeded max_reconnect_attempts",
|
||
|
|
):
|
||
|
|
await asyncio.wait_for(anext(source.messages()), timeout=1)
|
||
|
|
|
||
|
|
asyncio.run(exercise())
|
||
|
|
|
||
|
|
assert stub.open_count == 2
|
||
|
|
assert stub.start_count == 2
|
||
|
|
assert stub.events == ["start", "stream", "start", "stream"]
|
||
|
|
assert first_failure.cancel_count == 1
|
||
|
|
assert second_failure.cancel_count == 1
|
||
|
|
|
||
|
|
|
||
|
|
def test_stop_cancels_the_active_stream_call() -> None:
|
||
|
|
async def exercise(): # type: ignore[no-untyped-def]
|
||
|
|
call = _BlockingCall()
|
||
|
|
stub = _FakeStub(call)
|
||
|
|
source = _source(stub)
|
||
|
|
stream = source.messages()
|
||
|
|
pending = asyncio.create_task(anext(stream))
|
||
|
|
await asyncio.wait_for(call.entered.wait(), timeout=1)
|
||
|
|
|
||
|
|
await source.stop()
|
||
|
|
with pytest.raises(asyncio.CancelledError):
|
||
|
|
await asyncio.wait_for(pending, timeout=1)
|
||
|
|
await stream.aclose()
|
||
|
|
return call, source, stub
|
||
|
|
|
||
|
|
call, source, stub = asyncio.run(exercise())
|
||
|
|
|
||
|
|
assert call.cancel_count == 1
|
||
|
|
assert stub.events == ["start", "stream"]
|
||
|
|
assert source._call is None # type: ignore[attr-defined]
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
("params", "message"),
|
||
|
|
[
|
||
|
|
({"typo": True}, "unknown camera source parameter"),
|
||
|
|
({"device_id": None}, "non-empty device_id"),
|
||
|
|
({"device_id": " "}, "non-empty device_id"),
|
||
|
|
({"device_id": 1}, "non-empty device_id"),
|
||
|
|
({"pixel_format": ""}, "pixel_format must be a non-empty string"),
|
||
|
|
({"pixel_format": 1}, "pixel_format must be a non-empty string"),
|
||
|
|
({"reconnect": "true"}, "reconnect must be a boolean"),
|
||
|
|
({"reconnect": 1}, "reconnect must be a boolean"),
|
||
|
|
({"reconnect_initial_s": True}, "must be a positive number"),
|
||
|
|
({"reconnect_initial_s": 0}, "must be a positive number"),
|
||
|
|
({"reconnect_initial_s": -1}, "must be a positive number"),
|
||
|
|
({"reconnect_initial_s": float("nan")}, "must be a positive number"),
|
||
|
|
({"reconnect_initial_s": float("inf")}, "must be a positive number"),
|
||
|
|
({"reconnect_max_s": 0}, "must be a positive number"),
|
||
|
|
({"reconnect_max_s": float("nan")}, "must be a positive number"),
|
||
|
|
({"stream_log_interval_s": True}, "must be a positive number"),
|
||
|
|
({"stream_log_interval_s": 0}, "must be a positive number"),
|
||
|
|
({"stream_log_interval_s": -1}, "must be a positive number"),
|
||
|
|
({"stream_log_interval_s": float("nan")}, "must be a positive number"),
|
||
|
|
({"stream_log_interval_s": float("inf")}, "must be a positive number"),
|
||
|
|
(
|
||
|
|
{"reconnect_initial_s": 2, "reconnect_max_s": 1},
|
||
|
|
"reconnect_max_s must be >= reconnect_initial_s",
|
||
|
|
),
|
||
|
|
({"max_reconnect_attempts": True}, "must be an integer or null"),
|
||
|
|
({"max_reconnect_attempts": 1.5}, "must be an integer or null"),
|
||
|
|
({"max_reconnect_attempts": "1"}, "must be an integer or null"),
|
||
|
|
({"max_reconnect_attempts": -1}, "must not be negative"),
|
||
|
|
],
|
||
|
|
)
|
||
|
|
def test_camera_reconnect_parameters_are_strictly_validated(
|
||
|
|
params: dict[str, Any],
|
||
|
|
message: str,
|
||
|
|
) -> None:
|
||
|
|
configured: dict[str, Any] = {"device_id": "camera-1"}
|
||
|
|
configured.update(params)
|
||
|
|
|
||
|
|
with pytest.raises(ValueError, match=message):
|
||
|
|
CmvrCameraRgbStreamSource("camera", configured)
|
||
|
|
|
||
|
|
|
||
|
|
def test_numeric_strings_are_accepted_for_environment_expanded_delays() -> None:
|
||
|
|
source = CmvrCameraRgbStreamSource(
|
||
|
|
"camera",
|
||
|
|
{
|
||
|
|
"device_id": "camera-1",
|
||
|
|
"reconnect_initial_s": "0.25",
|
||
|
|
"reconnect_max_s": "2.5",
|
||
|
|
"max_reconnect_attempts": 0,
|
||
|
|
"stream_log_interval_s": "12.5",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
|
|
assert source._reconnect_initial_s == 0.25 # type: ignore[attr-defined]
|
||
|
|
assert source._reconnect_max_s == 2.5 # type: ignore[attr-defined]
|
||
|
|
assert source._max_reconnect_attempts == 0 # type: ignore[attr-defined]
|
||
|
|
assert source._stream_log_interval_s == 12.5 # type: ignore[attr-defined]
|