单文件拆分(仅搬迁无逻辑修改): - common.py 共享类型/头信息/_outcome/_is_stats_available - form.py form_slice + _get_form - h2h.py h2h_slice + _get_h2h - stats.py stats_slice(复用 form._get_form) - home_away.py home_away_slice + _get_home_away - standings.py standings_slice - aggregate.py build_context context_builder.py 改为纯 re-export 门面,公开签名不变。 同步修复测试 patch 目标(p0_home_away/h2h_perspective/multi_agent_cutoff) 与 regressions 源码断言(读 slices/*.py)。 全量测试 270 通过。
192 lines
6.8 KiB
Python
192 lines
6.8 KiB
Python
"""回归测试: P0-1 — form_slice / stats_slice 主客身份反转。
|
|
|
|
用 mock Match 对象验证:当某队在历史比赛中是「客队」时,
|
|
form_slice 必须正确识别该队当时是客场,赛果应为 L(输),
|
|
对手名字和进球数不能反转。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from src.llm.context_builder import MatchHeader, SliceResult, form_slice, stats_slice
|
|
|
|
|
|
def _make_team(team_id: int, name: str) -> MagicMock:
|
|
t = MagicMock()
|
|
t.id = team_id
|
|
t.name = name
|
|
t.name_zh = None
|
|
return t
|
|
|
|
|
|
def _make_stats(
|
|
home_xg=1.5,
|
|
away_xg=1.0,
|
|
home_shots=12,
|
|
away_shots=8,
|
|
home_sot=4,
|
|
away_sot=3,
|
|
home_poss=55.0,
|
|
available_at=None,
|
|
) -> MagicMock:
|
|
s = MagicMock()
|
|
s.home_xg = home_xg
|
|
s.away_xg = away_xg
|
|
s.home_shots = home_shots
|
|
s.away_shots = away_shots
|
|
s.home_shots_on_target = home_sot
|
|
s.away_shots_on_target = away_sot
|
|
s.home_possession = home_poss
|
|
s.available_at = available_at
|
|
return s
|
|
|
|
|
|
def _make_match(
|
|
match_id: int,
|
|
home_team_id: int,
|
|
away_team_id: int,
|
|
home_goals: int,
|
|
away_goals: int,
|
|
home_name: str = "H",
|
|
away_name: str = "A",
|
|
stats=None,
|
|
) -> MagicMock:
|
|
m = MagicMock()
|
|
m.id = match_id
|
|
m.home_team_id = home_team_id
|
|
m.away_team_id = away_team_id
|
|
m.home_goals = home_goals
|
|
m.away_goals = away_goals
|
|
m.stats = stats
|
|
m.home_team = _make_team(home_team_id, home_name)
|
|
m.away_team = _make_team(away_team_id, away_name)
|
|
return m
|
|
|
|
|
|
def _make_header(home_id=1, away_id=2, home_name="利物浦", away_name="阿森纳") -> MatchHeader:
|
|
return MatchHeader(
|
|
match_id=999,
|
|
home_name=home_name,
|
|
away_name=away_name,
|
|
league_name="英超",
|
|
season="2025-2026",
|
|
match_date="2026-01-15 20:00 UTC",
|
|
match_dt=None,
|
|
stage=None,
|
|
home_team_id=home_id,
|
|
away_team_id=away_id,
|
|
league_id=1,
|
|
)
|
|
|
|
|
|
class TestFormSliceHomeAwayIdentity:
|
|
"""P0-1: form_slice 必须根据每场历史比赛的真实主客来判断赛果。"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_home_team_away_loss_shows_L(self):
|
|
"""
|
|
场景: 本场利物浦是主队(home_id=1),历史上一场它作为客队 1-3 输给曼城。
|
|
正确输出: L 3-1 vs 曼城 (赛果为输,对手为曼城)
|
|
原bug: W 3-1 vs 曼城 (把客场输球算成主场赢球)
|
|
"""
|
|
header = _make_header(home_id=1, away_id=2, home_name="利物浦", away_name="阿森纳")
|
|
hist_match = _make_match(
|
|
match_id=100,
|
|
home_team_id=5, # 曼城主场
|
|
away_team_id=1, # 利物浦客场
|
|
home_goals=3,
|
|
away_goals=1,
|
|
home_name="曼城",
|
|
away_name="利物浦",
|
|
)
|
|
import src.llm.slices.form as cb
|
|
orig_get_form = cb._get_form
|
|
async def mock_get_form(db, team_id, before, *, limit):
|
|
return [hist_match] if team_id == 1 else []
|
|
cb._get_form = mock_get_form
|
|
try:
|
|
result = await form_slice(header, limit=5, before=None, db=MagicMock())
|
|
finally:
|
|
cb._get_form = orig_get_form
|
|
|
|
text = str(result)
|
|
assert "L 3-1 vs 曼城" in text, f"期望「L 3-1 vs 曼城」,实际输出:\n{text}"
|
|
assert "W 3-1" not in text, f"不应出现 W 3-1(客场输球不能算主场赢):\n{text}"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_away_team_home_win_shows_W_for_that_team(self):
|
|
"""
|
|
场景: 本场阿森纳是客队(away_id=2),历史上一场它作为主队 2-0 赢了切尔西。
|
|
从阿森纳视角: is_home=True → W 2-0 vs 切尔西。
|
|
原bug: side 固定为 "away" → _outcome(2,0,"away") = L → 输出 L 2-0 vs 切尔西(反转!)
|
|
"""
|
|
header = _make_header(home_id=1, away_id=2, home_name="利物浦", away_name="阿森纳")
|
|
hist_match = _make_match(
|
|
match_id=101,
|
|
home_team_id=2, # 阿森纳主场
|
|
away_team_id=4, # 切尔西客场
|
|
home_goals=2,
|
|
away_goals=0,
|
|
home_name="阿森纳",
|
|
away_name="切尔西",
|
|
)
|
|
import src.llm.slices.form as cb
|
|
orig_get_form = cb._get_form
|
|
async def mock_get_form(db, team_id, before, *, limit):
|
|
return [hist_match] if team_id == 2 else []
|
|
cb._get_form = mock_get_form
|
|
try:
|
|
result = await form_slice(header, limit=5, before=None, db=MagicMock())
|
|
finally:
|
|
cb._get_form = orig_get_form
|
|
|
|
text = str(result)
|
|
assert "W 2-0 vs 切尔西" in text, f"期望「W 2-0 vs 切尔西」,实际输出:\n{text}"
|
|
assert "L 2-0 vs 切尔西" not in text, f"不应出现 L 2-0(主场赢球不能算客场输):\n{text}"
|
|
|
|
|
|
class TestStatsSliceHomeAwayIdentity:
|
|
"""P0-1: stats_slice 进球/失球/xG 必须按历史比赛真实主客取值。"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_home_team_away_match_goals_not_swapped(self):
|
|
"""
|
|
场景: 本场利物浦是主队,历史上一场它作为客队 1-3 输给曼城(xG 0.8 vs 2.5)。
|
|
从利物浦视角: 进球=1(away_goals), 失球=3(home_goals), xG=0.8(away_xg)。
|
|
原bug: side="home" → 进球=3, 失球=1, xG=2.5 —— 全部反了!
|
|
"""
|
|
header = _make_header(home_id=1, away_id=2, home_name="利物浦", away_name="阿森纳")
|
|
hist_match = _make_match(
|
|
match_id=200,
|
|
home_team_id=5, # 曼城主场
|
|
away_team_id=1, # 利物浦客场
|
|
home_goals=3,
|
|
away_goals=1,
|
|
home_name="曼城",
|
|
away_name="利物浦",
|
|
stats=_make_stats(home_xg=2.5, away_xg=0.8, home_shots=15, away_shots=5,
|
|
home_sot=6, away_sot=2, home_poss=60.0),
|
|
)
|
|
import src.llm.slices.stats as cb
|
|
orig_get_form = cb._get_form
|
|
async def mock_get_form(db, team_id, before, *, limit):
|
|
return [hist_match] if team_id == 1 else []
|
|
cb._get_form = mock_get_form
|
|
try:
|
|
result = await stats_slice(header, limit=10, before=None, db=MagicMock())
|
|
finally:
|
|
cb._get_form = orig_get_form
|
|
|
|
text = str(result)
|
|
# 利物浦客场 1-3 输: 进球 1, 失球 3
|
|
assert "场均进球 1.00" in text, f"期望场均进球 1.00,实际输出:\n{text}"
|
|
assert "场均失球 3.00" in text, f"期望场均失球 3.00,实际输出:\n{text}"
|
|
# 原bug: 进球 3, 失球 1 (反了)
|
|
assert "场均进球 3.00" not in text, f"不应出现场均进球 3.00(反转):\n{text}"
|
|
# xG: 利物浦 away_xg=0.8
|
|
assert "场均 xG 0.80" in text, f"期望场均 xG 0.80,实际输出:\n{text}"
|
|
# shots: 利物浦 away_shots=5
|
|
assert "场均射门 5.0" in text, f"期望场均射门 5.0,实际输出:\n{text}"
|