test+docs: P0 回归测试与文档同步
回归测试(P0 三项,不依赖 DB 的静态断言):
- tests/test_regressions.py:
· context_builder 三个查询函数 + backtest 必须 eager-load 关系
· Match.{stats,home_team,away_team,league} 必须 lazy="selectin"
· bzzoiro normalized_matches 必须携带并解包 (nm, raw) 配对
这些是「集成方式」缺陷,原先的单元测试(全 mock slice_fn/provider)
照不出来,故此专项静态锁定。
文档同步(修正与实际不符的表述):
- docs/01-architecture.md: app.py 标注改为「lifespan 仅验证连接,不建表」;
补 deps.py / repositories.py / unit_of_work.py / validation.py /
backtest.py / sources.py / http_client.py / retry.py;迁移范围改 0001~0006
- docs/07-development.md: 目录树修正 leagues.py→matches.py,补齐
backtest.py / deps.py 及 0003~0006 迁移文件
附带: src/core/retry.py 加 NOTE 说明其当前无调用点(P3),
避免「看似有重试实则未生效」的误判。
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
"""回归测试:锁定 P0 三项「静默失效」缺陷不再复发。
|
||||
|
||||
这些用例不依赖数据库 —— 它们用静态分析检查代码结构,
|
||||
因为三个 P0 的本质都是「集成方式错误」,在 mock 掉 slice_fn /
|
||||
provider 的单元测试里永远照不出来(这正是它们当初漏网的原因)。
|
||||
|
||||
- P0-1/P0-2: 查询 Match 的函数必须 eager-load 切片会访问的关系
|
||||
- P0-3: bzzoiro 写 source_event_id 时用的 raw 必须与 nm 配对
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
SRC = Path(__file__).resolve().parent.parent / "src"
|
||||
|
||||
# 切片函数会读取的关系属性 → 查询时必须 eager-load
|
||||
MATCH_RELATIONS = ("stats", "home_team", "away_team", "league")
|
||||
|
||||
|
||||
def _read(rel: str) -> str:
|
||||
return (SRC / rel).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class TestEagerLoadCoverage:
|
||||
"""P0-1 / P0-2: 凡是 select(Match) 且后续访问关系的函数,必须有 eager-load。"""
|
||||
|
||||
def test_context_builder_getters_eager_load(self):
|
||||
"""_get_form / _get_h2h / _get_home_away 必须预加载关系。
|
||||
|
||||
models.py 已声明 lazy="selectin" 兜底,但这里同时检查显式
|
||||
selectinload —— 显式声明是查询意图的固化,也被 P0 修复所依赖。
|
||||
"""
|
||||
src = _read("llm/context_builder.py")
|
||||
for fn in ("_get_form", "_get_h2h", "_get_home_away"):
|
||||
# 截取函数体
|
||||
m = re.search(rf"async def {fn}\(.*?(?=\nasync def |\n# =|\Z)", src, re.S)
|
||||
assert m, f"{fn} 未找到"
|
||||
body = m.group(0)
|
||||
assert "selectinload" in body, (
|
||||
f"{fn} 查询 Match 但未 eager-load 关系 —— "
|
||||
"this would raise MissingGreenlet in async SQLAlchemy (P0-2)"
|
||||
)
|
||||
|
||||
def test_backtest_candidates_eager_load(self):
|
||||
"""回测取历史比赛必须 eager-load(否则 session 关闭后访问关系必炸)。"""
|
||||
src = _read("llm/backtest.py")
|
||||
assert "selectinload" in src, "backtest 未 eager-load 关系 (P0-1)"
|
||||
|
||||
def test_relationship_default_is_selectin(self):
|
||||
"""models.py 中 Match 的高频关系应声明 lazy='selectin' 作为兜底。"""
|
||||
src = _read("db/models.py")
|
||||
# 找到 Match 类定义段
|
||||
m = re.search(r"class Match\(Base\):.*?(?=\nclass )", src, re.S)
|
||||
assert m, "Match 类未找到"
|
||||
body = m.group(0)
|
||||
for rel in MATCH_RELATIONS:
|
||||
# 关系声明可能跨多行(stats/home_team/away_team 都是),因此按
|
||||
# 「从 `rel: Mapped` 到下一个 `xxx: Mapped` 之前」整段匹配。
|
||||
m_rel = re.search(
|
||||
rf"^\s*{rel}: Mapped.*?(?=^\s*\w+: Mapped|\Z)", body, re.M | re.S
|
||||
)
|
||||
assert m_rel, f"Match.{rel} 未找到"
|
||||
assert 'lazy="selectin"' in m_rel.group(0), (
|
||||
f"Match.{rel} 未声明 lazy='selectin' —— 兜底缺失 (P0-2)"
|
||||
)
|
||||
|
||||
|
||||
class TestBzzoiroLineage:
|
||||
"""P0-3: source_event_id 必须取配对的 raw,不能是循环残留变量。"""
|
||||
|
||||
def test_normalized_matches_carries_raw(self):
|
||||
src = _read("data/bzzoiro.py")
|
||||
# 规范化结果必须与原始 event 成对保存
|
||||
assert "normalized_matches.append((nm, raw))" in src, (
|
||||
"normalized_matches 未携带 (nm, raw) 元组 —— raw 变量泄漏会回归 (P0-3)"
|
||||
)
|
||||
# 内层消费循环必须解包成对变量
|
||||
assert re.search(r"for nm, raw in normalized_matches", src), (
|
||||
"消费循环未解包 (nm, raw) —— 血缘字段会取到错误 event (P0-3)"
|
||||
)
|
||||
|
||||
def test_no_orphan_raw_use(self):
|
||||
"""source_event_id 所在行必须在解包循环内(用缩进 + 上下文粗判)。"""
|
||||
src = _read("data/bzzoiro.py")
|
||||
lines = src.splitlines()
|
||||
# 找到 "for nm, raw in normalized_matches" 所在行号
|
||||
start = next(
|
||||
(i for i, ln in enumerate(lines) if "for nm, raw in normalized_matches" in ln),
|
||||
None,
|
||||
)
|
||||
assert start is not None
|
||||
# 该循环之后、下一个同/更低缩进的顶层语句之前的范围
|
||||
seg = "\n".join(lines[start:])
|
||||
uses = [ln for ln in seg.splitlines() if "source_event_id" in ln]
|
||||
assert uses, "未找到 source_event_id 赋值"
|
||||
assert all("raw.get(" in ln for ln in uses), (
|
||||
"source_event_id 未使用配对的 raw (P0-3)"
|
||||
)
|
||||
Reference in New Issue
Block a user