bone_2026/tests/test_api_contract.py

359 lines
17 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",
"violation_type_label", "violation_type_is_heuristic", "violation_type_note",
"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
def test_violation_type_comes_from_canonical_dictionary(self, payloads):
"""
Тип нарушения — код из единого словаря, а подпись — из него же.
Раньше интерфейс держал собственную таблицу подписей, и коды экспертной
таблицы (`artifact`, `roi_incorrect`, `positioning`) показывались как
есть. Тест фиксирует, что сервер отдаёт канонический код и подпись.
"""
from src.dxa.violations import VIOLATION_LABELS, VIOLATION_TYPES
for name, data in payloads:
code = data["violation_type"]
assert code == "" or code in VIOLATION_TYPES, f"{name}: unknown code {code}"
if code:
assert data["violation_type_label"] == VIOLATION_LABELS[code], name
assert data["violation_type_is_heuristic"] is True, name
assert "эвристик" in data["violation_type_note"], name
else:
assert data["violation_type_label"] == "", name
assert data["violation_type_is_heuristic"] is False, name
assert data["violation_type_note"] == "", name
def test_violation_type_matches_quality_class(self, payloads):
for name, data in payloads:
if data["quality_class"] == 0:
assert data["violation_type"] == "", name
else:
assert data["violation_type"] != "", name
@needs_assets
class TestHealthAndModelCard:
"""`/health` и `/api/v1/model` — источник сведений о модели для интерфейса."""
def test_health_reports_model_provenance(self, client):
data = client.get("/api/v1/health").json()
assert data["status"] == "ok" and data["model_loaded"] is True
model = data["model"]
assert model["loaded"] is True and model["exists"] is True
for key in ("path", "backbone", "head", "epoch", "threshold_logit",
"threshold_probability", "labels_csv", "val_metrics"):
assert key in model, f"health.model missing {key}"
# Разметка из экспертной таблицы — то, на чём обучен рабочий чекпоинт.
assert model["labels_csv"] == "labels/labels_images.csv"
assert 0.0 < model["threshold_probability"] < 1.0
assert 0.0 <= model["val_metrics"]["roc_auc"] <= 1.0
def test_model_card_covers_canonical_dictionary(self, client):
from src.dxa.violations import VIOLATION_TYPES
card = client.get("/api/v1/model").json()
codes = [v["code"] for v in card["violations"]]
assert codes == list(VIOLATION_TYPES)
for entry in card["violations"]:
assert entry["label"] and entry["note"]
assert entry["scope"] in ("spine", "hip", "any")
assert entry["source"] in ("expert_table", "condition_doctor", "system")
def test_model_card_metrics_are_intervals(self, client):
card = client.get("/api/v1/model").json()
cmp = card["comparison"]
for metric, variants in cmp["metrics"].items():
for name, triple in variants.items():
assert len(triple) == 3, f"{metric}/{name} is not [value, lo, hi]"
assert triple[1] <= triple[0] <= triple[2], f"{metric}/{name} CI inverted"
for metric, triple in cmp["paired_delta_table_minus_union"].items():
assert triple[1] <= triple[0] <= triple[2], f"delta {metric} CI inverted"
def test_model_card_reports_label_rule(self, client):
card = client.get("/api/v1/model").json()
assert card["model"]["label_rule"] == "table"
assert "экспертной таблицы" in card["labels"]["rule"]
# Правило выбиралось измерением: в карточке должно быть и решение, и основание.
assert card["comparison"]["decision"]
assert card["comparison"]["paired_wins"]
def test_model_card_explains_deployed_metrics(self, client):
card = client.get("/api/v1/model").json()
caveat = card["model"]["caveat"]
# Чекпоинт — обычный прогон, а не лучший из выборки; честная оценка рядом.
assert "seed по умолчанию" in caveat
assert card["model"]["val_roc_auc"] == pytest.approx(
card["comparison"]["metrics"]["roc_auc"]["table"][0], abs=0.02
)
def test_model_card_loads_model_on_cold_start(self, client):
"""
Первый же запрос к сервису должен отдавать полную карточку.
Блок `loaded` берётся из метаданных чекпоинта, поэтому эндпоинт обязан
сам инициировать загрузку модели. Здесь состояние сбрасывается, чтобы
воспроизвести холодный старт независимо от порядка тестов.
"""
import src.main as main
saved = (main.dxa_model, main.dxa_preprocess, main.dxa_threshold, main.dxa_metadata)
try:
main.dxa_model = None
main.dxa_metadata = {}
card = client.get("/api/v1/model").json()
assert card["loaded"]["loaded"] is True
assert card["loaded"]["val_metrics"], "метрики чекпоинта пусты после холодного старта"
assert card["loaded"]["val_metrics"]["roc_auc"] > 0.5
finally:
main.dxa_model, main.dxa_preprocess, main.dxa_threshold, main.dxa_metadata = saved
def test_frontend_reads_only_existing_card_fields(self, client):
"""
Фронтенд не должен обращаться к полям карточки, которых в ней нет.
Такой рассинхрон уже случался: ключи в карточке переименовали, а в
`dxa-app.js` остались старые — на панели «О модели» появлялись плитки с
прочерком вместо чисел.
"""
import re
from pathlib import Path
card = client.get("/api/v1/model").json()
source = Path("src/api/static/js/dxa-app.js").read_text(encoding="utf-8")
used = set(re.findall(r"\bds\.([a-z_]+)", source))
used |= set(re.findall(r"\bdeployed\.([a-z_]+)", source))
missing_dataset = sorted(k for k in used if k not in card["dataset"] and k not in card["model"])
assert not missing_dataset, f"фронтенд читает несуществующие поля: {missing_dataset}"
# Плитки блока «Данные» не должны получать прочерк из-за смены ключей.
tiles = re.findall(r"card\('([^']+)',\s*ds\.([a-z_]+)", source)
assert tiles, "не найдено ни одной плитки блока «Данные»"
for _title, key in tiles:
assert key in card["dataset"], f"плитка «{_title}» читает отсутствующий ключ ds.{key}"
def test_model_card_matches_loaded_checkpoint(self, client):
"""Карточка и фактически загруженный чекпоинт не должны расходиться."""
card = client.get("/api/v1/model").json()
loaded = card["loaded"]
assert loaded["backbone"] == card["model"]["backbone"]
assert loaded["head"] == card["model"]["head"]
assert loaded["labels_csv"] == card["model"]["labels_csv"]
assert loaded["threshold_logit"] == pytest.approx(
card["model"]["threshold_logit"], abs=1e-4
)
def test_model_card_lists_limitations_and_sources(self, client):
card = client.get("/api/v1/model").json()
assert len(card["limitations"]) >= 5
assert all(isinstance(text, str) and text for text in card["limitations"])
assert card["sources"]
assert card["snapshot_date"]
@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()