"""Tests for the FastAPI backend.""" from __future__ import annotations import base64 import io import numpy as np import pytest from fastapi.testclient import TestClient from PIL import Image from signbridge.backend import app @pytest.fixture() def client() -> TestClient: return TestClient(app) def _frame_b64(rgb: tuple[int, int, int] = (128, 128, 128), size: int = 64) -> str: arr = np.full((size, size, 3), rgb, dtype=np.uint8) img = Image.fromarray(arr) buf = io.BytesIO() img.save(buf, format="JPEG", quality=80) return base64.b64encode(buf.getvalue()).decode("ascii") class TestHealth: def test_healthz_returns_ok(self, client: TestClient) -> None: r = client.get("/healthz") assert r.status_code == 200 assert r.json() == {"status": "ok"} def test_info_returns_provider_block(self, client: TestClient) -> None: r = client.get("/info") assert r.status_code == 200 body = r.json() for key in ("provider", "composer_model", "vlm_model", "tts_model", "recognizer_mode"): assert key in body class TestRecognize: def test_empty_frame_rejected(self, client: TestClient) -> None: r = client.post("/recognize", json={"frame": ""}) assert r.status_code in (400, 422) def test_invalid_base64_rejected(self, client: TestClient) -> None: r = client.post("/recognize", json={"frame": "%%%not-base64%%%"}) assert r.status_code == 400 def test_valid_frame_no_provider_returns_empty_token(self, client: TestClient) -> None: # Without API keys, the VLM path returns ("", 0.0). The endpoint should # still respond 200 OK. r = client.post("/recognize", json={"frame": _frame_b64()}) assert r.status_code == 200 body = r.json() assert body == {"token": "", "confidence": 0.0} def test_data_url_prefix_tolerated(self, client: TestClient) -> None: b64 = _frame_b64() r = client.post( "/recognize", json={"frame": f"data:image/jpeg;base64,{b64}"} ) assert r.status_code == 200 class TestCompose: def test_empty_signs(self, client: TestClient) -> None: r = client.post("/compose", json={"signs": []}) assert r.status_code == 200 assert r.json() == {"sentence": ""} def test_fingerspelled_word(self, client: TestClient) -> None: r = client.post( "/compose", json={"signs": ["L", "U", "C", "A", "S"]} ) assert r.status_code == 200 # Naive joiner produces "Lucas." assert "Lucas" in r.json()["sentence"] def test_glosses(self, client: TestClient) -> None: r = client.post( "/compose", json={"signs": ["hello", "thank_you"]} ) assert r.status_code == 200 out = r.json()["sentence"].lower() assert "hello" in out assert "thank you" in out class TestSpeak: def test_empty_text_rejected(self, client: TestClient) -> None: r = client.post("/speak", json={"text": ""}) assert r.status_code == 400 def test_returns_audio(self, client: TestClient) -> None: r = client.post("/speak", json={"text": "hello"}) assert r.status_code == 200 assert r.headers["content-type"].startswith("audio/") assert len(r.content) > 0 class TestRecognizeFrames: def test_empty_frames_rejected(self, client: TestClient) -> None: r = client.post("/recognize", json={"frames": []}) # Pydantic min_length=1 rejects empty list (validation error 422). # If the validator collapses to 400 due to model_validator, accept that too. assert r.status_code in (400, 422) def test_single_frame_in_list_rejected(self, client: TestClient) -> None: # Multi-frame path requires >=2 frames. b64 = _frame_b64() r = client.post("/recognize", json={"frames": [b64]}) assert r.status_code == 400 detail = r.json().get("detail", "").lower() assert "at least 2" in detail or "2 frames" in detail or "frames" in detail def test_valid_multi_frame_no_provider(self, client: TestClient) -> None: b64 = _frame_b64() r = client.post("/recognize", json={"frames": [b64, b64, b64, b64]}) assert r.status_code == 200 assert r.json() == {"token": "", "confidence": 0.0} def test_too_many_frames_rejected(self, client: TestClient) -> None: b64 = _frame_b64() r = client.post("/recognize", json={"frames": [b64] * 100}) assert r.status_code in (400, 422) def test_oversized_frame_rejected(self, client: TestClient) -> None: # 6 MB base64 string is well past any reasonable webcam frame. big = "A" * (6 * 1024 * 1024) r = client.post("/recognize", json={"frame": big}) assert r.status_code in (400, 422) def test_both_frame_and_frames_rejected(self, client: TestClient) -> None: b64 = _frame_b64() r = client.post( "/recognize", json={"frame": b64, "frames": [b64, b64]}, ) assert r.status_code in (400, 422) def test_neither_frame_nor_frames_rejected(self, client: TestClient) -> None: r = client.post("/recognize", json={}) assert r.status_code in (400, 422)