Files
CADx-4/tests/fake_delivery.py
T
iomgaa bceb01e8fa feat: 装配两个库(不含 CEUS 结果)
按任务书 01 实现 delivery、library、answers、runs(快照部分)、build 五个模块,
scripts/build.py、scripts/scan_library.py 与 config/build.yaml(decode_codes: false)。
交付包整表检查 T1–T4、映射表检查 T5、逐例检查 C01–C13,一例遇到第一项不过即整例跳过。

与任务书原文的出入:
- C04 报告表名随视频名配对(负责人 2026-10-16 定)。
- 按两轮独立审阅加严 C11(更多换行符、比例号冒号)、C12(同义词与词根)、
  C13(空格、周岁、中文数字、繁体);泄露扫描做 NFKC;关键图只接受能保真重编码的 PNG;
  YAML 配置拒绝重复键和空路径。
- import-linter 第 1 条拆成互不依赖与不引用其他 cadx 模块两条。

文档:结构.md 第五、六、八部分按实际更新;新增 guides/装配两个库.md;WORK_LOG 更新。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 03:00:20 -04:00

268 lines
8.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""测试用假交付包:形状照 40 例交付包写(表头、工作表名、目录结构),内容全部编造。
这里的表头和文件名是按任务书 01 第二部分的表格独立写的,不取自 delivery 的常量,
这样 delivery 的常量写错时测试能发现。各条测试在 `FakeDelivery` 上改出要测的情况,再 `write`。
"""
from collections.abc import Sequence
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any, cast
import openpyxl
import yaml
from PIL import Image, PngImagePlugin
from cadx.build import BuildSummary, run_build
REPO_ROOT = Path(__file__).resolve().parents[1]
DIAGNOSIS_COLUMN = "病理类型(1 IPMN,2 SPN,3 MCN,4 SCN)"
CLINICAL_HEADER = [
"Case_ID",
"Sex",
"Age",
"Diagnosis",
"Symptom_Pain",
"Symptom_Jaundice",
"Symptom_WeightLoss",
"Symptom_Fever",
"Symptom_Asymptomatic",
"Hx_Pancreatitis",
"Hx_Alcohol",
"Hx_Gallstones",
"Hx_Malignancy",
"Hx_Family_PC",
"CA199",
"CA199_Level",
"CEA",
"CEA_Level",
"Amylase",
"Amylase_Level",
"Lipase",
"Lipase_Level",
"TBil",
"TBil_Level",
]
ULTRASOUND_HEADER = [
"患者ID",
"CEUS视频ID",
"性别",
"年龄",
DIAGNOSIS_COLUMN,
"推注开始时间s",
"超声描述",
]
MR_HEADER = ["患者ID", "性别", "年龄", DIAGNOSIS_COLUMN, "MR描述"]
CLINICAL_PATH = "Clinical_agent/临床工作簿_40例.xlsx"
ULTRASOUND_PATH = "CEUS_agent/超声报告文本_40例.xlsx"
MR_PATH = "MR_agent/MR报告文本_40例.xlsx"
# 编造的报告文字:不含亚型名、结论段标题、性别字、「数字岁」和复核措辞。
ULTRASOUND_TEXT = "胰体部见一无回声区,边界清晰,壁薄。造影后囊壁呈等增强,内部未见增强。"
MR_TEXT = "胰体部见一类圆形长T2信号灶,边界清楚,内见细分隔;增强后分隔轻度强化。"
# 测试用的样例映射表:含义是编的,只用来验证解码路径。
SAMPLE_CODE_MAPS: dict[str, dict[int, str]] = {
"Sex": {1: "男", 2: "女"},
"Diagnosis": {1: "IPMN", 2: "SPN", 3: "MCN", 4: "SCN"},
**{
column: {0: "否", 1: "是"}
for column in (
"Symptom_Pain",
"Symptom_Jaundice",
"Symptom_WeightLoss",
"Symptom_Fever",
"Symptom_Asymptomatic",
"Hx_Pancreatitis",
"Hx_Alcohol",
"Hx_Gallstones",
"Hx_Malignancy",
"Hx_Family_PC",
)
},
**{
column: {0: "正常", 1: "异常"}
for column in ("CA199_Level", "CEA_Level", "Amylase_Level", "Lipase_Level", "TBil_Level")
},
}
@dataclass
class FakeCase:
"""一例:三张表各一行,病例目录里的文件名和单例报告表的行。"""
number: int
clinical: dict[str, object]
ultrasound: dict[str, object]
mr: dict[str, object]
dir_files: list[str]
# None 表示单例报告表照抄超声汇总表那一行。
case_report_rows: list[list[object]] | None = None
case_report_sheet: str = "Sheet1"
@property
def no(self) -> str:
return f"{self.number:03d}"
def make_case(number: int) -> FakeCase:
"""一例全部检查都能通过的病例。诊断按病例号轮转 1–4。"""
no = f"{number:03d}"
sex = 1 + number % 2
age = 30 + number
diagnosis = 1 + (number - 1) % 4
clinical: dict[str, object] = dict.fromkeys(CLINICAL_HEADER, 0)
clinical.update(
{
"Case_ID": f"P{no}",
"Sex": sex,
"Age": age,
"Diagnosis": diagnosis,
"Symptom_Pain": 1,
"CA199": 12.5,
"CA199_Level": 0,
"CEA": 3,
"CEA_Level": 1,
"Amylase": None,
"Amylase_Level": None,
"Lipase": None,
"Lipase_Level": None,
"TBil": 10.2,
"TBil_Level": 0,
}
)
sex_text = {1: "男", 2: "女"}[sex]
ultrasound: dict[str, object] = {
"患者ID": str(number),
"CEUS视频ID": f"U{no}",
"性别": sex_text,
"年龄": f"{age}岁",
DIAGNOSIS_COLUMN: diagnosis,
"推注开始时间s": 5,
"超声描述": ULTRASOUND_TEXT,
}
mr: dict[str, object] = {
"患者ID": str(number),
"性别": sex_text,
"年龄": f"{age}岁",
DIAGNOSIS_COLUMN: diagnosis,
"MR描述": MR_TEXT,
}
files = [f"U{no}_key.png", f"U{no}_roi2.mp4", f"U{no}_roi2_report.xlsx"]
return FakeCase(number, clinical, ultrasound, mr, files)
@dataclass
class FakeDelivery:
"""一个假交付包。`extra_rows` 是附加在汇总表末尾的整行(测试编号认不出的行)。"""
cases: list[FakeCase]
sheet_names: dict[str, str] = field(
default_factory=lambda: {
CLINICAL_PATH: "Sheet",
ULTRASOUND_PATH: "超声报告",
MR_PATH: "MR报告",
}
)
headers: dict[str, list[str]] = field(
default_factory=lambda: {
CLINICAL_PATH: list(CLINICAL_HEADER),
ULTRASOUND_PATH: list(ULTRASOUND_HEADER),
MR_PATH: list(MR_HEADER),
}
)
extra_rows: dict[str, list[list[object]]] = field(
default_factory=lambda: {CLINICAL_PATH: [], ULTRASOUND_PATH: [], MR_PATH: []}
)
def case(self, number: int) -> FakeCase:
return next(c for c in self.cases if c.number == number)
def write(self, root: Path) -> Path:
tables = {
CLINICAL_PATH: [c.clinical for c in self.cases],
ULTRASOUND_PATH: [c.ultrasound for c in self.cases],
MR_PATH: [c.mr for c in self.cases],
}
for path, rows in tables.items():
header = self.headers[path]
values = [[row.get(h) for h in header] for row in rows]
_write_workbook(
root / path, self.sheet_names[path], [header, *values, *self.extra_rows[path]]
)
for case in self.cases:
folder = root / "CEUS_agent" / case.no
folder.mkdir(parents=True, exist_ok=True)
for name in case.dir_files:
if name.endswith(".png"):
_write_png(folder / name, case.number)
elif name.endswith(".xlsx"):
row = [case.ultrasound.get(h) for h in ULTRASOUND_HEADER]
rows = case.case_report_rows or [list(ULTRASOUND_HEADER), row]
_write_workbook(folder / name, case.case_report_sheet, rows)
else:
(folder / name).write_bytes(b"not a real video")
(root / "交付说明_40例_20260908.md").write_text("说明不读", encoding="utf-8")
return root
def make_delivery(count: int) -> FakeDelivery:
"""病例号 1..count、全部能通过检查的假交付包。"""
return FakeDelivery(cases=[make_case(n) for n in range(1, count + 1)])
def _write_workbook(path: Path, sheet: str, rows: Sequence[Sequence[object]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
workbook = openpyxl.Workbook()
worksheet = workbook.active
assert worksheet is not None
worksheet.title = sheet
for row in rows:
# 假数据里的值都是 openpyxl 认得的单元格类型(字符串、数字、None)。
worksheet.append(cast("list[Any]", list(row)))
workbook.save(path)
def _write_png(path: Path, number: int) -> None:
pixels = bytes(v for i in range(24) for v in (number * 10 % 256, i * 10, 255 - i * 10))
image = Image.frombytes("RGB", (6, 4), pixels)
info = PngImagePlugin.PngInfo()
info.add_text("PatientName", "should-not-survive")
image.save(path, format="PNG", pnginfo=info)
def write_config(
tmp_path: Path, delivery_dir: Path, seed: int, code_maps: dict[str, dict[int, str]] | None
) -> Path:
"""在 tmp_path 写一份 build.yaml(路径都写绝对路径);给了映射表就同时写 code_maps.yaml。"""
maps_path = None
if code_maps is not None:
maps_path = tmp_path / "code_maps.yaml"
maps_path.write_text(yaml.safe_dump(code_maps, allow_unicode=True), encoding="utf-8")
config = {
"delivery_dir": str(delivery_dir),
"library_root": str(tmp_path / "library"),
"answers_root": str(tmp_path / "answers"),
"alias_seed": seed,
"decode_codes": code_maps is not None,
"code_maps": None if maps_path is None else str(maps_path),
}
path = tmp_path / "build.yaml"
path.write_text(yaml.safe_dump(config, allow_unicode=True), encoding="utf-8")
return path
def build(
tmp_path: Path,
delivery: FakeDelivery,
seed: int,
code_maps: dict[str, dict[int, str]] | None,
now: datetime,
) -> BuildSummary:
"""写出假交付包和配置,跑一次构建。"""
delivery_dir = delivery.write(tmp_path / "delivery")
return run_build(write_config(tmp_path, delivery_dir, seed, code_maps), REPO_ROOT, now)