在准备环境前提交次全部更改。
This commit is contained in:
325
tests/test_etl_refactor_properties.py
Normal file
325
tests/test_etl_refactor_properties.py
Normal file
@@ -0,0 +1,325 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Monorepo 级属性测试 — ETL DWS/Flow 重构
|
||||
|
||||
集中存放跨模块的属性测试,验证重构后的正确性属性。
|
||||
"""
|
||||
import sys
|
||||
import string
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from hypothesis import given, settings, assume
|
||||
from hypothesis import strategies as st
|
||||
|
||||
# 将 ETL feiqiu 模块加入 sys.path,以便直接导入
|
||||
_ETL_ROOT = Path(__file__).resolve().parent.parent / "apps" / "etl" / "connectors" / "feiqiu"
|
||||
if str(_ETL_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_ETL_ROOT))
|
||||
|
||||
from cli.main import parse_layers, VALID_LAYERS # noqa: E402
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 5: --layers 解析正确性
|
||||
# Feature: etl-dws-flow-refactor, Property 5: --layers 解析正确性
|
||||
# Validates: Requirements 6.1, 6.2
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ALL_LAYERS = sorted(VALID_LAYERS)
|
||||
|
||||
_layer_subsets = st.lists(
|
||||
st.sampled_from(_ALL_LAYERS),
|
||||
min_size=1,
|
||||
max_size=4,
|
||||
unique=True,
|
||||
)
|
||||
|
||||
_invalid_layer = st.text(
|
||||
alphabet=string.ascii_letters,
|
||||
min_size=1,
|
||||
max_size=10,
|
||||
).filter(lambda s: s.upper() not in VALID_LAYERS)
|
||||
|
||||
|
||||
class TestParseLayersProperty:
|
||||
"""Property 5: --layers 解析正确性
|
||||
|
||||
# Feature: etl-dws-flow-refactor, Property 5: --layers 解析正确性
|
||||
"""
|
||||
|
||||
@given(subset=_layer_subsets)
|
||||
@settings(max_examples=100)
|
||||
def test_valid_subset_parsed_correctly(self, subset: list[str]):
|
||||
"""任意合法层子集经逗号拼接后,parse_layers 返回恰好该子集(大写)。
|
||||
|
||||
**Validates: Requirements 6.1, 6.2**
|
||||
"""
|
||||
raw = ",".join(subset)
|
||||
result = parse_layers(raw)
|
||||
assert set(result) == set(subset)
|
||||
assert all(l == l.upper() for l in result)
|
||||
assert len(result) == len(subset)
|
||||
|
||||
@given(
|
||||
valid=_layer_subsets,
|
||||
bad=_invalid_layer,
|
||||
)
|
||||
@settings(max_examples=100)
|
||||
def test_invalid_layer_raises(self, valid: list[str], bad: str):
|
||||
"""包含无效层名时必须抛出 ValueError。
|
||||
|
||||
**Validates: Requirements 6.1, 6.2**
|
||||
"""
|
||||
assume(bad.upper() not in VALID_LAYERS)
|
||||
raw = ",".join(valid + [bad])
|
||||
with pytest.raises(ValueError, match="无效的层名"):
|
||||
parse_layers(raw)
|
||||
|
||||
|
||||
from orchestration.flow_runner import FlowRunner # noqa: E402
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 6: 配置优先级——配置值优先于 Registry
|
||||
# Feature: etl-dws-flow-refactor, Property 6: 配置优先级
|
||||
# Validates: Requirements 7.2
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_LAYER_CONFIG_KEY = {
|
||||
"ODS": "run.ods_tasks",
|
||||
"DWD": "run.dwd_tasks",
|
||||
"DWS": "run.dws_tasks",
|
||||
"INDEX": "run.index_tasks",
|
||||
}
|
||||
|
||||
|
||||
class _DictConfig:
|
||||
"""模拟 AppConfig.get(),支持点号路径查找。"""
|
||||
|
||||
def __init__(self, data: dict):
|
||||
self._data = data
|
||||
|
||||
def get(self, key: str, default=None):
|
||||
keys = key.split(".")
|
||||
node = self._data
|
||||
for k in keys:
|
||||
if isinstance(node, dict) and k in node:
|
||||
node = node[k]
|
||||
else:
|
||||
return default
|
||||
return node
|
||||
|
||||
|
||||
def _make_runner(config_data: dict, registry_by_layer: dict) -> FlowRunner:
|
||||
"""构造最小 FlowRunner 实例,仅满足 _resolve_tasks 所需。"""
|
||||
import logging
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
config = _DictConfig(config_data)
|
||||
registry = MagicMock()
|
||||
registry.get_tasks_by_layer = MagicMock(
|
||||
side_effect=lambda layer: registry_by_layer.get(layer.upper(), [])
|
||||
)
|
||||
runner = object.__new__(FlowRunner)
|
||||
runner.config = config
|
||||
runner.task_registry = registry
|
||||
runner.logger = logging.getLogger("test_prop6")
|
||||
return runner
|
||||
|
||||
|
||||
_task_code_p6 = st.from_regex(r"[A-Z][A-Z0-9_]{2,20}", fullmatch=True)
|
||||
_task_list_p6 = st.lists(_task_code_p6, min_size=1, max_size=8, unique=True)
|
||||
_layer_p6 = st.sampled_from(["ODS", "DWD", "DWS", "INDEX"])
|
||||
|
||||
|
||||
class TestConfigPriorityProperty:
|
||||
"""Property 6: 配置优先级——配置值优先于 Registry
|
||||
|
||||
# Feature: etl-dws-flow-refactor, Property 6: 配置优先级
|
||||
"""
|
||||
|
||||
@given(layer=_layer_p6, config_tasks=_task_list_p6, registry_tasks=_task_list_p6)
|
||||
@settings(max_examples=100)
|
||||
def test_config_overrides_registry(
|
||||
self, layer: str, config_tasks: list[str], registry_tasks: list[str]
|
||||
):
|
||||
"""任意层名 + 非空配置任务列表 → 返回配置值,忽略 Registry。
|
||||
|
||||
**Validates: Requirements 7.2**
|
||||
"""
|
||||
assume(set(config_tasks) != set(registry_tasks))
|
||||
key = _LAYER_CONFIG_KEY[layer]
|
||||
parts = key.split(".")
|
||||
config_data = {parts[0]: {parts[1]: config_tasks}}
|
||||
runner = _make_runner(config_data, {layer: registry_tasks})
|
||||
result = runner._resolve_tasks([layer])
|
||||
assert result == config_tasks
|
||||
|
||||
@given(layer=_layer_p6, registry_tasks=_task_list_p6)
|
||||
@settings(max_examples=100)
|
||||
def test_empty_config_falls_back_to_registry(
|
||||
self, layer: str, registry_tasks: list[str]
|
||||
):
|
||||
"""配置为空时回退到 Registry 结果。
|
||||
|
||||
**Validates: Requirements 7.2**
|
||||
"""
|
||||
runner = _make_runner({}, {layer: registry_tasks})
|
||||
result = runner._resolve_tasks([layer])
|
||||
assert result == registry_tasks
|
||||
|
||||
|
||||
from orchestration.topological_sort import topological_sort # noqa: E402
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 7 & 8 辅助:轻量 TaskMeta 替身 + 简易 Registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from dataclasses import dataclass, field as dc_field
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeMeta:
|
||||
depends_on: list[str] = dc_field(default_factory=list)
|
||||
|
||||
|
||||
class _FakeRegistry:
|
||||
"""最小 Registry 替身,仅提供 get_metadata()。"""
|
||||
|
||||
def __init__(self, deps: dict[str, list[str]]):
|
||||
self._deps = deps
|
||||
|
||||
def get_metadata(self, code: str):
|
||||
if code in self._deps:
|
||||
return _FakeMeta(depends_on=self._deps[code])
|
||||
return _FakeMeta()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hypothesis 策略:DAG 生成 & 含环图生成
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@st.composite
|
||||
def dag_strategy(draw):
|
||||
"""生成随机 DAG:(task_codes, deps_dict)。
|
||||
|
||||
通过仅允许依赖索引更小的任务来保证无环。
|
||||
"""
|
||||
n = draw(st.integers(min_value=1, max_value=10))
|
||||
codes = [f"TASK_{i}" for i in range(n)]
|
||||
deps: dict[str, list[str]] = {}
|
||||
for i, code in enumerate(codes):
|
||||
if i > 0:
|
||||
possible = codes[:i]
|
||||
chosen = draw(
|
||||
st.lists(
|
||||
st.sampled_from(possible),
|
||||
max_size=min(3, i),
|
||||
unique=True,
|
||||
)
|
||||
)
|
||||
deps[code] = chosen
|
||||
else:
|
||||
deps[code] = []
|
||||
return codes, deps
|
||||
|
||||
|
||||
@st.composite
|
||||
def cyclic_graph_strategy(draw):
|
||||
"""生成一定包含至少一个环的有向图:(task_codes, deps_dict, cycle_tasks)。"""
|
||||
n = draw(st.integers(min_value=2, max_value=8))
|
||||
codes = [f"TASK_{i}" for i in range(n)]
|
||||
deps: dict[str, list[str]] = {code: [] for code in codes}
|
||||
# 选取 2-4 个任务构成环
|
||||
cycle_size = draw(st.integers(min_value=2, max_value=min(4, n)))
|
||||
cycle_tasks = draw(
|
||||
st.lists(
|
||||
st.sampled_from(codes),
|
||||
min_size=cycle_size,
|
||||
max_size=cycle_size,
|
||||
unique=True,
|
||||
)
|
||||
)
|
||||
for i in range(len(cycle_tasks)):
|
||||
next_task = cycle_tasks[(i + 1) % len(cycle_tasks)]
|
||||
if next_task not in deps[cycle_tasks[i]]:
|
||||
deps[cycle_tasks[i]].append(next_task)
|
||||
return codes, deps, cycle_tasks
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 7: 拓扑排序正确性
|
||||
# Feature: etl-dws-flow-refactor, Property 7: 拓扑排序正确性
|
||||
# Validates: Requirements 8.3
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTopologicalSortProperty:
|
||||
"""Property 7: 拓扑排序正确性
|
||||
|
||||
# Feature: etl-dws-flow-refactor, Property 7: 拓扑排序正确性
|
||||
"""
|
||||
|
||||
@given(data=dag_strategy())
|
||||
@settings(max_examples=100)
|
||||
def test_dependencies_precede_dependents(self, data):
|
||||
"""对于任意 DAG,排序结果中每个任务的依赖都排在它之前。
|
||||
|
||||
**Validates: Requirements 8.3**
|
||||
"""
|
||||
codes, deps = data
|
||||
registry = _FakeRegistry(deps)
|
||||
result = topological_sort(codes, registry)
|
||||
|
||||
# 结果包含且仅包含输入任务(无丢失、无重复)
|
||||
assert set(result) == set(codes)
|
||||
assert len(result) == len(codes)
|
||||
|
||||
# 每个任务的依赖(在列表内的)都排在它之前
|
||||
pos = {code: idx for idx, code in enumerate(result)}
|
||||
for code in codes:
|
||||
for dep in deps.get(code, []):
|
||||
if dep in pos:
|
||||
assert pos[dep] < pos[code], (
|
||||
f"依赖 {dep} 应排在 {code} 之前,"
|
||||
f"但位置为 {pos[dep]} vs {pos[code]}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 8: 循环依赖检测
|
||||
# Feature: etl-dws-flow-refactor, Property 8: 循环依赖检测
|
||||
# Validates: Requirements 8.4
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCyclicDependencyProperty:
|
||||
"""Property 8: 循环依赖检测
|
||||
|
||||
# Feature: etl-dws-flow-refactor, Property 8: 循环依赖检测
|
||||
"""
|
||||
|
||||
@given(data=cyclic_graph_strategy())
|
||||
@settings(max_examples=100)
|
||||
def test_cycle_raises_value_error(self, data):
|
||||
"""对于任意含环有向图,topological_sort 应抛出 ValueError。
|
||||
|
||||
**Validates: Requirements 8.4**
|
||||
"""
|
||||
codes, deps, cycle_tasks = data
|
||||
registry = _FakeRegistry(deps)
|
||||
with pytest.raises(ValueError, match="循环依赖"):
|
||||
topological_sort(codes, registry)
|
||||
|
||||
@given(data=cyclic_graph_strategy())
|
||||
@settings(max_examples=100)
|
||||
def test_cycle_error_mentions_involved_tasks(self, data):
|
||||
"""错误信息中应包含环中涉及的至少一个任务代码。
|
||||
|
||||
**Validates: Requirements 8.4**
|
||||
"""
|
||||
codes, deps, cycle_tasks = data
|
||||
registry = _FakeRegistry(deps)
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
topological_sort(codes, registry)
|
||||
msg = str(exc_info.value)
|
||||
# 至少一个环中的任务出现在错误信息中
|
||||
assert any(t in msg for t in cycle_tasks), (
|
||||
f"错误信息 '{msg}' 中未包含环任务 {cycle_tasks} 中的任何一个"
|
||||
)
|
||||
Reference in New Issue
Block a user