refactor: 以 bzzoiro 为唯一数据源的全面重构

数据源统一为 bzzoiro,移除 Understat 与 injuries:
- 删除 src/data/understat.py / injuries.py 及相关测试
- 删除 injuries 模型与表;扩展 match_stats(xG 之外增加 big_chances/fouls)
- 新增 standings 表(联赛积分榜:位置/积分/xG/走势/分区)
- matches 表增加 source_event_id 血缘列,支撑统计回填

采集管线(bzzoiro 三条管线):
- events:赛程/比分(/events/),记录 source_event_id
- standings:积分榜快照(/leagues/{id}/standings/)
- stats:已完赛比赛详细统计回填(/events/{id}/stats/)

预测增强:
- standings_slice 替代 injuries_slice;积分榜专家替代阵容完整性专家
- AGENT_META runtime_config 同步更新

管理后台:
- ingest 路由重写为单一 bzzoiro 入口 + task 参数(events/standings/stats/all)
- 新增 /admin/data-completeness 数据完整性分析 API
- 数据源状态页简化为 bzzoiro 单源

前端:
- 采集页重构为任务驱动(比赛/积分榜/统计回填/全量)
- 新增「数据完整性」可视化页(覆盖率矩阵/字段完整率/健康摘要)
- 新增主站积分榜页(/standings)与比赛详情完整统计面板
- agent 名称同步更新(injuries→standings)

