cropintel / tests /test_predict_contract.py
Jaithra Polavarapu
CropIntel — HF Space deploy (all-in-one app)
889dd1b
Raw
History Blame Contribute Delete
3.53 kB
"""Inference service contract tests.
Lock the JSON response shape that app/api/predict/route.ts consumes, including
the error-message strings it string-matches. Tests that need real model weights
are marked needs_model and skip automatically (e.g. in CI, where ml/models/ is
not committed).
"""
import io
import pytest
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient # noqa: E402
from tests.conftest import crops_with_models # noqa: E402
RESPONSE_KEYS = {
"success", "crop", "disease", "confidence", "is_healthy", "meets_threshold",
"not_in_catalog", "catalog_message", "known_diseases", "farmer_verification",
"image_quality", "all_predictions",
}
VERIFICATION_KEYS = {
"status", "confidence_margin", "image_quality_ok", "entropy",
"not_in_catalog", "recommendation",
}
@pytest.fixture(scope="module")
def client():
from ml.serve.inference_app import app
with TestClient(app) as c: # context manager triggers startup (model loading)
yield c
def _png_bytes(image) -> bytes:
buf = io.BytesIO()
image.save(buf, format="PNG")
return buf.getvalue()
def test_healthz(client):
r = client.get("/healthz")
assert r.status_code == 200
assert r.json() == {"status": "ok"}
def test_unknown_crop_rejected(client, green_leaf_image):
r = client.post("/predict", data={"crop": "banana"},
files={"image": ("leaf.png", _png_bytes(green_leaf_image), "image/png")})
assert r.status_code == 400
assert "Unknown crop" in r.json()["error"]
@pytest.mark.needs_model
@pytest.mark.parametrize("crop", crops_with_models() or ["__none__"])
def test_predict_contract(client, green_leaf_image, crop):
if crop == "__none__":
pytest.skip("no trained models available")
r = client.post("/predict", data={"crop": crop},
files={"image": ("leaf.png", _png_bytes(green_leaf_image), "image/png")})
assert r.status_code == 200
body = r.json()
assert set(body.keys()) == RESPONSE_KEYS
assert body["success"] is True
assert body["crop"] == crop
# confidence is a 0-100 percentage, not a 0-1 probability
assert 0.0 <= body["confidence"] <= 100.0
assert set(body["farmer_verification"].keys()) == VERIFICATION_KEYS
confs = [p["confidence"] for p in body["all_predictions"]]
assert confs == sorted(confs, reverse=True)
assert body["disease"] in body["known_diseases"]
@pytest.mark.needs_model
def test_tiny_image_maps_to_retake_message(client, tiny_image):
crops = crops_with_models()
if not crops:
pytest.skip("no trained models available")
r = client.post("/predict", data={"crop": crops[0]},
files={"image": ("leaf.png", _png_bytes(tiny_image), "image/png")})
assert r.status_code == 400
# exact string matched by route.ts
assert r.json()["error"] == "Please retake the image with the full leaf clearly visible."
def test_missing_model_returns_503(client, green_leaf_image, monkeypatch):
from ml.serve import inference_app
import threading
monkeypatch.setitem(
inference_app._registry, "corn",
{"predictor": None, "error": "boom", "lock": threading.Lock()},
)
r = client.post("/predict", data={"crop": "corn"},
files={"image": ("leaf.png", _png_bytes(green_leaf_image), "image/png")})
assert r.status_code == 503
# phrase matched by route.ts's model-not-ready branch
assert "no trained models found" in r.json()["error"].lower()