Spaces:
Sleeping
Sleeping
| """Shared test fixtures. | |
| SKIP_MODEL_LOAD must be set BEFORE importing the app: the FastAPI lifespan | |
| reads it to decide whether to download/load the ~500MB transformer. Unit | |
| tests never touch the real model β that's what keeps CI fast and free of | |
| torch as a dependency. | |
| """ | |
| import os | |
| os.environ["SKIP_MODEL_LOAD"] = "1" | |
| import pytest | |
| from fastapi.testclient import TestClient | |
| from app.main import app | |
| def client(): | |
| # TestClient used as a context manager so the lifespan (startup/shutdown) runs. | |
| with TestClient(app) as c: | |
| yield c | |
| class FakeModel: | |
| """Stands in for SentimentModel in unit tests. Same interface, canned | |
| output β API tests check wiring/validation, not ML quality (the | |
| integration suite covers that).""" | |
| is_loaded = True | |
| device = "cpu" | |
| labels = ["negative", "neutral", "positive"] | |
| def predict(self, texts): | |
| return [ | |
| {"label": "positive", "scores": {"negative": 0.05, "neutral": 0.15, "positive": 0.8}} | |
| for _ in texts | |
| ] | |
| def explain(self, text): | |
| return { | |
| "label": "positive", | |
| "scores": {"negative": 0.05, "neutral": 0.15, "positive": 0.8}, | |
| "tokens": [ | |
| {"token": "I", "attribution": 0.01}, | |
| {"token": " love", "attribution": 0.92}, | |
| {"token": " this", "attribution": 0.05}, | |
| ], | |
| } | |
| def client_with_model(): | |
| from app.routes import get_model | |
| app.dependency_overrides[get_model] = lambda: FakeModel() | |
| with TestClient(app) as c: | |
| yield c | |
| app.dependency_overrides.clear() | |
| class FakeBinaryModel: | |
| """Binary sentiment stand-in (DistilBERT-SST2 shape): negative/positive | |
| only, no neutral. Lets compare tests prove that a 2-class model emits just | |
| its own label keys β never a faked neutral score.""" | |
| is_loaded = True | |
| device = "cpu" | |
| labels = ["negative", "positive"] | |
| def predict(self, texts): | |
| return [ | |
| {"label": "negative", "scores": {"negative": 0.7, "positive": 0.3}} | |
| for _ in texts | |
| ] | |
| def client_with_compare_models(client): | |
| """Seed the lazy cache with fakes so /api/compare runs without real weights. | |
| get_or_load_model checks app.state.model_cache first, so a pre-seeded entry | |
| short-circuits the ~500MB load β no torch, still exercises the cache path.""" | |
| client.app.state.model_cache["twitter-roberta"] = FakeModel() | |
| client.app.state.model_cache["distilbert-sst2"] = FakeBinaryModel() | |
| return client | |
| class FakeDetector: | |
| """AI-detector stand-in: canonical {human, ai} scores, canned label. Lets | |
| the detector API tests prove wiring/validation/disagreement without torch β | |
| real label mapping and probabilities are covered by the integration suite.""" | |
| is_loaded = True | |
| device = "cpu" | |
| labels = ["human", "ai"] | |
| def __init__(self, label: str = "ai", scores: dict | None = None) -> None: | |
| self._label = label | |
| self._scores = scores or {"human": 0.1, "ai": 0.9} | |
| def predict(self, texts): | |
| return [{"label": self._label, "scores": self._scores} for _ in texts] | |
| def client_with_detectors(client): | |
| """Seed the cache with three fake detectors. desklib + fakespot call it AI, | |
| oxidane calls it human β so the default (all-detector) compare disagrees.""" | |
| cache = client.app.state.model_cache | |
| cache["desklib-ai-detector"] = FakeDetector("ai", {"human": 0.05, "ai": 0.95}) | |
| cache["fakespot-ai-detector"] = FakeDetector("ai", {"human": 0.2, "ai": 0.8}) | |
| cache["oxidane-ai-detector"] = FakeDetector("human", {"human": 0.7, "ai": 0.3}) | |
| return client | |