在准备环境前提交次全部更改。
This commit is contained in:
275
apps/backend/tests/test_task_config_properties.py
Normal file
275
apps/backend/tests/test_task_config_properties.py
Normal file
@@ -0,0 +1,275 @@
|
||||
# -*- 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
|
||||
Reference in New Issue
Block a user