7e7f32a13d
- ceus:灌注指标改为对病灶去暗区(roi_perf)和紫圈(roi_pan)两组算,roi_all 指标删除; D1–D4 仍要 roi_all、roi_perf 两条都过才定位(负责人 2026-10-17:医生要正常区与病灶对比看) - build/library:tic.json 只出两组 11 个指标与 7 个槽位的视频时间;医生截图挪进 ultrasound/keyframes/key.png,UltrasoundRoute 去掉 key_image、keyframes 多一个 key - review:TIC参数.csv 取代 TIC结果.csv;问题清单按原因对照表写成大白话,对照表外的原因报错; 程序原文进 snapshot/问题清单_原文.csv;说明.pdf 整份重写为合作者口吻 - 文档:结构.md、医生核对包、装配两个库、CEUS定量 任务书 04。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
392 lines
17 KiB
Python
392 lines
17 KiB
Python
"""构建产物:可见性、泄露扫描、化名、解码开关、关键图、清单与读取、不覆盖、答案库内容。"""
|
||
|
||
import csv
|
||
import json
|
||
import re
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
from PIL import Image
|
||
|
||
from cadx.answers import read_aliases, read_diagnoses
|
||
from cadx.build import make_aliases, run_build
|
||
from cadx.library import (
|
||
LibraryCase,
|
||
list_aliases,
|
||
read_clinical,
|
||
read_mr,
|
||
read_ultrasound,
|
||
scan_for_leaks,
|
||
write_library,
|
||
)
|
||
from fake_delivery import (
|
||
CLINICAL_HEADER,
|
||
MR_TEXT,
|
||
REPO_ROOT,
|
||
SAMPLE_CODE_MAPS,
|
||
ULTRASOUND_TEXT,
|
||
build,
|
||
make_delivery,
|
||
write_config,
|
||
)
|
||
|
||
NOW = datetime(2026, 10, 16, 9, 30, 0).astimezone()
|
||
|
||
# 独立写的泄露词表,不取自 delivery 的常量。
|
||
SUBTYPES = (
|
||
"IPMN",
|
||
"SPN",
|
||
"MCN",
|
||
"SCN",
|
||
"导管内乳头状黏液性肿瘤",
|
||
"实性假乳头状瘤",
|
||
"黏液性囊性肿瘤",
|
||
"浆液性囊性肿瘤",
|
||
"浆液性囊腺瘤",
|
||
)
|
||
DIAGNOSIS_COLUMNS = ("病理类型(1 IPMN,2 SPN,3 MCN,4 SCN)", "病理类型", "Diagnosis", "Case_ID")
|
||
DELIVERY_FILE_NAMES = ("临床工作簿_40例.xlsx", "超声报告文本_40例.xlsx", "MR报告文本_40例.xlsx")
|
||
FORBIDDEN_KEYS = ("diagnosis", "label", "gold", "pathology", "name", "row")
|
||
|
||
|
||
def _forbidden_strings(case_nos: list[str]) -> list[str]:
|
||
per_case = [
|
||
term
|
||
for no in case_nos
|
||
for term in (
|
||
f"P{no}",
|
||
f"U{no}",
|
||
f"U{no}_key.png",
|
||
f"U{no}_roi2.mp4",
|
||
f"U{no}_roi2_report.xlsx",
|
||
)
|
||
]
|
||
return [*per_case, *DELIVERY_FILE_NAMES, *SUBTYPES, *DIAGNOSIS_COLUMNS]
|
||
|
||
|
||
def _case_dirs(library_dir: Path) -> list[Path]:
|
||
return sorted((library_dir / "cases").iterdir())
|
||
|
||
|
||
def test_visibility_sex_and_age_only_in_clinical_route(tmp_path: Path) -> None:
|
||
delivery = make_delivery(4)
|
||
summary = build(tmp_path, delivery, 1, SAMPLE_CODE_MAPS, NOW)
|
||
clinical_names = [*CLINICAL_HEADER, "男", "女", "岁"]
|
||
for case_dir in _case_dirs(summary.library_dir):
|
||
files = sorted(p.relative_to(case_dir).as_posix() for p in case_dir.rglob("*.*"))
|
||
assert files == [
|
||
"clinical/clinical.json",
|
||
"mr/report.txt",
|
||
"ultrasound/keyframes/key.png",
|
||
"ultrasound/report.txt",
|
||
]
|
||
clinical = json.loads((case_dir / "clinical/clinical.json").read_text(encoding="utf-8"))
|
||
assert set(clinical["fields"]) == set(CLINICAL_HEADER) - {"Case_ID", "Diagnosis"}
|
||
for report in ("ultrasound/report.txt", "mr/report.txt"):
|
||
text = (case_dir / report).read_text(encoding="utf-8")
|
||
assert [name for name in clinical_names if name in text] == []
|
||
ages = {f"{30 + n}" for n in range(1, 5)}
|
||
for alias in list_aliases(summary.library_dir):
|
||
assert str(read_clinical(summary.library_dir, alias).fields["Age"]) in ages
|
||
assert read_ultrasound(summary.library_dir, alias).report == ULTRASOUND_TEXT
|
||
assert read_mr(summary.library_dir, alias).report == MR_TEXT
|
||
|
||
|
||
def test_leak_scan_finds_nothing_in_clean_build(tmp_path: Path) -> None:
|
||
summary = build(tmp_path, make_delivery(4), 1, None, NOW)
|
||
case_nos = [f"{n:03d}" for n in range(1, 5)]
|
||
hits = scan_for_leaks(summary.library_dir, _forbidden_strings(case_nos), FORBIDDEN_KEYS)
|
||
assert hits == []
|
||
|
||
|
||
def test_leak_scan_detects_planted_leaks(tmp_path: Path) -> None:
|
||
summary = build(tmp_path, make_delivery(3), 1, None, NOW)
|
||
case_dir = _case_dirs(summary.library_dir)[0]
|
||
(case_dir / "mr/report.txt").write_text(MR_TEXT + "见 P002 。", encoding="utf-8")
|
||
(case_dir / "ultrasound/note.json").write_text('{"Label": "x"}', encoding="utf-8")
|
||
(case_dir / "clinical/extra.json").write_text(
|
||
'{"k": "\\u6d46\\u6db2\\u6027\\u56ca\\u6027\\u80bf\\u7624"}', encoding="utf-8"
|
||
)
|
||
hits = scan_for_leaks(summary.library_dir, _forbidden_strings(["002"]), FORBIDDEN_KEYS)
|
||
found = {(Path(h.file).name, h.kind, h.term) for h in hits}
|
||
assert found == {
|
||
("report.txt", "字符串", "P002"),
|
||
("note.json", "键名", "Label"),
|
||
("extra.json", "字符串", "浆液性囊性肿瘤"),
|
||
}
|
||
|
||
|
||
def test_leak_scan_normalizes_fullwidth_and_case(tmp_path: Path) -> None:
|
||
summary = build(tmp_path, make_delivery(2), 1, None, NOW)
|
||
case_dir = _case_dirs(summary.library_dir)[0]
|
||
leaked = MR_TEXT + "形态似ipmn,见p002。"
|
||
(case_dir / "mr/report.txt").write_text(leaked, encoding="utf-8")
|
||
hits = scan_for_leaks(summary.library_dir, ["IPMN", "P002", "U002"], FORBIDDEN_KEYS)
|
||
assert {(Path(h.file).name, h.term) for h in hits} == {
|
||
("report.txt", "IPMN"),
|
||
("report.txt", "P002"),
|
||
}
|
||
(case_dir / "mr/report.txt").write_text(MR_TEXT + "U002", encoding="utf-8")
|
||
hits = scan_for_leaks(summary.library_dir, ["U002"], FORBIDDEN_KEYS)
|
||
assert [(Path(h.file).name, h.term) for h in hits] == [("report.txt", "U002")]
|
||
|
||
|
||
def test_aliases_are_reproducible_and_well_formed(tmp_path: Path) -> None:
|
||
first = build(tmp_path / "a", make_delivery(5), 11, None, NOW)
|
||
second = build(tmp_path / "b", make_delivery(5), 11, None, NOW)
|
||
other = build(tmp_path / "c", make_delivery(5), 12, None, NOW)
|
||
aliases = list_aliases(first.library_dir)
|
||
assert aliases == list_aliases(second.library_dir)
|
||
assert aliases != list_aliases(other.library_dir)
|
||
assert len(set(aliases)) == 5
|
||
assert all(re.fullmatch(r"[a-z0-9]{4}", a) for a in aliases)
|
||
|
||
|
||
def test_aliases_avoid_subtype_substrings_and_repeats() -> None:
|
||
case_nos = [f"{n:03d}" for n in range(1, 1000)]
|
||
aliases = make_aliases(case_nos, 3)
|
||
assert len(set(aliases.values())) == 999
|
||
banned = ("ipmn", "spn", "mcn", "scn")
|
||
assert [a for a in aliases.values() if any(b in a for b in banned)] == []
|
||
|
||
|
||
def test_alias_order_does_not_depend_on_input_order() -> None:
|
||
case_nos = ["003", "001", "002"]
|
||
assert make_aliases(case_nos, 5) == make_aliases(sorted(case_nos), 5)
|
||
|
||
|
||
def test_decode_off_keeps_raw_numbers(tmp_path: Path) -> None:
|
||
summary = build(tmp_path, make_delivery(3), 1, None, NOW)
|
||
for alias in list_aliases(summary.library_dir):
|
||
clinical = read_clinical(summary.library_dir, alias)
|
||
assert clinical.codes_decoded is False
|
||
assert clinical.fields["Sex"] in (1, 2)
|
||
assert clinical.fields["Symptom_Pain"] == 1
|
||
assert clinical.fields["CEA_Level"] == 1
|
||
assert [d.name for d in read_diagnoses(summary.answers_dir)] == [None, None, None]
|
||
manifest = json.loads((summary.library_dir / "manifest.json").read_text(encoding="utf-8"))
|
||
assert manifest["codes_decoded"] is False
|
||
assert manifest["uncompared"] == ["临床性别未比对"]
|
||
|
||
|
||
def test_decode_on_uses_code_maps(tmp_path: Path) -> None:
|
||
summary = build(tmp_path, make_delivery(3), 1, SAMPLE_CODE_MAPS, NOW)
|
||
by_case = {row.case_no: row.alias for row in read_aliases(summary.answers_dir)}
|
||
# 假交付包:病例 n 的性别编码是 1 + n % 2,诊断编码是 1 + (n - 1) % 4。
|
||
expected_sex = {"001": "女", "002": "男", "003": "女"}
|
||
for case_no, alias in by_case.items():
|
||
clinical = read_clinical(summary.library_dir, alias)
|
||
assert clinical.codes_decoded is True
|
||
assert clinical.fields["Sex"] == expected_sex[case_no]
|
||
assert clinical.fields["Symptom_Pain"] == "是"
|
||
assert clinical.fields["CEA_Level"] == "异常"
|
||
assert clinical.fields["Amylase_Level"] is None
|
||
assert clinical.fields["Age"] == 30 + int(case_no)
|
||
names = {d.case_no: d.name for d in read_diagnoses(summary.answers_dir)}
|
||
assert names == {"001": "IPMN", "002": "SPN", "003": "MCN"}
|
||
|
||
|
||
def test_key_image_pixels_kept_and_text_chunk_dropped(tmp_path: Path) -> None:
|
||
summary = build(tmp_path, make_delivery(2), 1, None, NOW)
|
||
by_case = {row.case_no: row.alias for row in read_aliases(summary.answers_dir)}
|
||
for case_no, alias in by_case.items():
|
||
source = tmp_path / "delivery" / "CEUS_agent" / case_no / f"U{case_no}_key.png"
|
||
with Image.open(source) as original:
|
||
assert original.info.get("PatientName") == "should-not-survive"
|
||
original_mode, original_pixels = original.mode, original.tobytes()
|
||
copied = read_ultrasound(summary.library_dir, alias).keyframes["key"]
|
||
with Image.open(copied) as image:
|
||
assert image.mode == original_mode
|
||
assert image.tobytes() == original_pixels
|
||
assert "PatientName" not in image.info
|
||
assert _png_chunks(copied.read_bytes()) == ["IHDR", "IDAT", "IEND"]
|
||
|
||
|
||
def _png_chunks(data: bytes) -> list[str]:
|
||
"""按 PNG 格式逐块读出块类型(8 字节签名后:4 字节长度、4 字节类型、数据、4 字节 CRC)。"""
|
||
chunks: list[str] = []
|
||
offset = 8
|
||
while offset < len(data):
|
||
length = int.from_bytes(data[offset : offset + 4], "big")
|
||
chunks.append(data[offset + 4 : offset + 8].decode("ascii"))
|
||
offset += 12 + length
|
||
return chunks
|
||
|
||
|
||
def _build_with_key_image(tmp_path: Path, image: Image.Image) -> None:
|
||
root = make_delivery(2).write(tmp_path / "delivery")
|
||
image.save(root / "CEUS_agent" / "002" / "U002_key.png", format="PNG")
|
||
run_build(write_config(tmp_path, root, 1, None, None), REPO_ROOT, NOW)
|
||
|
||
|
||
def _assert_key_image_skipped(tmp_path: Path, reason: str) -> None:
|
||
"""002 这一例记 C14 跳过、涉及值是原因;001 照常进库。"""
|
||
(library_dir,) = list((tmp_path / "library").iterdir())
|
||
answers_dir = tmp_path / "answers" / library_dir.name
|
||
assert [r.case_no for r in read_aliases(answers_dir)] == ["001"]
|
||
assert len(list_aliases(library_dir)) == 1
|
||
skipped = _csv_rows(answers_dir / "skipped.csv")
|
||
assert [row[:4] for row in skipped[1:]] == [
|
||
["002", "C14", "关键图能原样重编码", "CEUS_agent/002/U002_key.png"]
|
||
]
|
||
assert json.loads(skipped[1][5]) == reason
|
||
|
||
|
||
def test_key_image_16_bit_source_skips_case(tmp_path: Path) -> None:
|
||
_build_with_key_image(tmp_path, Image.new("I;16", (4, 4)))
|
||
_assert_key_image_skipped(tmp_path, "位深 16、颜色类型 0 不能逐像素保真重编码")
|
||
|
||
|
||
def test_key_image_with_transparency_skips_case(tmp_path: Path) -> None:
|
||
image = Image.new("RGB", (4, 4))
|
||
image.info["transparency"] = (0, 0, 0)
|
||
_build_with_key_image(tmp_path, image)
|
||
_assert_key_image_skipped(tmp_path, "带 tRNS 透明色,重编码会丢透明")
|
||
|
||
|
||
def test_key_image_palette_source_skips_case(tmp_path: Path) -> None:
|
||
_build_with_key_image(tmp_path, Image.new("P", (4, 4)))
|
||
# 只有一种颜色的调色板图,Pillow 存成 1 位。
|
||
_assert_key_image_skipped(tmp_path, "位深 1、颜色类型 3 不能逐像素保真重编码")
|
||
|
||
|
||
def test_key_image_not_png_skips_case(tmp_path: Path) -> None:
|
||
root = make_delivery(2).write(tmp_path / "delivery")
|
||
Image.new("RGB", (4, 4)).save(root / "CEUS_agent" / "002" / "U002_key.png", format="JPEG")
|
||
run_build(write_config(tmp_path, root, 1, None, None), REPO_ROOT, NOW)
|
||
_assert_key_image_skipped(tmp_path, "不是 PNG")
|
||
|
||
|
||
def test_write_library_rejects_unchecked_key_image(tmp_path: Path) -> None:
|
||
"""build 漏了 C14 检查时,library 写图那一步报错停下。"""
|
||
source = tmp_path / "key.png"
|
||
Image.new("P", (4, 4)).save(source, format="PNG")
|
||
target = tmp_path / "out"
|
||
target.mkdir()
|
||
case = LibraryCase(
|
||
alias="abcd",
|
||
clinical_fields={},
|
||
ultrasound_report="",
|
||
key_image_source=source,
|
||
mr_report="",
|
||
ceus=None,
|
||
)
|
||
with pytest.raises(ValueError, match="check_key_image"):
|
||
write_library(target, "b", "c", True, {}, False, (), None, [case])
|
||
|
||
|
||
def test_key_image_one_bit_kept_as_is(tmp_path: Path) -> None:
|
||
source = Image.frombytes("1", (8, 2), bytes([0b10110010, 0b01001101]))
|
||
_build_with_key_image(tmp_path, source)
|
||
library_root = tmp_path / "library"
|
||
(library_dir,) = list(library_root.iterdir())
|
||
by_case = {r.case_no: r.alias for r in read_aliases(tmp_path / "answers" / library_dir.name)}
|
||
copied = read_ultrasound(library_dir, by_case["002"]).keyframes["key"]
|
||
with Image.open(copied) as image:
|
||
assert image.mode == "1"
|
||
assert image.tobytes() == source.tobytes()
|
||
assert _png_chunks(copied.read_bytes()) == ["IHDR", "IDAT", "IEND"]
|
||
|
||
|
||
def test_library_read_rejects_modified_file(tmp_path: Path) -> None:
|
||
summary = build(tmp_path, make_delivery(2), 1, None, NOW)
|
||
alias = list_aliases(summary.library_dir)[0]
|
||
report = summary.library_dir / "cases" / alias / "mr" / "report.txt"
|
||
report.write_text(MR_TEXT + "改", encoding="utf-8")
|
||
with pytest.raises(ValueError, match="SHA-256"):
|
||
read_mr(summary.library_dir, alias)
|
||
|
||
|
||
def test_library_read_rejects_partial_directory(tmp_path: Path) -> None:
|
||
summary = build(tmp_path, make_delivery(2), 1, None, NOW)
|
||
partial = summary.library_dir.with_name(summary.library_dir.name + ".partial")
|
||
summary.library_dir.rename(partial)
|
||
with pytest.raises(ValueError, match="partial"):
|
||
list_aliases(partial)
|
||
|
||
|
||
def test_answers_read_rejects_modified_file(tmp_path: Path) -> None:
|
||
summary = build(tmp_path, make_delivery(2), 1, None, NOW)
|
||
path = summary.answers_dir / "diagnoses.csv"
|
||
path.write_text(path.read_text(encoding="utf-8-sig") + "x\n", encoding="utf-8-sig")
|
||
with pytest.raises(ValueError, match="SHA-256"):
|
||
read_diagnoses(summary.answers_dir)
|
||
|
||
|
||
def test_existing_build_directory_is_not_overwritten(tmp_path: Path) -> None:
|
||
summary = build(tmp_path, make_delivery(2), 1, None, NOW)
|
||
manifest = (summary.library_dir / "manifest.json").read_bytes()
|
||
with pytest.raises(FileExistsError):
|
||
build(tmp_path, make_delivery(3), 1, None, NOW) # 同一时刻、同一提交 → 同一构建号
|
||
assert (summary.library_dir / "manifest.json").read_bytes() == manifest
|
||
assert len(list_aliases(summary.library_dir)) == 2
|
||
|
||
|
||
def _csv_rows(path: Path) -> list[list[str]]:
|
||
with path.open(encoding="utf-8-sig", newline="") as handle:
|
||
return list(csv.reader(handle))
|
||
|
||
|
||
def test_answers_contents(tmp_path: Path) -> None:
|
||
delivery = make_delivery(4)
|
||
delivery.case(3).mr["年龄"] = "99岁" # C10
|
||
delivery.case(1).ultrasound["超声描述"] = "胰体部见无回声区。考虑囊性病灶;边界清。"
|
||
summary = build(tmp_path, delivery, 1, None, NOW)
|
||
|
||
assert summary.total_cases == 4
|
||
assert summary.passed_cases == 3
|
||
assert summary.skipped_cases == 1
|
||
assert summary.skipped_by_check == {"C10": 1}
|
||
assert summary.review_rows == 1
|
||
|
||
skipped = _csv_rows(summary.answers_dir / "skipped.csv")
|
||
assert skipped[0] == ["病例", "检查编号", "检查说明", "文件", "列", "涉及值"]
|
||
assert [row[:2] for row in skipped[1:]] == [["003", "C10"]]
|
||
assert json.loads(skipped[1][5])["年龄"]["MR 表"] == "99岁"
|
||
|
||
review = _csv_rows(summary.answers_dir / "review.csv")
|
||
alias_of_1 = {r.case_no: r.alias for r in read_aliases(summary.answers_dir)}["001"]
|
||
assert review[1:] == [[alias_of_1, "超声", "考虑", "考虑囊性病灶"]]
|
||
|
||
assert [r.case_no for r in read_aliases(summary.answers_dir)] == ["001", "002", "004"]
|
||
assert [r.video_id for r in read_aliases(summary.answers_dir)] == ["U001", "U002", "U004"]
|
||
codes = [
|
||
(d.clinical_code, d.ultrasound_code, d.mr_code) for d in read_diagnoses(summary.answers_dir)
|
||
]
|
||
assert codes == [(1, 1, 1), (2, 2, 2), (4, 4, 4)]
|
||
|
||
manifest = json.loads((summary.answers_dir / "manifest.json").read_text(encoding="utf-8"))
|
||
assert manifest["build_id"] == summary.build_id
|
||
assert "不代表没有问题" in manifest["skipped_note"]
|
||
assert sorted(manifest["files"]) == [
|
||
"aliases.csv",
|
||
"diagnoses.csv",
|
||
"review.csv",
|
||
"skipped.csv",
|
||
"snapshot/build.yaml",
|
||
"snapshot/finished.json",
|
||
"snapshot/snapshot.json",
|
||
]
|
||
|
||
|
||
def test_snapshot_records_config_and_git(tmp_path: Path) -> None:
|
||
summary = build(tmp_path, make_delivery(2), 1, SAMPLE_CODE_MAPS, NOW)
|
||
snapshot_dir = summary.answers_dir / "snapshot"
|
||
assert (snapshot_dir / "build.yaml").read_bytes() == (tmp_path / "build.yaml").read_bytes()
|
||
assert (snapshot_dir / "code_maps.yaml").read_bytes() == (
|
||
tmp_path / "code_maps.yaml"
|
||
).read_bytes()
|
||
snapshot = json.loads((snapshot_dir / "snapshot.json").read_text(encoding="utf-8"))
|
||
assert snapshot["build_id"] == summary.build_id
|
||
assert re.fullmatch(r"[0-9a-f]{40}", snapshot["git_commit"])
|
||
assert summary.build_id.startswith("20261016-093000-" + snapshot["git_commit"][:7])
|
||
assert summary.build_id.endswith("-dirty") == (snapshot["git_clean"] is False)
|
||
assert set(snapshot["config_sha256"]) == {"build.yaml", "code_maps.yaml"}
|
||
library_manifest = json.loads(
|
||
(summary.library_dir / "manifest.json").read_text(encoding="utf-8")
|
||
)
|
||
assert library_manifest["config_sha256"] == snapshot["config_sha256"]
|
||
assert library_manifest["ceus_source"] is None
|
||
assert library_manifest["aliases"] == sorted(library_manifest["aliases"])
|