Files
bambuddy/backend/tests/unit/services/test_camera_diagnose.py
maziggy 134847a3bd feat(camera): in-app diagnostic for "Connection lost" (#1395 follow-up)
Step 2 of the camera architecture overhaul agreed after #1395. When
  the camera viewer hits its error state OR before a print at any
  time, a Diagnose button runs a staged check against the printer and
  renders the result inline: which stage failed, how long it took,
  and a translated remediation hint. Cuts off the "user opens a
  'camera broken' ticket → ask for support bundle → triage" loop at
  the user's screen.

  Backend

  - New `backend/app/services/camera_diagnose.py` orchestrator with
    CameraDiagnoseResult / CameraDiagnoseStage dataclasses.
  - New POST /printers/{id}/camera/diagnose route in camera.py.
  - Stages:
      tcp_reachable — TCP socket open to 322 (RTSP) / 6000 (chamber)
        with 3 s timeout. Distinguishes timeout, refused, and host-
        unreachable into distinct summary codes so the frontend can
        show a precise remediation (firewall vs LAN-only off vs
        wrong IP).
      first_frame — captures one JPEG end-to-end via the existing
        capture_camera_frame_bytes pipeline. Auth + RTSP handshake +
        first keyframe collapse into one stage; the user-facing
        answer is the same regardless of which sub-layer failed.
  - Live-stream shortcut: when a viewer is currently watching the
    camera with a buffered frame < 10 s old, the diagnostic skips
    the real test and returns live_stream_active_healthy. Opening a
    fresh socket would kick the live viewer off on single-camera-
    connection firmwares (the #1348 reconnect-storm trigger), so we
    trust the real-world evidence instead.
  - Response surfaces protocol, port, and profile name for support
    triage — lets us ask "what does your modal say?" instead of
    "send the support bundle".

  Frontend

  - New CameraDiagnoseModal renders one row per stage with green-
    check / red-X / grey-skipped icons, the per-stage duration in
    ms, a remediation banner styled by overall status, and a Run
    again button.
  - Two entry points:
      1. The viewer's error overlay grows a Diagnose button next to
         Retry. Retry stays the primary action; Diagnose is the
         escape hatch for users who can't see what's wrong.
      2. A stethoscope icon in the viewer's always-visible control
         bar, between Refresh and Fullscreen. Pre-flight testing
         ("did my firmware update break the camera?", "is the
         camera up before I send a print?") doesn't require waiting
         for the stream to fail first.
  - Also lifted the previously-hard-coded "Camera unavailable" /
    "Retry" strings into camera.unavailable / camera.retry so the
    error UI is fully translated alongside the new keys.
2026-05-18 10:52:02 +02:00

282 lines
10 KiB
Python

"""Unit tests for the staged camera diagnostic.
Covers the per-stage pass/fail contract that drives the frontend
remediation hints. The live-stream shortcut and the failure-to-summary
mapping are the load-bearing pieces — both are pinned with explicit
tests so future profile/protocol changes don't silently turn
"camera_port_closed" into "printer_unreachable".
"""
from unittest.mock import AsyncMock, patch
import pytest
from backend.app.services.camera_diagnose import (
_LIVE_FRAME_FRESHNESS_SECONDS,
diagnose_camera,
)
class TestLiveStreamShortcut:
"""If a viewer is currently watching the camera with a fresh frame,
diagnose must NOT open a fresh socket — single-camera-connection
firmwares would kick the live viewer off. Trust the live evidence.
"""
@pytest.mark.asyncio
async def test_skips_test_when_fresh_frame_in_active_stream(self):
result = await diagnose_camera(
ip_address="192.0.2.1",
access_code="x",
model="X1C",
printer_id=1,
has_live_stream=True,
live_frame_age_seconds=2.0,
)
assert result.overall_status == "ok"
assert result.summary_code == "live_stream_active_healthy"
assert len(result.stages) == 1
assert result.stages[0].name == "live_stream_active"
assert result.stages[0].status == "ok"
@pytest.mark.asyncio
async def test_runs_test_when_stale_frame_in_active_stream(self):
"""An active stream with a stale buffered frame (e.g. mid-
reconnect) shouldn't short-circuit — the stream might be
wedged and the user needs the real test."""
with patch(
"backend.app.services.camera_diagnose.asyncio.open_connection",
new_callable=AsyncMock,
side_effect=TimeoutError,
):
result = await diagnose_camera(
ip_address="192.0.2.1",
access_code="x",
model="X1C",
printer_id=1,
has_live_stream=True,
live_frame_age_seconds=_LIVE_FRAME_FRESHNESS_SECONDS + 5,
)
# No short-circuit — we ran the real check and it failed.
assert result.summary_code != "live_stream_active_healthy"
assert any(s.name == "tcp_reachable" for s in result.stages)
class TestTcpStage:
"""The first stage answers "can we even talk to the printer at all".
The three failure modes (timeout / refused / unreachable) map to
distinct user-facing remediation hints, so the codes must round-
trip correctly through ``_summary_for_stages``."""
@pytest.mark.asyncio
async def test_timeout_maps_to_printer_unreachable(self):
with patch(
"backend.app.services.camera_diagnose.asyncio.open_connection",
new_callable=AsyncMock,
side_effect=TimeoutError,
):
result = await diagnose_camera(
ip_address="192.0.2.99",
access_code="x",
model="P2S",
printer_id=1,
)
assert result.overall_status == "failed"
assert result.summary_code == "printer_unreachable"
first = result.stages[0]
assert first.name == "tcp_reachable"
assert first.code == "tcp_timeout"
# Second stage was skipped — no point spawning ffmpeg with no socket.
assert result.stages[1].name == "first_frame"
assert result.stages[1].status == "skipped"
@pytest.mark.asyncio
async def test_connection_refused_maps_to_camera_port_closed(self):
"""ConnectionRefusedError = printer up, port closed. Common
cause: LAN-only mode off, or developer mode off. The user
sees a specific remediation hint, not the generic
'unreachable' message."""
with patch(
"backend.app.services.camera_diagnose.asyncio.open_connection",
new_callable=AsyncMock,
side_effect=ConnectionRefusedError(),
):
result = await diagnose_camera(
ip_address="192.0.2.1",
access_code="x",
model="P2S",
printer_id=1,
)
assert result.summary_code == "camera_port_closed"
assert result.stages[0].code == "tcp_refused"
@pytest.mark.asyncio
async def test_oserror_maps_to_printer_unreachable(self):
"""Generic OSError (no-route-to-host etc.) lumps under
'printer_unreachable' — same remediation as timeout."""
with patch(
"backend.app.services.camera_diagnose.asyncio.open_connection",
new_callable=AsyncMock,
side_effect=OSError("No route to host"),
):
result = await diagnose_camera(
ip_address="192.0.2.1",
access_code="x",
model="P2S",
printer_id=1,
)
assert result.summary_code == "printer_unreachable"
assert result.stages[0].code == "tcp_unreachable"
class TestFirstFrameStage:
"""The second stage answers "is the camera actually producing
frames". If TCP passes but no frame comes back, the answer is the
same regardless of which sub-layer failed (auth, RTSP handshake,
keyframe probe): the user can't see the camera."""
@pytest.mark.asyncio
async def test_no_frame_maps_to_no_frame_summary(self):
async def _tcp_ok(*_a, **_kw):
writer = AsyncMock()
return AsyncMock(), writer
with (
patch(
"backend.app.services.camera_diagnose.asyncio.open_connection",
new=_tcp_ok,
),
patch(
"backend.app.services.camera_diagnose.capture_camera_frame_bytes",
new_callable=AsyncMock,
return_value=None,
),
):
result = await diagnose_camera(
ip_address="192.0.2.1",
access_code="x",
model="P2S",
printer_id=1,
)
assert result.overall_status == "failed"
assert result.summary_code == "no_frame"
assert result.stages[0].status == "ok"
assert result.stages[1].name == "first_frame"
assert result.stages[1].code == "no_frame"
@pytest.mark.asyncio
async def test_capture_exception_maps_to_no_frame_summary(self):
"""ffmpeg crash / TLS proxy startup failure / etc. — all the
sub-layer exceptions surface as 'no_frame' for the user, with
a distinct ``capture_exception`` code in the stage so the
support log retains the distinction."""
async def _tcp_ok(*_a, **_kw):
writer = AsyncMock()
return AsyncMock(), writer
with (
patch(
"backend.app.services.camera_diagnose.asyncio.open_connection",
new=_tcp_ok,
),
patch(
"backend.app.services.camera_diagnose.capture_camera_frame_bytes",
new_callable=AsyncMock,
side_effect=RuntimeError("ffmpeg died"),
),
):
result = await diagnose_camera(
ip_address="192.0.2.1",
access_code="x",
model="P2S",
printer_id=1,
)
assert result.summary_code == "no_frame"
assert result.stages[1].code == "capture_exception"
@pytest.mark.asyncio
async def test_full_success_path(self):
async def _tcp_ok(*_a, **_kw):
writer = AsyncMock()
return AsyncMock(), writer
with (
patch(
"backend.app.services.camera_diagnose.asyncio.open_connection",
new=_tcp_ok,
),
patch(
"backend.app.services.camera_diagnose.capture_camera_frame_bytes",
new_callable=AsyncMock,
return_value=b"\xff\xd8\xff\xd9", # tiny valid-looking JPEG
),
):
result = await diagnose_camera(
ip_address="192.0.2.1",
access_code="x",
model="P2S",
printer_id=1,
)
assert result.overall_status == "ok"
assert result.summary_code == "all_ok"
assert all(s.status == "ok" for s in result.stages)
class TestResultMetadata:
"""Surface fields the support triage relies on — protocol, port,
profile name. The frontend renders these so we can ask the user
'is your profile 'P2S' or 'default'?' over a screenshot rather
than asking for the support bundle."""
@pytest.mark.asyncio
async def test_p2s_reports_p2s_profile_and_rtsp_protocol(self):
with patch(
"backend.app.services.camera_diagnose.asyncio.open_connection",
new_callable=AsyncMock,
side_effect=TimeoutError,
):
result = await diagnose_camera(
ip_address="192.0.2.1",
access_code="x",
model="P2S",
printer_id=1,
)
assert result.protocol == "rtsp"
assert result.profile == "P2S"
assert result.port == 322
@pytest.mark.asyncio
async def test_a1_reports_default_profile_and_chamber_protocol(self):
with patch(
"backend.app.services.camera_diagnose.asyncio.open_connection",
new_callable=AsyncMock,
side_effect=TimeoutError,
):
result = await diagnose_camera(
ip_address="192.0.2.1",
access_code="x",
model="A1",
printer_id=1,
)
assert result.protocol == "chamber_image"
assert result.profile == "default"
assert result.port == 6000
@pytest.mark.asyncio
async def test_x1c_reports_default_profile_and_rtsp(self):
with patch(
"backend.app.services.camera_diagnose.asyncio.open_connection",
new_callable=AsyncMock,
side_effect=TimeoutError,
):
result = await diagnose_camera(
ip_address="192.0.2.1",
access_code="x",
model="X1C",
printer_id=1,
)
assert result.protocol == "rtsp"
assert result.profile == "default"
assert result.port == 322