"""CEUS ROI 科研配置 schema + loader 单元测试。 验证目标: 1. `load_tracking_config` 读默认 yaml 后返回 `TrackingConfig`,且关键默认值 等于 findings §2A/§3 的「yaml 生产值」(而非参考代码的保守默认)。 2. findings §5 硬约束生效(偶数 temporal_window / 面积比逆序 / percentile 越界报错)。 3. 子模型 `extra="forbid"` 拒绝未知键(防 typo 静默退化)。 4. findings §2B 死键不在 schema 内(混入即被 extra="forbid" 拒绝)。 5. design/0030 那几项:合成零点四键已删且残留即报错、`config_version` 必填、 `pan_source` 校验器、基线默认值等于规范 §3.3。 """ from typing import Any import pytest from pydantic import ValidationError from cadx.ceus import CeusConfig as TrackingConfig from conftest import load_ceus_section def _merge(base: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]: """把 payload 逐层盖到 base 上(只盖 payload 里写到的键)。""" merged = dict(base) for key, value in payload.items(): if isinstance(value, dict) and isinstance(merged.get(key), dict): merged[key] = _merge(merged[key], value) # pyright: ignore[reportUnknownArgumentType] else: merged[key] = value return merged def _validate(payload: dict[str, Any]) -> TrackingConfig: """在 config/ceus.yaml 的 ceus 段上盖上 payload 再校验。 配置不设默认值、每个字段必填;旧测试只写自己关心的那几个键、其余靠默认值, 这里改为其余键取 config/ceus.yaml 的值。 """ return TrackingConfig.model_validate(_merge(load_ceus_section(), payload)) def load_tracking_config() -> TrackingConfig: """读 config/ceus.yaml 的 ceus 段(旧测试读的是旧 yaml 整份文件)。""" return TrackingConfig.model_validate(load_ceus_section()) def test_load_default_config_returns_tracking_config() -> None: """默认 yaml 应能加载为 TrackingConfig 实例。""" cfg = load_tracking_config() assert isinstance(cfg, TrackingConfig) def test_parameter_boundary_fraction_cannot_overlap_both_sides() -> None: with pytest.raises(ValidationError, match="parameter_boundary_fraction"): _validate({"tic_analysis": {"fitting": {"parameter_boundary_fraction": 0.5}}}) def test_default_values_match_findings_production_values() -> None: """关键默认值须等于 findings §2A/§3 的 yaml 生产值,而非代码保守默认。""" cfg = load_tracking_config() # findings §2A:删键即静默退化的 14 项中的代表项 assert cfg.interior_dark_region.enabled is True # 非代码默认 False assert cfg.interior_dark_region.analysis_view == "source" # 非 target assert cfg.interior_dark_region.exclude_prompt_color is True # 非 False assert cfg.interior_dark_region.threshold_method == "auto_ensemble" # 非 otsu assert cfg.interior_dark_region.min_area == 300 # 非代码默认 64 assert cfg.interior_dark_region.min_area_ratio == 0.015 # 非 0.0 assert cfg.roi_shape.source == "prompt_prior" # 非代码默认 medsam2 assert cfg.constraints.keyframe_position.enabled is True # 非 False # findings §3:其余生产值 assert cfg.keyframes.iou_threshold == 0.92 assert cfg.keyframes.min_gap_frames == 1 assert cfg.smoothing.temporal_window == 15 assert cfg.smoothing.contour_points == 64 assert cfg.roi_shape.min_area_ratio_to_prompt == 0.70 assert cfg.roi_shape.max_area_ratio_to_prompt == 1.25 # findings §2C/§3.8:暗部方法子参(yaml 未列、代码默认) assert cfg.interior_dark_region.percentile == 45 assert cfg.interior_dark_region.adaptive_block_size == 31 # design/0044:两条正式病灶曲线使用 Lognormal v2;SG 只做候选检测。 assert cfg.tic_analysis.fitting.algorithm_version == "lognormal-v2" assert cfg.tic_analysis.fitting.candidate_polyorder == 2 assert cfg.tic_analysis.fitting.multi_peak_minimum_separation_s == 5.0 assert cfg.tic_analysis.fitting.multi_peak_minimum_relative_height == 0.50 assert cfg.tic_analysis.fitting.multi_peak_maximum_valley_fraction == 0.25 assert cfg.tic_analysis.fitting.candidate_window_s == 1.5 assert cfg.tic_analysis.fitting.detect_duplicate_frames is True def test_temporal_window_must_be_odd() -> None: """findings §5:smoothing.temporal_window 须为正奇数,偶数报错。""" with pytest.raises(ValidationError): _validate({"smoothing": {"temporal_window": 14}}) def test_adaptive_block_size_must_be_odd() -> None: """findings §5:adaptive_block_size 须为正奇数,偶数报错。""" with pytest.raises(ValidationError): _validate({"interior_dark_region": {"adaptive_block_size": 30}}) def test_area_ratio_min_must_be_less_than_max() -> None: """findings §5:min_area_ratio_to_prompt 须 < max,逆序报错。""" with pytest.raises(ValidationError): _validate( { "roi_shape": { "min_area_ratio_to_prompt": 1.3, "max_area_ratio_to_prompt": 1.25, } } ) def test_percentile_must_be_within_bounds() -> None: """findings §5:暗部 percentile 须 ∈ [0, 100],越界报错。""" with pytest.raises(ValidationError): _validate({"interior_dark_region": {"percentile": 150}}) def test_keyframe_position_strength_clipped_to_unit_interval() -> None: """findings §5:constraints.keyframe_position.strength clip 到 [0, 1]。""" cfg = _validate({"constraints": {"keyframe_position": {"strength": 1.8}}}) assert cfg.constraints.keyframe_position.strength == 1.0 cfg_neg = _validate({"constraints": {"keyframe_position": {"strength": -0.5}}}) assert cfg_neg.constraints.keyframe_position.strength == 0.0 def test_extra_keys_forbidden() -> None: """子模型 extra='forbid':未知键(含 typo / 死键)报错。""" with pytest.raises(ValidationError): _validate( {"interior_dark_region": {"temporal_window": 9}} # findings §2B 死键 ) with pytest.raises(ValidationError): _validate({"unknown_top_level_key": 1}) def test_reference_region_manual_requires_center_xy() -> None: """findings §3.11:reference_region.mode=manual 时 center_xy 必填,缺则报错。""" with pytest.raises(ValidationError): _validate({"tic_analysis": {"reference_region": {"mode": "manual"}}}) def test_reference_region_manual_with_center_xy_passes() -> None: """findings §3.11:manual 模式提供 center_xy 即合法。""" cfg = _validate( {"tic_analysis": {"reference_region": {"mode": "manual", "center_xy": [100.0, 200.0]}}} ) assert cfg.tic_analysis.reference_region.mode == "manual" assert cfg.tic_analysis.reference_region.center_xy == [100.0, 200.0] def test_invalid_layout_rejected() -> None: """闭集枚举:非法 views.layout 应被 Literal 拒绝(防 typo 穿透)。""" with pytest.raises(ValidationError): _validate({"views": {"layout": "diagonal"}}) def test_invalid_threshold_method_rejected() -> None: """闭集枚举:非法 interior_dark_region.threshold_method 应被 Literal 拒绝。""" with pytest.raises(ValidationError): _validate({"interior_dark_region": {"threshold_method": "foobar"}}) def test_invalid_reference_region_mode_rejected() -> None: """闭集枚举:非法 reference_region.mode 应被 Literal 拒绝。""" with pytest.raises(ValidationError): _validate({"tic_analysis": {"reference_region": {"mode": "random"}}}) # ---------- design/0030 决策二:合成零点那四个键已删,残留即报错 ---------- @pytest.mark.parametrize( "payload", [ {"add_zero_timepoint": True}, {"shift_frame_times_after_zero": True}, {"zero_time_sec": 0.0}, {"smoothing": {"force_zero_start": True}}, ], ) def test_zero_timepoint_keys_are_gone(payload: dict[str, Any]) -> None: """design/0030 决策二:合成零点那四个键已删,残留即被 extra='forbid' 拒掉。 理由写在 `TicAnalysisConfig` 的 docstring 上(CLAUDE.md §1.6)。 这条守的只是名字,换个名字把同一个概念加回来它照样绿,所以那两段 docstring 是不可省 的另一半。 """ with pytest.raises(ValidationError): _validate({"tic_analysis": payload}) def test_anchor_frames_defaults_mirror_contract() -> None: """anchor_frames 段的默认值镜像自《临床口径契约》(2026-08-25 版,同步 2026-09-01)。 这批数不来自 findings 也不来自工程直觉——动任何一个之前先去 Linear 那边核对。 钉在测试里是为了防手滑改配置时漏看注释:改了数这条就红,红的含义是「抄本动过了, 去核对契约」。 """ cfg = load_tracking_config() a = cfg.tic_analysis.anchor_frames assert a.arrival.baseline_sd_multiplier == 3.0 assert a.arrival.relative_peak_threshold == 0.10 assert a.arrival.persistence_s == 0.5 # 突出度倍数与到达判据倍数是两个独立的键(契约明写改一个不应连带改另一个)。 assert a.peak_prominence_baseline_sd_multiplier == 3.0 assert a.baseline_frame_backoff_s == 0.3 assert a.d1_window_after_s == 0.5 assert a.d2_window_s == 0.3 assert a.d3_window_s == 0.5 # D4/B0 两档契约空着、第 12 题委托工程侧,取曲线锚点档里较宽的一档。 assert a.d4_window_s == 0.5 assert a.b0_window_s == 0.5 assert a.late_phase.preferred_time_s == 60.0 assert a.late_phase.tolerance_s == 2.0 assert a.f1_anchor_s == 20.0 def test_anchor_frames_unknown_key_rejected() -> None: """schema 拒绝未知键:照抄契约时拼错键名在加载配置这一步就报错,不进流水线。""" with pytest.raises(ValidationError): _validate({"tic_analysis": {"anchor_frames": {"arrival_threshold": 3.0}}}) def test_yaml_with_leftover_zero_key_fails_to_load() -> None: """yaml 里留着 `add_zero_timepoint` 时加载当场失败,不留兼容路径。""" section = load_ceus_section() section["tic_analysis"]["add_zero_timepoint"] = True with pytest.raises(ValidationError): TrackingConfig.model_validate(section) # ---------- design/0030 决策十:config_version ---------- def test_config_version_is_required() -> None: """design/0030 决策十:`config_version` 无默认,缺了直接报错。 有默认值等于「yaml 里删掉这行也能起来」,那时两批不同参数跑出来的结果落在库里 长得一模一样,没有任何东西能把它们分开。 """ section = load_ceus_section() del section["config_version"] with pytest.raises(ValidationError, match="config_version"): TrackingConfig.model_validate(section) def test_config_version_rejects_empty_string() -> None: """空串在**加载配置这一步**就得被拒,不能等到整条流水线跑完才炸。 实体 ``CeusRoiTracking`` 上有同一道判据,但那是落库前的最后一关:解码、分割、六趟 遍历全跑完,走到写库才报错,而这一趟是白跑的。 """ with pytest.raises(ValidationError, match="config_version"): _validate({"config_version": ""}) def test_default_yaml_declares_config_version() -> None: """默认 yaml 顶层给了 `config_version`。 "4" 起于双曲线 Lognormal v2(design/0044):拟合与锚点语义发生变化, 两批产物靠这个号分开(design/0030 决策十)。 """ cfg = load_tracking_config() assert cfg.config_version == "4" # ---------- design/0030 决策二:基线区间默认值 ---------- def test_baseline_defaults_match_spec_section_3_3() -> None: """基线两个时长的默认值等于规范 §3.3 给的首选 2 秒 / 最短 1 秒。""" cfg = load_tracking_config() assert cfg.tic_analysis.baseline.preferred_duration_s == 2.0 assert cfg.tic_analysis.baseline.minimum_duration_s == 1.0 # 规范 §3.3 没给稳定性判据,这个数是我们定的占位。 assert cfg.tic_analysis.baseline.stability_sd_multiplier == 3.0 def test_baseline_minimum_must_not_exceed_preferred() -> None: """最短时长超过首选时长即报错——那样「退到最短」比首选还长,等于没有退路。""" with pytest.raises(ValidationError): _validate( { "tic_analysis": { "baseline": { "preferred_duration_s": 1.0, "minimum_duration_s": 2.0, } } } ) def test_baseline_durations_must_be_positive() -> None: """非正时长即报错(0 秒的基线段算不出 B 与 σ0)。""" with pytest.raises(ValidationError): _validate({"tic_analysis": {"baseline": {"minimum_duration_s": 0.0}}}) # ---------- design/0030 决策四:pan_source(紫色轮廓检测器) ---------- def test_pan_source_defaults_match_default_yaml() -> None: """默认 yaml 里的紫色占位值原样加载(这一组待真实素材标定)。""" cfg = load_tracking_config() assert cfg.pan_source.hsv_lower == [125, 60, 60] assert cfg.pan_source.hsv_upper == [165, 255, 255] assert cfg.pan_source.min_area == 2000 assert cfg.pan_source.min_detected_frame_fraction == 0.9 # 顶部文字带同绿框那条:紫色轮廓和绿框画在同一画面上。 assert cfg.pan_source.ignore_regions == cfg.prompt_source.ignore_regions @pytest.mark.parametrize("bound", ["hsv_lower", "hsv_upper"]) def test_pan_source_hsv_must_have_three_components(bound: str) -> None: """HSV 阈值分量数不对即报错(否则要到 cv2.inRange 里才炸)。""" with pytest.raises(ValidationError): _validate({"pan_source": {bound: [125, 60]}}) def test_pan_source_hsv_upper_must_not_be_below_lower() -> None: """上下界写反即报错——inRange 不会报错,只会恒返回空掩膜,静默变成「检不到紫色」。""" with pytest.raises(ValidationError): _validate({"pan_source": {"hsv_lower": [125, 60, 60], "hsv_upper": [120, 255, 255]}}) def test_pan_source_min_area_must_be_positive() -> None: """min_area 须 ≥1。""" with pytest.raises(ValidationError): _validate({"pan_source": {"min_area": 0}}) @pytest.mark.parametrize("fraction", [0.0, -0.1, 1.5]) def test_pan_source_detected_fraction_out_of_bounds_rejected(fraction: float) -> None: """检出帧占比须 ∈ (0, 1]:取 0 这道门形同虚设,>1 则永远判不出有效。""" with pytest.raises(ValidationError): _validate({"pan_source": {"min_detected_frame_fraction": fraction}}) def test_pan_source_detected_fraction_accepts_one() -> None: """1.0 合法(虽然默认不取它——一帧漏检就整段作废太脆)。""" cfg = _validate({"pan_source": {"min_detected_frame_fraction": 1.0}}) assert cfg.pan_source.min_detected_frame_fraction == 1.0 # ---------- 任务书 02:配置不设默认值,删掉的旧字段不再接受 ---------- def _leaf_paths(node: dict[str, Any], prefix: tuple[str, ...]) -> list[tuple[str, ...]]: paths: list[tuple[str, ...]] = [] for key, value in node.items(): if isinstance(value, dict): paths.extend(_leaf_paths(value, (*prefix, key))) # pyright: ignore[reportUnknownArgumentType] paths.append((*prefix, key)) return paths @pytest.mark.parametrize("path", _leaf_paths(load_ceus_section(), ()), ids="/".join) def test_every_field_is_required(path: tuple[str, ...]) -> None: """ceus 段删掉任何一个键(含整个子段)都报错:没有一个字段有默认值。""" section = load_ceus_section() node = section for key in path[:-1]: node = node[key] del node[path[-1]] with pytest.raises(ValidationError): TrackingConfig.model_validate(section) @pytest.mark.parametrize( "payload", [ {"views": {"source_view": "left"}}, {"views": {"target_view": "right"}}, {"views": {"effective_region": {"type": "full"}}}, {"mapping": {"type": "normalized_view_coordinates", "anchor_points": []}}, ], ) def test_removed_fields_are_rejected(payload: dict[str, Any]) -> None: """造影在哪一半、裁黑边、映射锚点这几个旧字段已删,写了就报错。""" with pytest.raises(ValidationError): _validate(payload) def test_config_is_frozen() -> None: cfg = load_tracking_config() with pytest.raises(ValidationError): cfg.keyframes.iou_threshold = 0.5 # pyright: ignore[reportAttributeAccessIssue]