bone_2026/tests/test_api_contract.py

210 lines
9.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Тесты контракта 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()