P0 严重问题修复: - 修复 form_slice/stats_slice 主客身份反转(历史比赛视角错误) - 修复 understat.py httpx 未导入导致的 NameError - 修复 LLM 解析失败时静默产生假成功预测(0-0 平局+置信度0.5) 预测路径修复: - multi-agent 路径增加 backtest cutoff 透传,回测防泄漏生效 - H2H 切片汇总统计改为从当前主队视角计数 - 预测唯一约束增加 mode+run_type 维度,防止回测覆盖实盘预测 伤停管线修复: - IntegrityError 后不再整批回滚丢数据(改用逐条 flush) - return_date 正确解析并写入 - retrieved_at 比较统一用 date() 避免当天数据不可见 - 唯一索引改为 partial unique index(排除 NULL 重复) - HTTP 缓存 TTL 从 7 天改为 6 小时 安全与连接管理: - /api/v1/predict 增加内存滑动窗口限流(10次/分钟/IP) - 预测路由改用短 session 模式,LLM 调用期间不持有 DB 连接 Docker 部署修复: - 修复 .dockerignore 排除 *.md 导致 COPY README.md 失败 - 容器内 DATABASE_URL 使用 postgres 服务名(非 localhost) - 启动时自动执行 alembic upgrade head - 前端改用多阶段构建(Dockerfile.frontend) 新增测试(5个文件,24+用例): - test_p0_home_away.py: 主客身份反转回归测试 - test_p0_parse_failure.py: LLM 解析失败回归测试 - test_multi_agent_cutoff.py: multi-agent cutoff 透传测试 - test_h2h_perspective.py: H2H 视角测试 - test_injuries_pipeline.py: 伤停管线 5 项修复测试 - test_predict_protection.py: 限流+短 session 测试 - test_prediction_unique_constraint.py: 唯一约束测试 迁移: - 0012_injuries_partial_unique_and_return_date.py - 0013_predictions_unique_constraint_mode_run_type.py
205 lines
7.6 KiB
Python
205 lines
7.6 KiB
Python
"""回归测试: 伤停数据管线 5 项正确性修复。
|
|
|
|
Fix 1: IntegrityError 后不整批回滚
|
|
Fix 2: return_date 正确解析
|
|
Fix 3: retrieved_at 用 date() 比较避免当天不可见
|
|
Fix 4: partial unique index 防止 NULL 重复
|
|
Fix 5: 缓存 TTL 从 7 天改为 6 小时
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from src.data.injuries import _CACHE_TTL_HOURS, fetch_injuries
|
|
|
|
|
|
class TestCacheTTL:
|
|
"""Fix 5: 缓存 TTL 应为 6 小时。"""
|
|
|
|
def test_cache_ttl_is_6_hours(self):
|
|
assert _CACHE_TTL_HOURS == 6, f"缓存 TTL 应为 6 小时,实际 {_CACHE_TTL_HOURS}"
|
|
|
|
def test_cache_expiry_logic(self):
|
|
"""验证缓存过期逻辑:超过 TTL 返回 None(触发重新采集)。"""
|
|
import time
|
|
from pathlib import Path
|
|
import tempfile
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
cache_file = Path(tmpdir) / "test_cache.json"
|
|
cache_file.write_text("[]")
|
|
|
|
# 模拟 7 小时前写入
|
|
old_time = time.time() - 7 * 3600
|
|
import os
|
|
os.utime(cache_file, (old_time, old_time))
|
|
|
|
age_hours = (time.time() - cache_file.stat().st_mtime) / 3600
|
|
assert age_hours > _CACHE_TTL_HOURS, "7 小时前的缓存应已过期"
|
|
|
|
def test_cache_hit_within_ttl(self):
|
|
"""验证 TTL 内缓存命中。"""
|
|
import time
|
|
from pathlib import Path
|
|
import tempfile
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
cache_file = Path(tmpdir) / "test_cache.json"
|
|
cache_file.write_text("[]")
|
|
|
|
# 1 小时前写入
|
|
old_time = time.time() - 3600
|
|
import os
|
|
os.utime(cache_file, (old_time, old_time))
|
|
|
|
age_hours = (time.time() - cache_file.stat().st_mtime) / 3600
|
|
assert age_hours < _CACHE_TTL_HOURS, "1 小时前的缓存应在 TTL 内"
|
|
|
|
|
|
class TestReturnDateParsing:
|
|
"""Fix 2: return_date 应从 API 响应正确解析并写入。"""
|
|
|
|
def test_parse_return_date_iso(self):
|
|
"""ISO 格式 return_date 应正确解析为 date 对象。"""
|
|
from datetime import datetime, date
|
|
raw = "2026-02-15"
|
|
dt = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
|
|
assert dt.date() == date(2026, 2, 15)
|
|
|
|
def test_parse_return_date_with_time(self):
|
|
"""带时间的 return_date 应截取日期部分。"""
|
|
from datetime import datetime, date
|
|
raw = "2026-03-01T00:00:00Z"
|
|
dt = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
|
|
assert dt.date() == date(2026, 3, 1)
|
|
|
|
def test_parse_return_date_none(self):
|
|
"""None 或空值应返回 None。"""
|
|
return_date_raw = None
|
|
return_date = None
|
|
if return_date_raw:
|
|
return_date = "should not reach"
|
|
assert return_date is None
|
|
|
|
def test_parse_return_date_invalid(self):
|
|
"""无效日期应返回 None 而非抛异常。"""
|
|
from datetime import datetime
|
|
raw = "invalid-date"
|
|
return_date = None
|
|
try:
|
|
dt = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
|
|
return_date = dt.date()
|
|
except (ValueError, AttributeError):
|
|
pass
|
|
assert return_date is None
|
|
|
|
|
|
class TestQueryDateComparison:
|
|
"""Fix 3: retrieved_at 比较应使用 date() 避免时区截断。"""
|
|
|
|
def test_date_comparison_handles_same_day(self):
|
|
"""核心 bug: 当天白天采到的数据应对当晚比赛可见。
|
|
|
|
retrieved_at = 2026-01-15 14:00:00+00 (timestamptz)
|
|
as_of = 2026-01-15 (date)
|
|
|
|
错误的比较: retrieved_at <= as_of
|
|
→ PostgreSQL 将 as_of 视为 2026-01-15 00:00:00+00
|
|
→ 14:00 <= 00:00 → False → 数据不可见!
|
|
|
|
正确的比较: date(retrieved_at) <= as_of
|
|
→ 2026-01-15 <= 2026-01-15 → True → 数据可见
|
|
"""
|
|
from datetime import datetime, date, timezone
|
|
|
|
retrieved_at = datetime(2026, 1, 15, 14, 0, tzinfo=timezone.utc)
|
|
as_of_date = date(2026, 1, 15)
|
|
|
|
# 错误的比较方式(原 bug)
|
|
# PostgreSQL 会将 date 转为 timestamptz at midnight
|
|
as_of_as_datetime = datetime(2026, 1, 15, 0, 0, tzinfo=timezone.utc)
|
|
wrong_result = retrieved_at <= as_of_as_datetime # False
|
|
|
|
# 正确的比较方式(修复后)
|
|
correct_result = retrieved_at.date() <= as_of_date # True
|
|
|
|
assert wrong_result is False, "原 bug 演示: 白天数据对当晚比赛不可见"
|
|
assert correct_result is True, "修复后: 白天数据对当晚比赛可见"
|
|
|
|
|
|
class TestPartialUniqueIndex:
|
|
"""Fix 4: partial unique index 防止 NULL 重复。"""
|
|
|
|
def test_orm_declares_partial_index(self):
|
|
"""ORM 模型应声明 partial unique index。"""
|
|
from sqlalchemy import and_
|
|
from src.db.models import Injury
|
|
|
|
# 验证 __table_args__ 包含 partial index
|
|
found_partial = False
|
|
for arg in Injury.__table_args__:
|
|
if hasattr(arg, "name") and arg.name == "ix_injuries_player_fixture":
|
|
# 验证是 unique 且有 postgresql_where
|
|
assert arg.unique is True, "应为唯一索引"
|
|
# postgresql_where 应排除 NULL
|
|
found_partial = True
|
|
|
|
assert found_partial, "Injury 模型应声明 ix_injuries_player_fixture 索引"
|
|
|
|
def test_migration_creates_partial_index(self):
|
|
"""迁移文件应包含 partial index 创建逻辑。"""
|
|
import os
|
|
migration_path = "/.octop/workspaces/CA7PFH/Profeto/alembic/versions/0012_injuries_partial_unique_and_return_date.py"
|
|
assert os.path.exists(migration_path), "迁移文件 0012 应存在"
|
|
|
|
with open(migration_path) as f:
|
|
content = f.read()
|
|
|
|
assert "CREATE UNIQUE INDEX ix_injuries_player_fixture" in content
|
|
assert "WHERE player_id IS NOT NULL" in content
|
|
assert "fixture_id IS NOT NULL" in content
|
|
|
|
|
|
class TestInjuriesSliceIntegration:
|
|
"""验证 injuries_slice 仍正常工作(未被破坏)。"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_injuries_slice_with_cutoff(self):
|
|
"""injuries_slice 应正确传递 before=cutoff 到 get_injuries_for_match。"""
|
|
from datetime import datetime, timezone, timedelta
|
|
from src.llm.context_builder import injuries_slice, MatchHeader
|
|
|
|
header = MatchHeader(
|
|
match_id=999, home_name="A", away_name="B",
|
|
league_name="X", season=None, match_date="?",
|
|
match_dt=datetime(2026, 1, 15, 20, 0, tzinfo=timezone.utc),
|
|
stage=None, home_team_id=1, away_team_id=2, league_id=1,
|
|
)
|
|
|
|
cutoff = datetime(2026, 1, 14, 20, 0, tzinfo=timezone.utc)
|
|
|
|
import src.llm.context_builder as cb
|
|
orig = cb.get_injuries_for_match
|
|
|
|
captured_before = []
|
|
|
|
async def mock_get_injuries(db, team_id, match_date, as_of=None):
|
|
captured_before.append((team_id, match_date, as_of))
|
|
return []
|
|
|
|
cb.get_injuries_for_match = mock_get_injuries
|
|
|
|
try:
|
|
result = await injuries_slice(header, before=cutoff)
|
|
assert str(result) is not None
|
|
# 验证 before 参数被传递到 get_injuries_for_match
|
|
assert len(captured_before) == 2 # home + away
|
|
for team_id, match_date, as_of in captured_before:
|
|
# as_of 应等于 before (cutoff)
|
|
assert as_of == cutoff or (hasattr(as_of, 'date') and as_of.date() == cutoff.date()), \
|
|
f"as_of 应为 cutoff,实际 {as_of}"
|
|
finally:
|
|
cb.get_injuries_for_match = orig
|