210 lines
9.1 KiB
Python
210 lines
9.1 KiB
Python
"""
|
||
Тесты контракта API для веб-интерфейса.
|
||
|
||
Проверяют, что ответы эндпоинтов содержат поля, которые читает
|
||
`src/api/static/js/dxa-app.js`. Ранее эти ключи разошлись: панель деталей
|
||
показывала прочерки и одинаковые значения при любом клике, потому что API
|
||
отдавал `metrics` другой структуры. Тесты фиксируют контракт.
|
||
"""
|
||
import os
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||
|
||
DATASET = Path("dataset_hack/Для теста")
|
||
CHECKPOINT = Path("models/dxa_model.pth")
|
||
|
||
pytest.importorskip("fastapi")
|
||
pytest.importorskip("httpx")
|
||
|
||
needs_assets = pytest.mark.skipif(
|
||
not (DATASET.is_dir() and CHECKPOINT.exists()),
|
||
reason="test DICOM files or trained checkpoint are not available",
|
||
)
|
||
|
||
# Поля, которые панель деталей читает напрямую из ответа.
|
||
DETAIL_TOP_LEVEL = [
|
||
"anatomical_region", "quality_class", "quality_label", "violation_type",
|
||
"reason", "confidence", "confidence_per_class", "threshold_probability",
|
||
"region_confidence", "overall_quality", "severity", "metrics",
|
||
"view_quality", "reasons", "metrics_note",
|
||
]
|
||
# Ключи внутри metrics, которые панель использует.
|
||
METRICS_KEYS = ["sharpness_laplacian", "sharpness_fft", "roi", "calibration"]
|
||
ROI_KEYS = ["size", "bounding_box", "valid", "reason", "issues"]
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def client():
|
||
os.environ.setdefault("DXA_MODEL_PATH", str(CHECKPOINT))
|
||
from fastapi.testclient import TestClient
|
||
|
||
from src.main import app
|
||
|
||
return TestClient(app)
|
||
|
||
|
||
def _test_files():
|
||
return sorted(DATASET.glob("*.dcm"))
|
||
|
||
|
||
@needs_assets
|
||
class TestDetailedContract:
|
||
@pytest.fixture(scope="class")
|
||
def payloads(self, client):
|
||
out = []
|
||
for path in _test_files():
|
||
response = client.post(
|
||
"/api/v1/analyze/detailed",
|
||
files={"file": (path.name, path.read_bytes(), "application/dicom")},
|
||
params={"include_visualization": "true"},
|
||
)
|
||
assert response.status_code == 200, f"{path.name}: {response.text[:200]}"
|
||
out.append((path.name, response.json()))
|
||
return out
|
||
|
||
def test_all_documented_fields_present(self, payloads):
|
||
for name, data in payloads:
|
||
missing = [k for k in DETAIL_TOP_LEVEL if k not in data]
|
||
assert not missing, f"{name} missing {missing}"
|
||
|
||
def test_confidence_per_class_is_coherent(self, payloads):
|
||
for name, data in payloads:
|
||
cpc = data["confidence_per_class"]
|
||
assert set(cpc) == {"correct", "violation"}, name
|
||
assert cpc["correct"] + cpc["violation"] == pytest.approx(1.0, abs=1e-3), name
|
||
# Вероятность класса не должна расходиться с полем confidence.
|
||
assert data["confidence"] == pytest.approx(cpc["violation"], abs=1e-3), name
|
||
|
||
def test_metrics_have_expected_structure(self, payloads):
|
||
for name, data in payloads:
|
||
metrics = data["metrics"]
|
||
assert isinstance(metrics, dict) and metrics, f"{name}: metrics is empty"
|
||
missing = [k for k in METRICS_KEYS if k not in metrics]
|
||
assert not missing, f"{name} metrics missing {missing}"
|
||
for key in ROI_KEYS:
|
||
assert key in metrics["roi"], f"{name} roi missing {key}"
|
||
|
||
def test_metrics_differ_between_distinct_images(self, payloads):
|
||
"""
|
||
Метрики должны различаться для разных снимков.
|
||
|
||
Одинаковые значения были главным симптомом бага: пороги эвристик были
|
||
насыщены, и панель выглядела «не обновляющейся» при кликах.
|
||
"""
|
||
sharp = [d["metrics"]["sharpness_laplacian"] for _, d in payloads]
|
||
assert len(set(sharp)) > 1, "sharpness does not vary across images"
|
||
|
||
def test_duplicate_images_share_metrics(self, payloads):
|
||
"""Побайтные дубликаты должны давать идентичные метрики."""
|
||
by_study = {}
|
||
for name, data in payloads:
|
||
key = (data["study_uid"], data["image_uid"])
|
||
by_study.setdefault(key, []).append(data["metrics"]["sharpness_laplacian"])
|
||
for key, values in by_study.items():
|
||
if len(values) > 1:
|
||
assert max(values) == pytest.approx(min(values)), f"duplicates differ for {key}"
|
||
|
||
def test_region_specific_sections_match_region(self, payloads):
|
||
for name, data in payloads:
|
||
region = data["anatomical_region"]
|
||
if region == "spine":
|
||
assert "spine_completeness" in data, name
|
||
assert data["spine_completeness"], f"{name}: spine section empty"
|
||
if region.startswith("hip"):
|
||
assert data.get("hip_completeness") or data.get("hip_rotation"), name
|
||
|
||
def test_no_uncalibrated_verdicts_are_exposed(self, payloads):
|
||
"""
|
||
Эвристики не должны выглядеть как заключение.
|
||
|
||
Проверка фиксирует, что наружу не отдаются некалиброванные вердикты:
|
||
например число «позвонков» (эвристика выдавала 46) или тексты вида
|
||
«Позвонок 1 обрезан».
|
||
"""
|
||
for name, data in payloads:
|
||
spine = data.get("spine_completeness") or {}
|
||
assert "num_vertebrae" not in spine, f"{name}: raw vertebrae count exposed"
|
||
assert "issues" not in spine, f"{name}: raw issue texts exposed"
|
||
assert "reason" not in spine, f"{name}: raw verdict text exposed"
|
||
blob = str(data)
|
||
assert "Позвонок 1 обрезан" not in blob, f"{name}: verdict leaked into payload"
|
||
|
||
def test_quality_label_is_not_duplicated_in_badge(self, payloads):
|
||
"""Подпись класса не должна совпадать с текстом бейджа (было «OK OK»)."""
|
||
for name, data in payloads:
|
||
if data["quality_class"] == 0:
|
||
assert data["quality_label"] != "OK", name
|
||
|
||
def test_visualizations_are_valid_base64(self, payloads):
|
||
import base64
|
||
|
||
for name, data in payloads:
|
||
for field in ("image", "mask"):
|
||
raw = data.get(field)
|
||
assert raw, f"{name}: {field} is empty"
|
||
decoded = base64.b64decode(raw)
|
||
assert decoded[:8] == b"\x89PNG\r\n\x1a\n", f"{name}: {field} is not PNG"
|
||
|
||
def test_reasons_explain_the_decision(self, payloads):
|
||
for name, data in payloads:
|
||
reasons = data["reasons"]
|
||
assert isinstance(reasons, list) and reasons, name
|
||
text = " ".join(reasons)
|
||
assert data["anatomical_region"] in text or "область" in text, name
|
||
|
||
def test_metrics_note_warns_about_calibration(self, payloads):
|
||
for name, data in payloads:
|
||
assert "не калиброваны" in data["metrics_note"], name
|
||
|
||
|
||
@needs_assets
|
||
class TestBasicContract:
|
||
def test_analyze_returns_basic_fields(self, client):
|
||
path = _test_files()[0]
|
||
response = client.post(
|
||
"/api/v1/analyze",
|
||
files={"file": (path.name, path.read_bytes(), "application/dicom")},
|
||
)
|
||
assert response.status_code == 200
|
||
data = response.json()
|
||
for key in ("anatomical_region", "quality_class", "confidence", "processing_status"):
|
||
assert key in data
|
||
# Базовый эндпоинт не должен тянуть тяжёлые метрики
|
||
assert data["metrics"] == {}
|
||
|
||
|
||
@needs_assets
|
||
class TestExportContract:
|
||
def test_export_has_required_columns(self, client):
|
||
import io
|
||
|
||
import pandas as pd
|
||
|
||
files = [(f.name, f.read_bytes(), "application/dicom") for f in _test_files()]
|
||
response = client.post("/api/v1/export", files=[("files", f) for f in files])
|
||
assert response.status_code == 200
|
||
df = pd.read_excel(io.BytesIO(response.content))
|
||
required = [
|
||
"path_to_study", "study_uid", "image_uid", "anatomical_region",
|
||
"quality_class", "violation_type", "processing_status", "time_of_processing",
|
||
]
|
||
# Столбцы задания идут первыми и в заданном порядке.
|
||
assert list(df.columns)[: len(required)] == required
|
||
assert len(df) == len(_test_files())
|
||
assert (df["processing_status"] == "Success").all()
|
||
|
||
|
||
@needs_assets
|
||
class TestErrorHandling:
|
||
def test_garbage_upload_returns_500_not_crash(self, client):
|
||
response = client.post(
|
||
"/api/v1/analyze",
|
||
files={"file": ("junk.dcm", b"not a dicom", "application/dicom")},
|
||
)
|
||
assert response.status_code == 500
|
||
assert "error" in response.json()
|