"""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)