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:
co-authored by
new-provider/LongCat-2.0 <
parent
ec8f36abb2
commit
f05dc1ae15
@@ -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"),
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 小时缓冲内时,统计学不可用(回测防泄漏)。
|
||||
|
||||
|
||||
@@ -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 ===")
|
||||
@@ -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())
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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="无数据"),
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user