Newer
Older
TIASJudgeModel / tests / data / test_manifest.py
"""manifest の生成・読み書き・検証のテスト.

仕様: SPEC_01「データ契約」・TEST_01「フォルダ構成とラベル対応」・error-handling ルール
(fail fast・例外メッセージに患者 ID / ファイル名を含めない).
"""

import csv
import logging
from pathlib import Path

import pytest
from PIL import Image

from tias_judge_model.data.manifest import (
    ManifestRecord,
    build_manifest_records,
    read_manifest,
    resolve_image_path,
    validate_records,
    write_manifest,
)

# 例外メッセージ・ログに出てはならない識別子(患者 ID・ファイル名を模した値)
SECRET_PATIENT = "SECRETPATIENT0001"
SECRET_FILE_STEM = "SECRETFILE0001"

TEST01_TABLE = {
    "light_red_normal": ("light_red", "normal"),
    "dark_red_strong": ("dark_red", "strong"),
    "dark_red_mild": ("dark_red", "mild"),
    "purple_mild": ("purple", "mild"),
    "red_strong": ("red", "strong"),
    "red_mild": ("red", "mild"),
    "purple_strong": ("purple", "strong"),
    "pale_strong": ("pale", "strong"),
    "pale_mild": ("pale", "mild"),
}


def _rec(
    image_path: str = "red_strong/a.png",
    patient_id: str = "p001",
    label: str = "red",
    severity: str = "strong",
    note: str = "",
) -> ManifestRecord:
    return ManifestRecord(
        image_path=image_path, patient_id=patient_id, label=label, severity=severity, note=note
    )


def _save_png(path: Path) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    Image.new("RGB", (4, 4), (0, 0, 0)).save(path)


def _write_csv(path: Path, header: list[str], rows: list[list[str]]) -> None:
    with path.open("w", encoding="utf-8", newline="") as f:
        writer = csv.writer(f)
        writer.writerow(header)
        writer.writerows(rows)


def _assert_no_pii(message: str, *secrets: str) -> None:
    for secret in secrets:
        assert secret not in message


# --- validate_records ---------------------------------------------------------------------


def test_validate_accepts_valid_records() -> None:
    validate_records(
        [
            _rec("light_red_normal/a.png", "p1", "light_red", "normal"),
            _rec("pale_mild/b.png", "p2", "pale", "mild"),
            _rec("purple_strong/c.png", "p3", "purple", "strong"),
        ]
    )


def test_validate_accepts_multiple_images_of_same_patient_with_same_label() -> None:
    # 将来 1 患者複数枚になっても,label が一致していれば有効(SPEC_01)
    validate_records(
        [
            _rec("red_strong/a.png", "p1", "red", "strong"),
            _rec("red_mild/b.png", "p1", "red", "mild"),
        ]
    )


@pytest.mark.parametrize("patient_id", ["", "   "])
def test_validate_rejects_missing_patient_id(patient_id: str) -> None:
    with pytest.raises(ValueError):
        validate_records([_rec(patient_id="p0"), _rec("red_strong/b.png", patient_id=patient_id)])


@pytest.mark.parametrize("label", ["orange", "Red", "", "紅"])
def test_validate_rejects_unknown_label(label: str) -> None:
    with pytest.raises(ValueError):
        validate_records([_rec(label=label)])


@pytest.mark.parametrize("severity", ["weak", "Strong", "", "あり"])
def test_validate_rejects_unknown_severity(severity: str) -> None:
    with pytest.raises(ValueError):
        validate_records([_rec(severity=severity)])


@pytest.mark.parametrize(
    ("label", "severity"),
    [
        ("light_red", "strong"),
        ("light_red", "mild"),
        ("pale", "normal"),
        ("red", "normal"),
        ("dark_red", "normal"),
        ("purple", "normal"),
    ],
)
def test_validate_rejects_invalid_label_severity_combination(label: str, severity: str) -> None:
    with pytest.raises(ValueError):
        validate_records([_rec(label=label, severity=severity)])


