| """Integration tests — exercise multi-step user flows end-to-end.""" |
|
|
| 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 |
| from signbridge.space import _capture_sign, _clear, _new_session, _speak |
|
|
|
|
| @pytest.fixture() |
| def client() -> TestClient: |
| return TestClient(app) |
|
|
|
|
| def _frame(rgb: tuple[int, int, int] = (180, 200, 160), size: int = 96) -> np.ndarray: |
| return np.full((size, size, 3), rgb, dtype=np.uint8) |
|
|
|
|
| def _frame_b64(rgb: tuple[int, int, int] = (180, 200, 160), size: int = 96) -> str: |
| arr = _frame(rgb, size) |
| img = Image.fromarray(arr) |
| buf = io.BytesIO() |
| img.save(buf, format="JPEG", quality=80) |
| return base64.b64encode(buf.getvalue()).decode("ascii") |
|
|
|
|
| class TestUserFlowFingerspell: |
| """User fingerspells L-U-C-A-S then presses Speak.""" |
|
|
| def test_via_space_helpers(self, monkeypatch: pytest.MonkeyPatch) -> None: |
| |
| from signbridge.recognizer import vlm |
|
|
| responses = iter(["L", "U", "C", "A", "S"]) |
|
|
| class _Resp: |
| def __init__(self, c: str) -> None: |
| self.choices = [type("C", (), {"message": type("M", (), {"content": c})()})()] |
|
|
| class _FakeClient: |
| class chat: |
| class completions: |
| @staticmethod |
| def create(**_: object) -> _Resp: |
| return _Resp(next(responses)) |
|
|
| monkeypatch.setattr(vlm, "_resolve_client", lambda: (_FakeClient(), "test")) |
|
|
| state = _new_session() |
| for _ in range(5): |
| _, _, state = _capture_sign(_frame(), state) |
| assert state.sign_history == ["L", "U", "C", "A", "S"] |
|
|
| sentence, audio_path, state = _speak(state) |
| |
| assert "Lucas" in sentence |
| assert audio_path |
|
|
| def test_via_backend_endpoints(self, client: TestClient) -> None: |
| |
| for _letter in "LUCAS": |
| r = client.post("/recognize", json={"frame": _frame_b64()}) |
| assert r.status_code == 200 |
| |
| assert r.json()["token"] == "" |
|
|
| |
| r = client.post("/compose", json={"signs": ["L", "U", "C", "A", "S"]}) |
| assert r.status_code == 200 |
| assert "Lucas" in r.json()["sentence"] |
|
|
| |
| r = client.post("/speak", json={"text": "My name is Lucas."}) |
| assert r.status_code == 200 |
| assert len(r.content) > 0 |
|
|
|
|
| class TestClearResetsCleanly: |
| def test_full_round_trip(self, monkeypatch: pytest.MonkeyPatch) -> None: |
| from signbridge.recognizer import vlm |
|
|
| class _FakeClient: |
| class chat: |
| class completions: |
| @staticmethod |
| def create(**_: object): |
| return type( |
| "R", |
| (), |
| { |
| "choices": [ |
| type( |
| "C", |
| (), |
| {"message": type("M", (), {"content": "hello"})()}, |
| )() |
| ] |
| }, |
| )() |
|
|
| monkeypatch.setattr(vlm, "_resolve_client", lambda: (_FakeClient(), "test")) |
|
|
| state = _new_session() |
| _, _, state = _capture_sign(_frame(), state) |
| _, _, state = _capture_sign(_frame(), state) |
| assert state.sign_history == ["hello", "hello"] |
|
|
| sentence, audio, state = _speak(state) |
| assert sentence |
| assert audio |
|
|
| latest, history, sentence_box, audio_out, state = _clear(state) |
| assert state.sign_history == [] |
| assert state.last_sentence == "" |
| assert state.last_audio_path is None |
| assert "no signs" in history.lower() |
|
|
|
|
| class TestEdgeCases: |
| def test_huge_sign_sequence(self, client: TestClient) -> None: |
| |
| signs = list("ABCDEFGHIJ" * 20) |
| r = client.post("/compose", json={"signs": signs}) |
| assert r.status_code == 200 |
| assert r.json()["sentence"] |
|
|
| def test_unicode_in_compose(self, client: TestClient) -> None: |
| |
| r = client.post("/compose", json={"signs": ["héllo", "wörld"]}) |
| assert r.status_code == 200 |
|
|
| def test_speak_very_long_text(self, client: TestClient) -> None: |
| r = client.post("/speak", json={"text": "a " * 500}) |
| assert r.status_code == 200 |
|
|
| def test_recognize_jpeg_with_data_url_jpg(self, client: TestClient) -> None: |
| b64 = _frame_b64() |
| r = client.post( |
| "/recognize", json={"frame": f"data:image/jpg;base64,{b64}"} |
| ) |
| |
| assert r.status_code == 200 |
|
|
| def test_recognize_png_frame(self, client: TestClient) -> None: |
| arr = _frame() |
| img = Image.fromarray(arr) |
| buf = io.BytesIO() |
| img.save(buf, format="PNG") |
| b64 = base64.b64encode(buf.getvalue()).decode("ascii") |
| r = client.post("/recognize", json={"frame": b64}) |
| assert r.status_code == 200 |
|
|
| def test_compose_with_only_punctuation_glosses(self, client: TestClient) -> None: |
| |
| r = client.post("/compose", json={"signs": ["a", "b"]}) |
| assert r.status_code == 200 |
| |
| |
| assert r.json()["sentence"] == "A b." |
|
|
| def test_health_after_recognize_failure(self, client: TestClient) -> None: |
| |
| client.post("/recognize", json={"frame": "%%%bad%%%"}) |
| r = client.get("/healthz") |
| assert r.status_code == 200 |
|
|
|
|
| class TestBackendInfoEndpoint: |
| def test_info_reflects_env(self, client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: |
| monkeypatch.setenv("SIGNBRIDGE_PROVIDER", "openai") |
| monkeypatch.setenv( |
| "SIGNBRIDGE_COMPOSER_MODEL", "meta-llama/Llama-3.1-8B-Instruct" |
| ) |
| r = client.get("/info") |
| assert r.status_code == 200 |
| body = r.json() |
| assert body["provider"] == "openai" |
| assert body["composer_model"].endswith("Llama-3.1-8B-Instruct") |
|
|