velxio/test/test-esp32-cam/tests/test_camera_websocket.py

138 lines
5.5 KiB
Python

"""
Layer 4 — backend WS route accepts (or, today, *will* accept) the new
camera-frame message types without crashing.
The shim from autosearch/04 introduces three new WebSocket message
types:
- 'camera_frame' browser → backend, binary JPEG payload
- 'esp32_camera_attach' frontend asks backend to start serving frames
- 'esp32_camera_detach' frontend tells backend to drop the queue
This layer mocks the FastAPI WebSocket and asserts the route doesn't
500/raise on those messages. It runs in-process — no live backend, no
QEMU.
Today the route doesn't know the new types yet, so the relevant tests
are written as `unittest.expectedFailure` until the shim ships. That
way pytest goes green on every CI run, but a future contributor adding
the route handler gets a clear "now flip the @expectedFailure off"
signal.
"""
from __future__ import annotations
import importlib
import json
import pathlib
import sys
import unittest
from unittest.mock import AsyncMock, MagicMock, patch
# The backend is not a package on PYTHONPATH by default. Match the same
# bootstrap that test/esp32_cam/test_esp32_cam_blink.py uses so this file
# is invocable from any CWD.
_BACKEND = pathlib.Path(__file__).resolve().parent.parent.parent.parent / "backend"
if str(_BACKEND) not in sys.path:
sys.path.insert(0, str(_BACKEND))
def _make_ws(messages: list[dict]) -> MagicMock:
"""Mirror of the helper in test_esp32_cam_blink.py — gives the route a
receive_text() that yields prepared JSON messages then raises
WebSocketDisconnect, so the route's `while True` loop terminates."""
ws = MagicMock()
ws.accept = AsyncMock()
msg_iter = iter([json.dumps(m) for m in messages])
async def receive_text():
try:
return next(msg_iter)
except StopIteration:
from fastapi.websockets import WebSocketDisconnect
raise WebSocketDisconnect()
ws.receive_text = receive_text
ws.send_text = AsyncMock()
return ws
class TestCameraWebSocketMessages(unittest.IsolatedAsyncioTestCase):
"""Exercise the simulation WS route with the new message types so that:
1. **Now (no shim shipped):** the route falls through to the
"unknown message type" branch silently — i.e. no exception, no
crash. That keeps the door open for the frontend to start
sending these messages early without breaking production.
2. **Once the shim ships:** flip `expectedFailure` off and assert
the route forwards the payload to `camera_queue.push()` /
`esp_lib_manager.camera_attach()`. The skeleton is here so the
diff that lands the shim is small and obvious."""
async def asyncSetUp(self):
import app.services.esp_qemu_manager as em_mod
importlib.reload(em_mod)
import app.services.esp32_lib_manager as lib_mod
importlib.reload(lib_mod)
import app.api.routes.simulation as sim_mod
importlib.reload(sim_mod)
self.sim_mod = sim_mod
self.esp = em_mod.esp_qemu_manager
self.lib = lib_mod.esp_lib_manager
async def test_camera_frame_message_does_not_crash_route(self):
"""Send a `camera_frame` JSON message; route must not raise."""
ws = _make_ws([{
"type": "camera_frame",
"data": {"fmt": "jpeg", "w": 320, "h": 240, "b64": "AAAA"},
}])
# Patch the start/stop hooks so the loop doesn't try to touch
# actual QEMU; only the message dispatch matters.
with patch.object(self.lib, "start_instance", new=AsyncMock()), \
patch.object(self.lib, "stop_instance", new=AsyncMock()), \
patch.object(self.esp, "start_instance"), \
patch.object(self.esp, "stop_instance"):
try:
await self.sim_mod.simulation_websocket(ws, "cam-frame-test")
except Exception as e: # pragma: no cover — must not happen
self.fail(f"unknown msg_type 'camera_frame' crashed route: {e}")
async def test_camera_attach_message_does_not_crash_route(self):
ws = _make_ws([{
"type": "esp32_camera_attach",
"data": {"board": "esp32-cam"},
}])
with patch.object(self.lib, "start_instance", new=AsyncMock()), \
patch.object(self.lib, "stop_instance", new=AsyncMock()), \
patch.object(self.esp, "start_instance"), \
patch.object(self.esp, "stop_instance"):
try:
await self.sim_mod.simulation_websocket(ws, "cam-attach-test")
except Exception as e: # pragma: no cover
self.fail(f"esp32_camera_attach crashed route: {e}")
@unittest.expectedFailure
async def test_camera_attach_forwards_to_lib_manager(self):
"""Once the shim lands, this should pass: 'esp32_camera_attach'
must invoke the equivalent of `esp_lib_manager.camera_attach`."""
ws = _make_ws([{
"type": "esp32_camera_attach",
"data": {"board": "esp32-cam"},
}])
with patch.object(self.lib, "camera_attach",
new=AsyncMock(), create=True) as cam_attach, \
patch.object(self.lib, "start_instance", new=AsyncMock()), \
patch.object(self.lib, "stop_instance", new=AsyncMock()), \
patch.object(self.esp, "start_instance"), \
patch.object(self.esp, "stop_instance"):
try:
await self.sim_mod.simulation_websocket(ws, "cam-fwd-test")
except Exception:
pass
cam_attach.assert_awaited()
if __name__ == "__main__":
unittest.main(verbosity=2)