def test_validate_rejects_duplicate_image_path() -> None:
    records = [_rec("red_strong/a.png", "p1"), _rec("red_strong/a.png", "p2")]
    with pytest.raises(ValueError):
        validate_records(records)


def test_validate_rejects_same_patient_with_different_labels() -> None:
    records = [
        _rec("red_strong/a.png", "p1", "red", "strong"),
        _rec("purple_strong/b.png", "p1", "purple", "strong"),
    ]
    with pytest.raises(ValueError):
        validate_records(records)


@pytest.mark.parametrize("image_path", ["", "/abs/red_strong/a.png", "../outside/a.png"])
def test_validate_rejects_image_path_not_relative_to_images(image_path: str) -> None:
    with pytest.raises(ValueError):
        validate_records([_rec(image_path=image_path)])


# --- 例外メッセージに個人情報を出さない ----------------------------------------------------------


def _pii_cases() -> list[tuple[str, list[ManifestRecord]]]:
    path = f"red_strong/{SECRET_FILE_STEM}.png"
    return [
        ("duplicate_image_path", [_rec(path, "pA"), _rec(path, SECRET_PATIENT)]),
        (
            "conflicting_labels",
            [
                _rec(path, SECRET_PATIENT, "red", "strong"),
                _rec(f"purple_mild/{SECRET_FILE_STEM}x.png", SECRET_PATIENT, "purple", "mild"),
            ],
        ),
        ("unknown_label", [_rec(path, SECRET_PATIENT, "orange", "strong")]),
        ("unknown_severity", [_rec(path, SECRET_PATIENT, "red", "weak")]),
        ("invalid_combination", [_rec(path, SECRET_PATIENT, "light_red", "strong")]),
        ("empty_patient_id", [_rec(path, "")]),
        ("absolute_path", [_rec(f"/{path}", SECRET_PATIENT)]),
    ]


@pytest.mark.parametrize(("case", "records"), _pii_cases(), ids=[c for c, _ in _pii_cases()])
def test_validation_error_message_has_no_patient_id_or_file_name(
    case: str, records: list[ManifestRecord]
) -> None:
    with pytest.raises(ValueError) as exc_info:
        validate_records(records)
    _assert_no_pii(str(exc_info.value), SECRET_PATIENT, SECRET_FILE_STEM)


def test_validation_error_message_points_to_line_number() -> None:
    records = [_rec("red_strong/a.png", "p1"), _rec("red_strong/b.png", "")]
    with pytest.raises(ValueError, match="3"):
        validate_records(records)


# --- write_manifest / read_manifest -----------------------------------------------------------


def test_write_then_read_roundtrip(tmp_path: Path) -> None:
    records = [
        _rec("light_red_normal/a.png", "p1", "light_red", "normal"),
        _rec("dark_red_mild/b.png", "p2", "dark_red", "mild", note="除外候補"),
    ]
    path = tmp_path / "sub" / "manifest.csv"
    write_manifest(records, path)
    assert read_manifest(path) == records


def test_written_manifest_has_spec_columns(tmp_path: Path) -> None:
    path = tmp_path / "manifest.csv"
    write_manifest([_rec()], path)
    with path.open(encoding="utf-8", newline="") as f:
        header = next(csv.reader(f))
    assert header == ["image_path", "patient_id", "label", "severity", "note"]


def test_read_accepts_manifest_without_note_column(tmp_path: Path) -> None:
    path = tmp_path / "manifest.csv"
    _write_csv(
        path,
        ["image_path", "patient_id", "label", "severity"],
        [["red_mild/a.png", "p1", "red", "mild"]],
    )
    assert read_manifest(path) == [_rec("red_mild/a.png", "p1", "red", "mild")]


@pytest.mark.parametrize("missing", ["image_path", "patient_id", "label", "severity"])
def test_read_rejects_missing_required_column(tmp_path: Path, missing: str) -> None:
    full = {"image_path": "red_mild/a.png", "patient_id": "p1", "label": "red", "severity": "mild"}
    header = [c for c in full if c != missing]
    path = tmp_path / "manifest.csv"
    _write_csv(path, header, [[full[c] for c in header]])
    with pytest.raises(ValueError):
        read_manifest(path)


