cmvr_edge_ai/tests/unit/test_camera_connector.py

577 lines
19 KiB
Python
Raw Normal View History

2026-07-20 16:59:37 +08:00
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]