"""管理域评分配置/引擎判别测试(批次B Task B2)。""" import json import sqlalchemy as sa from app.db.models import EmployeeScoreConfig from app.management.employee_metrics import PeriodMetrics from app.management.metric_definitions import ( DEFAULT_SCORE_CONFIG, METRIC_DEFINITIONS, validate_config, ) from app.management.scoring import normalize, score_employee def test_config_weights_sum_and_structure(): dims = DEFAULT_SCORE_CONFIG["dimensions"] assert sum(d["weight"] for d in dims.values()) == 100 # 一期 pending_data 的维度不参与试算 available = [d for d in dims.values() if d.get("status") != "pending_data"] assert sum(d["weight"] for d in available) == 70 # 30+25+15 assert dims["action"]["status"] == "pending_data" assert dims["risk"]["status"] == "pending_data" def test_config_normalization_declared(): assert DEFAULT_SCORE_CONFIG["normalization"] == "available_only_rescale" def test_validate_config_rejects_bad_weight_sum(): bad = json.loads(json.dumps(DEFAULT_SCORE_CONFIG)) # deep copy bad["dimensions"]["service"]["weight"] = 26 # 总和 101 problems = validate_config(bad) assert any("权重" in p for p in problems) def test_metric_definitions_cover_scored_metrics(): # 每个参与评分的 metric_code 都有元数据(名称/单位/方向) for dim in DEFAULT_SCORE_CONFIG["dimensions"].values(): for code in dim.get("metrics", {}): # band 型指标的 key 带 _band 后缀,spec 里有 metric 字段: spec = dim["metrics"][code] src = spec.get("metric", code) assert src in METRIC_DEFINITIONS m = METRIC_DEFINITIONS[src] assert m["name"] and m["unit"] and m["direction"] in (1, -1) async def test_seed_and_idempotent(make_session): from app.management.metric_definitions import _seed_score_config # 纯会话函数 s = make_session v1 = await _seed_score_config(s) await s.commit() assert v1 == "v1.0" rows = (await s.execute(sa.select(EmployeeScoreConfig))).scalars().all() assert len(rows) == 1 and rows[0].status == "active" # 幂等:已存在同版本返回 None 不新建 assert await _seed_score_config(s) is None await s.commit() assert len((await s.execute(sa.select(EmployeeScoreConfig))).scalars().all()) == 1 # ---- 评分引擎(Task B2) ---- def _pm(**overrides): """全零/None 默认的 PeriodMetrics 构造器(22 字段,覆盖式改)。""" base = { "user_id": "u1", "days_with_data": 0, "msg_sent": 0, "msg_recv": 0, "cust_threads": 0, "threads_answered": 0, "fr_sum_ms": 0, "fr_p50_ms": None, "fr_p90_ms": None, "reply_2h_count": 0, "reply_24h_count": 0, "active_friends": 0, "active_rooms_avg": 0.0, "new_customer": 0, "cooling_customer": 0, "dormant_customer": 0, "end_active_friends": 0, "reactivated_customer": 0, "call_count": 0, "call_seconds": 0, "answered_call": 0, "call_followup": 0, } base.update(overrides) return PeriodMetrics(**base) def _full_marks(**overrides): """三可用维度全满分:率 1.0/fr 10min/占比 0.6/两 band 区间内,样本全 ok。""" full = dict( cust_threads=50, reply_2h_count=50, reply_24h_count=50, fr_p90_ms=600_000, # 10min ≤ 30min 满分线 end_active_friends=9, cooling_customer=3, dormant_customer=3, # 9/15=0.6 active_friends=50, # band [5,80] 内且样本 ok call_count=20, # band [1,200] 内 ) full.update(overrides) return _pm(**full) def test_normalize_target_three_segments(): spec = {"type": "target", "floor": 0.40, "target": 0.85} assert normalize(spec, 0.20) == 0.0 # ≤floor assert normalize(spec, 0.40) == 0.0 # floor 处 0 assert normalize(spec, 0.625) == 50.0 # 中点 50 assert normalize(spec, 0.85) == 100.0 # target 处 100 assert normalize(spec, 0.99) == 100.0 # ≥target 截断 assert normalize(spec, None) is None # 无值≠0 分 def test_normalize_reverse_ends_and_midpoint(): spec = {"type": "reverse", "target_ms": 1_800_000, "floor_ms": 14_400_000} assert normalize(spec, 1_800_000) == 100.0 # 30min 满分 assert normalize(spec, 600_000) == 100.0 # 更快也封顶 100 assert normalize(spec, 8_100_000) == 50.0 # (30min+4h) 中点 50 assert normalize(spec, 14_400_000) == 0.0 # 4h 0 分 assert normalize(spec, 28_800_000) == 0.0 # 更慢截断 0 assert normalize(spec, None) is None def test_normalize_band_three_segments(): spec = {"type": "band", "metric": "active_friends", "low": 5, "high": 80, "basis": "period_distinct"} assert normalize(spec, 3) is None # target 0.60 assert any("active_share" in p and "floor floor_ms 14.4M assert any("fr_p90_ms" in p and "target_mshigh 200 assert any("call_band" in p and "low