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}")