后端: - settle_prediction 拒绝 degraded/failed(明确错误信息) - get_eval_summary 支持 provider/model/prompt_version/mode 筛选 - 返回 filtered_settled/evaluated/skipped_degraded 等计数 - matches 游标分页方向修复(scheduled ASC 用 > 条件) - available_at 加 2h 缓冲(近似完赛时间) - bzzziro 统计字段映射注释(待真实响应验证) - injuries 区分 no_local_data 与 success 空名单 前端: - 新增 EvalPage(筛选控件 + 汇总卡片 + 准确率表格) - 挂载 /admin/eval 路由与导航 测试: - test_matches_cursor.py:游标方向 - test_available_at.py:2h 缓冲与回测防泄漏 - test_bzzoirot_stats.py:统计字段映射 - test_injuries_no_local_data.py:no_local_data vs success - test_injuries_inserted_count.py:失败批不计入 - test_eval_excludes_degraded.py:degraded 排除准确率
214 lines
7.1 KiB
Python
214 lines
7.1 KiB
Python
"""回归测试: bzzoiro 采集链路统计字段映射。
|
|
|
|
⚠️ 重要说明:
|
|
当前字段名基于常见足球 API 模式推测,未经真实 bzzoiro 响应校验。
|
|
以下测试验证的是「若真实字段与推测一致,映射应正确」的假设。
|
|
|
|
待用户提供真实 event 样例后,需核对并修正以下字段名:
|
|
- 射门: home_shots / away_shots
|
|
- 射正: home_shots_on_target / away_shots_on_target
|
|
- 角球: home_corners / away_corners
|
|
- 控球: home_possession
|
|
- xG: home_xg / away_xg
|
|
- 黄牌: home_yellow_cards / away_yellow_cards
|
|
- 红牌: home_red_cards / away_red_cards
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
import pytest
|
|
|
|
from src.data.normalize import NormalizedMatch, normalize_bzzoiro
|
|
|
|
|
|
class TestNormalizeBzzoirotStats:
|
|
"""normalize_bzzoiro 应正确映射统计字段(基于推测字段名)。"""
|
|
|
|
def test_maps_shots(self):
|
|
"""API 提供 shots 字段时应正确映射。"""
|
|
raw = {
|
|
"event_date": "2026-01-15T15:00:00Z",
|
|
"status": "finished",
|
|
"home_team": "Man City",
|
|
"away_team": "Man United",
|
|
"home_score": 2,
|
|
"away_score": 1,
|
|
"home_shots": 15,
|
|
"away_shots": 8,
|
|
"home_shots_on_target": 6,
|
|
"away_shots_on_target": 3,
|
|
}
|
|
m = normalize_bzzoiro(raw, "E0")
|
|
assert m is not None
|
|
assert m.home_shots == 15
|
|
assert m.away_shots == 8
|
|
assert m.home_shots_on_target == 6
|
|
assert m.away_shots_on_target == 3
|
|
|
|
def test_maps_corners(self):
|
|
"""API 提供 corners 字段时应正确映射。"""
|
|
raw = {
|
|
"event_date": "2026-01-15T15:00:00Z",
|
|
"status": "finished",
|
|
"home_team": "Liverpool",
|
|
"away_team": "Chelsea",
|
|
"home_score": 1,
|
|
"away_score": 1,
|
|
"home_corners": 7,
|
|
"away_corners": 4,
|
|
}
|
|
m = normalize_bzzoiro(raw, "E0")
|
|
assert m is not None
|
|
assert m.home_corners == 7
|
|
assert m.away_corners == 4
|
|
|
|
def test_maps_possession(self):
|
|
"""API 提供 possession 字段时应正确映射。"""
|
|
raw = {
|
|
"event_date": "2026-01-15T15:00:00Z",
|
|
"status": "finished",
|
|
"home_team": "Barcelona",
|
|
"away_team": "Real Madrid",
|
|
"home_score": 2,
|
|
"away_score": 0,
|
|
"home_possession": 62.5,
|
|
}
|
|
m = normalize_bzzoiro(raw, "SP1")
|
|
assert m is not None
|
|
assert m.home_possession == 62.5
|
|
|
|
def test_maps_xg(self):
|
|
"""API 提供 xG 字段时应正确映射。"""
|
|
raw = {
|
|
"event_date": "2026-01-15T15:00:00Z",
|
|
"status": "finished",
|
|
"home_team": "Bayern",
|
|
"away_team": "Dortmund",
|
|
"home_score": 3,
|
|
"away_score": 1,
|
|
"home_xg": 2.5,
|
|
"away_xg": 0.8,
|
|
}
|
|
m = normalize_bzzoiro(raw, "D1")
|
|
assert m is not None
|
|
assert m.home_xg == 2.5
|
|
assert m.away_xg == 0.8
|
|
|
|
def test_maps_cards(self):
|
|
"""API 提供 cards 字段时应正确映射。"""
|
|
raw = {
|
|
"event_date": "2026-01-15T15:00:00Z",
|
|
"status": "finished",
|
|
"home_team": "Arsenal",
|
|
"away_team": "Tottenham",
|
|
"home_score": 1,
|
|
"away_score": 0,
|
|
"home_yellow_cards": 2,
|
|
"away_yellow_cards": 3,
|
|
"home_red_cards": 0,
|
|
"away_red_cards": 1,
|
|
}
|
|
m = normalize_bzzoiro(raw, "E0")
|
|
assert m is not None
|
|
assert m.home_yellow_cards == 2
|
|
assert m.away_yellow_cards == 3
|
|
assert m.home_red_cards == 0
|
|
assert m.away_red_cards == 1
|
|
|
|
def test_missing_stats_stay_none(self):
|
|
"""API 没有统计字段时应保持 None,不伪造。"""
|
|
raw = {
|
|
"event_date": "2026-01-15T15:00:00Z",
|
|
"status": "finished",
|
|
"home_team": "A",
|
|
"away_team": "B",
|
|
"home_score": 1,
|
|
"away_score": 0,
|
|
}
|
|
m = normalize_bzzoiro(raw, "E0")
|
|
assert m is not None
|
|
# 无比分数据时不应有统计字段
|
|
assert m.home_shots is None
|
|
assert m.away_shots is None
|
|
assert m.home_xg is None
|
|
assert m.away_xg is None
|
|
assert m.home_possession is None
|
|
assert m.home_corners is None
|
|
|
|
def test_alternative_field_names(self):
|
|
"""API 使用替代字段名时也应正确映射。"""
|
|
raw = {
|
|
"event_date": "2026-01-15T15:00:00Z",
|
|
"status": "finished",
|
|
"home_team": "A",
|
|
"away_team": "B",
|
|
"home_score": 1,
|
|
"away_score": 0,
|
|
"shots_home": 12,
|
|
"shots_away": 6,
|
|
"sot_home": 5,
|
|
"sot_away": 2,
|
|
"corners_home": 8,
|
|
"corners_away": 3,
|
|
"xg_home": 1.8,
|
|
"xg_away": 0.5,
|
|
}
|
|
m = normalize_bzzoiro(raw, "E0")
|
|
assert m is not None
|
|
assert m.home_shots == 12
|
|
assert m.away_shots == 6
|
|
assert m.home_shots_on_target == 5
|
|
assert m.away_shots_on_target == 2
|
|
assert m.home_corners == 8
|
|
assert m.away_corners == 3
|
|
assert m.home_xg == 1.8
|
|
assert m.away_xg == 0.5
|
|
|
|
|
|
class TestIngestionCondition:
|
|
"""入库条件应不再强制要求 xG。"""
|
|
|
|
def test_any_stat_field_triggers_stats_creation(self):
|
|
"""任一统计字段存在即可触发 MatchStats 创建。"""
|
|
# 模拟 normalize 后的结果
|
|
nm = NormalizedMatch(
|
|
league_type="E0",
|
|
date=datetime(2026, 1, 15, 15, 0, tzinfo=timezone.utc),
|
|
home_team="A",
|
|
away_team="B",
|
|
match_status="finished",
|
|
home_goals=1,
|
|
away_goals=0,
|
|
# 只有 shots,无 xG
|
|
home_shots=10,
|
|
away_shots=5,
|
|
)
|
|
# 验证:任一统计字段存在
|
|
has_stats = (
|
|
nm.home_xg is not None or nm.away_xg is not None
|
|
or nm.home_shots is not None or nm.away_shots is not None
|
|
or nm.home_corners is not None or nm.away_corners is not None
|
|
or nm.home_possession is not None
|
|
)
|
|
assert has_stats is True, "有 shots 时应视为有统计数据"
|
|
|
|
def test_no_stats_means_no_match_stats(self):
|
|
"""没有任何统计字段时不应创建 MatchStats。"""
|
|
nm = NormalizedMatch(
|
|
league_type="E0",
|
|
date=datetime(2026, 1, 15, 15, 0, tzinfo=timezone.utc),
|
|
home_team="A",
|
|
away_team="B",
|
|
match_status="finished",
|
|
home_goals=1,
|
|
away_goals=0,
|
|
)
|
|
has_stats = (
|
|
nm.home_xg is not None or nm.away_xg is not None
|
|
or nm.home_shots is not None or nm.away_shots is not None
|
|
or nm.home_corners is not None or nm.away_corners is not None
|
|
or nm.home_possession is not None
|
|
)
|
|
assert has_stats is False, "无统计字段时不应创建 MatchStats"
|