@pytest.mark.parametrize(
    "bad_row",
    [
        ["red_mild/b.png", "", "red", "mild", ""],  # patient_id 欠落
        ["red_mild/b.png", "p2", "orange", "mild", ""],  # 未知の label
        ["red_mild/b.png", "p2", "red", "weak", ""],  # 未知の severity
        ["red_mild/b.png", "p2", "light_red", "strong", ""],  # 無効な組み合わせ
        ["red_mild/a.png", "p2", "red", "mild", ""],  # image_path の重複
        ["purple_mild/b.png", "p1", "purple", "mild", ""],  # 同一患者に異なる label
    ],
)
def test_read_stops_on_invalid_row_instead_of_dropping_it(
    tmp_path: Path, bad_row: list[str]
) -> None:
    path = tmp_path / "manifest.csv"
    _write_csv(
        path,
        ["image_path", "patient_id", "label", "severity", "note"],
        [["red_mild/a.png", "p1", "red", "mild", ""], bad_row],
    )
    with pytest.raises(ValueError):
        read_manifest(path)


def test_read_rejects_missing_file(tmp_path: Path) -> None:
    with pytest.raises(ValueError):
        read_manifest(tmp_path / "nothing.csv")


def test_read_rejects_manifest_without_data_rows(tmp_path: Path) -> None:
    path = tmp_path / "manifest.csv"
    _write_csv(path, ["image_path", "patient_id", "label", "severity", "note"], [])
    with pytest.raises(ValueError):
        read_manifest(path)


def test_read_error_message_has_no_patient_id(tmp_path: Path) -> None:
    path = tmp_path / "manifest.csv"
    _write_csv(
        path,
        ["image_path", "patient_id", "label", "severity", "note"],
        [
            [f"red_mild/{SECRET_FILE_STEM}.png", SECRET_PATIENT, "red", "mild", ""],
            [f"pale_mild/{SECRET_FILE_STEM}b.png", SECRET_PATIENT, "pale", "mild", ""],
        ],
    )
    with pytest.raises(ValueError) as exc_info:
        read_manifest(path)
    _assert_no_pii(str(exc_info.value), SECRET_PATIENT, SECRET_FILE_STEM)


