"""医生核对包:目录结构、CSV 规格、TIC参数、问题清单的医生写法、程序原文快照、逐字节拷贝、PDF。 预期值照任务书 03、04 写:目录和文件名、列名、问题清单的「环节」与「问题」、TIC参数.csv 的中文名、 单位和小数位都按任务书的文字手写,不从实现里推。原因对照表的逐条预期在 `test_review_reasons.py`。 假数据(8 例): - P001:进库;病灶去暗区曲线拟合失败(parameter_on_boundary),B0、D1–D4 因此未取到;F2 落在视频 之后;F1 取到;紫圈不可用。TIC参数两组都空。 - P002:CEUS 第 2 步失败(两半都没有绿框,C15 跳过)。P003:CEUS 运行里没有结果目录(C15 跳过)。 - P004:进库;7 个槽位都取到,D2、F1 的时间改成 5.04、12.46 秒;拟合都成功;紫圈可用;两组指标都有, 病灶一组观测不够的 4 项为 null。 - P005:MR 表诊断、临床表年龄与另两张表不同(C10 跳过);CEUS 运行里却有它完整的结果目录。 - P006:进库;病灶整体曲线拟合失败(credible_peak_not_found),B0、D1–D4 未取到;紫圈可用但拟合失败 (fit_quality_failed)。TIC参数只有病灶一组。 - P007:MR 表性别与超声表不同(C10 跳过);CEUS 第 1 步失败(两半配准不一致)。 - P008:进库;拟合都成功;B0 基线是估计的、D2 和 D3 的候选帧被占、D4 落在视频之后、 F2 附近只有重复帧。 """ import csv import hashlib import json import re import shutil import subprocess from datetime import datetime from pathlib import Path from typing import Any import pytest import yaml from cadx.answers import read_aliases from cadx.build import BuildSummary, run_build from cadx.library import read_ultrasound from cadx.review import run_review_pack from cadx.review._pdf import markdown_to_pdf, report_markdown from fake_ceus_run import perfusion, summary, write_run from fake_delivery import DIAGNOSIS_COLUMN, REPO_ROOT, make_delivery, write_config BUILD_NOW = datetime(2026, 10, 17, 9, 0, 0).astimezone() PACK_NOW = datetime(2026, 10, 17, 10, 0, 0).astimezone() RUN_ID = "20261017-080000-abcdef0" ALL_SLOTS = ("B0", "D1", "D2", "D3", "D4", "F1", "F2") DYNAMIC = ("B0", "D1", "D2", "D3", "D4") CONS = ",程序没法处理这段视频" SEX_NOTE = "(临床表的性别是数字编码,还没有编码对照表,这次没有拿来比)" LESION = ";B0、D1–D4 要病灶整体和病灶去暗区两条曲线都拟合上才取,所以这 5 个关键帧没有取" BOUNDARY = ( "试着贴出来的曲线在峰高、峰出现的时间、曲线宽窄、开始变亮的时刻或增强前水平上," "有一项卡在了程序允许范围的边上" ) FIT_FAILED = "病灶两条曲线(roi_all、roi_perf)的 Lognormal 拟合没有都过质量门" F2_AFTER = ( "推注后第 60 秒在视频第 55.00 秒(推注时间 X=5 秒),落在视频有效帧范围 [0.00, 40.20] 秒之外" ) STEP2 = "左右两半都没有找到绿框,选不出提示帧" STEP1 = "两半配准不一致:互相关估计 (dx, dy) = (3, 4),模板匹配估计 (9, 4),相差 6 像素,超过门槛 4" SUMMARY_HEADER = [ "病例号", "视频号", "化名", "是否进入资料库", "没进的原因", "CEUS 定量是否成功", "CEUS 失败在第几步", *ALL_SLOTS, ] PROBLEM_HEADER = ["病例号", "视频号", "环节", "问题"] TIC_HEADER = ["指标", "单位", "胰腺实质(紫圈)", "病灶(去暗区)"] METRIC_NAMES = [ ["峰值增强 PE", "灰度"], ["达峰时间 TTP", "秒"], ["最大洗入斜率 WiR", "灰度/秒"], ["上升时间 RT", "秒"], ["洗入曲线下面积 WiAUC", "灰度·秒"], ["洗入灌注指数 WiPI", "灰度"], ["最大洗出斜率 WoR", "灰度/秒"], ["下降时间 FT", "秒"], ["洗出曲线下面积 WoAUC", "灰度·秒"], ["洗入洗出曲线下面积 WiWoAUC", "灰度·秒"], ["平均渡越时间 mTTl", "秒"], ] # fake_ceus_run.perfusion(1.0) 的 11 个值按 TIC参数.csv 的行序、按小数位写出: # PE 1、WiAUC 2、RT 3、mTTl 4、TTP 5、WiR 6、WiPI 7、WoAUC 8、WiWoAUC 9、FT 10、WoR −11。 SCALE1 = ["1.00", "5.0", "6.00", "3.0", "2.00", "7.00", "-11.00", "10.0", "8.00", "9.00", "4.0"] BLANK = [""] * 11 ANSWER_HEADERS = { "化名对照.csv": ["化名", "病例号", "视频号"], "诊断.csv": ["化名", "病例号", "临床表诊断编码", "超声表诊断编码", "MR表诊断编码", "诊断名"], "跳过病例.csv": ["病例", "检查编号", "检查说明", "文件", "列", "涉及值"], "报告复核句.csv": ["化名", "路", "命中的词", "所在句子"], } CASE_FILES = {"临床信息.csv", "超声报告.pdf", "MR报告.pdf", "关键帧"} CEUS_FILES = {"TIC曲线.png", "TIC参数.csv", "回放视频.mp4"} # 任务书 04 验收第 3 条:问题清单里不许出现的东西。 FORBIDDEN = re.compile(r"C[0-9]|roi_|_|\{|\[|band|[0-9]{8}-[0-9]{6}") def _rewrite_summary(run: Path, video_id: str, located: tuple[str, ...], edit: Any) -> None: content = summary(video_id, located) edit(content) # 最大洗出斜率 WoR 在真实运行里是负数,假数据也照此写。 for group in content["perfusion_metrics"].values(): if group is not None and group["values"]["WoR_au_per_s"] is not None: group["values"]["WoR_au_per_s"] = -abs(group["values"]["WoR_au_per_s"]) (run / video_id / "summary.json").write_text(json.dumps(content), encoding="utf-8") def _unlocated(content: dict[str, Any], reasons: dict[str, str]) -> None: for slot, reason in reasons.items(): content["anchor_frames"][slot]["reason"] = reason def _fail(content: dict[str, Any], curve: str, code: str) -> None: content["fits"][curve].update(success=False, failure_reason=code) if curve in content["perfusion_metrics"]: content["perfusion_metrics"][curve] = None def _pan_ok(content: dict[str, Any]) -> None: content["reference_valid"] = True content["fits"]["roi_pan"].update(success=True, failure_reason=None) content["perfusion_metrics"]["roi_pan"] = perfusion(1.0) def _p001(content: dict[str, Any]) -> None: _fail(content, "roi_perf", "parameter_on_boundary") content["fits"]["roi_pan"]["failure_reason"] = "insufficient_valid_samples" _unlocated(content, {**dict.fromkeys(DYNAMIC, FIT_FAILED), "F2": F2_AFTER}) def _p004(content: dict[str, Any]) -> None: _pan_ok(content) content["anchor_frames"]["D2"]["frame_time_video_s"] = 5.04 content["anchor_frames"]["F1"]["frame_time_video_s"] = 12.46 values = content["perfusion_metrics"]["roi_perf"]["values"] values.update( PE_au=21.456, TTP_s=13.04, WiR_au_per_s=3.2, RT_s=6.26, WiAUC_au_s=80, WiPI_au=12.777, WoR_au_per_s=-1.234, FT_s=None, WoAUC_au_s=None, WiWoAUC_au_s=None, mTTl_s=None, ) def _p006(content: dict[str, Any]) -> None: _fail(content, "roi_all", "credible_peak_not_found") content["reference_valid"] = True content["fits"]["roi_pan"]["failure_reason"] = "fit_quality_failed" _unlocated(content, dict.fromkeys(DYNAMIC, FIT_FAILED)) def _p008(content: dict[str, Any]) -> None: _pan_ok(content) taken = "{} 容差窗口 [4.70, 5.30] 秒 内的候选帧都被时间更早的槽位占用,无法保持先后顺序" _unlocated( content, { "B0": "视频开头没有观测到稳定的增强前基线(基线是估计的),B0 不定义", "D2": taken.format("D2"), "D3": taken.format("D3"), "D4": "D4 时刻 41.50 秒落在视频有效帧范围 [0.00, 40.20] 秒 之外", "F2": "视频第 55.00 秒 ±2 秒内没有不重复的帧", }, ) LOCATED: dict[str, tuple[str, ...]] = { "U001": ("F1",), "U004": ALL_SLOTS, "U005": ALL_SLOTS, "U006": ("F1", "F2"), "U008": ("D1", "F1"), } def _build_with_ceus(root: Path) -> tuple[BuildSummary, Path]: delivery = make_delivery(8) delivery.case(5).mr[DIAGNOSIS_COLUMN] = 2 delivery.case(5).clinical["Age"] = 28 delivery.case(7).mr["性别"] = "男" delivery_dir = delivery.write(root / "delivery") run = write_run( root / "runs" / RUN_ID, delivery_dir, LOCATED, [("U002", 2, STEP2), ("U007", 1, STEP1)] ) for video_id, edit in (("U001", _p001), ("U004", _p004), ("U006", _p006), ("U008", _p008)): _rewrite_summary(run, video_id, LOCATED[video_id], edit) _rewrite_summary(run, "U005", ALL_SLOTS, _pan_ok) return run_build(write_config(root, delivery_dir, 1, None, run), REPO_ROOT, BUILD_NOW), run def _pack_config(root: Path, library_dir: Path, answers_dir: Path) -> Path: config = { "library_dir": str(library_dir), "answers_dir": str(answers_dir), "output_root": str(root / "review_pack"), } path = root / "review_pack.yaml" path.write_text(yaml.safe_dump(config, allow_unicode=True), encoding="utf-8") return path def _tree_hashes(folder: Path) -> dict[str, str]: return { p.relative_to(folder).as_posix(): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted(folder.rglob("*")) if p.is_file() } class Packed: """构建一次、出一次包,各条测试共用。""" def __init__(self, root: Path) -> None: self.build, self.run = _build_with_ceus(root) self.library_before = _tree_hashes(self.build.library_dir) self.answers_before = _tree_hashes(self.build.answers_dir) config = _pack_config(root, self.build.library_dir, self.build.answers_dir) self.config = config self.result = run_review_pack(config, REPO_ROOT, PACK_NOW) self.pack = self.result.pack_dir self.alias = {r.case_no: r.alias for r in read_aliases(self.build.answers_dir)} @pytest.fixture(scope="module") def packed(tmp_path_factory: pytest.TempPathFactory) -> Packed: return Packed(tmp_path_factory.mktemp("review")) def _csv(path: Path) -> list[list[str]]: raw = path.read_bytes() assert raw.startswith(b"\xef\xbb\xbf"), path with path.open(encoding="utf-8-sig", newline="") as handle: return list(csv.reader(handle)) def test_directory_layout(packed: Packed) -> None: pack = packed.pack assert pack.name == f"医生核对包_{packed.build.build_id}" assert sorted(p.name for p in packed.result.run_dir.iterdir()) == sorted( ["snapshot", pack.name] ) assert {p.name for p in pack.iterdir()} == { "说明.pdf", "问题清单.csv", "病例总表.csv", "标准答案", "病例", } assert {p.name for p in (pack / "标准答案").iterdir()} == set(ANSWER_HEADERS) assert {p.name for p in (pack / "病例").iterdir()} == {"P001", "P004", "P006", "P008"} for case in ("P001", "P004", "P006", "P008"): assert {p.name for p in (pack / "病例" / case).iterdir()} == CASE_FILES | CEUS_FILES assert not [p for p in pack.rglob("*") if p.name in ("TIC结果.csv", "关键图.png")] keyframes = { "P001": {"医生截图.png", "F1_第12.5秒.png"}, "P004": { "医生截图.png", "B0_第0.0秒.png", "D1_第2.5秒.png", "D2_第5.0秒.png", "D3_第7.5秒.png", "D4_第10.0秒.png", "F1_第12.5秒.png", "F2_第15.0秒.png", }, "P006": {"医生截图.png", "F1_第12.5秒.png", "F2_第15.0秒.png"}, "P008": {"医生截图.png", "D1_第2.5秒.png", "F1_第12.5秒.png"}, } for case, names in keyframes.items(): assert {p.name for p in (pack / "病例" / case / "关键帧").iterdir()} == names def test_every_csv_has_bom_and_no_placeholder_for_missing(packed: Packed) -> None: csvs = sorted(packed.pack.rglob("*.csv")) assert len(csvs) == 2 + 4 + 4 * 2 for path in csvs: for row in _csv(path): assert not {"null", "None", "NaN", "nan"} & set(row), path assert _csv(packed.pack / "病例总表.csv")[0] == SUMMARY_HEADER assert _csv(packed.pack / "问题清单.csv")[0] == PROBLEM_HEADER for name, header in ANSWER_HEADERS.items(): assert _csv(packed.pack / "标准答案" / name)[0] == header clinical = _csv(packed.pack / "病例" / "P001" / "临床信息.csv") assert len(clinical) == 2 and "Case_ID" not in clinical[0] and "Diagnosis" not in clinical[0] assert clinical[0][:3] == ["Sex", "Age", "Symptom_Pain"] # 假交付包里没测的化验是 None:留空。 assert clinical[1][clinical[0].index("Amylase")] == "" def test_case_summary(packed: Packed) -> None: rows = _csv(packed.pack / "病例总表.csv")[1:] blank = [""] * 7 a = packed.alias no = "未取到" assert rows == [ ["P001", "U001", a["001"], "是", "", "是", "", no, no, no, no, no, "12.5", no], ["P002", "U002", "", "否", f"视频里找不到画好的绿框{CONS}", "否", "2", *blank], ["P003", "U003", "", "否", "造影视频没有处理结果", "", "", *blank], [ "P004", "U004", a["004"], "是", "", "是", "", "0.0", "2.5", "5.04", "7.5", "10.0", "12.46", "15.0", ], [ "P005", "U005", "", "否", "三张表的病理诊断编码对不上:临床表写 1,超声表写 1,MR 表写 2;" "三张表的年龄对不上:临床表 28 岁,超声表 35 岁,MR 表 35 岁", "是", "", *blank, ], ["P006", "U006", a["006"], "是", "", "是", "", no, no, no, no, no, "12.5", "15.0"], [ "P007", "U007", "", "否", f"超声表和 MR 表的性别对不上:超声表 女,MR 表 男{SEX_NOTE}", "否", "1", *blank, ], ["P008", "U008", a["008"], "是", "", "是", "", no, "2.5", no, no, no, "12.5", no], ] EXPECTED_PROBLEMS = [ [ "P001", "U001", "TIC 曲线", f"病灶去暗区那条曲线没能拟合({BOUNDARY}){LESION},TIC 参数里病灶一组也空着", ], [ "P001", "U001", "关键帧", "F2 没有取:换算到视频里的时刻不在录到的范围内" "(F2 是推注后第 60 秒,应在视频第 55.0 秒,视频只录到第 40.2 秒)", ], [ "P001", "U001", "紫圈", "视频里大部分时间认不出紫圈,紫圈那条曲线不能用,TIC 参数里胰腺实质一组空着", ], ["P002", "U002", "造影视频处理", f"视频里找不到画好的绿框{CONS},这一例没进资料库"], ["P003", "U003", "造影视频处理", "造影视频没有处理结果,这一例没进资料库"], [ "P005", "U005", "资料核对", "三张表的病理诊断编码对不上:临床表写 1,超声表写 1,MR 表写 2;" "三张表的年龄对不上:临床表 28 岁,超声表 35 岁,MR 表 35 岁,这一例没进资料库", ], [ "P006", "U006", "TIC 曲线", f"病灶整体那条曲线没能拟合(曲线太平,没有明显的峰){LESION}", ], [ "P006", "U006", "TIC 曲线", "紫圈那条曲线没能拟合(曲线起伏杂乱,平滑曲线和它差得太多),TIC 参数里胰腺实质一组空着", ], [ "P007", "U007", "资料核对", f"超声表和 MR 表的性别对不上:超声表 女,MR 表 男{SEX_NOTE},这一例没进资料库", ], ["P007", "U007", "造影视频处理", f"视频左右两屏的位置对不齐{CONS}"], [ "P008", "U008", "关键帧", "B0 没有取:视频一开始病灶就已经在变亮,没录到增强之前的那一段", ], [ "P008", "U008", "关键帧", "D2、D3 没有取:这个时刻附近能用的画面都不晚于前一个关键帧取的那一帧" "(D1–D4 要按时间先后各取一帧,后一个必须比前一个晚)", ], [ "P008", "U008", "关键帧", "D4 没有取:算出的时刻不在视频能用的画面范围里" "(D4 在视频第 41.5 秒,能用的画面是第 0.0–40.2 秒)", ], ["P008", "U008", "关键帧", "F2 没有取:这个时刻前后没有能用的画面(都是重复帧)"], ] def test_problem_list_in_plain_words(packed: Packed) -> None: assert _csv(packed.pack / "问题清单.csv")[1:] == EXPECTED_PROBLEMS counts = {"资料核对": 2, "造影视频处理": 3, "TIC 曲线": 3, "关键帧": 5, "紫圈": 1} assert packed.result.problems_by_stage == dict(sorted(counts.items())) def test_problem_list_has_no_program_words(packed: Packed) -> None: """任务书 04 验收第 3 条:问题清单全文不含检查编号、字段名、下划线、括号、band、运行号。""" text = (packed.pack / "问题清单.csv").read_text(encoding="utf-8-sig") assert FORBIDDEN.findall(text) == [] def test_raw_problem_list_in_snapshot(packed: Packed) -> None: """程序原文照任务书 03 的写法留在快照里,不进医生的目录。""" assert not (packed.pack / "问题清单_原文.csv").exists() rows = _csv(packed.result.run_dir / "snapshot" / "问题清单_原文.csv") skipped = {row[0]: row for row in _csv(packed.pack / "标准答案" / "跳过病例.csv")[1:]} fit_failed = [f"{slot} 未取到:{FIT_FAILED}" for slot in DYNAMIC] p008 = { "B0": "视频开头没有观测到稳定的增强前基线(基线是估计的),B0 不定义", "D2": "D2 容差窗口 [4.70, 5.30] 秒 内的候选帧都被时间更早的槽位占用,无法保持先后顺序", "D3": "D3 容差窗口 [4.70, 5.30] 秒 内的候选帧都被时间更早的槽位占用,无法保持先后顺序", "D4": "D4 时刻 41.50 秒落在视频有效帧范围 [0.00, 40.20] 秒 之外", "F2": "视频第 55.00 秒 ±2 秒内没有不重复的帧", } def c10(no: str) -> list[str]: row = skipped[no] return [ f"P{no}", f"U{no}", "装配资料库", f"C10 跨表一致(诊断、年龄、性别);文件:{row[3]};列:{row[4]};涉及值:{row[5]}", ] assert rows == [ PROBLEM_HEADER, ["P001", "U001", "TIC 拟合", "病灶去暗区曲线未完成拟合(parameter_on_boundary)"], *[["P001", "U001", "关键帧", text] for text in fit_failed], ["P001", "U001", "关键帧", f"F2 未取到:{F2_AFTER}"], ["P001", "U001", "紫圈", "紫圈曲线不可用"], [ "P002", "U002", "装配资料库", f'C15 CEUS 定量结果齐全;文件:{RUN_ID}/failed.csv;涉及值:"第 2 步失败:{STEP2}"', ], ["P002", "U002", "CEUS 第 2 步", f"第 2 步失败:{STEP2}"], [ "P003", "U003", "装配资料库", f"C15 CEUS 定量结果齐全;文件:{RUN_ID}/U003;" '涉及值:"CEUS 运行目录里没有这一例的结果目录"', ], c10("005"), ["P006", "U006", "TIC 拟合", "病灶整体曲线未完成拟合(credible_peak_not_found)"], *[["P006", "U006", "关键帧", text] for text in fit_failed], c10("007"), ["P007", "U007", "CEUS 第 1 步", f"第 1 步失败:{STEP1}"], *[["P008", "U008", "关键帧", f"{slot} 未取到:{text}"] for slot, text in p008.items()], ] def test_tic_params(packed: Packed) -> None: def table(case: str) -> list[list[str]]: return _csv(packed.pack / "病例" / case / "TIC参数.csv") def rows(pan: list[str], perf: list[str]) -> list[list[str]]: return [[*m, a, b] for m, a, b in zip(METRIC_NAMES, pan, perf, strict=True)] p004_perf = ["21.46", "13.0", "3.20", "6.3", "80.00", "12.78", "-1.23", "", "", "", ""] assert table("P004") == [TIC_HEADER, *rows(SCALE1, p004_perf)] assert table("P008") == [TIC_HEADER, *rows(SCALE1, SCALE1)] assert table("P006") == [ TIC_HEADER, *rows(BLANK, SCALE1), ["没算出的原因", "", "曲线起伏杂乱,平滑曲线和它差得太多,没能拟合", ""], ] assert table("P001") == [ TIC_HEADER, *rows(BLANK, BLANK), [ "没算出的原因", "", "视频里大部分时间认不出紫圈", f"{BOUNDARY},没能拟合", ], ] def test_tic_params_match_library(packed: Packed) -> None: """TIC参数.csv 的每一格都是资料库 tic.json 里那个数按小数位写出(null 为空)。""" keys = ["PE_au", "TTP_s", "WiR_au_per_s", "RT_s", "WiAUC_au_s", "WiPI_au"] keys += ["WoR_au_per_s", "FT_s", "WoAUC_au_s", "WiWoAUC_au_s", "mTTl_s"] for case_no in ("001", "004", "006", "008"): tic = read_ultrasound(packed.build.library_dir, packed.alias[case_no]).tic assert tic is not None rows = _csv(packed.pack / "病例" / f"P{case_no}" / "TIC参数.csv")[1:12] for row, key in zip(rows, keys, strict=True): for cell, group in zip(row[2:], ("roi_pan", "roi_perf"), strict=True): values = tic["perfusion_metrics"][group] value = None if values is None else values[key] digits = 1 if key in ("TTP_s", "RT_s", "FT_s", "mTTl_s") else 2 assert cell == ("" if value is None else f"{round(value, digits):.{digits}f}") def test_copies_are_byte_identical(packed: Packed) -> None: for case_no, video_id in (("001", "U001"), ("004", "U004")): case_dir = packed.pack / "病例" / f"P{case_no}" route = read_ultrasound(packed.build.library_dir, packed.alias[case_no]) key = (case_dir / "关键帧" / "医生截图.png").read_bytes() assert key == route.keyframes["key"].read_bytes() assert route.tic is not None copied = { p.name.split("_")[0]: p for p in (case_dir / "关键帧").iterdir() if p.name != "医生截图.png" } slots = {slot: path for slot, path in route.keyframes.items() if slot != "key"} assert sorted(copied) == sorted(slots) for slot, source in slots.items(): assert copied[slot].read_bytes() == source.read_bytes() seconds = route.tic["keyframe_times_s"][slot] assert copied[slot].name == f"{slot}_第{round(seconds, 1)}秒.png" run_case = packed.run / video_id assert (case_dir / "TIC曲线.png").read_bytes() == (run_case / "tic.png").read_bytes() assert (case_dir / "回放视频.mp4").read_bytes() == (run_case / "overlay.mp4").read_bytes() answers = packed.build.answers_dir for source, name in ( ("aliases.csv", "化名对照.csv"), ("diagnoses.csv", "诊断.csv"), ("skipped.csv", "跳过病例.csv"), ("review.csv", "报告复核句.csv"), ): assert (packed.pack / "标准答案" / name).read_bytes() == (answers / source).read_bytes() def _tool_output(tool: str, *args: str) -> str: path = shutil.which(tool) assert path is not None, f"找不到 {tool}" return subprocess.run([path, *args], check=True, capture_output=True, text=True).stdout # noqa: S603 def test_pdfs_use_noto_cjk_and_carry_ids(packed: Packed) -> None: pdfs = sorted(packed.pack.rglob("*.pdf")) assert len(pdfs) == packed.result.pdfs == 1 + 4 * 2 for pdf in pdfs: assert pdf.read_bytes().startswith(b"%PDF") and pdf.stat().st_size > 1000 readme = packed.pack / "说明.pdf" fonts = _tool_output("pdffonts", str(readme)) assert "NotoSerifCJK" in fonts and "NotoSansCJK" in fonts pages = re.search(r"^Pages:\s+([0-9]+)$", _tool_output("pdfinfo", str(readme)), re.M) assert pages is not None and int(pages.group(1)) <= 4 text = _tool_output("pdftotext", str(readme), "-") assert packed.build.build_id in text and RUN_ID in text # 说明里不写检查编号、原因码、字段名。 for word in ("C15", "parameter_on_boundary", "perfusion_metrics", "贵方", "MedSAM"): assert word not in text report = _tool_output("pdftotext", str(packed.pack / "病例" / "P001" / "超声报告.pdf"), "-") assert report.startswith("病例号 P001 超声描述") def test_snapshot_and_no_writes_to_libraries(packed: Packed) -> None: snapshot = packed.result.run_dir / "snapshot" assert (snapshot / "review_pack.yaml").read_bytes() == packed.config.read_bytes() record = json.loads((snapshot / "review.json").read_text(encoding="utf-8")) assert record["build_id"] == packed.build.build_id and record["ceus_run_id"] == RUN_ID assert record["pandoc"].startswith("pandoc ") and "LibreOffice" in record["soffice"] assert (snapshot / "finished.json").is_file() assert _tree_hashes(packed.build.library_dir) == packed.library_before assert _tree_hashes(packed.build.answers_dir) == packed.answers_before def test_existing_run_dir_stops(packed: Packed) -> None: with pytest.raises(FileExistsError): run_review_pack(packed.config, REPO_ROOT, PACK_NOW) def test_unknown_reason_stops(tmp_path: Path) -> None: """关键帧未取到的原因不在对照表里(假运行的默认原因是编造的):报错停下,不出包。""" delivery_dir = make_delivery(1).write(tmp_path / "delivery") run = write_run(tmp_path / "runs" / RUN_ID, delivery_dir, {"U001": ("D1",)}, []) build = run_build(write_config(tmp_path, delivery_dir, 1, None, run), REPO_ROOT, BUILD_NOW) config = _pack_config(tmp_path, build.library_dir, build.answers_dir) with pytest.raises(ValueError, match="不在对照表里"): run_review_pack(config, REPO_ROOT, PACK_NOW) assert not [p for p in (tmp_path / "review_pack").iterdir() if not p.name.endswith(".partial")] def test_build_without_ceus(tmp_path: Path) -> None: delivery_dir = make_delivery(2).write(tmp_path / "delivery") build = run_build(write_config(tmp_path, delivery_dir, 1, None, None), REPO_ROOT, BUILD_NOW) result = run_review_pack( _pack_config(tmp_path, build.library_dir, build.answers_dir), REPO_ROOT, PACK_NOW ) for case in ("P001", "P002"): case_dir = result.pack_dir / "病例" / case assert {p.name for p in case_dir.iterdir()} == CASE_FILES assert {p.name for p in (case_dir / "关键帧").iterdir()} == {"医生截图.png"} alias = {r.case_no: r.alias for r in read_aliases(build.answers_dir)} rows = _csv(result.pack_dir / "病例总表.csv")[1:] assert [row[:5] for row in rows] == [ ["P001", "U001", alias["001"], "是", ""], ["P002", "U002", alias["002"], "是", ""], ] assert all(row[5:] == [""] * 9 for row in rows) assert _csv(result.pack_dir / "问题清单.csv") == [PROBLEM_HEADER] assert result.problems_by_stage == {} and result.video_bytes == 0 def test_build_ids_differ_stops(tmp_path: Path) -> None: config = _pack_config(tmp_path, tmp_path / "library" / "a", tmp_path / "answers" / "b") with pytest.raises(ValueError, match="不是同一次构建"): run_review_pack(config, REPO_ROOT, PACK_NOW) assert not (tmp_path / "review_pack").exists() def test_report_pdf_keeps_markdown_characters(tmp_path: Path) -> None: """报告正文里的 Markdown 记号、引号、连字符、换行照抄,不被 pandoc 当成格式。 行首空格抽回文字时只剩一个(pdftotext 按版面估空格),所以每行比较去掉首尾空白后的内容。 """ text = ( '1. 囊壁*增厚* #2 a_b_c x [链接](y) -- "引号" ... `码` | 竖线\n 第二行行首两个空格' ) target = tmp_path / "报告.pdf" markdown_to_pdf({target: report_markdown("病例号 P001 超声描述", text)}) lines = _tool_output("pdftotext", "-layout", str(target), "-").splitlines() assert [line.strip() for line in lines if line.strip()][1:] == [ line.strip() for line in text.splitlines() ] def _gap_after_ab(pdf: Path) -> float: """pdftotext -bbox 里词「ab」右边界到下一个词左边界的距离(点)。""" boxes = re.findall( r'xMin="([0-9.]+)"[^>]*xMax="([0-9.]+)"[^>]*>([^<]*)', _tool_output("pdftotext", "-bbox", str(pdf), "-"), ) words = [(float(x0), float(x1), w) for x0, x1, w in boxes] index = next(i for i, (_, _, w) in enumerate(words) if w == "ab") return words[index + 1][0] - words[index][1] def test_report_pdf_keeps_repeated_spaces(tmp_path: Path) -> None: """一行里连续三个空格不被 pandoc 并成一个。 pdftotext 抽文字时按间距估空格,三个空格也只吐出一个,所以量版面:「ab」与「cd」之间的间距是 单个空格那份 PDF 的约三倍。 """ one, three = tmp_path / "一个空格.pdf", tmp_path / "三个空格.pdf" markdown_to_pdf( { one: report_markdown("病例号 P001 超声描述", "ab cd"), three: report_markdown("病例号 P001 超声描述", "ab cd"), } ) assert _gap_after_ab(three) / _gap_after_ab(one) == pytest.approx(3.0, abs=0.3) def test_report_with_tab_stops() -> None: with pytest.raises(ValueError, match="病例号 P001 超声描述:报告原文里有 Tab"): report_markdown("病例号 P001 超声描述", "囊壁厚\t约2mm")