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