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>
This commit is contained in:
2026-10-16 03:00:20 -04:00
parent bcd5932a66
commit bceb01e8fa
30 changed files with 3600 additions and 18 deletions
+267
View File
@@ -0,0 +1,267 @@
"""测试用假交付包:形状照 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)
+352
View File
@@ -0,0 +1,352 @@
"""构建产物:可见性、泄露扫描、化名、解码开关、关键图、清单与读取、不覆盖、答案库内容。"""
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 (
list_aliases,
read_clinical,
read_mr,
read_ultrasound,
scan_for_leaks,
)
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/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).key_image
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), REPO_ROOT, NOW)
def test_key_image_16_bit_source_stops_build(tmp_path: Path) -> None:
image = Image.new("I;16", (4, 4))
with pytest.raises(ValueError, match=r"U002_key\.png"):
_build_with_key_image(tmp_path, image)
assert not (tmp_path / "library" / f"{NOW:%Y%m%d-%H%M%S}").exists()
assert [p for p in (tmp_path / "library").iterdir() if not p.name.endswith(".partial")] == []
def test_key_image_with_transparency_stops_build(tmp_path: Path) -> None:
image = Image.new("RGB", (4, 4))
image.info["transparency"] = (0, 0, 0)
with pytest.raises(ValueError, match=r"U002_key\.png"):
_build_with_key_image(tmp_path, image)
def test_key_image_palette_source_stops_build(tmp_path: Path) -> None:
with pytest.raises(ValueError, match=r"U002_key\.png"):
_build_with_key_image(tmp_path, Image.new("P", (4, 4)))
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"]).key_image
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/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"])
+187
View File
@@ -0,0 +1,187 @@
"""整张表的问题 T1–T5 与配置错误:整次构建停下,两个库下不留完成的构建目录。"""
from datetime import datetime
from pathlib import Path
import pytest
from pydantic import ValidationError
from cadx.build import load_build_config, load_code_maps, run_build
from cadx.delivery import DeliveryError
from fake_delivery import (
CLINICAL_PATH,
MR_PATH,
REPO_ROOT,
SAMPLE_CODE_MAPS,
ULTRASOUND_PATH,
make_delivery,
write_config,
)
NOW = datetime(2026, 10, 16, 9, 0, 0).astimezone()
def _assert_no_finished_build(tmp_path: Path) -> None:
for root in (tmp_path / "library", tmp_path / "answers"):
if root.exists():
assert all(p.name.endswith(".partial") for p in root.iterdir())
def _run(tmp_path: Path, root: Path, code_maps: dict[str, dict[int, str]] | None) -> None:
run_build(write_config(tmp_path, root, 7, code_maps), REPO_ROOT, NOW)
def test_t1_missing_summary_table(tmp_path: Path) -> None:
root = make_delivery(3).write(tmp_path / "delivery")
(root / MR_PATH).unlink()
with pytest.raises(DeliveryError, match=r"^T1"):
_run(tmp_path, root, None)
_assert_no_finished_build(tmp_path)
def test_t2_wrong_sheet_name(tmp_path: Path) -> None:
delivery = make_delivery(3)
delivery.sheet_names[ULTRASOUND_PATH] = "Sheet1"
root = delivery.write(tmp_path / "delivery")
with pytest.raises(DeliveryError, match=r"^T2"):
_run(tmp_path, root, None)
_assert_no_finished_build(tmp_path)
def test_t3_header_differs(tmp_path: Path) -> None:
delivery = make_delivery(3)
delivery.headers[MR_PATH][3] = "病理类型" # 诊断列名里写的编码含义丢了
root = delivery.write(tmp_path / "delivery")
with pytest.raises(DeliveryError, match=r"^T3"):
_run(tmp_path, root, None)
_assert_no_finished_build(tmp_path)
def test_t4_extra_file_in_clinical_folder(tmp_path: Path) -> None:
root = make_delivery(3).write(tmp_path / "delivery")
(root / CLINICAL_PATH).with_name("备注.txt").write_text("x", encoding="utf-8")
with pytest.raises(DeliveryError, match=r"^T4"):
_run(tmp_path, root, None)
_assert_no_finished_build(tmp_path)
def test_t4_unexpected_entry_in_ceus_folder(tmp_path: Path) -> None:
root = make_delivery(3).write(tmp_path / "delivery")
(root / "CEUS_agent" / "41").mkdir() # 不是三位数字
with pytest.raises(DeliveryError, match=r"^T4"):
_run(tmp_path, root, None)
_assert_no_finished_build(tmp_path)
def test_t5_code_maps_missing_a_column(tmp_path: Path) -> None:
root = make_delivery(3).write(tmp_path / "delivery")
maps = {k: v for k, v in SAMPLE_CODE_MAPS.items() if k != "TBil_Level"}
with pytest.raises(ValueError, match=r"^T5"):
_run(tmp_path, root, maps)
_assert_no_finished_build(tmp_path)
def test_t5_code_maps_extra_column(tmp_path: Path) -> None:
root = make_delivery(3).write(tmp_path / "delivery")
maps = {**SAMPLE_CODE_MAPS, "Age": {1: "一"}}
with pytest.raises(ValueError, match=r"^T5"):
_run(tmp_path, root, maps)
_assert_no_finished_build(tmp_path)
def test_t5_sex_must_map_to_chinese_characters(tmp_path: Path) -> None:
root = make_delivery(3).write(tmp_path / "delivery")
maps = {**SAMPLE_CODE_MAPS, "Sex": {1: "M", 2: "F"}}
with pytest.raises(ValueError, match=r"^T5"):
_run(tmp_path, root, maps)
_assert_no_finished_build(tmp_path)
def test_t5_diagnosis_must_map_to_four_subtypes(tmp_path: Path) -> None:
root = make_delivery(3).write(tmp_path / "delivery")
maps = {**SAMPLE_CODE_MAPS, "Diagnosis": {1: "IPMN", 2: "SPN", 3: "MCN", 4: "MCN"}}
with pytest.raises(ValueError, match=r"^T5"):
_run(tmp_path, root, maps)
_assert_no_finished_build(tmp_path)
def _write_yaml(tmp_path: Path, body: str) -> Path:
path = tmp_path / "build.yaml"
path.write_text(body, encoding="utf-8")
return path
_BASE = "delivery_dir: d\nlibrary_root: l\nanswers_root: a\nalias_seed: 1\n"
def test_config_decode_true_needs_code_maps(tmp_path: Path) -> None:
path = _write_yaml(tmp_path, _BASE + "decode_codes: true\ncode_maps: null\n")
with pytest.raises(ValidationError):
load_build_config(path, tmp_path)
def test_config_decode_false_needs_null_code_maps(tmp_path: Path) -> None:
path = _write_yaml(tmp_path, _BASE + "decode_codes: false\ncode_maps: maps.yaml\n")
with pytest.raises(ValidationError):
load_build_config(path, tmp_path)
def test_config_field_missing(tmp_path: Path) -> None:
path = _write_yaml(tmp_path, _BASE + "decode_codes: false\n")
with pytest.raises(ValidationError):
load_build_config(path, tmp_path)
def test_config_extra_field(tmp_path: Path) -> None:
path = _write_yaml(tmp_path, _BASE + "decode_codes: false\ncode_maps: null\nseed: 2\n")
with pytest.raises(ValidationError):
load_build_config(path, tmp_path)
def test_config_seed_must_be_int_not_string(tmp_path: Path) -> None:
body = _BASE.replace("alias_seed: 1", "alias_seed: '1'")
path = _write_yaml(tmp_path, body + "decode_codes: false\ncode_maps: null\n")
with pytest.raises(ValidationError):
load_build_config(path, tmp_path)
def test_config_relative_paths_resolve_against_repo_root(tmp_path: Path) -> None:
path = _write_yaml(tmp_path, _BASE + "decode_codes: false\ncode_maps: null\n")
config = load_build_config(path, Path("/repo"))
assert config.delivery_dir == Path("/repo/d")
assert config.library_root == Path("/repo/l")
assert config.answers_root == Path("/repo/a")
assert config.code_maps is None
def test_committed_config_is_valid() -> None:
config = load_build_config(REPO_ROOT / "config" / "build.yaml", REPO_ROOT)
assert config.decode_codes is False
assert config.code_maps is None
def test_config_duplicate_key(tmp_path: Path) -> None:
body = _BASE + "decode_codes: false\ncode_maps: null\nalias_seed: 2\n"
path = _write_yaml(tmp_path, body)
with pytest.raises(ValueError, match="重复的键"):
load_build_config(path, tmp_path)
def test_config_empty_path(tmp_path: Path) -> None:
body = _BASE.replace("delivery_dir: d", "delivery_dir: ''")
path = _write_yaml(tmp_path, body + "decode_codes: false\ncode_maps: null\n")
with pytest.raises(ValidationError, match="空串"):
load_build_config(path, tmp_path)
def test_config_empty_code_maps_path(tmp_path: Path) -> None:
path = _write_yaml(tmp_path, _BASE + "decode_codes: true\ncode_maps: ''\n")
with pytest.raises(ValidationError, match="空串"):
load_build_config(path, tmp_path)
def test_code_maps_duplicate_key(tmp_path: Path) -> None:
path = tmp_path / "code_maps.yaml"
path.write_text("Sex: {1: 男, 1: 女}\n", encoding="utf-8")
with pytest.raises(ValueError, match="重复的键"):
load_code_maps(path)
+420
View File
@@ -0,0 +1,420 @@
"""逐例检查 C01–C13:只让一例违反一项,这一例跳过、记对检查编号,其余病例照常通过。
预期值照任务书 01 第二部分「单个病例的问题」的文字写。
"""
import shutil
from pathlib import Path
from cadx.delivery import DeliveryResult, read_delivery
from cadx.delivery._text import conclusion_headings
from fake_delivery import (
CLINICAL_PATH,
DIAGNOSIS_COLUMN,
MR_PATH,
MR_TEXT,
SAMPLE_CODE_MAPS,
ULTRASOUND_HEADER,
ULTRASOUND_PATH,
ULTRASOUND_TEXT,
FakeDelivery,
make_delivery,
)
def _read(
tmp_path: Path, delivery: FakeDelivery, code_maps: dict[str, dict[int, str]] | None
) -> DeliveryResult:
return read_delivery(delivery.write(tmp_path / "delivery"), code_maps)
def _assert_only_skipped(result: DeliveryResult, case: str, check: str, total: int) -> None:
assert [(s.case, s.check) for s in result.skipped] == [(case, check)]
passed = [f"{n:03d}" for n in range(1, total + 1) if f"{n:03d}" != case]
assert [c.case_no for c in result.cases] == passed
assert result.total_cases == total
def test_all_pass(tmp_path: Path) -> None:
result = _read(tmp_path, make_delivery(4), None)
assert [c.case_no for c in result.cases] == ["001", "002", "003", "004"]
assert result.skipped == ()
assert result.unrecognized_rows == 0
def test_c01_unrecognized_clinical_id_is_its_own_row_and_case_missing(tmp_path: Path) -> None:
delivery = make_delivery(4)
delivery.case(2).clinical["Case_ID"] = "P2" # 临床表要求 P 加三位数字
result = _read(tmp_path, delivery, None)
# 第 1 行是表头,病例 2 在第 3 行;同一个病人因此在 C03(缺在临床表)再记一条。
assert [(s.case, s.check) for s in result.skipped] == [
(f"{CLINICAL_PATH} 第 3 行", "C01"),
("002", "C03"),
]
assert result.unrecognized_rows == 1
assert result.total_cases == 4
assert [c.case_no for c in result.cases] == ["001", "003", "004"]
def test_c01_float_report_id_is_unrecognized(tmp_path: Path) -> None:
delivery = make_delivery(3)
delivery.case(3).mr["患者ID"] = 3.5 # 浮点不是认得出的写法
result = _read(tmp_path, delivery, None)
assert [(s.case, s.check) for s in result.skipped] == [
(f"{MR_PATH} 第 4 行", "C01"),
("003", "C03"),
]
def test_c01_report_ids_accept_int_and_zero_padded_strings(tmp_path: Path) -> None:
delivery = make_delivery(3)
delivery.case(1).ultrasound["患者ID"] = 1
delivery.case(2).mr["患者ID"] = "002"
delivery.case(2).ultrasound["患者ID"] = "02"
result = _read(tmp_path, delivery, None)
assert result.skipped == ()
assert len(result.cases) == 3
def test_c02_duplicate_id_in_one_table(tmp_path: Path) -> None:
delivery = make_delivery(4)
duplicate = delivery.case(2).mr
delivery.extra_rows[MR_PATH].append([duplicate[h] for h in delivery.headers[MR_PATH]])
_assert_only_skipped(_read(tmp_path, delivery, None), "002", "C02", 4)
def test_c03_case_directory_missing(tmp_path: Path) -> None:
delivery = make_delivery(4)
root = delivery.write(tmp_path / "delivery")
shutil.rmtree(root / "CEUS_agent" / "003")
_assert_only_skipped(read_delivery(root, None), "003", "C03", 4)
def test_c04_missing_key_image(tmp_path: Path) -> None:
delivery = make_delivery(4)
delivery.case(4).dir_files.remove("U004_key.png")
_assert_only_skipped(_read(tmp_path, delivery, None), "004", "C04", 4)
def test_c04_extra_file(tmp_path: Path) -> None:
delivery = make_delivery(3)
delivery.case(1).dir_files.append("U001.mp4") # 两个视频共四个文件
_assert_only_skipped(_read(tmp_path, delivery, None), "001", "C04", 3)
def test_c04_roi2_video_with_roi2_report_passes(tmp_path: Path) -> None:
delivery = make_delivery(3)
assert delivery.case(2).dir_files == ["U002_key.png", "U002_roi2.mp4", "U002_roi2_report.xlsx"]
assert _read(tmp_path, delivery, None).skipped == ()
def test_c04_plain_video_with_plain_report_passes(tmp_path: Path) -> None:
delivery = make_delivery(3)
delivery.case(3).dir_files = ["U003_key.png", "U003.mp4", "U003_report.xlsx"]
result = _read(tmp_path, delivery, None)
assert result.skipped == ()
assert len(result.cases) == 3
def test_c04_roi2_video_with_plain_report_is_skipped(tmp_path: Path) -> None:
delivery = make_delivery(3)
# 文件名被 gitleaks 的通用密钥规则误报,不是密钥。
delivery.case(1).dir_files = [
"U001_key.png",
"U001_roi2.mp4", # gitleaks:allow
"U001_report.xlsx",
]
_assert_only_skipped(_read(tmp_path, delivery, None), "001", "C04", 3)
def test_c04_plain_video_with_roi2_report_is_skipped(tmp_path: Path) -> None:
delivery = make_delivery(3)
delivery.case(2).dir_files = ["U002_key.png", "U002.mp4", "U002_roi2_report.xlsx"]
_assert_only_skipped(_read(tmp_path, delivery, None), "002", "C04", 3)
def test_c06_reads_plain_report_when_paired_with_plain_video(tmp_path: Path) -> None:
delivery = make_delivery(3)
case = delivery.case(3)
case.dir_files = ["U003_key.png", "U003.mp4", "U003_report.xlsx"]
row = [case.ultrasound[h] for h in ULTRASOUND_HEADER]
row[-1] = ULTRASOUND_TEXT + "补充一句。"
case.case_report_rows = [list(ULTRASOUND_HEADER), row]
_assert_only_skipped(_read(tmp_path, delivery, None), "003", "C06", 3)
def test_c05_video_id_mismatch(tmp_path: Path) -> None:
delivery = make_delivery(4)
delivery.case(2).ultrasound["CEUS视频ID"] = "U003"
_assert_only_skipped(_read(tmp_path, delivery, None), "002", "C05", 4)
def test_c06_case_report_cell_differs(tmp_path: Path) -> None:
delivery = make_delivery(4)
case = delivery.case(3)
row = [case.ultrasound[h] for h in ULTRASOUND_HEADER]
row[-1] = ULTRASOUND_TEXT + "补充一句。"
case.case_report_rows = [list(ULTRASOUND_HEADER), row]
result = _read(tmp_path, delivery, None)
_assert_only_skipped(result, "003", "C06", 4)
assert result.skipped[0].columns == ("超声描述",)
def test_c06_case_report_cell_type_differs(tmp_path: Path) -> None:
delivery = make_delivery(3)
case = delivery.case(1)
row = [case.ultrasound[h] for h in ULTRASOUND_HEADER]
row[0] = 1 # 汇总表里是字符串 "1"
case.case_report_rows = [list(ULTRASOUND_HEADER), row]
_assert_only_skipped(_read(tmp_path, delivery, None), "001", "C06", 3)
def test_c06_case_report_wrong_sheet(tmp_path: Path) -> None:
delivery = make_delivery(3)
delivery.case(2).case_report_sheet = "Sheet2"
_assert_only_skipped(_read(tmp_path, delivery, None), "002", "C06", 3)
def test_c06_case_report_two_data_rows(tmp_path: Path) -> None:
delivery = make_delivery(3)
case = delivery.case(2)
row = [case.ultrasound[h] for h in ULTRASOUND_HEADER]
case.case_report_rows = [list(ULTRASOUND_HEADER), row, row]
_assert_only_skipped(_read(tmp_path, delivery, None), "002", "C06", 3)
def test_c07_bool_sex_is_not_an_int(tmp_path: Path) -> None:
delivery = make_delivery(3)
delivery.case(1).clinical["Sex"] = True # bool 是 int 的子类,不能当作 1
_assert_only_skipped(_read(tmp_path, delivery, None), "001", "C07", 3)
def test_c01_bool_report_id_is_unrecognized(tmp_path: Path) -> None:
delivery = make_delivery(3)
delivery.case(1).ultrasound["患者ID"] = True
result = _read(tmp_path, delivery, None)
assert [(s.case, s.check) for s in result.skipped] == [
(f"{ULTRASOUND_PATH} 第 2 行", "C01"),
("001", "C03"),
]
def test_c07_sex_out_of_range(tmp_path: Path) -> None:
delivery = make_delivery(4)
delivery.case(1).clinical["Sex"] = 3
_assert_only_skipped(_read(tmp_path, delivery, None), "001", "C07", 4)
def test_c07_non_integer_age(tmp_path: Path) -> None:
delivery = make_delivery(3)
delivery.case(2).clinical["Age"] = 32.5
_assert_only_skipped(_read(tmp_path, delivery, None), "002", "C07", 3)
def test_c07_symptom_empty(tmp_path: Path) -> None:
delivery = make_delivery(3)
delivery.case(2).clinical["Symptom_Fever"] = None
_assert_only_skipped(_read(tmp_path, delivery, None), "002", "C07", 3)
def test_c07_lab_only_one_column_empty(tmp_path: Path) -> None:
delivery = make_delivery(4)
delivery.case(2).clinical["CA199"] = None # 档位列仍是 0
result = _read(tmp_path, delivery, None)
_assert_only_skipped(result, "002", "C07", 4)
assert set(result.skipped[0].columns) == {"CA199", "CA199_Level"}
def test_c07_lab_both_empty_is_fine(tmp_path: Path) -> None:
delivery = make_delivery(3)
delivery.case(1).clinical["TBil"] = None
delivery.case(1).clinical["TBil_Level"] = None
assert _read(tmp_path, delivery, None).skipped == ()
def test_c07_value_missing_from_code_map(tmp_path: Path) -> None:
delivery = make_delivery(3)
maps = {**SAMPLE_CODE_MAPS, "Hx_Alcohol": {0: "否"}} # 映射表里没有 1
delivery.case(2).clinical["Hx_Alcohol"] = 1
_assert_only_skipped(_read(tmp_path, delivery, maps), "002", "C07", 3)
def test_c08_ultrasound_sex_invalid(tmp_path: Path) -> None:
delivery = make_delivery(4)
delivery.case(4).ultrasound["性别"] = "M"
_assert_only_skipped(_read(tmp_path, delivery, None), "004", "C08", 4)
def test_c08_negative_bolus_time(tmp_path: Path) -> None:
delivery = make_delivery(3)
delivery.case(3).ultrasound["推注开始时间s"] = -1
_assert_only_skipped(_read(tmp_path, delivery, None), "003", "C08", 3)
def test_c08_empty_description(tmp_path: Path) -> None:
delivery = make_delivery(3)
delivery.case(1).ultrasound["超声描述"] = None
_assert_only_skipped(_read(tmp_path, delivery, None), "001", "C08", 3)
def test_c09_mr_age_not_number_plus_sui(tmp_path: Path) -> None:
delivery = make_delivery(4)
delivery.case(3).mr["年龄"] = "33"
_assert_only_skipped(_read(tmp_path, delivery, None), "003", "C09", 4)
def test_c09_mr_diagnosis_out_of_range(tmp_path: Path) -> None:
delivery = make_delivery(3)
delivery.case(2).mr[DIAGNOSIS_COLUMN] = 5
_assert_only_skipped(_read(tmp_path, delivery, None), "002", "C09", 3)
def test_c10_diagnosis_mismatch(tmp_path: Path) -> None:
delivery = make_delivery(4)
delivery.case(1).clinical["Diagnosis"] = 2 # 两张报告表仍是 1
result = _read(tmp_path, delivery, None)
_assert_only_skipped(result, "001", "C10", 4)
assert result.skipped[0].columns == ("诊断",)
def test_c10_age_mismatch(tmp_path: Path) -> None:
delivery = make_delivery(4)
delivery.case(2).mr["年龄"] = "99岁"
result = _read(tmp_path, delivery, None)
_assert_only_skipped(result, "002", "C10", 4)
assert result.skipped[0].columns == ("年龄",)
def test_c10_sex_mismatch_between_reports(tmp_path: Path) -> None:
delivery = make_delivery(4)
delivery.case(3).mr["性别"] = "男" if delivery.case(3).ultrasound["性别"] == "女" else "女"
result = _read(tmp_path, delivery, None)
_assert_only_skipped(result, "003", "C10", 4)
assert result.skipped[0].columns == ("性别",)
def test_c10_clinical_sex_compared_only_when_decoding(tmp_path: Path) -> None:
delivery = make_delivery(3)
case = delivery.case(2)
case.clinical["Sex"] = 1 if case.clinical["Sex"] == 2 else 2 # 报告表的性别不变
undecoded = _read(tmp_path / "a", delivery, None)
assert undecoded.skipped == ()
assert undecoded.uncompared == ("临床性别未比对",)
decoded = _read(tmp_path / "b", delivery, SAMPLE_CODE_MAPS)
_assert_only_skipped(decoded, "002", "C10", 3)
assert decoded.uncompared == ()
def test_c11_heading_at_start_with_fullwidth_colon(tmp_path: Path) -> None:
delivery = make_delivery(4)
delivery.case(2).ultrasound["超声描述"] = "结论:胰体部囊性病灶。" + ULTRASOUND_TEXT
_assert_only_skipped(_read(tmp_path, delivery, None), "002", "C11", 4)
def test_c11_heading_after_newline(tmp_path: Path) -> None:
delivery = make_delivery(4)
delivery.case(3).mr["MR描述"] = MR_TEXT + "\n印象 : 胰体部囊性病灶。"
result = _read(tmp_path, delivery, None)
_assert_only_skipped(result, "003", "C11", 4)
assert result.skipped[0].columns == ("MR描述",)
def test_c11_heading_mid_text_is_not_a_failure(tmp_path: Path) -> None:
delivery = make_delivery(3)
delivery.case(1).ultrasound["超声描述"] = "胰体部见一无回声区。造影提示:囊壁呈等增强。"
result = _read(tmp_path, delivery, None)
assert result.skipped == ()
assert len(result.cases) == 3
def test_c12_subtype_name_fullwidth_lowercase(tmp_path: Path) -> None:
delivery = make_delivery(4)
delivery.case(4).mr["MR描述"] = MR_TEXT + "形态类似ipmn。"
_assert_only_skipped(_read(tmp_path, delivery, None), "004", "C12", 4)
def test_c12_chinese_subtype_name(tmp_path: Path) -> None:
delivery = make_delivery(3)
delivery.case(1).ultrasound["超声描述"] = ULTRASOUND_TEXT + "符合浆液性囊腺瘤表现。"
_assert_only_skipped(_read(tmp_path, delivery, None), "001", "C12", 3)
def test_c11_heading_after_vertical_tab_or_form_feed() -> None:
# xlsx(XML)里写不进 \x0b、\x0c,openpyxl 拒绝生成,所以直接测规则函数。
for separator in ("\x0b", "\x0c"):
text = MR_TEXT + separator + "诊断:胰体部囊性病灶。"
assert [k for k, _ in conclusion_headings(text)] == ["诊断"]
def test_c11_heading_after_unicode_line_separator(tmp_path: Path) -> None:
for i, separator in enumerate(("\x85", "\u2028", "\u2029")):
delivery = make_delivery(3)
delivery.case(2).mr["MR描述"] = MR_TEXT + separator + "诊断:胰体部囊性病灶。"
_assert_only_skipped(_read(tmp_path / str(i), delivery, None), "002", "C11", 3)
def test_c11_ratio_sign_as_colon(tmp_path: Path) -> None:
delivery = make_delivery(3)
delivery.case(1).ultrasound["超声描述"] = "超声提示∶胰体部囊性病灶。" + ULTRASOUND_TEXT
result = _read(tmp_path, delivery, None)
_assert_only_skipped(result, "001", "C11", 3)
assert '"关键词": "超声提示"' in result.skipped[0].value_json
def test_c12_new_subtype_terms(tmp_path: Path) -> None:
terms = (
"SPT",
"sptp",
"IPMT",
"导管内乳头",
"实性假乳头",
"黏液囊性",
"粘液囊性",
"囊腺瘤",
"浆液性微囊",
)
for i, term in enumerate(terms):
delivery = make_delivery(3)
delivery.case(3).ultrasound["超声描述"] = ULTRASOUND_TEXT + f"形态与{term}相似。"
_assert_only_skipped(_read(tmp_path / str(i), delivery, None), "003", "C12", 3)
def test_c12_serous_alone_is_fine(tmp_path: Path) -> None:
delivery = make_delivery(3)
delivery.case(1).ultrasound["超声描述"] = ULTRASOUND_TEXT + "囊液呈浆液性。"
assert _read(tmp_path, delivery, None).skipped == ()
def test_c13_age_in_report(tmp_path: Path) -> None:
delivery = make_delivery(4)
delivery.case(1).ultrasound["超声描述"] = "患者45岁。" + ULTRASOUND_TEXT
_assert_only_skipped(_read(tmp_path, delivery, None), "001", "C13", 4)
def test_c13_age_variants(tmp_path: Path) -> None:
for i, age in enumerate(("四十五岁", "45 岁", "6周岁", "三十歲", "12周岁")):
delivery = make_delivery(3)
delivery.case(2).mr["MR描述"] = f"患者{age}。" + MR_TEXT
_assert_only_skipped(_read(tmp_path / str(i), delivery, None), "002", "C13", 3)
def test_c13_sex_character_in_report(tmp_path: Path) -> None:
delivery = make_delivery(3)
delivery.case(3).mr["MR描述"] = "女性患者。" + MR_TEXT
_assert_only_skipped(_read(tmp_path, delivery, None), "003", "C13", 3)
def test_only_first_failing_check_is_recorded(tmp_path: Path) -> None:
delivery = make_delivery(3)
delivery.case(2).ultrasound["性别"] = "M" # C08
delivery.case(2).mr["年龄"] = "99岁" # C10
_assert_only_skipped(_read(tmp_path, delivery, None), "002", "C08", 3)
def test_skip_record_names_file_and_column(tmp_path: Path) -> None:
delivery = make_delivery(3)
delivery.case(2).ultrasound["推注开始时间s"] = 2.5
record = _read(tmp_path, delivery, None).skipped[0]
assert record.files == (ULTRASOUND_PATH,)
assert record.columns == ("推注开始时间s",)
assert record.value_json == '{"推注开始时间s": 2.5}'