"""逐例检查 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}' def test_case_record_carries_video_path_and_bolus_time(tmp_path: Path) -> None: delivery = make_delivery(3) delivery.case(2).dir_files = ["U002_key.png", "U002.mp4", "U002_report.xlsx"] delivery.case(3).ultrasound["推注开始时间s"] = 0 # 0 是合法取值:视频第 0 秒正好推注 root = delivery.write(tmp_path / "delivery") result = read_delivery(root, None) folder = root / "CEUS_agent" assert [c.video for c in result.cases] == [ folder / "001" / "U001_roi2.mp4", folder / "002" / "U002.mp4", folder / "003" / "U003_roi2.mp4", ] # 假交付包的推注时间是 5,第 3 例改成 0。 assert [c.bolus_start_s for c in result.cases] == [5, 5, 0]