233 lines
9.8 KiB
Python
233 lines
9.8 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
Feature: spi-spending-power-index — SPI 消费力指数属性测试
|
||
|
||
使用 hypothesis 验证 SPI 算法的正确性属性:
|
||
- Property 1: SPI 总分非负性
|
||
- Property 2: Level 子分关于消费金额单调非递减
|
||
- Property 3: Speed 子分关于 spend_30 单调非递减
|
||
- Property 4: Stability 子分取值范围 [0, 1]
|
||
- Property 5: Display Score 取值范围 [0, 10]
|
||
|
||
测试策略:
|
||
- 子分计算为 @staticmethod 纯函数,不依赖数据库,直接调用
|
||
- batch_normalize_to_display 为实例方法,通过 MagicMock 构造最小实例
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import sys
|
||
from pathlib import Path
|
||
from unittest.mock import MagicMock
|
||
|
||
from hypothesis import given, settings, assume
|
||
import hypothesis.strategies as st
|
||
|
||
# ── 将 ETL 模块加入 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 tasks.dws.index.spending_power_index_task import (
|
||
SpendingPowerIndexTask,
|
||
SPIMemberFeatures,
|
||
)
|
||
from tasks.dws.index.base_index_task import BaseIndexTask
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════
|
||
# 辅助:构造最小可用的 SpendingPowerIndexTask 实例(仅用于 Property 5)
|
||
# ══════════════════════════════════════════════════════════════════
|
||
|
||
def _make_spi_task() -> SpendingPowerIndexTask:
|
||
"""构造不依赖真实 DB/API 的 SPI 任务实例,仅用于调用 batch_normalize_to_display。"""
|
||
config = MagicMock()
|
||
# BaseTask.__init__ 会调用 config.get("app.timezone", "Asia/Shanghai")
|
||
# MagicMock.get() 默认返回 Mock 对象,导致 ZoneInfo 报错,需要正确返回字符串
|
||
config.get = lambda key, default=None: {
|
||
"app.timezone": "Asia/Shanghai",
|
||
}.get(key, default)
|
||
db = MagicMock()
|
||
api = MagicMock()
|
||
logger = MagicMock()
|
||
return SpendingPowerIndexTask(config, db, api, logger)
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════
|
||
# Property 1: SPI 总分非负性
|
||
# ══════════════════════════════════════════════════════════════════
|
||
|
||
@given(
|
||
level=st.floats(min_value=0, max_value=100),
|
||
speed=st.floats(min_value=0, max_value=100),
|
||
stability=st.floats(min_value=0, max_value=1),
|
||
)
|
||
@settings(max_examples=200)
|
||
def test_spi_raw_non_negative(level, speed, stability):
|
||
"""Property 1: SPI 总分非负性
|
||
|
||
对于任意非负的 Level、Speed、Stability 子分,
|
||
compute_spi_raw 的返回值应为非负。
|
||
|
||
**Validates: Requirements 6.1, 10.1**
|
||
"""
|
||
params = SpendingPowerIndexTask.DEFAULT_PARAMS
|
||
result = SpendingPowerIndexTask.compute_spi_raw(level, speed, stability, params)
|
||
assert result >= 0, f"SPI_raw={result} < 0 (L={level}, S={speed}, P={stability})"
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════
|
||
# Property 2: Level 子分关于消费金额单调非递减
|
||
# ══════════════════════════════════════════════════════════════════
|
||
|
||
@given(
|
||
spend_30=st.floats(min_value=0, max_value=50000),
|
||
spend_90=st.floats(min_value=0, max_value=150000),
|
||
recharge_90=st.floats(min_value=0, max_value=100000),
|
||
avg_ticket_90=st.floats(min_value=0, max_value=5000),
|
||
delta=st.floats(min_value=0.01, max_value=10000),
|
||
)
|
||
@settings(max_examples=200)
|
||
def test_level_monotonic_on_spend(spend_30, spend_90, recharge_90, avg_ticket_90, delta):
|
||
"""Property 2: Level 子分关于消费金额单调非递减
|
||
|
||
在其他条件不变时,增加 spend_30 或 spend_90 不会导致 Level 子分下降。
|
||
|
||
**Validates: Requirements 3.1, 10.2**
|
||
"""
|
||
params = SpendingPowerIndexTask.DEFAULT_PARAMS
|
||
|
||
base = SPIMemberFeatures(
|
||
member_id=1, site_id=1,
|
||
spend_30=spend_30, spend_90=spend_90,
|
||
recharge_90=recharge_90, avg_ticket_90=avg_ticket_90,
|
||
)
|
||
level_before = SpendingPowerIndexTask.compute_level(base, params)
|
||
|
||
# 增加 spend_30
|
||
inc_30 = SPIMemberFeatures(
|
||
member_id=1, site_id=1,
|
||
spend_30=spend_30 + delta, spend_90=spend_90,
|
||
recharge_90=recharge_90, avg_ticket_90=avg_ticket_90,
|
||
)
|
||
level_after_30 = SpendingPowerIndexTask.compute_level(inc_30, params)
|
||
assert level_after_30 >= level_before, (
|
||
f"Level 下降: spend_30 增加 {delta} 后 {level_after_30} < {level_before}"
|
||
)
|
||
|
||
# 增加 spend_90
|
||
inc_90 = SPIMemberFeatures(
|
||
member_id=1, site_id=1,
|
||
spend_30=spend_30, spend_90=spend_90 + delta,
|
||
recharge_90=recharge_90, avg_ticket_90=avg_ticket_90,
|
||
)
|
||
level_after_90 = SpendingPowerIndexTask.compute_level(inc_90, params)
|
||
assert level_after_90 >= level_before, (
|
||
f"Level 下降: spend_90 增加 {delta} 后 {level_after_90} < {level_before}"
|
||
)
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════
|
||
# Property 3: Speed 子分关于 spend_30 单调非递减
|
||
# ══════════════════════════════════════════════════════════════════
|
||
|
||
@given(
|
||
spend_30=st.floats(min_value=0, max_value=50000),
|
||
spend_90=st.floats(min_value=0, max_value=150000),
|
||
visit_days_30=st.integers(min_value=0, max_value=30),
|
||
daily_spend_ewma_90=st.floats(min_value=0, max_value=10000),
|
||
delta=st.floats(min_value=0.01, max_value=10000),
|
||
)
|
||
@settings(max_examples=200)
|
||
def test_speed_monotonic_on_spend_30(spend_30, spend_90, visit_days_30, daily_spend_ewma_90, delta):
|
||
"""Property 3: Speed 子分关于 spend_30 单调非递减
|
||
|
||
在其他条件不变时,增加 spend_30 不会导致 Speed 子分下降。
|
||
|
||
**Validates: Requirements 4.1, 4.4, 10.3**
|
||
"""
|
||
params = SpendingPowerIndexTask.DEFAULT_PARAMS
|
||
|
||
base = SPIMemberFeatures(
|
||
member_id=1, site_id=1,
|
||
spend_30=spend_30, spend_90=spend_90,
|
||
visit_days_30=visit_days_30,
|
||
daily_spend_ewma_90=daily_spend_ewma_90,
|
||
)
|
||
speed_before = SpendingPowerIndexTask.compute_speed(base, params)
|
||
|
||
inc = SPIMemberFeatures(
|
||
member_id=1, site_id=1,
|
||
spend_30=spend_30 + delta, spend_90=spend_90,
|
||
visit_days_30=visit_days_30,
|
||
daily_spend_ewma_90=daily_spend_ewma_90,
|
||
)
|
||
speed_after = SpendingPowerIndexTask.compute_speed(inc, params)
|
||
assert speed_after >= speed_before, (
|
||
f"Speed 下降: spend_30 增加 {delta} 后 {speed_after} < {speed_before}"
|
||
)
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════
|
||
# Property 4: Stability 子分取值范围 [0, 1]
|
||
# ══════════════════════════════════════════════════════════════════
|
||
|
||
@given(active_weeks=st.integers(min_value=0, max_value=13))
|
||
@settings(max_examples=200)
|
||
def test_stability_in_range(active_weeks):
|
||
"""Property 4: Stability 子分取值范围 [0, 1]
|
||
|
||
对于任意 active_weeks_90 ∈ [0, 13],compute_stability 返回值应在 [0, 1]。
|
||
|
||
**Validates: Requirements 5.2, 5.4, 10.4**
|
||
"""
|
||
params = SpendingPowerIndexTask.DEFAULT_PARAMS
|
||
|
||
features = SPIMemberFeatures(
|
||
member_id=1, site_id=1,
|
||
active_weeks_90=active_weeks,
|
||
)
|
||
stability = SpendingPowerIndexTask.compute_stability(features, params)
|
||
assert 0 <= stability <= 1, (
|
||
f"Stability={stability} 超出 [0, 1] (active_weeks_90={active_weeks})"
|
||
)
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════
|
||
# Property 5: Display Score 取值范围 [0, 10]
|
||
# ══════════════════════════════════════════════════════════════════
|
||
|
||
@given(
|
||
raw_scores=st.lists(
|
||
st.floats(min_value=0, max_value=1000),
|
||
min_size=1,
|
||
max_size=50,
|
||
),
|
||
)
|
||
@settings(max_examples=200)
|
||
def test_display_score_in_range(raw_scores):
|
||
"""Property 5: Display Score 取值范围 [0, 10]
|
||
|
||
对于任意非空的非负 raw_score 列表,batch_normalize_to_display
|
||
映射后的 display_score 应在 [0.00, 10.00]。
|
||
|
||
**Validates: Requirements 6.6, 10.5**
|
||
"""
|
||
task = _make_spi_task()
|
||
|
||
# 构造 (entity_id, raw_score) 输入
|
||
input_scores = [(i, s) for i, s in enumerate(raw_scores)]
|
||
|
||
results = task.batch_normalize_to_display(
|
||
raw_scores=input_scores,
|
||
compression=None, # 无压缩,直接 MinMax
|
||
use_smoothing=False, # 不使用 EWMA 平滑(避免 DB 调用)
|
||
)
|
||
|
||
for entity_id, raw_score, display_score in results:
|
||
assert 0.0 <= display_score <= 10.0, (
|
||
f"display_score={display_score} 超出 [0, 10] (raw={raw_score})"
|
||
)
|