# -*- coding: utf-8 -*- """TaskConfig 属性测试(Property-Based Testing)。 使用 hypothesis 验证 TaskConfig 相关的通用正确性属性: - Property 1: TaskConfig 序列化往返一致性 - Property 6: 时间窗口验证 - Property 7: TaskConfig 到 CLI 命令转换完整性 """ import datetime from hypothesis import given, settings, assume from hypothesis import strategies as st from pydantic import ValidationError from app.schemas.tasks import TaskConfigSchema from app.services.cli_builder import CLIBuilder, VALID_FLOWS, VALID_PROCESSING_MODES from app.services.task_registry import ALL_TASKS # --------------------------------------------------------------------------- # 策略(Strategies) # --------------------------------------------------------------------------- # 从真实任务注册表中采样任务代码 _task_codes = [t.code for t in ALL_TASKS] _tasks_st = st.lists( st.sampled_from(_task_codes), min_size=1, max_size=5, unique=True, ) _pipeline_st = st.sampled_from(sorted(VALID_FLOWS)) _processing_mode_st = st.sampled_from(sorted(VALID_PROCESSING_MODES)) _window_mode_st = st.sampled_from(["lookback", "custom"]) # 日期策略:生成 YYYY-MM-DD 格式字符串 _date_st = st.dates( min_value=datetime.date(2020, 1, 1), max_value=datetime.date(2030, 12, 31), ).map(lambda d: d.isoformat()) _window_split_st = st.sampled_from([None, "none", "day"]) _window_split_days_st = st.one_of(st.none(), st.sampled_from([1, 10, 30])) _lookback_hours_st = st.integers(min_value=1, max_value=720) _overlap_seconds_st = st.integers(min_value=0, max_value=7200) _store_id_st = st.one_of(st.none(), st.integers(min_value=1, max_value=2**31 - 1)) # DWD 表名采样 _dwd_table_names = [ "dwd.dim_site", "dwd.dim_member", "dwd.dwd_settlement_head", ] _dwd_only_tables_st = st.one_of( st.none(), st.lists(st.sampled_from(_dwd_table_names), min_size=1, max_size=3, unique=True), ) def _valid_task_config_st(): """生成有效的 TaskConfigSchema 的复合策略。 确保 window_mode=custom 时 window_end >= window_start, 避免触发 Pydantic 验证错误。 """ @st.composite def _build(draw): tasks = draw(_tasks_st) pipeline = draw(_pipeline_st) processing_mode = draw(_processing_mode_st) dry_run = draw(st.booleans()) window_mode = draw(_window_mode_st) store_id = draw(_store_id_st) dwd_only_tables = draw(_dwd_only_tables_st) window_split = draw(_window_split_st) window_split_days = draw(_window_split_days_st) fetch_before_verify = draw(st.booleans()) skip_ods = draw(st.booleans()) ods_local = draw(st.booleans()) if window_mode == "custom": d1 = draw(st.dates( min_value=datetime.date(2020, 1, 1), max_value=datetime.date(2030, 12, 31), )) d2 = draw(st.dates( min_value=datetime.date(2020, 1, 1), max_value=datetime.date(2030, 12, 31), )) # 保证 end >= start window_start = min(d1, d2).isoformat() window_end = max(d1, d2).isoformat() lookback_hours = 24 overlap_seconds = 600 else: window_start = None window_end = None lookback_hours = draw(_lookback_hours_st) overlap_seconds = draw(_overlap_seconds_st) return TaskConfigSchema( tasks=tasks, pipeline=pipeline, processing_mode=processing_mode, dry_run=dry_run, window_mode=window_mode, window_start=window_start, window_end=window_end, window_split=window_split, window_split_days=window_split_days, lookback_hours=lookback_hours, overlap_seconds=overlap_seconds, fetch_before_verify=fetch_before_verify, skip_ods_when_fetch_before_verify=skip_ods, ods_use_local_json=ods_local, store_id=store_id, dwd_only_tables=dwd_only_tables, extra_args={}, ) return _build() # --------------------------------------------------------------------------- # Feature: admin-web-console, Property 1: TaskConfig 序列化往返一致性 # **Validates: Requirements 11.1, 11.2, 11.3** # --------------------------------------------------------------------------- @settings(max_examples=200) @given(config=_valid_task_config_st()) def test_task_config_round_trip(config: TaskConfigSchema): """Property 1: 序列化为 JSON 后再反序列化,应产生与原始对象等价的结果。""" json_str = config.model_dump_json() restored = TaskConfigSchema.model_validate_json(json_str) assert restored == config, ( f"往返不一致:\n原始={config}\n还原={restored}" ) # --------------------------------------------------------------------------- # Feature: admin-web-console, Property 6: 时间窗口验证 # **Validates: Requirements 2.3** # --------------------------------------------------------------------------- @settings(max_examples=200) @given( d1=st.dates( min_value=datetime.date(2020, 1, 1), max_value=datetime.date(2030, 12, 31), ), d2=st.dates( min_value=datetime.date(2020, 1, 1), max_value=datetime.date(2030, 12, 31), ), ) def test_time_window_validation(d1: datetime.date, d2: datetime.date): """Property 6: window_end < window_start 时验证应失败,否则应通过。""" start_str = d1.isoformat() end_str = d2.isoformat() if end_str < start_str: # window_end 早于 window_start → 验证应失败 try: TaskConfigSchema( tasks=["ODS_MEMBER"], window_mode="custom", window_start=start_str, window_end=end_str, ) raise AssertionError( f"期望 ValidationError,但验证通过了:start={start_str}, end={end_str}" ) except ValidationError: pass # 预期行为 else: # window_end >= window_start → 验证应通过 config = TaskConfigSchema( tasks=["ODS_MEMBER"], window_mode="custom", window_start=start_str, window_end=end_str, ) assert config.window_start == start_str assert config.window_end == end_str # --------------------------------------------------------------------------- # Feature: admin-web-console, Property 7: TaskConfig 到 CLI 命令转换完整性 # **Validates: Requirements 2.5, 2.6** # --------------------------------------------------------------------------- _builder = CLIBuilder() _ETL_PATH = "/fake/etl/project" @settings(max_examples=200) @given(config=_valid_task_config_st()) def test_task_config_to_cli_completeness(config: TaskConfigSchema): """Property 7: CLIBuilder 生成的命令应包含 TaskConfig 中所有非空字段对应的 CLI 参数。""" cmd = _builder.build_command(config, _ETL_PATH) # 1) --pipeline 始终存在且值正确 assert "--pipeline" in cmd idx = cmd.index("--pipeline") assert cmd[idx + 1] == config.pipeline # 2) --processing-mode 始终存在且值正确 assert "--processing-mode" in cmd idx = cmd.index("--processing-mode") assert cmd[idx + 1] == config.processing_mode # 3) 非空任务列表 → --tasks 存在 if config.tasks: assert "--tasks" in cmd idx = cmd.index("--tasks") assert set(cmd[idx + 1].split(",")) == set(config.tasks) # 4) 时间窗口参数 if config.window_mode == "lookback": # lookback 模式 → --lookback-hours 和 --overlap-seconds if config.lookback_hours is not None: assert "--lookback-hours" in cmd idx = cmd.index("--lookback-hours") assert cmd[idx + 1] == str(config.lookback_hours) if config.overlap_seconds is not None: assert "--overlap-seconds" in cmd idx = cmd.index("--overlap-seconds") assert cmd[idx + 1] == str(config.overlap_seconds) # lookback 模式不应出现 custom 参数 assert "--window-start" not in cmd assert "--window-end" not in cmd else: # custom 模式 → --window-start / --window-end if config.window_start: assert "--window-start" in cmd if config.window_end: assert "--window-end" in cmd # custom 模式不应出现 lookback 参数 assert "--lookback-hours" not in cmd assert "--overlap-seconds" not in cmd # 5) dry_run → --dry-run if config.dry_run: assert "--dry-run" in cmd else: assert "--dry-run" not in cmd # 6) store_id → --store-id if config.store_id is not None: assert "--store-id" in cmd idx = cmd.index("--store-id") assert cmd[idx + 1] == str(config.store_id) else: assert "--store-id" not in cmd # 7) fetch_before_verify → 仅 verify_only 模式下生成 if config.fetch_before_verify and config.processing_mode == "verify_only": assert "--fetch-before-verify" in cmd else: assert "--fetch-before-verify" not in cmd # 8) window_split(非 None 且非 "none")→ --window-split if config.window_split and config.window_split != "none": assert "--window-split" in cmd idx = cmd.index("--window-split") assert cmd[idx + 1] == config.window_split if config.window_split_days is not None: assert "--window-split-days" in cmd else: assert "--window-split" not in cmd