docs/05-data.md:业务唯一=同联赛同主客同自然天; source_event_id 用于统计回填与血缘,新增部分唯一索引说明与 upsert 查找顺序。 新增 ix_matches_source_event_id_unique(WHERE IS NOT NULL), 兼容存量空值历史行;MatchRepository.find_by_source_event_id; events upsert 优先按 event_id 定位,回退自然键。 迁移 0021 + 回归测试修复(find_by_source_event_id 方法调用误判)。
235 lines
11 KiB
Python
235 lines
11 KiB
Python
"""回归测试:锁定 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.stats 刻意排除: 它按设计用 lazy="select",由 selectinload(Match.stats)
|
|
# 显式预加载(见 test_stats_relationship_is_lazy_select_by_design)。
|
|
MATCH_RELATIONS = ("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' 作为兜底。
|
|
|
|
这里只要求「高频一起读取」的关系(set MATCH_RELATIONS)声明 selectin。
|
|
Match.stats 刻意用 lazy="select" —— 它只在 stats 管线里按需取,不在
|
|
每个切片都读,而且它的预加载由 selectinload(Match.stats) 显式表达
|
|
(见 test_context_builder_getters_eager_load)。
|
|
"""
|
|
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:
|
|
# 只取 relationship(...) 调用本身的括号内内容。
|
|
# 注意: 不能把整段(含注释)做子串匹配 —— 关系声明下方的注释里
|
|
# 恰好也写着 lazy="selectin",会导致「删掉真实 kwarg 但测试仍绿」
|
|
# 的假阳性(已用变异测试证实: 移除 league 的 lazy 后断言依旧通过)。
|
|
m_rel = re.search(
|
|
rf"^[ \t]*{rel}: Mapped.*?relationship\((.*?)\)[ \t]*$",
|
|
body, re.M | re.S,
|
|
)
|
|
assert m_rel, f"Match.{rel} 未找到 relationship(...) 声明"
|
|
call_args = m_rel.group(1)
|
|
assert 'lazy="selectin"' in call_args, (
|
|
f"Match.{rel} 的 relationship() 未声明 lazy='selectin' —— 兜底缺失 (P0-2)"
|
|
)
|
|
|
|
def test_stats_relationship_is_lazy_select_by_design(self):
|
|
"""Match.stats 刻意保持 lazy="select"(不是回归)。
|
|
|
|
它是唯一需要显式 selectinload 才预加载的关系 —— 若哪天有人把它也
|
|
改成 selectin,上面的 test_context_builder_getters_eager_load 和
|
|
bzzoiro stats 管线仍应工作,但本用例会提醒复核该设计决定。
|
|
"""
|
|
src = _read("db/models.py")
|
|
body = re.search(r"class Match\(Base\):.*?(?=\nclass )", src, re.S).group(0)
|
|
m_rel = re.search(
|
|
r"^[ \t]*stats: Mapped.*?(?=^[ \t]*\w+:[^\n]*Mapped|\Z)",
|
|
body, re.M | re.S,
|
|
)
|
|
assert m_rel, "Match.stats 未找到"
|
|
assert 'lazy="select"' in m_rel.group(0), (
|
|
"Match.stats 预期为 lazy='select'(按需加载),实际声明已变 —— 请复核设计"
|
|
)
|
|
|
|
|
|
class TestBzzoiroLineage:
|
|
"""P0-3: source_event_id 必须取配对的 raw,不能是循环残留变量。"""
|
|
|
|
# 消费循环的起始行匹配模式(见 bzzoiro.py 顶部 events 管线的内层循环)
|
|
_LOOP_PATTERN = "for nm, raw in normalized_matches"
|
|
|
|
# source_event_id 的合法赋值形状(两种,都必须取配对的 raw):
|
|
# 1) 构造新比赛: `source_event_id=_to_int_or_none(raw.get("id")),`
|
|
# 2) 回填已有比赛: `eid = _to_int_or_none(raw.get("id"))` →
|
|
# `existing_match.source_event_id = eid`
|
|
# 非法形状(即 P0-3 回归): 直接用未配对的变量给 ORM 对象赋值。
|
|
_ASSIGN_DIRECT = re.compile(
|
|
r"source_event_id\s*=\s*(?:[A-Za-z_][\w.]*\s*\(\s*)?raw(?:\.get\(|\s*\[)"
|
|
)
|
|
# 赋值给未配对的局部变量: `source_event_id = <变量>`
|
|
_ASSIGN_VIA_VAR = re.compile(
|
|
r"source_event_id\s*=\s*([A-Za-z_]\w*)\s*$"
|
|
)
|
|
|
|
def _consume_loop_body(self, src: str) -> str:
|
|
"""截取 `for nm, raw in normalized_matches` 循环体,不含循环之后的下游代码。
|
|
|
|
原实现是 `seg = "\\n".join(lines[start:])`,一直取到文件末尾,于是把
|
|
无关的下游 stats 管线(bzzoiro.py 的 `_backfill_stats`)也扫了进来 ——
|
|
那里合法地在 ORM 对象上访问 `m.source_event_id`,导致误报 P0-3。
|
|
这里按缩进边界正确收口:循环体内每行要么是空行/注释,要么缩进严格
|
|
大于 `for` 行。
|
|
"""
|
|
lines = src.splitlines()
|
|
start = next(
|
|
(i for i, ln in enumerate(lines) if self._LOOP_PATTERN in ln), None
|
|
)
|
|
assert start is not None, f"未找到消费循环: {self._LOOP_PATTERN}"
|
|
|
|
for_indent = len(lines[start]) - len(lines[start].lstrip())
|
|
kept: list[str] = [lines[start]]
|
|
for ln in lines[start + 1:]:
|
|
stripped = ln.strip()
|
|
# 顺序要保持: 空行与注释行缩进为 0,不能拿它们做边界判断
|
|
if not stripped or stripped.startswith("#"):
|
|
continue
|
|
indent = len(ln) - len(ln.lstrip())
|
|
if indent <= for_indent:
|
|
break # 循环结束,后续属下游代码
|
|
kept.append(ln)
|
|
# 右侧剥离注释:避免 `# ... raw.get(...)` 这类注释误命中赋值正则
|
|
return "\n".join(ln.split("#", 1)[0] for ln in kept)
|
|
|
|
def _bad_assignments(self, seg: str) -> list[str]:
|
|
"""返回循环体内未取配对 raw 的 source_event_id 赋值行。"""
|
|
# 先收集"来自配对 raw"的局部变量: `eid = _to_int_or_none(raw.get("id"))`
|
|
# (不受行序影响,所以必须先建好,再判定中转赋值)
|
|
raw_vars: set[str] = set()
|
|
for ln in seg.splitlines():
|
|
m = re.match(
|
|
r"\s*([A-Za-z_]\w*)\s*=\s*.*raw(?:\.get\(|\s*\[)", ln
|
|
)
|
|
if m:
|
|
raw_vars.add(m.group(1))
|
|
|
|
bad: list[str] = []
|
|
for ln in seg.splitlines():
|
|
stripped = ln.strip()
|
|
if "source_event_id" not in stripped:
|
|
continue
|
|
# 读取判断/比较(`if x.source_event_id is None:`)不算赋值
|
|
if re.search(r"source_event_id\s*(?:is|==|!=)", stripped):
|
|
continue
|
|
if re.search(r"source_event_id\s*\.\s*\w+\s*\(", stripped):
|
|
continue # 方法调用(obj.source_event_id(...)),不是赋值
|
|
if re.search(r"\w*source_event_id\s*\(", stripped):
|
|
continue # 方法调用(如 find_by_source_event_id(eid)),不是赋值
|
|
if self._ASSIGN_DIRECT.search(stripped):
|
|
continue # 直接取配对 raw
|
|
m_var = self._ASSIGN_VIA_VAR.search(stripped)
|
|
if m_var and m_var.group(1) in raw_vars:
|
|
continue # 经由已确认来自 raw 的局部变量中转
|
|
bad.append(stripped)
|
|
return bad
|
|
|
|
def test_normalized_matches_carries_raw(self):
|
|
src = _read("data/bzzoiro_events.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 赋值都必须取自配对的 raw(P0-3)。
|
|
|
|
用正则而不是 `raw.get(` 子串匹配:合法写法含「回填已有比赛」那条
|
|
(`eid = raw.get("id")` 之后 `existing_match.source_event_id = eid`),
|
|
它不是 `raw.get(` 同一行,但同样正确。非法写法(回归)是直接
|
|
`existing_match.source_event_id = orphan_var`。
|
|
"""
|
|
src = _read("data/bzzoiro_events.py")
|
|
seg = self._consume_loop_body(src)
|
|
bad = self._bad_assignments(seg)
|
|
assert len(bad) == 0, (
|
|
f"source_event_id 未使用配对的 raw (P0-3),问题行: {bad}"
|
|
)
|
|
|
|
def test_loop_body_scope_excludes_downstream_stats_pipeline(self):
|
|
"""作用域守卫: 截取段不能扫到循环之后的下游 stats 管线。
|
|
|
|
下游 `_backfill_stats` 里合法地在 ORM 对象上访问 `m.source_event_id`
|
|
(与配对 raw 无关)。若 seg 越界,test_no_orphan_raw_use 会误报。
|
|
"""
|
|
src = _read("data/bzzoiro_events.py")
|
|
seg = self._consume_loop_body(src)
|
|
assert "m.source_event_id" not in seg, (
|
|
"循环体截取越界,扫到了下游 stats 管线 —— 会误报 P0-3"
|
|
)
|
|
# 但配对使用必须仍在作用域内
|
|
assert "raw.get(" in seg, "循环体内应保留 `raw.get(...)` 的配对用法"
|
|
|
|
def test_guard_detects_orphan_variable_regression(self):
|
|
"""守卫有效性: 若 source_event_id 改成取循环外残留变量,必须被判失败。
|
|
|
|
回归保护的"元测试"——确保上面的正则在真实缺陷面前确实会红,
|
|
而不是恒真的空断言。
|
|
"""
|
|
orphan = """
|
|
for nm, raw in normalized_matches:
|
|
m = Match(
|
|
league_id=1,
|
|
source_event_id=_to_int_or_none(orphan.get("id")),
|
|
)
|
|
"""
|
|
seg = self._consume_loop_body(orphan)
|
|
bad = self._bad_assignments(seg)
|
|
assert bad, "守卫失效: 未配对的 orphan 变量未被识别为 P0-3 回归"
|