迁移 0015_bzzoiro_single_source 已在容器内验证通过,后端测试全部通过。

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
This commit is contained in:
shangfangjian
2026-09-20 19:13:07 +08:00
co-authored by new-provider/LongCat-2.0 <
parent ec8f36abb2
commit f05dc1ae15
41 changed files with 1603 additions and 1885 deletions
+1 -1
View File
@@ -76,7 +76,7 @@ class TestOrchestratorWritesAgentWeights:
AgentReport(agent="form", status="ok", analysis="good"),
AgentReport(agent="stats", status="error", analysis="failed"),
AgentReport(agent="home_away", status="ok", analysis="good"),
AgentReport(agent="injuries", status="no_data", analysis="无数据"),
AgentReport(agent="standings", status="no_data", analysis="无数据"),
AgentReport(agent="h2h", status="error", analysis="failed"),
]
+5 -5
View File
@@ -22,7 +22,7 @@ class TestNoDataGate:
def test_stub_no_data_report(self):
from src.llm.agents.base import _stub_no_data
r = _stub_no_data("injuries")
r = _stub_no_data("standings")
assert r.status == "no_data"
assert r.data_sufficiency == "none"
assert r.home_edge is None
@@ -31,7 +31,7 @@ class TestNoDataGate:
class TestPromptLoading:
"""agent prompt 模板加载。"""
@pytest.mark.parametrize("name", ["form", "stats", "home_away", "injuries", "h2h", "aggregator"])
@pytest.mark.parametrize("name", ["form", "stats", "home_away", "standings", "h2h", "aggregator"])
def test_all_prompts_exist(self, name):
tpl = load_agent_prompt(name, "v1")
assert "{{context}}" in tpl or "{{agent_reports}}" in tpl
@@ -126,7 +126,7 @@ class TestRunAgent:
async def empty_slice(header, before=None):
return "── 伤停 ──\n 无数据"
spec = AgentSpec(name="injuries", system_prompt="s", slice_fn=empty_slice)
spec = AgentSpec(name="standings", system_prompt="s", slice_fn=empty_slice)
header = self._make_header()
class ExplodingProvider:
@@ -201,7 +201,7 @@ class TestOrchestratorAggregation:
reports = [
AgentReport(agent="h2h", status="ok", home_edge=0.5, subjective_confidence=0.8, analysis="a"),
AgentReport(agent="injuries", status="no_data", data_sufficiency="none"),
AgentReport(agent="standings", status="no_data", data_sufficiency="none"),
]
text = _reports_to_json(reports)
data = json.loads(text)
@@ -270,7 +270,7 @@ class TestAgentWeightsValidation:
w = validate_agent_weights({"form": 0.4, "h2h": 0.4, "bogus": 0.2, "stats": 0.4})
assert "bogus" not in w
assert set(w) <= {"form", "stats", "home_away", "injuries", "h2h"}
assert set(w) <= {"form", "stats", "home_away", "standings", "h2h"}
def test_out_of_range_clamped(self):
from src.llm.validation import validate_agent_weights
+6 -16
View File
@@ -5,8 +5,9 @@
2. _is_stats_available: available_at is None + cutoff is None(实盘) → 可用(兼容旧数据)
3. _is_stats_available: available_at > cutoff → 不可用
4. _is_stats_available: available_at <= cutoff → 可用
5. 写入策略: available_at = match_date + 2h 缓冲
5. 写入策略: bzzoiro 写入 available_at 使用 match_date + 2h 缓冲
6. cutoff 在缓冲内时不可用(available_at > cutoff → False)
7. stats 回填(bzzoiro event stats)也使用 2h 缓冲
"""
from __future__ import annotations
@@ -73,37 +74,26 @@ class TestIsStatsAvailable:
class TestWriteBufferStrategy:
"""验证 bzzoiro/understat 写入 available_at 使用 match_date + 2h 缓冲。"""
"""验证 bzzoiro(events + stats 回填)写入 available_at 使用 match_date + 2h 缓冲。"""
def test_bzzoirot_new_match_available_at_is_two_hours_after_kickoff(self):
"""bzzoiro 新建比赛时 available_at 应为开球 + 2 小时。"""
"""bzzoiro 新建比赛(stats 回填创建 MatchStats)时 available_at 应为开球 + 2 小时。"""
import inspect
from src.data import bzzoiro
source = inspect.getsource(bzzoiro)
# 验证:使用 timedelta(hours=2) 作为缓冲
assert 'timedelta(hours=2)' in source, \
"bzzoiro 应使用 match_date + timedelta(hours=2) 作为 available_at"
def test_bzzoirot_existing_match_uses_two_hour_buffer(self):
"""bzzoiro 更新已有比赛时也应使用 2 小时缓冲。"""
def test_bzzoirot_multiple_writes_use_two_hour_buffer(self):
"""bzzoiro 多处写入(创建/更新)都应使用 2 小时缓冲。"""
import inspect
from src.data import bzzoiro
source = inspect.getsource(bzzoiro)
# 两处写入都应使用 timedelta(hours=2)
count = source.count('timedelta(hours=2)')
assert count >= 2, f"期望至少 2 处 timedelta(hours=2),实际 {count}"
def test_understat_uses_two_hour_buffer(self):
"""understat 回填 xG 时也应使用 2 小时缓冲。"""
import inspect
from src.data import understat
source = inspect.getsource(understat)
assert 'timedelta(hours=2)' in source, \
"understat 应使用 match_date + timedelta(hours=2) 作为 available_at"
def test_cutoff_within_buffer_makes_stats_unavailable(self):
"""cutoff 在 2 小时缓冲内时,统计学不可用(回测防泄漏)。
-166
View File
@@ -1,166 +0,0 @@
"""回归测试: injuries 入库 IntegrityError 后 inserted 计数准确。
验证:
1. flush 失败的批次不计入 inserted
2. 成功的批次正常计数
3. 总计数 = 成功批次记录数之和
"""
from __future__ import annotations
import asyncio
from unittest.mock import MagicMock, AsyncMock, patch
import pytest
from sqlalchemy.exc import IntegrityError
from src.data.injuries import ingest_injuries
class FakeSession:
"""模拟 AsyncSession,记录 flush 调用和 begin_nested 使用。"""
def __init__(self, fail_on_flush_indices: set[int] | None = None):
self.flush_count = 0
self.nested_count = 0
self.added_records = []
self.committed_batches = []
self.fail_on = fail_on_flush_indices or set()
async def execute(self, stmt):
class Result:
def all(self_inner):
return []
def scalar_one_or_none(self_inner):
return None
return Result()
async def get(self, cls, id):
return None
def add(self, obj):
self.added_records.append({"player_id": obj.player_id, "fixture_id": obj.fixture_id})
async def flush(self):
self.flush_count += 1
if self.flush_count in self.fail_on:
raise IntegrityError("mock duplicate", None, None)
def begin_nested(self):
class NestedCtx:
async def __aenter__(nested_self):
return nested_self
async def __aexit__(nested_self, exc_type, exc, tb):
return exc_type is not None
return NestedCtx()
@pytest.mark.asyncio
async def test_inserted_count_excludes_failed_batches():
"""flush 失败的批次不应计入 inserted。
场景:6 条记录,每批 2 条(BATCH_SIZE=2),第 2 批 flush 失败。
期望:inserted = 2(第 1 批成功) + 0(第 2 批失败) + 2(第 3 批成功) = 4
"""
session = FakeSession(fail_on_flush_indices={2}) # 第 2 次 flush 失败
# 构造 6 条待插入记录
pending = [
{"player_id": i, "player_name": f"Player{i}", "team_id": 1,
"fixture_id": 100 + i, "injury_type": "Hamstring",
"reason": "strain", "injury_date": None, "return_date": None}
for i in range(6)
]
# 临时覆盖 BATCH_SIZE 为 2
original = ingest_injuries.__globals__.get("BATCH_SIZE")
result = {"count": 0, "inserted": 0, "errors": []}
# 模拟核心逻辑(与 ingest_injuries 一致)
async def run():
BATCH_SIZE = 2 # 小批量便于测试
batch = []
async def _flush_batch():
if not batch:
return 0
count = len(batch)
async with session.begin_nested():
for obj in batch:
session.add(obj)
await db_flush()
batch.clear()
return count
async def db_flush():
session.flush_count += 1
if session.flush_count in session.fail_on:
raise IntegrityError("mock", None, None)
session.committed_batches.append(count)
for rec in pending:
batch.append(type("Injury", (), rec))
if len(batch) >= BATCH_SIZE:
try:
result["inserted"] += await _flush_batch()
except IntegrityError:
batch.clear()
continue
try:
result["inserted"] += await _flush_batch()
except IntegrityError:
batch.clear()
await run()
# 第 1 批(0,1)成功,第 2 批(2,3)失败,第 3 批(4,5)成功
assert result["inserted"] == 4, f"期望 inserted=4,实际 {result['inserted']}"
print(f"PASS: inserted={result['inserted']} (排除失败批次)")
@pytest.mark.asyncio
async def test_all_success_count_is_total(self):
"""全部成功时,inserted 应等于总记录数。"""
session = FakeSession() # 无失败
pending = [
{"player_id": i, "player_name": f"P{i}", "team_id": 1,
"fixture_id": 100 + i, "injury_type": None,
"reason": None, "injury_date": None, "return_date": None}
for i in range(6)
]
result = {"inserted": 0}
BATCH_SIZE = 2
batch = []
async def _flush_batch():
if not batch:
return 0
count = len(batch)
async with session.begin_nested():
for obj in batch:
session.add(obj)
await db_flush()
batch.clear()
return count
async def db_flush():
session.flush_count += 1
session.committed_batches.append(batch.copy())
for rec in pending:
batch.append(type("Injury", (), rec))
if len(batch) >= BATCH_SIZE:
result["inserted"] += await _flush_batch()
result["inserted"] += await _flush_batch()
assert result["inserted"] == 6, f"期望 6,实际 {result['inserted']}"
print(f"PASS: 全部成功 inserted={result['inserted']}")
if __name__ == "__main__":
asyncio.run(test_inserted_count_excludes_failed_batches())
asyncio.run(test_all_success_count_is_total())
print("\n=== ALL TESTS PASSED ===")
-172
View File
@@ -1,172 +0,0 @@
"""回归测试: injuries IntegrityError 处理不再整批回滚。
模拟场景:连续插入多条伤停记录,中间一批触发 IntegrityError,
断言其它批次记录不会丢失。
"""
from __future__ import annotations
import asyncio
from unittest.mock import MagicMock, AsyncMock, patch
import pytest
from src.data import injuries as inj_mod
class FakeNestedCtx:
"""模拟 SQLAlchemy begin_nested() 上下文。
__enter__:标记进入 savepoint
__exit__:如果有异常,模拟 ROLLBACK TO SAVEPOINT(不清空已 flush 的对象)
"""
def __init__(self, session):
self.session = session
self.rolled_back = False
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
if exc_type is not None:
# ROLLBACK TO SAVEPOINT — 不清空 session 中已存在的对象
self.rolled_back = True
return True # suppress exception
class FakeSession:
"""模拟 AsyncSession,记录 flush 调用和 begin_nested 使用。"""
def __init__(self, fail_on_flush_indices: set[int] | None = None):
self.flush_count = 0
self.nested_count = 0
self.flushed_records: list[dict] = []
self.added_records: list[dict] = []
self.fail_on = fail_on_flush_indices or set()
async def execute(self, stmt):
class Result:
def all(self_inner):
return []
return Result()
async def get(self, cls, id):
return None
def add(self, obj):
self.added_records.append(obj)
async def flush(self):
self.flush_count += 1
if self.flush_count in self.fail_on:
from sqlalchemy.exc import IntegrityError
raise IntegrityError("mock duplicate", None, None)
@property
def _nested_ctx(self):
return FakeNestedCtx(self)
def begin_nested(self):
self.nested_count += 1
return self._nested_ctx
@pytest.mark.asyncio
async def test_integrity_error_does_not_lose_other_batches():
"""核心测试:一批触发 IntegrityError,其它批次记录不丢失。
场景:3 批记录,第 2 批 flush 时 IntegrityError。
断言:第 1 批和第 3 批的记录仍存在于 flushed_records 中。
"""
session = FakeSession(fail_on_flush_indices={2}) # 第 2 次 flush 失败
# 构造 3 批记录,每批 2 条(BATCH_SIZE 用 2 方便测试)
pending = [
{"player_id": i, "player_name": f"Player{i}", "team_id": 1,
"fixture_id": 100 + i, "injury_type": "Hamstring",
"reason": "strain", "injury_date": None, "return_date": None}
for i in range(6)
]
# 临时覆盖 BATCH_SIZE
original_batch_size = 50
try:
inj_mod.ingest_injuries.__globals__['__dict__'] # no-op
# 手动模拟 ingest_injuries 的核心逻辑
batch = []
flushed_ids = []
errors = []
async def _flush_batch():
if not batch:
return
async with session.begin_nested():
for obj in batch:
session.add(obj)
await session.flush()
flushed_ids.extend([r["player_id"] for r in batch])
batch.clear()
for rec in pending:
batch.append(rec)
if len(batch) >= 2: # BATCH_SIZE = 2
try:
await _flush_batch()
except Exception:
batch.clear()
continue
# 最终 flush
try:
await _flush_batch()
except Exception:
batch.clear()
except Exception:
pass
# 断言:flush 成功的记录是第 1 批(id=0,1)和第 3 批(id=4,5)
# 第 2 批(id=2,3)因 IntegrityError 被 savepoint 回滚
# 关键:第 1 批和第 3 批的记录必须仍在 flushed_ids 中
assert 0 in flushed_ids, "第 1 批记录 0 不应丢失"
assert 1 in flushed_ids, "第 1 批记录 1 不应丢失"
assert 4 in flushed_ids or 5 in flushed_ids, "第 3 批记录不应丢失"
# 第 2 批(flush 失败的)不应在 flushed_ids 中
assert 2 not in flushed_ids, "第 2 批应被回滚"
assert 3 not in flushed_ids, "第 2 批应被回滚"
print("PASS: IntegrityError 只回滚失败批次,其它批次不丢失")
@pytest.mark.asyncio
async def test_begin_nested_is_used():
"""验证 begin_nested() 被调用(而非全事务 rollback)。"""
session = FakeSession()
batch = [{"player_id": i, "player_name": f"P{i}", "team_id": 1,
"fixture_id": 100 + i, "injury_type": "None",
"reason": None, "injury_date": None, "return_date": None}
for i in range(3)]
async def _flush_batch():
if not batch:
return
async with session.begin_nested():
for obj in batch:
session.add(obj)
await session.flush()
batch.clear()
try:
await _flush_batch()
except Exception:
pass
# 验证 begin_nested 被调用(说明使用了 savepoint)
assert session.nested_count >= 1, "应使用 begin_nested(SAVEPOINT)"
print(f"PASS: begin_nested 被调用 {session.nested_count}")
if __name__ == "__main__":
asyncio.run(test_integrity_error_does_not_lose_other_batches())
asyncio.run(test_begin_nested_is_used())
-75
View File
@@ -1,75 +0,0 @@
"""回归测试: 伤停切片区分「本地无数据」与「查询成功但空名单」。
验证:
1. API Key 已配置但 injuries 表无任何记录 → has_data=False
2. 有历史伤停记录但当前比赛日无缺阵 → has_data=True
"""
from __future__ import annotations
from datetime import date
from unittest.mock import MagicMock, patch
import pytest
from src.data.injuries import InjuryQueryResult, get_injuries_for_match
from src.llm.context_builder import MatchHeader, injuries_slice
def _make_header():
return MatchHeader(
match_id=999, home_name="A", away_name="B",
league_name="X", season=None, match_date="?",
match_dt=None, stage=None,
home_team_id=1, away_team_id=2, league_id=1,
)
class TestNoLocalData:
"""区分「本地无数据」与「查询成功但空名单」。"""
@pytest.mark.asyncio
async def test_no_local_data_yields_has_data_false(self):
"""API Key 已配置但 injuries 表无任何记录 → has_data=False。"""
header = _make_header()
async def mock_query(db, team_id, match_date, as_of=None):
return InjuryQueryResult(records=[], query_status="no_local_data")
with patch("src.data.injuries.get_injuries_for_match", mock_query):
result = await injuries_slice(header, before=None)
assert result.has_data is False, "no_local_data 应 has_data=False"
assert "本地尚无伤停数据" in result.text
print("PASS: no_local_data → has_data=False")
@pytest.mark.asyncio
async def test_success_empty_yields_has_data_true(self):
"""API Key 已配置且查询成功 + 空名单 → has_data=True。"""
header = _make_header()
async def mock_query(db, team_id, match_date, as_of=None):
return InjuryQueryResult(records=[], query_status="success")
with patch("src.data.injuries.get_injuries_for_match", mock_query):
result = await injuries_slice(header, before=None)
assert result.has_data is True, "success + 空名单应 has_data=True"
assert "当前无伤停记录" in result.text
print("PASS: success + empty → has_data=True")
@pytest.mark.asyncio
async def test_mixed_status_uses_has_data_false(self):
"""主队 success + 客队 no_local_data → has_data=False(保守)。"""
header = _make_header()
async def mock_query(db, team_id, match_date, as_of=None):
if team_id == 1:
return InjuryQueryResult(records=[], query_status="success")
return InjuryQueryResult(records=[], query_status="no_local_data")
with patch("src.data.injuries.get_injuries_for_match", mock_query):
result = await injuries_slice(header, before=None)
# 任一 no_local_data → 保守 has_data=False
assert result.has_data is False
print("PASS: mixed status保守 has_data=False")
-204
View File
@@ -1,204 +0,0 @@
"""回归测试: 伤停数据管线 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
-110
View File
@@ -1,110 +0,0 @@
"""回归测试: 伤停切片区分「查询成功但无人伤停」与「无数据/未接入」。
验证:
1. 查询成功 + 空结果 → has_data=True
2. 源未配置 → has_data=False
3. 查询异常 → has_data=False
4. 查询成功 + 有数据 → has_data=True
"""
from __future__ import annotations
from datetime import datetime, timezone
from unittest.mock import MagicMock, AsyncMock, patch
import pytest
from src.data.injuries import InjuryQueryResult, get_injuries_for_match
from src.llm.context_builder import MatchHeader, injuries_slice
def _make_header():
return MatchHeader(
match_id=999, home_name="A", away_name="B",
league_name="X", season=None, match_date="?",
match_date=None, stage=None,
home_team_id=1, away_team_id=2, league_id=1,
)
class TestInjuryQueryResult:
"""InjuryQueryResult 基础属性。"""
def test_has_data_success(self):
result = InjuryQueryResult(records=[], query_status="success")
assert result.has_data is True
def test_has_data_source_not_configured(self):
result = InjuryQueryResult(records=[], query_status="source_not_configured")
assert result.has_data is False
def test_has_data_query_error(self):
result = InjuryQueryResult(records=[], query_status="query_error")
assert result.has_data is False
class TestInjuriesSliceEmptyVsNotConfigured:
"""injuries_slice 应区分「查询成功但为空」与「无数据/未接入」。"""
@pytest.mark.asyncio
async def test_empty_result_has_data_true(self):
"""查询成功 + 空结果 → has_data=True,文案显示「当前无伤停记录」。"""
header = _make_header()
# Mock get_injuries_for_match 返回成功但空的结果
async def mock_query(db, team_id, match_date, as_of=None):
return InjuryQueryResult(records=[], query_status="success")
with patch("src.llm.context_builder.get_injuries_for_match", mock_query):
result = await injuries_slice(header, before=None)
assert result.has_data is True, "查询成功+空结果应 has_data=True"
assert "当前无伤停记录" in result.text, "文案应表明无伤停"
@pytest.mark.asyncio
async def test_source_not_configured_has_data_false(self):
"""源未配置 → has_data=False。"""
header = _make_header()
async def mock_query(db, team_id, match_date, as_of=None):
return InjuryQueryResult(records=[], query_status="source_not_configured")
with patch("src.llm.context_builder.get_injuries_for_match", mock_query):
result = await injuries_slice(header, before=None)
assert result.has_data is False, "源未配置应 has_data=False"
assert "伤停源未配置" in result.text
@pytest.mark.asyncio
async def test_query_error_has_data_false(self):
"""查询异常 → has_data=False。"""
header = _make_header()
async def mock_query(db, team_id, match_date, as_of=None):
return InjuryQueryResult(records=[], query_status="query_error")
with patch("src.llm.context_builder.get_injuries_for_match", mock_query):
result = await injuries_slice(header, before=None)
assert result.has_data is False, "查询异常应 has_data=False"
assert "查询异常" in result.text
@pytest.mark.asyncio
async def test_with_records_has_data_true(self):
"""查询成功 + 有数据 → has_data=True。"""
header = _make_header()
mock_inj = MagicMock()
mock_inj.reason = "Hamstring"
mock_inj.injury_type = None
mock_inj.player_name = "Player A"
async def mock_query(db, team_id, match_date, as_of=None):
if team_id == 1:
return InjuryQueryResult(records=[mock_inj], query_status="success")
return InjuryQueryResult(records=[], query_status="success")
with patch("src.llm.context_builder.get_injuries_for_match", mock_query):
result = await injuries_slice(header, before=None)
assert result.has_data is True
assert "Player A" in result.text
+3 -3
View File
@@ -30,7 +30,7 @@ def _all_error_reports():
AgentReport(agent="form", status="error", analysis="slice failed"),
AgentReport(agent="stats", status="error", analysis="slice failed"),
AgentReport(agent="home_away", status="error", analysis="slice failed"),
AgentReport(agent="injuries", status="error", analysis="slice failed"),
AgentReport(agent="standings", status="error", analysis="slice failed"),
AgentReport(agent="h2h", status="error", analysis="slice failed"),
]
@@ -41,7 +41,7 @@ def _all_no_data_reports():
AgentReport(agent="form", status="no_data", analysis="无数据"),
AgentReport(agent="stats", status="no_data", analysis="无数据"),
AgentReport(agent="home_away", status="no_data", analysis="无数据"),
AgentReport(agent="injuries", status="no_data", analysis="无数据"),
AgentReport(agent="standings", status="no_data", analysis="无数据"),
AgentReport(agent="h2h", status="no_data", analysis="无数据"),
]
@@ -52,7 +52,7 @@ def _mixed_reports():
AgentReport(agent="form", status="ok", analysis="good"),
AgentReport(agent="stats", status="error", analysis="failed"),
AgentReport(agent="home_away", status="no_data", analysis="无数据"),
AgentReport(agent="injuries", status="error", analysis="failed"),
AgentReport(agent="standings", status="error", analysis="failed"),
AgentReport(agent="h2h", status="no_data", analysis="无数据"),
]