File size: 5,319 Bytes
18d028b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ca5affd
18d028b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ca5affd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
"""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)