Spaces:
Sleeping
Sleeping
File size: 10,844 Bytes
50d6286 | 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 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 | """
PneumoOps β Automated Test Suite
==================================
Tests the FastAPI backend endpoints without requiring real model files.
Uses synthetic images so the test suite runs on any machine (CI/CD included).
Run:
python -m pytest tests/ -v
Requirements:
pip install pytest httpx pillow numpy
"""
import io
import json
import os
import sys
from pathlib import Path
import numpy as np
import pytest
from PIL import Image
# βββ Make sure the project root is in the path ββββββββββββββββββββββββββββββββ
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
# βββ Skip model-loading tests if model files are missing ββββββββββββββββββββββ
MODEL_DIR = Path(__file__).resolve().parents[1] / "models" / "chestmnist_mobilenetv3"
MODELS_AVAILABLE = (
(MODEL_DIR / "mobilenetv3_chestmnist.pth").exists()
and (MODEL_DIR / "mobilenetv3_chestmnist.onnx").exists()
)
# βββ Synthetic image helpers ββββββββββββββββββββββββββββββββββββββββββββββββββ
def make_synthetic_xray(size: int = 224) -> bytes:
"""Create a fake grayscale chest X-ray as PNG bytes."""
arr = np.random.normal(loc=0.35, scale=0.12, size=(size, size))
arr = np.clip(arr * 255, 0, 255).astype(np.uint8)
img = Image.fromarray(arr, mode="L").convert("RGB")
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
def make_color_image(size: int = 224) -> bytes:
"""Create a colorful non-X-ray image to trigger drift detection."""
arr = np.zeros((size, size, 3), dtype=np.uint8)
arr[:, :, 0] = 200 # strong red channel
arr[:, :, 1] = 100 # green
arr[:, :, 2] = 50 # blue
img = Image.fromarray(arr, mode="RGB")
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
# βββ Live API tests (require running Docker container) ββββββββββββββββββββββββ
@pytest.mark.live
class TestLiveAPI:
"""
These tests hit the running Docker container.
Start it first: docker compose up -d app
Then run: python -m pytest tests/ -v -m live
"""
BASE_URL = os.getenv("PNEUMOOPS_TEST_URL", "http://127.0.0.1:7860")
def test_health_check(self):
"""Backend /health must return status=ok with both models loaded."""
import requests
resp = requests.get(f"{self.BASE_URL}/health", timeout=10)
assert resp.status_code == 200, f"Health check failed: {resp.text}"
data = resp.json()
assert data["status"] == "ok", "Status field is not 'ok'"
assert data["class_count"] == 14, "Expected 14 ChestMNIST classes"
print(f"\n β
Health OK β PyTorch:{data['pytorch_model_loaded']} ONNX:{data['onnx_model_loaded']}")
def test_metrics_endpoint(self):
"""Prometheus /metrics endpoint must return text with pneumoops counters."""
import requests
resp = requests.get(f"{self.BASE_URL}/metrics", timeout=10)
assert resp.status_code == 200
assert "pneumoops_requests_total" in resp.text, "Counter metric missing"
assert "pneumoops_inference_latency_ms" in resp.text, "Latency histogram missing"
print("\n β
Prometheus metrics endpoint OK")
def test_predict_with_synthetic_xray(self):
"""POST /predict with a synthetic X-ray must return valid prediction JSON."""
import requests
image_bytes = make_synthetic_xray()
resp = requests.post(
f"{self.BASE_URL}/predict",
files={"file": ("test_xray.png", image_bytes, "image/png")},
timeout=30,
)
assert resp.status_code == 200, f"Predict failed: {resp.text}"
data = resp.json()
# Required fields
assert "predicted_labels" in data, "Missing predicted_labels"
assert "top_predictions" in data, "Missing top_predictions"
assert "drift" in data, "Missing drift field"
assert "selected_arm" in data, "Missing selected_arm (A/B)"
assert data["selected_arm"] in ("A", "B"), f"Invalid arm: {data['selected_arm']}"
# Top predictions structure
for pred in data["top_predictions"]:
assert "label" in pred
assert "confidence" in pred
assert 0.0 <= pred["confidence"] <= 100.0
# Latency fields
assert "request_latency_ms" in data
assert data["request_latency_ms"] > 0
print(f"\n β
Predict OK β arm={data['selected_arm']} labels={data['predicted_labels']}")
def test_drift_detected_on_color_image(self):
"""A strongly colored non-X-ray image should trigger drift detection."""
import requests
image_bytes = make_color_image()
# Color images fail the channel_delta validation check first (400),
# which is also correct behavior β the system rejects them before inference.
resp = requests.post(
f"{self.BASE_URL}/predict",
files={"file": ("color.png", image_bytes, "image/png")},
timeout=30,
)
# Either rejected with 400 (color image guard) OR passes with DRIFT_DETECTED
if resp.status_code == 400:
print("\n β
Color image correctly rejected (channel_delta guard)")
else:
assert resp.status_code == 200
data = resp.json()
assert data["drift"]["drift_alert"] == "DRIFT_DETECTED", (
f"Expected DRIFT_DETECTED for color image, got: {data['drift']}"
)
print("\n β
Drift correctly detected on color image")
def test_history_endpoint(self):
"""GET /history must return a list of recent requests."""
import requests
resp = requests.get(f"{self.BASE_URL}/history", timeout=10)
assert resp.status_code == 200
data = resp.json()
assert "recent_requests" in data
assert isinstance(data["recent_requests"], list)
print(f"\n β
History OK β {len(data['recent_requests'])} recent entries")
def test_class_rates_endpoint(self):
"""GET /metrics/class-rates must return per-class rates for all 14 classes."""
import requests
resp = requests.get(f"{self.BASE_URL}/metrics/class-rates", timeout=10)
assert resp.status_code == 200
data = resp.json()
rates = data.get("per_class_prediction_rate", {})
expected_classes = {
"Atelectasis", "Cardiomegaly", "Effusion", "Infiltration",
"Mass", "Nodule", "Pneumonia", "Pneumothorax",
"Consolidation", "Edema", "Emphysema", "Fibrosis",
"Pleural_Thickening", "Hernia",
}
assert expected_classes.issubset(set(rates.keys())), (
f"Missing classes: {expected_classes - set(rates.keys())}"
)
print(f"\n β
Class-rates OK β {len(rates)} classes tracked")
# βββ Offline unit tests (no server needed) ββββββββββββββββββββββββββββββββββββ
class TestImageHelpers:
"""Tests for standalone utility functions that need no server."""
def test_synthetic_xray_is_valid_png(self):
"""Synthetic X-ray generator must produce a decodable image."""
raw = make_synthetic_xray()
img = Image.open(io.BytesIO(raw))
assert img.format == "PNG"
assert img.mode == "RGB"
assert img.size == (224, 224)
def test_synthetic_xray_is_grayscale_like(self):
"""Synthetic X-ray channels should be very similar (low channel_delta)."""
raw = make_synthetic_xray()
img = Image.open(io.BytesIO(raw))
arr = np.array(img, dtype=np.float32) / 255.0
r, g, b = arr[:, :, 0], arr[:, :, 1], arr[:, :, 2]
# Because we generated from grayscale, R==G==B
np.testing.assert_array_equal(r, g)
np.testing.assert_array_equal(g, b)
def test_color_image_has_high_channel_delta(self):
"""Color image generator must produce an image with high RGB channel variance."""
raw = make_color_image()
img = Image.open(io.BytesIO(raw))
arr = np.array(img, dtype=np.float32) / 255.0
r, g, b = arr[:, :, 0], arr[:, :, 1], arr[:, :, 2]
delta = float(
np.mean(np.abs(r - g)) + np.mean(np.abs(g - b)) + np.mean(np.abs(r - b))
) / 3.0
assert delta > 0.08, f"Expected high channel delta for color image, got {delta:.4f}"
def test_ethics_file_exists(self):
"""ETHICS.md must be present in the project root."""
ethics_path = Path(__file__).resolve().parents[1] / "ETHICS.md"
assert ethics_path.exists(), "ETHICS.md is missing from project root!"
content = ethics_path.read_text()
assert "NOT a Medical Device" in content or "NOT A Medical Device" in content or "not a medical device" in content.lower()
assert "Data Privacy" in content
assert "Bias" in content
def test_training_metrics_json_is_valid(self):
"""training_metrics.json must exist and contain expected keys."""
metrics_path = MODEL_DIR / "training_metrics.json"
if not metrics_path.exists():
pytest.skip("Model files not downloaded β run Task B1 first.")
with open(metrics_path) as f:
metrics = json.load(f)
assert "class_names" in metrics
assert len(metrics["class_names"]) == 14, "Expected 14 ChestMNIST classes"
assert "test_macro_roc_auc" in metrics
assert 0.0 <= metrics["test_macro_roc_auc"] <= 1.0
print(f"\n β
Metrics valid β Macro AUROC: {metrics['test_macro_roc_auc']:.3f}")
def test_baseline_stats_json_is_valid(self):
"""baseline_stats.json must exist and contain drift reference fields."""
stats_path = MODEL_DIR / "baseline_stats.json"
if not stats_path.exists():
pytest.skip("Model files not downloaded β run Task B1 first.")
with open(stats_path) as f:
stats = json.load(f)
# Support both key formats (old: pixel_mean_mean / new: pixel_mean)
has_mean = "pixel_mean_mean" in stats or "pixel_mean" in stats
has_std = "pixel_std_mean" in stats or "pixel_std" in stats
assert has_mean, f"Missing pixel mean key. Keys found: {list(stats.keys())}"
assert has_std, f"Missing pixel std key. Keys found: {list(stats.keys())}"
mean_val = stats.get("pixel_mean", stats.get("pixel_mean_mean", 0))
assert -2.0 <= mean_val <= 2.0, f"Unexpected pixel mean value: {mean_val}"
print(f"\n β
Baseline stats valid β pixel_mean={mean_val:.4f}")
|