def test_read_logs_have_no_patient_id(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
    path = tmp_path / "manifest.csv"
    write_manifest([_rec(f"red_mild/{SECRET_FILE_STEM}.png", SECRET_PATIENT, "red", "mild")], path)
    with caplog.at_level(logging.DEBUG):
        read_manifest(path)
    _assert_no_pii(caplog.text, SECRET_PATIENT, SECRET_FILE_STEM)


# --- build_manifest_records --------------------------------------------------------------------


def test_build_maps_every_dir_to_test01_label_and_severity(tmp_path: Path) -> None:
    images = tmp_path / "images"
    for i, dir_name in enumerate(sorted(TEST01_TABLE)):
        _save_png(images / dir_name / f"img{i:03d}.png")

    records = build_manifest_records(images)

    assert len(records) == 9
    for r in records:
        dir_name, file_name = r.image_path.split("/")
        assert (r.label, r.severity) == TEST01_TABLE[dir_name]
        # 1 患者 1 枚のため画像 ID(ファイル名の stem)を patient_id とする(SPEC_01)
        assert r.patient_id == Path(file_name).stem
        assert r.note == ""


def test_build_uses_path_relative_to_images_dir(tmp_path: Path) -> None:
    images = tmp_path / "images"
    _save_png(images / "red_mild" / "x1.png")
    _save_png(images / "red_mild" / "x2.png")

    records = build_manifest_records(images)

    assert sorted(r.image_path for r in records) == ["red_mild/x1.png", "red_mild/x2.png"]
    for r in records:
        assert resolve_image_path(images, r).is_file()


def test_build_output_passes_read_after_write(tmp_path: Path) -> None:
    images = tmp_path / "images"
    for i, dir_name in enumerate(sorted(TEST01_TABLE)):
        _save_png(images / dir_name / f"img{i:03d}.png")
    records = build_manifest_records(images)
    write_manifest(records, tmp_path / "manifest.csv")
    assert read_manifest(tmp_path / "manifest.csv") == records


@pytest.mark.parametrize("dir_name", ["light_red_strong", "orange_mild", "淡紅(正常)", "misc"])
def test_build_rejects_unexpected_dir_name(tmp_path: Path, dir_name: str) -> None:
    images = tmp_path / "images"
    _save_png(images / "red_mild" / "a.png")
    _save_png(images / dir_name / "b.png")
    with pytest.raises(ValueError):
        build_manifest_records(images)


@pytest.mark.parametrize("file_name", ["b.jpg", "b.txt", "b", "Thumbs.db"])
def test_build_rejects_non_png_file(tmp_path: Path, file_name: str) -> None:
    images = tmp_path / "images"
    _save_png(images / "red_mild" / "a.png")
    (images / "red_mild" / file_name).write_bytes(b"dummy")
    with pytest.raises(ValueError):
        build_manifest_records(images)


def test_build_rejects_subdirectory_in_class_dir(tmp_path: Path) -> None:
    # 実データは 9 フォルダの直下に画像がありサブフォルダは無い(TEST_01)
    images = tmp_path / "images"
    _save_png(images / "red_mild" / "a.png")
    _save_png(images / "red_mild" / "nested" / "b.png")
    with pytest.raises(ValueError):
        build_manifest_records(images)


def test_build_rejects_duplicate_stem_instead_of_guessing_patient(tmp_path: Path) -> None:
    # 画像 ID を患者 ID と見なせない兆候があれば自動で続行しない(SPEC_01)
    images = tmp_path / "images"
    _save_png(images / "red_mild" / "same.png")
    _save_png(images / "pale_mild" / "same.png")
    with pytest.raises(ValueError):
        build_manifest_records(images)


def test_build_rejects_missing_images_dir(tmp_path: Path) -> None:
    with pytest.raises(ValueError):
        build_manifest_records(tmp_path / "images")


def test_build_rejects_empty_images_dir(tmp_path: Path) -> None:
    (tmp_path / "images").mkdir()
    with pytest.raises(ValueError):
        build_manifest_records(tmp_path / "images")


def test_build_rejects_empty_class_dir(tmp_path: Path) -> None:
    # 他のクラスに画像があっても,空のクラスディレクトリがあれば止める(SPEC_01 検証規則).
    images = tmp_path / "images"
    _save_png(images / "red_mild" / "a.png")
    (images / "pale_mild").mkdir()
    with pytest.raises(ValueError, match="pale_mild"):
        build_manifest_records(images)


def test_build_error_message_has_no_file_name(tmp_path: Path) -> None:
    images = tmp_path / "images"
    _save_png(images / "red_mild" / "a.png")
    (images / "red_mild" / f"{SECRET_FILE_STEM}.jpg").write_bytes(b"dummy")
    with pytest.raises(ValueError) as exc_info:
        build_manifest_records(images)
    _assert_no_pii(str(exc_info.value), SECRET_FILE_STEM)


def test_build_duplicate_stem_error_message_has_no_file_name(tmp_path: Path) -> None:
    images = tmp_path / "images"
    _save_png(images / "red_mild" / f"{SECRET_FILE_STEM}.png")
    _save_png(images / "pale_mild" / f"{SECRET_FILE_STEM}.png")
    with pytest.raises(ValueError) as exc_info:
        build_manifest_records(images)
    _assert_no_pii(str(exc_info.value), SECRET_FILE_STEM)


def test_build_logs_have_no_file_name(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
    images = tmp_path / "images"
    _save_png(images / "red_mild" / f"{SECRET_FILE_STEM}.png")
    with caplog.at_level(logging.DEBUG):
        build_manifest_records(images)
    _assert_no_pii(caplog.text, SECRET_FILE_STEM)