在前后端开发联调前 的提交20260223
This commit is contained in:
454
tests/test_property_5_etl_param_parsing.py
Normal file
454
tests/test_property_5_etl_param_parsing.py
Normal file
@@ -0,0 +1,454 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Feature: dataflow-field-completion, Property 5: ETL 参数解析与 CLI 命令构建正确性
|
||||
|
||||
**Validates: Requirements 14.1, 14.2**
|
||||
|
||||
对于任意合法的 ETL 执行参数组合(门店列表、数据源模式、校验模式、时间范围、
|
||||
窗口切分、force-full 标志、任务选择),Backend 构建的 CLI 命令字符串应包含
|
||||
所有指定参数,且参数值与输入一致。
|
||||
|
||||
测试策略:
|
||||
- 使用 hypothesis 生成随机 TaskConfigSchema 实例
|
||||
- 随机 flow(从 VALID_FLOWS 中选择)
|
||||
- 随机 processing_mode(从 VALID_PROCESSING_MODES 中选择)
|
||||
- 随机任务代码列表(从 task_registry 中选择)
|
||||
- 随机时间窗口模式(lookback / custom)
|
||||
- 随机 window_split 和 window_split_days
|
||||
- 随机 force_full / dry_run / fetch_before_verify 布尔值
|
||||
- 随机 store_id
|
||||
- 随机 ods_use_local_json
|
||||
|
||||
验证:
|
||||
1. 构建的 CLI 命令包含 --flow 且值与 flow 一致
|
||||
2. 任务代码通过 --tasks 正确传递
|
||||
3. 时间范围参数格式正确且值一致
|
||||
4. 布尔标志(--force-full / --dry-run / --fetch-before-verify)正确出现或缺失
|
||||
5. --store-id 值与输入一致
|
||||
6. --window-split / --window-split-days 正确传递
|
||||
7. --data-source offline 在 ods_use_local_json=True 时出现
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from hypothesis import given, settings, HealthCheck, assume
|
||||
import hypothesis.strategies as st
|
||||
|
||||
# ── 将后端模块加入 sys.path ──
|
||||
_BACKEND_ROOT = Path(__file__).resolve().parent.parent / "apps" / "backend"
|
||||
if str(_BACKEND_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_BACKEND_ROOT))
|
||||
|
||||
from app.services.cli_builder import CLIBuilder, VALID_FLOWS, VALID_PROCESSING_MODES
|
||||
from app.schemas.tasks import TaskConfigSchema
|
||||
from app.services.task_registry import ALL_TASKS
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
# 常量与策略
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
|
||||
# 所有合法任务代码
|
||||
_ALL_TASK_CODES: list[str] = [t.code for t in ALL_TASKS]
|
||||
|
||||
# 合法的 flow 值
|
||||
_VALID_FLOWS_LIST = sorted(VALID_FLOWS)
|
||||
|
||||
# 合法的 processing_mode 值
|
||||
_VALID_MODES_LIST = sorted(VALID_PROCESSING_MODES)
|
||||
|
||||
# 合法的 window_split 值(CLI 支持的切分模式)
|
||||
_VALID_WINDOW_SPLITS = ["none", "day", "week", "month"]
|
||||
|
||||
# 日期格式策略:YYYY-MM-DD
|
||||
_date_str = st.dates(
|
||||
min_value=st.just(2024, 1, 1).__wrapped__ if False else __import__("datetime").date(2024, 1, 1),
|
||||
max_value=__import__("datetime").date(2026, 12, 31),
|
||||
).map(lambda d: d.isoformat())
|
||||
|
||||
|
||||
@st.composite
|
||||
def _valid_config(draw) -> TaskConfigSchema:
|
||||
"""生成一个合法的 TaskConfigSchema 实例"""
|
||||
# 随机选择 1-5 个任务代码
|
||||
tasks = draw(st.lists(
|
||||
st.sampled_from(_ALL_TASK_CODES),
|
||||
min_size=1,
|
||||
max_size=5,
|
||||
unique=True,
|
||||
))
|
||||
|
||||
flow_id = draw(st.sampled_from(_VALID_FLOWS_LIST))
|
||||
processing_mode = draw(st.sampled_from(_VALID_MODES_LIST))
|
||||
|
||||
# 时间窗口模式
|
||||
window_mode = draw(st.sampled_from(["lookback", "custom"]))
|
||||
|
||||
window_start = None
|
||||
window_end = None
|
||||
lookback_hours = 24
|
||||
overlap_seconds = 600
|
||||
|
||||
if window_mode == "custom":
|
||||
# 生成合法的 start <= end 日期对
|
||||
start = draw(_date_str)
|
||||
end = draw(_date_str)
|
||||
if start > end:
|
||||
start, end = end, start
|
||||
window_start = start
|
||||
window_end = end
|
||||
else:
|
||||
lookback_hours = draw(st.integers(min_value=1, max_value=720))
|
||||
overlap_seconds = draw(st.integers(min_value=0, max_value=7200))
|
||||
|
||||
# 窗口切分
|
||||
window_split = draw(st.sampled_from(_VALID_WINDOW_SPLITS))
|
||||
window_split_days = None
|
||||
if window_split != "none":
|
||||
window_split_days = draw(st.integers(min_value=1, max_value=30))
|
||||
|
||||
# 布尔标志
|
||||
force_full = draw(st.booleans())
|
||||
dry_run = draw(st.booleans())
|
||||
fetch_before_verify = draw(st.booleans())
|
||||
ods_use_local_json = draw(st.booleans())
|
||||
|
||||
# store_id:可能为 None 或正整数
|
||||
store_id = draw(st.one_of(st.none(), st.integers(min_value=1, max_value=999999)))
|
||||
|
||||
return TaskConfigSchema(
|
||||
tasks=tasks,
|
||||
flow=flow_id,
|
||||
processing_mode=processing_mode,
|
||||
window_mode=window_mode,
|
||||
window_start=window_start,
|
||||
window_end=window_end,
|
||||
lookback_hours=lookback_hours,
|
||||
overlap_seconds=overlap_seconds,
|
||||
window_split=window_split,
|
||||
window_split_days=window_split_days,
|
||||
force_full=force_full,
|
||||
dry_run=dry_run,
|
||||
fetch_before_verify=fetch_before_verify,
|
||||
ods_use_local_json=ods_use_local_json,
|
||||
store_id=store_id,
|
||||
)
|
||||
|
||||
|
||||
# 全局 CLIBuilder 实例
|
||||
_builder = CLIBuilder()
|
||||
_ETL_PATH = "apps/etl/connectors/feiqiu"
|
||||
|
||||
|
||||
def _build(config: TaskConfigSchema) -> list[str]:
|
||||
"""便捷包装:构建命令列表"""
|
||||
return _builder.build_command(config, _ETL_PATH)
|
||||
|
||||
|
||||
def _get_arg_value(cmd: list[str], flag: str) -> str | None:
|
||||
"""从命令列表中提取指定 flag 后面的值"""
|
||||
try:
|
||||
idx = cmd.index(flag)
|
||||
if idx + 1 < len(cmd):
|
||||
return cmd[idx + 1]
|
||||
except ValueError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _has_flag(cmd: list[str], flag: str) -> bool:
|
||||
"""检查命令列表中是否包含指定 flag"""
|
||||
return flag in cmd
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
# Property 5a: --flow 参数与 flow 一致
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
|
||||
@given(config=_valid_config())
|
||||
@settings(
|
||||
max_examples=100,
|
||||
deadline=None,
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture, HealthCheck.too_slow],
|
||||
)
|
||||
def test_flow_param_matches_flow(config: TaskConfigSchema):
|
||||
"""
|
||||
**Validates: Requirements 14.1, 14.2**
|
||||
|
||||
构建的 CLI 命令必须包含 --flow 参数,且值与 config.flow 一致。
|
||||
"""
|
||||
cmd = _build(config)
|
||||
flow_value = _get_arg_value(cmd, "--flow")
|
||||
assert flow_value is not None, "CLI 命令缺少 --flow 参数"
|
||||
assert flow_value == config.flow, (
|
||||
f"--flow 值 {flow_value!r} != config.flow {config.flow!r}"
|
||||
)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
# Property 5b: --tasks 参数包含所有任务代码
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
|
||||
@given(config=_valid_config())
|
||||
@settings(
|
||||
max_examples=100,
|
||||
deadline=None,
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture, HealthCheck.too_slow],
|
||||
)
|
||||
def test_tasks_param_contains_all_codes(config: TaskConfigSchema):
|
||||
"""
|
||||
**Validates: Requirements 14.1, 14.2**
|
||||
|
||||
构建的 CLI 命令中 --tasks 参数应包含所有指定的任务代码(逗号分隔)。
|
||||
"""
|
||||
cmd = _build(config)
|
||||
tasks_value = _get_arg_value(cmd, "--tasks")
|
||||
|
||||
if config.tasks:
|
||||
assert tasks_value is not None, "CLI 命令缺少 --tasks 参数"
|
||||
parsed_tasks = set(tasks_value.split(","))
|
||||
expected_tasks = set(config.tasks)
|
||||
assert parsed_tasks == expected_tasks, (
|
||||
f"--tasks 解析结果 {parsed_tasks} != 期望 {expected_tasks}"
|
||||
)
|
||||
# tasks 为空列表时,CLIBuilder 不添加 --tasks(符合预期)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
# Property 5c: 时间窗口参数正确传递
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
|
||||
@given(config=_valid_config())
|
||||
@settings(
|
||||
max_examples=100,
|
||||
deadline=None,
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture, HealthCheck.too_slow],
|
||||
)
|
||||
def test_time_window_params_correct(config: TaskConfigSchema):
|
||||
"""
|
||||
**Validates: Requirements 14.1, 14.2**
|
||||
|
||||
- lookback 模式:命令包含 --lookback-hours 和 --overlap-seconds
|
||||
- custom 模式:命令包含 --window-start 和 --window-end
|
||||
- 两种模式互斥
|
||||
"""
|
||||
cmd = _build(config)
|
||||
|
||||
if config.window_mode == "lookback":
|
||||
# lookback 模式:应有 --lookback-hours
|
||||
lh = _get_arg_value(cmd, "--lookback-hours")
|
||||
assert lh is not None, "lookback 模式缺少 --lookback-hours"
|
||||
assert lh == str(config.lookback_hours), (
|
||||
f"--lookback-hours {lh!r} != {config.lookback_hours}"
|
||||
)
|
||||
os_val = _get_arg_value(cmd, "--overlap-seconds")
|
||||
assert os_val is not None, "lookback 模式缺少 --overlap-seconds"
|
||||
assert os_val == str(config.overlap_seconds), (
|
||||
f"--overlap-seconds {os_val!r} != {config.overlap_seconds}"
|
||||
)
|
||||
# 不应有 custom 参数
|
||||
assert not _has_flag(cmd, "--window-start"), (
|
||||
"lookback 模式不应包含 --window-start"
|
||||
)
|
||||
assert not _has_flag(cmd, "--window-end"), (
|
||||
"lookback 模式不应包含 --window-end"
|
||||
)
|
||||
else:
|
||||
# custom 模式
|
||||
if config.window_start:
|
||||
ws = _get_arg_value(cmd, "--window-start")
|
||||
assert ws == config.window_start, (
|
||||
f"--window-start {ws!r} != {config.window_start!r}"
|
||||
)
|
||||
if config.window_end:
|
||||
we = _get_arg_value(cmd, "--window-end")
|
||||
assert we == config.window_end, (
|
||||
f"--window-end {we!r} != {config.window_end!r}"
|
||||
)
|
||||
# 不应有 lookback 参数
|
||||
assert not _has_flag(cmd, "--lookback-hours"), (
|
||||
"custom 模式不应包含 --lookback-hours"
|
||||
)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
# Property 5d: 布尔标志正确出现或缺失
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
|
||||
@given(config=_valid_config())
|
||||
@settings(
|
||||
max_examples=100,
|
||||
deadline=None,
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture, HealthCheck.too_slow],
|
||||
)
|
||||
def test_boolean_flags_correct(config: TaskConfigSchema):
|
||||
"""
|
||||
**Validates: Requirements 14.1, 14.2**
|
||||
|
||||
- force_full=True → 命令包含 --force-full
|
||||
- dry_run=True → 命令包含 --dry-run
|
||||
- fetch_before_verify=True 且 processing_mode="verify_only" → 命令包含 --fetch-before-verify
|
||||
- ods_use_local_json=True → 命令包含 --data-source offline
|
||||
"""
|
||||
cmd = _build(config)
|
||||
|
||||
# force_full
|
||||
if config.force_full:
|
||||
assert _has_flag(cmd, "--force-full"), "force_full=True 但命令缺少 --force-full"
|
||||
else:
|
||||
assert not _has_flag(cmd, "--force-full"), "force_full=False 但命令包含 --force-full"
|
||||
|
||||
# dry_run
|
||||
if config.dry_run:
|
||||
assert _has_flag(cmd, "--dry-run"), "dry_run=True 但命令缺少 --dry-run"
|
||||
else:
|
||||
assert not _has_flag(cmd, "--dry-run"), "dry_run=False 但命令包含 --dry-run"
|
||||
|
||||
# fetch_before_verify(仅 verify_only 模式生效)
|
||||
if config.fetch_before_verify and config.processing_mode == "verify_only":
|
||||
assert _has_flag(cmd, "--fetch-before-verify"), (
|
||||
"fetch_before_verify=True + verify_only 但命令缺少 --fetch-before-verify"
|
||||
)
|
||||
else:
|
||||
assert not _has_flag(cmd, "--fetch-before-verify"), (
|
||||
"非 verify_only 模式或 fetch_before_verify=False 但命令包含 --fetch-before-verify"
|
||||
)
|
||||
|
||||
# ods_use_local_json
|
||||
if config.ods_use_local_json:
|
||||
ds = _get_arg_value(cmd, "--data-source")
|
||||
assert ds == "offline", (
|
||||
f"ods_use_local_json=True 但 --data-source={ds!r}(期望 'offline')"
|
||||
)
|
||||
else:
|
||||
# 不应有 --data-source offline(除非 extra_args 中有 data_source)
|
||||
if "data_source" not in config.extra_args:
|
||||
ds = _get_arg_value(cmd, "--data-source")
|
||||
assert ds is None, (
|
||||
f"ods_use_local_json=False 但命令包含 --data-source {ds!r}"
|
||||
)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
# Property 5e: --store-id 正确传递
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
|
||||
@given(config=_valid_config())
|
||||
@settings(
|
||||
max_examples=100,
|
||||
deadline=None,
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture, HealthCheck.too_slow],
|
||||
)
|
||||
def test_store_id_param_correct(config: TaskConfigSchema):
|
||||
"""
|
||||
**Validates: Requirements 14.1, 14.2**
|
||||
|
||||
store_id 不为 None 时,命令应包含 --store-id 且值一致;
|
||||
store_id 为 None 时,命令不应包含 --store-id。
|
||||
"""
|
||||
cmd = _build(config)
|
||||
sid = _get_arg_value(cmd, "--store-id")
|
||||
|
||||
if config.store_id is not None:
|
||||
assert sid is not None, "store_id 不为 None 但命令缺少 --store-id"
|
||||
assert sid == str(config.store_id), (
|
||||
f"--store-id {sid!r} != {config.store_id}"
|
||||
)
|
||||
else:
|
||||
assert sid is None, f"store_id=None 但命令包含 --store-id {sid!r}"
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
# Property 5f: --window-split / --window-split-days 正确传递
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
|
||||
@given(config=_valid_config())
|
||||
@settings(
|
||||
max_examples=100,
|
||||
deadline=None,
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture, HealthCheck.too_slow],
|
||||
)
|
||||
def test_window_split_params_correct(config: TaskConfigSchema):
|
||||
"""
|
||||
**Validates: Requirements 14.1, 14.2**
|
||||
|
||||
window_split 不为 "none" 时,命令应包含 --window-split 和 --window-split-days;
|
||||
window_split 为 "none" 时,命令不应包含这些参数。
|
||||
"""
|
||||
cmd = _build(config)
|
||||
|
||||
if config.window_split and config.window_split != "none":
|
||||
ws = _get_arg_value(cmd, "--window-split")
|
||||
assert ws == config.window_split, (
|
||||
f"--window-split {ws!r} != {config.window_split!r}"
|
||||
)
|
||||
if config.window_split_days is not None:
|
||||
wsd = _get_arg_value(cmd, "--window-split-days")
|
||||
assert wsd == str(config.window_split_days), (
|
||||
f"--window-split-days {wsd!r} != {config.window_split_days}"
|
||||
)
|
||||
else:
|
||||
assert not _has_flag(cmd, "--window-split"), (
|
||||
"window_split='none' 但命令包含 --window-split"
|
||||
)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
# Property 5g: --processing-mode 正确传递
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
|
||||
@given(config=_valid_config())
|
||||
@settings(
|
||||
max_examples=100,
|
||||
deadline=None,
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture, HealthCheck.too_slow],
|
||||
)
|
||||
def test_processing_mode_param_correct(config: TaskConfigSchema):
|
||||
"""
|
||||
**Validates: Requirements 14.1, 14.2**
|
||||
|
||||
processing_mode 不为空时,命令应包含 --processing-mode 且值一致。
|
||||
"""
|
||||
cmd = _build(config)
|
||||
|
||||
if config.processing_mode:
|
||||
pm = _get_arg_value(cmd, "--processing-mode")
|
||||
assert pm is not None, "processing_mode 不为空但命令缺少 --processing-mode"
|
||||
assert pm == config.processing_mode, (
|
||||
f"--processing-mode {pm!r} != {config.processing_mode!r}"
|
||||
)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
# Property 5h: 命令字符串与命令列表一致
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
|
||||
@given(config=_valid_config())
|
||||
@settings(
|
||||
max_examples=100,
|
||||
deadline=None,
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture, HealthCheck.too_slow],
|
||||
)
|
||||
def test_command_string_consistent_with_list(config: TaskConfigSchema):
|
||||
"""
|
||||
**Validates: Requirements 14.1, 14.2**
|
||||
|
||||
build_command_string() 的输出应与 build_command() 的列表拼接结果一致
|
||||
(对含空格的参数自动加引号)。
|
||||
"""
|
||||
cmd_list = _builder.build_command(config, _ETL_PATH)
|
||||
cmd_str = _builder.build_command_string(config, _ETL_PATH)
|
||||
|
||||
# 逐个参数验证:每个参数都应出现在字符串中
|
||||
for arg in cmd_list:
|
||||
if " " in arg or '"' in arg:
|
||||
# 含空格的参数应被引号包裹
|
||||
assert f'"{arg}"' in cmd_str, (
|
||||
f"含空格参数 {arg!r} 未在命令字符串中被正确引用"
|
||||
)
|
||||
else:
|
||||
assert arg in cmd_str, (
|
||||
f"参数 {arg!r} 未出现在命令字符串中"
|
||||
)
|
||||
Reference in New Issue
Block a user