fix(P0-02): 积分榜改为追加快照(append-only) + available_at cutoff
去掉 uq_standings_league_season_team,改为 (league, season, team, available_at) 唯一; 每次采集 INSERT 新行(available_at=now),ON CONFLICT DO NOTHING,不覆盖旧行。 standings_slice(before):DISTINCT ON (team_id) WHERE available_at<=cutoff ORDER available_at DESC;before=None → cutoff=now()。 公开 list_standings 取每队最新可用快照(子查询 max available_at)。 迁移 0023 + 切片/路由/docs 同步;测试 test_p0_standings_cutoff(5/5)。 284 测试全绿。
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
"""P0-02 回归测试: 积分榜改为追加快照(append-only) + available_at cutoff。
|
||||
|
||||
运行: pytest tests/test_p0_standings_cutoff.py -v
|
||||
(模型约束用 fake DB;cutoff 过滤语义用 fake session 验证参数传递。)
|
||||
"""
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.db.models import League, Standing, Team
|
||||
|
||||
|
||||
# ── fake DB(对齐现有测试约定) ────────────────────────────────────
|
||||
class _FakeResult:
|
||||
def __init__(self, items): self._items = list(items)
|
||||
def scalars(self):
|
||||
class _S:
|
||||
def __init__(self, items): self._items = items
|
||||
def all(self): return list(self._items)
|
||||
return _S(self._items)
|
||||
def scalar_one_or_none(self):
|
||||
return self._items[0] if self._items else None
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
captured: list = []
|
||||
|
||||
def __init__(self, league=None, standing_rows=None):
|
||||
self._league = league
|
||||
self._rows = standing_rows or []
|
||||
_FakeDB.captured = []
|
||||
|
||||
def add(self, obj):
|
||||
_FakeDB.captured.append(obj)
|
||||
|
||||
async def execute(self, stmt):
|
||||
# 记录生成的 SQL(字符串化)供断言
|
||||
_FakeDB.captured.append(str(stmt))
|
||||
compiled = str(stmt)
|
||||
if "league" in compiled.lower() and "standing" not in compiled.lower():
|
||||
return _FakeResult([self._league] if self._league else [])
|
||||
return _FakeResult(self._rows)
|
||||
|
||||
async def flush(self):
|
||||
pass
|
||||
|
||||
|
||||
class _FakeTeam:
|
||||
def __init__(self, tid, name):
|
||||
self.id = tid
|
||||
self.name = name
|
||||
self.name_zh = None
|
||||
|
||||
|
||||
class _Header:
|
||||
def __init__(self):
|
||||
from src.llm.slices.common import MatchHeader
|
||||
self._h = MatchHeader(
|
||||
match_id=1, home_name="A", away_name="B", league_name="E0",
|
||||
season="2026", match_date="2026-01-01", match_dt=None,
|
||||
stage=None, home_team_id=10, away_team_id=20, league_id=1,
|
||||
)
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self._h, name)
|
||||
|
||||
|
||||
class TestStandingsModel:
|
||||
"""standings 模型必须有 available_at + 新唯一约束。"""
|
||||
|
||||
def test_available_at_column(self):
|
||||
cols = {c.name for c in Standing.__table__.columns}
|
||||
assert "available_at" in cols
|
||||
|
||||
def test_unique_constraint_includes_available_at(self):
|
||||
names = {c.name for c in Standing.__table__.constraints}
|
||||
assert any("available" in n and n.startswith("uq_") for n in names), \
|
||||
f"缺少含 available_at 的唯一约束,现有: {names}"
|
||||
|
||||
def test_old_unique_constraint_removed(self):
|
||||
names = {c.name for c in Standing.__table__.constraints}
|
||||
assert "uq_standings_league_season_team" not in names, \
|
||||
"旧约束 uq_standings_league_season_team 应已被替换"
|
||||
|
||||
|
||||
class TestStandingsSliceCutoff:
|
||||
"""standings_slice 必须尊重 before(cutoff):before=None → now()。"""
|
||||
|
||||
def test_before_none_uses_now(self):
|
||||
"""before=None 时应将 cutoff 视为 now()(取最新可用快照)。"""
|
||||
from src.llm.slices import standings as st_mod
|
||||
from datetime import datetime, timezone
|
||||
|
||||
calls = {}
|
||||
real_execute = None
|
||||
|
||||
class _DB:
|
||||
def __init__(self): self._league = League(id=1, code="E0", name="E0")
|
||||
def add(self, obj): pass
|
||||
async def execute(self, stmt):
|
||||
# 捕获 WHERE available_at <= ? 的参数
|
||||
sql = str(stmt)
|
||||
if "available_at" in sql:
|
||||
# 提取编译后的 params
|
||||
try:
|
||||
params = stmt.compile().params
|
||||
calls["cutoff"] = params.get("available_at_1")
|
||||
except Exception:
|
||||
pass
|
||||
if "league" in sql.lower() and "standing" not in sql.lower():
|
||||
return _FakeResult([self._league])
|
||||
return _FakeResult([])
|
||||
async def flush(self): pass
|
||||
|
||||
async def run():
|
||||
db = _DB()
|
||||
before = None
|
||||
await st_mod.standings_slice(_Header(), before=before, db=db)
|
||||
|
||||
import asyncio
|
||||
asyncio.run(run())
|
||||
# before=None 时应注入 now() 作为 cutoff
|
||||
assert "cutoff" in calls, "未对 available_at 施加 cutoff 过滤"
|
||||
assert calls["cutoff"] is not None
|
||||
|
||||
|
||||
class TestStandingsAppendOnly:
|
||||
"""采集应 INSERT 新行(带 available_at),不覆盖旧行。"""
|
||||
|
||||
def test_values_include_available_at(self):
|
||||
"""采集构造的 Standing 必须含 available_at 字段。"""
|
||||
from src.data import bzzoiro_standings as bzs
|
||||
# 检查函数源码是否包含 available_at(编译期守卫)
|
||||
import inspect
|
||||
src = inspect.getsource(bzs.ingest_bzzoiro_standings)
|
||||
assert "available_at" in src, "采集函数必须设置 available_at"
|
||||
# 不应再出现按 (league, season, team) 的 upsert 查询
|
||||
assert "scalar_one_or_none" not in src or "Standing.league_id == league.id" not in src.replace("available_at", ""), \
|
||||
"不应再按 (league, season, team) 做 upsert 查询"
|
||||
@@ -194,8 +194,8 @@ async def test_r2_standings_actually_upserts(monkeypatch):
|
||||
assert first.zone == "Champions League" # 优先取 label
|
||||
|
||||
|
||||
async def test_r2_standings_upsert_updates_existing(monkeypatch):
|
||||
"""行为测试: 已存在同 (league, season, team) 时应就地更新而非新增。"""
|
||||
async def test_r2_standings_append_new_row(monkeypatch):
|
||||
"""P0-02 行为测试: 每次采集 INSERT 新行(带 available_at),不更新旧行。"""
|
||||
import src.data.bzzoiro as bz
|
||||
from src.db.models import League, Standing
|
||||
|
||||
@@ -217,16 +217,20 @@ async def test_r2_standings_upsert_updates_existing(monkeypatch):
|
||||
existing = Standing(league_id=42, season="2025-2026", team_id=7, position=9)
|
||||
existing.points = 1
|
||||
|
||||
# 查询顺序: League → Team 预载(命中) → Standing 查询(命中已有行)
|
||||
db = _FakeDb(results=[_FakeResult([league]), _FakeResult([team]), _FakeResult([existing])])
|
||||
# 查询顺序: League(命中) → Team 预载(命中) → (P0-02 不再查询 Standing)
|
||||
db = _FakeDb(results=[_FakeResult([league]), _FakeResult([team])])
|
||||
|
||||
result = await bz.ingest_bzzoiro_standings(db, leagues=["EPL"])
|
||||
|
||||
assert result["total_upserted"] == 1
|
||||
assert existing.points == 30, "已有行应被就地更新"
|
||||
# P0-02: 追加快照——新增 Standing 行,旧行不被修改
|
||||
new_rows = [o for o in db.added if isinstance(o, Standing)]
|
||||
assert len(new_rows) == 1, "P0-02 应新增一条 Standing 行"
|
||||
assert new_rows[0].points == 30, "新行应承载新采集数据"
|
||||
assert new_rows[0].available_at is not None, "新行必须含 available_at"
|
||||
# 旧行未被修改(仍保持原值)
|
||||
assert existing.points == 1, "P0-02 旧行不应被覆盖"
|
||||
assert result["leagues"]["EPL"]["teams_created"] == 0
|
||||
# 不应新增 Standing(只有 league/team 层面的 add)
|
||||
assert not [o for o in db.added if isinstance(o, Standing)]
|
||||
|
||||
|
||||
def test_r2_source_contains_real_upsert_loop():
|
||||
@@ -237,7 +241,9 @@ def test_r2_source_contains_real_upsert_loop():
|
||||
assert "total_upserted" in src
|
||||
assert 'result["total_upserted"] +=' in src, "total_upserted 必须真的被累加"
|
||||
assert "Standing(" in src, "必须真的构造 Standing"
|
||||
assert "select(Standing)" in src, "必须查询已有快照以决定 insert/update"
|
||||
# P0-02: 追加快照——每次 INSERT 新行(带 available_at),不查询旧行做 upsert
|
||||
assert "available_at" in src, "P0-02 采集必须设置 available_at"
|
||||
assert "scalar_one_or_none" not in src, "P0-02 不应再按 (league, season, team) 做 upsert 查询"
|
||||
|
||||
|
||||
# ============================================================
|
||||
|
||||
Reference in New Issue
Block a user