"""build 装入 CEUS 结果:C15 跳过、tic.json 只有白名单字段、关键帧去附加数据、运行目录不合规时停下。 预期的 tic.json 键集合照任务书 02 第四阶段的文字手写:四条曲线各自拟合是否成功和拟合优度(R²); 病灶两条曲线的灌注指标(同旧 replay summary 的 perfusion_metrics 段);紫圈曲线是否可用; 7 个槽位各自是否定位到、定位到的视频时间、未定位的原因。 """ import csv import json from datetime import datetime from pathlib import Path from typing import Any, cast import pytest from PIL import Image from cadx.answers import read_aliases from cadx.build import BuildSummary, run_build from cadx.library import list_aliases, read_ultrasound, scan_for_leaks from fake_ceus_run import UNLOCATED_REASON, summary, write_run from fake_delivery import REPO_ROOT, make_delivery, write_config NOW = datetime(2026, 10, 16, 11, 0, 0).astimezone() RUN_ID = "20261016-100000-abcdef0" LOCATED = ("D1", "D2", "F1") _PERFUSION_KEYS = { "signal_domain", "time_origin", "area_curve", "values", "supporting_times_s", } _PERFUSION_VALUES = { "PE_au", "WiAUC_au_s", "RT_s", "mTTl_s", "TTP_s", "WiR_au_per_s", "WiPI_au", "WoAUC_au_s", "WiWoAUC_au_s", "FT_s", "WoR_au_per_s", } def _build(tmp_path: Path, count: int, run_dir: Path) -> BuildSummary: root = make_delivery(count).write(tmp_path / "delivery") return run_build(write_config(tmp_path, root, 1, None, run_dir), REPO_ROOT, NOW) def _skipped(answers_dir: Path) -> list[list[str]]: with (answers_dir / "skipped.csv").open(encoding="utf-8-sig", newline="") as handle: return list(csv.reader(handle))[1:] def _png_chunks(data: bytes) -> list[str]: 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 _key_tree(node: Any) -> Any: """嵌套字典的键结构:字典换成「键 → 子结构」,其余值换成 None。""" if isinstance(node, dict): return {key: _key_tree(value) for key, value in cast(dict[str, Any], node).items()} return None def test_c15_skips_failed_and_missing_cases(tmp_path: Path) -> None: run = write_run( tmp_path / "runs" / RUN_ID, {"U001": LOCATED, "U004": LOCATED}, [("U002", 2, "两半都没有绿框")], ) result = _build(tmp_path, 4, run) assert [r.case_no for r in read_aliases(result.answers_dir)] == ["001", "004"] assert len(list_aliases(result.library_dir)) == 2 skipped = _skipped(result.answers_dir) assert [row[:4] for row in skipped] == [ ["002", "C15", "CEUS 定量结果齐全", f"{RUN_ID}/failed.csv"], ["003", "C15", "CEUS 定量结果齐全", f"{RUN_ID}/U003"], ] assert json.loads(skipped[0][5]) == "第 2 步失败:两半都没有绿框" assert "没有这一例的结果目录" in json.loads(skipped[1][5]) assert result.skipped_by_check == {"C15": 2} manifest = json.loads((result.library_dir / "manifest.json").read_text(encoding="utf-8")) assert manifest["ceus_source"] == RUN_ID def test_tic_json_has_exactly_the_listed_fields(tmp_path: Path) -> None: result = _build(tmp_path, 1, write_run(tmp_path / "runs" / RUN_ID, {"U001": LOCATED}, [])) (alias,) = list_aliases(result.library_dir) tic = read_ultrasound(result.library_dir, alias).tic perfusion = { **dict.fromkeys(_PERFUSION_KEYS - {"values", "supporting_times_s"}), "values": dict.fromkeys(_PERFUSION_VALUES), "supporting_times_s": dict.fromkeys(("TI", "mTT", "TO")), } slot = {"located": None, "video_time_s": None, "reason": None} curves = ("roi_all", "roi_perf", "roi_pan", "background_circle") assert _key_tree(tic) == { "fits": {name: {"success": None, "r2": None} for name in curves}, "perfusion_metrics": {"roi_all": perfusion, "roi_perf": None}, "pan_curve_usable": None, "keyframes": {s: slot for s in ("B0", "D1", "D2", "D3", "D4", "F1", "F2")}, } assert tic is not None assert tic["fits"]["roi_all"] == {"success": True, "r2": 0.93} assert tic["fits"]["roi_pan"] == {"success": False, "r2": None} assert tic["pan_curve_usable"] is False assert tic["perfusion_metrics"]["roi_all"]["values"]["PE_au"] == 1.0 # 假数据里 D1 是第 2 个槽位:视频时间 1 × 2.5 秒。 assert tic["keyframes"]["D1"] == {"located": True, "video_time_s": 2.5, "reason": None} assert tic["keyframes"]["D4"] == { "located": False, "video_time_s": None, "reason": UNLOCATED_REASON, } def test_keyframes_metadata_dropped_and_pixels_kept(tmp_path: Path) -> None: run = write_run(tmp_path / "runs" / RUN_ID, {"U001": LOCATED}, []) result = _build(tmp_path, 1, run) (alias,) = list_aliases(result.library_dir) route = read_ultrasound(result.library_dir, alias) assert sorted(route.keyframes) == sorted(LOCATED) for slot, copied in route.keyframes.items(): assert ( copied == result.library_dir / "cases" / alias / "ultrasound/keyframes" / f"{slot}.png" ) with Image.open(run / "U001" / "keyframes" / f"{slot}.png") as original: assert "Source" in original.info and "exif" in original.info pixels = original.tobytes() with Image.open(copied) as image: assert image.mode == "RGB" and image.tobytes() == pixels assert "Source" not in image.info and "exif" not in image.info assert _png_chunks(copied.read_bytes()) == ["IHDR", "IDAT", "IEND"] def test_null_ceus_run_has_no_ceus_files(tmp_path: Path) -> None: root = make_delivery(2).write(tmp_path / "delivery") result = run_build(write_config(tmp_path, root, 1, None, None), REPO_ROOT, NOW) for alias in list_aliases(result.library_dir): route = read_ultrasound(result.library_dir, alias) assert route.tic is None and route.keyframes == {} assert sorted( p.name for p in (result.library_dir / "cases" / alias / "ultrasound").iterdir() ) == [ "key.png", "report.txt", ] def test_scan_finds_nothing_then_hits_video_id_in_tic_json(tmp_path: Path) -> None: run = write_run(tmp_path / "runs" / RUN_ID, {"U001": LOCATED, "U002": LOCATED}, []) result = _build(tmp_path, 2, run) forbidden = ["P001", "P002", "U001", "U002", "U001_roi2.mp4", "U002_roi2.mp4", "U999"] assert scan_for_leaks(result.library_dir, forbidden, ("name",)) == [] alias = list_aliases(result.library_dir)[0] tic_path = result.library_dir / "cases" / alias / "ultrasound" / "tic.json" tic = json.loads(tic_path.read_text(encoding="utf-8")) tic["keyframes"]["D4"]["reason"] = "见 U002 第 3 帧" tic_path.write_text(json.dumps(tic, ensure_ascii=False), encoding="utf-8") hits = scan_for_leaks(result.library_dir, forbidden, ("name",)) assert [(Path(h.file).name, h.kind, h.term) for h in hits] == [("tic.json", "字符串", "U002")] def test_reason_with_video_id_stops_build(tmp_path: Path) -> None: run = write_run(tmp_path / "runs" / RUN_ID, {"U001": LOCATED}, []) content = summary("U001", LOCATED) content["anchor_frames"]["D4"]["reason"] = "U001 的 D4 不在视频里" (run / "U001" / "summary.json").write_text(json.dumps(content), encoding="utf-8") with pytest.raises(ValueError, match="视频号或路径"): _build(tmp_path, 1, run) assert not (tmp_path / "library").exists() def test_keyframe_that_cannot_be_reencoded_skips_case_as_c14(tmp_path: Path) -> None: run = write_run(tmp_path / "runs" / RUN_ID, {"U001": LOCATED, "U002": LOCATED}, []) Image.new("I;16", (4, 4)).save(run / "U002" / "keyframes" / "D2.png", format="PNG") result = _build(tmp_path, 2, run) assert [r.case_no for r in read_aliases(result.answers_dir)] == ["001"] skipped = _skipped(result.answers_dir) assert [row[:4] for row in skipped] == [ ["002", "C14", "关键图能原样重编码", f"{RUN_ID}/U002/keyframes/D2.png"] ] def _assert_stops(tmp_path: Path, run: Path, error: type[Exception]) -> None: with pytest.raises(error): _build(tmp_path, 1, run) assert not (tmp_path / "library").exists() and not (tmp_path / "answers").exists() def test_run_dir_missing_stops(tmp_path: Path) -> None: _assert_stops(tmp_path, tmp_path / "runs" / RUN_ID, FileNotFoundError) def test_partial_run_dir_stops(tmp_path: Path) -> None: run = write_run(tmp_path / "runs" / f"{RUN_ID}.partial", {"U001": LOCATED}, []) _assert_stops(tmp_path, run, ValueError) def test_run_dir_without_failed_csv_stops(tmp_path: Path) -> None: run = write_run(tmp_path / "runs" / RUN_ID, {"U001": LOCATED}, []) (run / "failed.csv").unlink() _assert_stops(tmp_path, run, FileNotFoundError) def test_run_dir_without_snapshot_stops(tmp_path: Path) -> None: run = write_run(tmp_path / "runs" / RUN_ID, {"U001": LOCATED}, []) (run / "snapshot" / "snapshot.json").unlink() _assert_stops(tmp_path, run, FileNotFoundError)