Files
qlib/scripts/verify_factor_params.py
Simon 36fe018075 docs+chore: 同步操作说明与端到端自检(字段库/单位换算、因子参数化)
- docs/USAGE.md:
  · 因子层一行改为「代码注册表投影 + 参数化实例,参数写在名字里以冻结口径」;
  · 新增 `GET/POST/PATCH /api/factors`、`GET /api/factors/templates` 与
    `/api/condition-fields`、`/fields` 的说明;
  · 新增「参数化因子(2026-10)」块:受控范围、键必须写全参数(缺项就靠可改的
    默认值兜底 = 追溯改义,所以拒绝)、口径文案按代码收敛、停用 ≠ 删除、
    参数化因子也能当过滤条件;
  · 自检清单一并更新(pytest 500 条;verify_strategy_workspace 145 项 skip-job;
    verify_ui_alignment 8 页 160 项;新增 verify_unit_conversion、verify_factor_params)。
- scripts/:新增 verify_unit_conversion.py(单位只能在给定范围里选 + 界面单位⇄
  基准单位换算)、verify_factor_params.py(参数暴露/界面新建/越界拒绝/停用语义,
  跑完自动清掉临时因子);verify_strategy_workspace.py 加 [5.7b] 因子参数化一节,
  临时因子的清理挪进 finally(断言中途失败也不给真人库留垃圾)。
- .gitignore:docs/screenshots/ 是临时验证证据,不入库(文件留在磁盘)。
2026-10-01 16:38:54 +08:00

517 lines
24 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""端到端验证:因子参数化(暴露真实参数、可编辑、且真生效、真冻结)(2026-10)。
为什么单独一条契约脚本:因子参数写错**不会报错**,只会让策略按另一个口径选股
(「窗口 20」悄悄算成「窗口 90」= 另一个因子的结果)。所以这里从界面一路查到引擎:
1. `/factors`:目录暴露 template/params/param_specs(中文名含参数、允许范围 2~500、
方向二选一);展开行给出精确引擎键与来源;页面不再是「不可修改」的旧说法。
2. `/factors`:用界面新建参数化因子(窗口 90、越低越好)—— 预览键必须与后端规范键
逐字符一致;创建成功后 API 里出现该行,中文名/回看/方向全部由引擎投影。
3. `/factors`:越界参数(窗口 999)在界面上被拒,且**没有**留下任何垃圾行。
4. `/factors`:停用/启用真的写库,且停用后仍能被引擎解析(历史不变义)。
5. `/strategies`:条件字段下拉里出现该参数化因子(因子目录并入字段库),
因子下拉显示中文名(含参数)而不是又长又生的引擎键。
6. 375px:页面无横向溢出、几何检查全过。
7. 收尾:删掉验证期间创建的临时因子(目录没有删除接口,直接按主键清),回到 11 条内置。
需要浏览器可用的 Web(:3000)与 API(:8000)。复用 verify_ui_alignment 的 CDP 管线
(系统 Chrome,无需 playwright)。
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
sys.path.insert(0, "/Users/summer/project/qlib/scripts")
from verify_ui_alignment import CDP, check_geometry, find_chrome, free_port, probe # noqa: E402
WEB = "http://127.0.0.1:3000"
API = "http://127.0.0.1:8000"
TMP_KEY = "momentum(window=90,direction=lower_is_better)"
BAD_KEY = "momentum(window=999,direction=lower_is_better)"
FACTORS_READ = r"""
(() => {
const t = (el) => (el ? el.innerText.replace(/\s+/g, " ").trim() : null);
const rows = [...document.querySelectorAll("table.tbl tbody tr")];
const find = (name) => rows.find((r) => (r.innerText || "").includes(name));
const mv = find("momentum_60");
const mvCells = mv ? [...mv.querySelectorAll("td")].map((td) => t(td)) : [];
const probe = find("__PROBE__");
const probeCells = probe ? [...probe.querySelectorAll("td")].map((td) => t(td)) : [];
const probeBtn = probe
? [...probe.querySelectorAll("button")].map((b) => t(b))
: [];
return JSON.stringify({
rowCount: rows.length,
headers: [...document.querySelectorAll("table.tbl thead th")].map((th) => t(th)),
momentumCells: mvCells,
probeCells,
probeButtons: probeBtn,
// 展开行的文本:主行的下一个兄弟 tr(class=expand-cell)就是展开内容
probeExpandedText: probe ? t(probe.nextElementSibling) : null,
bodyText: t(document.body),
});
})()
"""
PREVIEW_READ = r"""
(() => {
const t = (el) => (el ? el.innerText.replace(/\s+/g, " ").trim() : null);
const w = document.querySelector("#factor-new-window");
const d = document.querySelector("#factor-new-direction");
const all = t(document.body) || "";
// 预览键 = <b class="mono">模板名(参数=值,…)</b>。**不要**扫整页文本:
// 页面说明里也有一个示例键,扫文本会拿示例当预览(假通过)。
const previewKeys = [
...new Set(
[...document.querySelectorAll("b.mono")]
.map((el) => t(el))
.filter((x) => x && /^[a-z_][a-z0-9_]*\(/.test(x)),
),
];
return JSON.stringify({
windowValue: w ? w.value : null,
windowMin: w ? w.min : null,
windowMax: w ? w.max : null,
directionOptions: d ? [...d.options].map((o) => o.textContent.trim()) : [],
directionValue: d ? d.value : null,
templateOptions: [...(document.querySelectorAll("#factor-new-template option") || [])]
.map((o) => o.textContent.trim()),
previewKeys,
bodyText: all,
});
})()
"""
STRATEGY_FORM_READ = r"""
(() => {
const t = (el) => (el ? el.innerText.replace(/\s+/g, " ").trim() : null);
const factorSel = document.querySelector('select[aria-label="第 1 个因子的名称"]');
const condSel = document.querySelector('select[aria-label="第 1 个条件的字段"]');
// 注意:<option> 没有 innerText(它不被渲染),只能用 textContent
const opts = (sel) =>
sel
? [...sel.querySelectorAll("option")].map((o) =>
(o.textContent || "").replace(/\s+/g, " ").trim(),
)
: [];
// optgroup 的 label 是字符串(不是元素):别喂给只处理元素的 t()
const groups = (sel) =>
sel
? [...sel.querySelectorAll("optgroup")].map((g) =>
(g.getAttribute("label") || "").trim(),
)
: [];
return JSON.stringify({
hasFactorSelect: !!factorSel,
hasCondSelect: !!condSel,
factorOptions: opts(factorSel),
condOptions: opts(condSel),
condGroups: groups(condSel),
});
})()
"""
def api(method: str, path: str, body: dict | None = None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(
f"{API}{path}",
method=method,
data=data,
headers={"Content-Type": "application/json"} if data else {},
)
try:
with urllib.request.urlopen(req, timeout=30) as r:
return r.status, json.loads(r.read() or "{}")
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read() or "{}")
def eval_json(cdp: CDP, expr: str, wait: float = 1.0):
time.sleep(wait)
r = cdp.call(
"Runtime.evaluate", {"expression": expr, "returnByValue": True, "awaitPromise": True}
)
val = r.get("result", {}).get("value")
if isinstance(val, str):
try:
return json.loads(val)
except json.JSONDecodeError:
return val
return val
def click_by_text(cdp: CDP, text: str, nth: int = 0, exact: bool = True) -> str:
expr = (
"(() => { const bs=[...document.querySelectorAll('button,a')];"
f"const m=bs.filter(x=>x.innerText.replace(/\\s+/g,'').trim()"
f"{'===' if exact else '.includes('}'{text}'{')' if not exact else ''});"
f"const b=m[{nth}];"
"if(!b) return 'not-found'; b.click(); return 'clicked'; })()"
)
r = cdp.call("Runtime.evaluate", {"expression": expr, "returnByValue": True})
return r.get("result", {}).get("value")
def click_row(cdp: CDP, row_needle: str) -> str:
"""点整行展开定义(页面的展开热区是 <tr> 本身,不是按钮)。"""
expr = (
"(() => {const rows=[...document.querySelectorAll('table.tbl tbody tr')];"
f"const r=rows.find(x=>x.innerText.includes({row_needle!r}) && "
"!x.classList.contains('expand-cell'));"
"if(!r)return 'no-row';r.click();return 'clicked';})()"
)
r = cdp.call("Runtime.evaluate", {"expression": expr, "returnByValue": True})
return r.get("result", {}).get("value")
def click_in_row(cdp: CDP, row_needle: str, btn_text: str) -> str:
expr = (
"(() => {const rows=[...document.querySelectorAll('table.tbl tbody tr')];"
f"const r=rows.find(x=>x.innerText.includes({row_needle!r}));if(!r)return 'no-row';"
f"const b=[...r.querySelectorAll('button')].find(x=>x.innerText.includes({btn_text!r}));"
"if(!b)return 'no-btn';b.click();return 'clicked';})()"
)
r = cdp.call("Runtime.evaluate", {"expression": expr, "returnByValue": True})
return r.get("result", {}).get("value")
def set_input(cdp: CDP, selector: str, value: str) -> str:
expr = (
"(() => {"
f"const el=document.querySelector({selector!r});"
"if(!el) return 'not-found';"
"const proto=el.tagName==='SELECT'?HTMLSelectElement.prototype:HTMLInputElement.prototype;"
"const setter=Object.getOwnPropertyDescriptor(proto,'value').set;"
f"setter.call(el,{value!r});"
"el.dispatchEvent(new Event('input',{bubbles:true}));"
"el.dispatchEvent(new Event('change',{bubbles:true}));"
"return el.value; })()"
)
r = cdp.call("Runtime.evaluate", {"expression": expr, "returnByValue": True})
return r.get("result", {}).get("value", f"<err {r.get('exceptionDetails', {}).get('text')}>")
BACKEND = "/Users/summer/project/qlib/backend"
PY = os.environ.get("QLIB_PY") or f"{BACKEND}/.venv/bin/python"
def cleanup_factor(key: str) -> bool:
"""删掉临时参数化因子:目录没有删除接口(设计如此),所以按主键直接清库。
走子进程 + 后端 venv 解释器:本脚本刻意只用标准库(CDP 管线不依赖第三方),
但删库要用 SQLAlchemy/ORM 配置,不能要求调用方先把 venv 激起来。
"""
code = (
"import sys; sys.path.insert(0, %r)\n"
"from sqlalchemy import delete\n"
"from app.infrastructure.persistence.sqlalchemy.models.factor import FactorDefinitionModel\n"
"from app.infrastructure.persistence.sqlalchemy.session import SessionLocal\n"
"with SessionLocal() as s:\n"
" n = s.execute(delete(FactorDefinitionModel).where(FactorDefinitionModel.name == %r)).rowcount\n"
" s.commit()\n"
"print(n)\n"
) % (BACKEND, key)
out = subprocess.run([PY, "-c", code], capture_output=True, text=True, timeout=120)
if out.returncode != 0:
raise RuntimeError(out.stderr.strip()[-200:])
return out.stdout.strip() == "1"
def main() -> int:
chrome = os.environ.get("QLIB_CHROME") or find_chrome()
if not chrome:
print("no chrome", file=sys.stderr)
return 2
port = free_port()
profile = tempfile.mkdtemp(prefix="qlib-factorparams-")
proc = subprocess.Popen(
[
chrome,
f"--remote-debugging-port={port}",
"--headless=new",
"--disable-gpu",
"--no-first-run",
"--no-default-browser-check",
"--hide-scrollbars",
"--disable-extensions",
f"--user-data-dir={profile}",
"--window-size=1500,1200",
"about:blank",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
failed = 0
created = False
original_enabled: bool | None = None
def report(good: bool, name: str, extra: str = "") -> None:
nonlocal failed
print(f" {'✅' if good else '❌'} {name}" + (f" — {extra}" if extra else ""), flush=True)
failed += 0 if good else 1
try:
ws = None
for _ in range(80):
try:
with urllib.request.urlopen(f"http://127.0.0.1:{port}/json/list", timeout=2) as r:
for tab in json.loads(r.read()):
if tab.get("type") == "page":
ws = tab["webSocketDebuggerUrl"]
break
if ws:
break
except Exception:
pass
time.sleep(0.3)
cdp = CDP(ws)
cdp.call("Page.enable")
cdp.call("Runtime.enable")
before = {f["name"]: f for f in api("GET", "/api/factors")[1]}
original_enabled = before.get(TMP_KEY, {}).get("enabled")
# ---------- 1. 目录暴露真实参数 ----------
print("\n=== /factors @1500px:真实参数与允许范围 ===", flush=True)
probe(cdp, f"{WEB}/factors", 1500, 1200, 3.2)
data = eval_json(cdp, FACTORS_READ.replace("__PROBE__", "momentum_60"), wait=0.5)
print(f" 表头:{data['headers']}", flush=True)
print(f" momentum_60 行:{data['momentumCells']}", flush=True)
report("参数" in " ".join(data["headers"]), "表格有「参数」列", str(data["headers"]))
joined = " ".join(data["momentumCells"])
report("窗口 60" in joined and "越高越好" in joined, "行内显示真实参数(窗口 60 · 越高越好)", joined)
report("动量" in joined, "因子列显示中文名(含参数)", joined)
body = data["bodyText"] or ""
report("不可修改" not in body, "旧的「不可修改」说法已下线(不再说谎)")
report(
("新身份" in body or "不变义" in body) and "参数" in body,
"页面说明「改参数 = 新身份,旧因子/既有策略不变义」",
)
# 展开行:精确键 + 参数范围 + 来源
exp = click_row(cdp, "momentum_60")
print(f" 展开 momentum_60 → {exp}", flush=True)
detail = eval_json(cdp, FACTORS_READ.replace("__PROBE__", "momentum_60"), wait=0.8)
dtext = " ".join(detail["momentumCells"]) + " " + (detail["probeExpandedText"] or "")
report("momentum_60" in dtext, "展开行给出精确引擎键", dtext[-90:])
report("2 ~ 500" in dtext or "2~500" in dtext, "展开行给出参数允许范围(2 ~ 500)")
report("越高越好" in dtext and ("内置" in dtext or "注册表" in dtext), "展开行标出方向与来源")
# ---------- 2. 界面新建参数化因子(预览键必须与后端一致) ----------
print("\n=== /factors:用界面新建参数化因子 ===", flush=True)
picked = set_input(cdp, "#factor-new-template", "momentum")
print(f" 选模板 momentum → {picked}", flush=True)
prev = eval_json(cdp, PREVIEW_READ, wait=1.0)
report("动量" in " ".join(prev["templateOptions"]), "模板下拉显示中文名与已有实例",
str(prev["templateOptions"][:2]))
report(prev["windowValue"] == "20", "窗口默认值来自模板(20)", str(prev["windowValue"]))
report(
prev["windowMin"] == "2" and prev["windowMax"] == "500",
"窗口输入框带 min/max(2/500,受控)",
f"min={prev['windowMin']} max={prev['windowMax']}",
)
report(
prev["directionOptions"] == ["越高越好", "越低越好"],
"方向下拉只有两个中文选项(不是自由文本)",
str(prev["directionOptions"]),
)
# 两次赋值之间要留一拍:受控 <select> 的原生赋值会被 React 的提交还原,
# 连着改两个控件时第二个改动会丢(这是无头驱动的时序,不是页面 bug)
set_input(cdp, "#factor-new-window", "90")
time.sleep(0.6)
set_input(cdp, "#factor-new-direction", "lower_is_better")
time.sleep(0.6)
prev2 = eval_json(cdp, PREVIEW_READ, wait=0.9)
keys = prev2["previewKeys"] or []
ptext = prev2["bodyText"] or ""
print(f" 预览键:{keys}", flush=True)
report(TMP_KEY in keys, "预览的规范键与后端逐字符一致", str(keys))
report("动量(窗口 90,越低越好)" in ptext, "预览中文名含参数与方向", ptext[:120])
print(f" 点「新建」→ {click_by_text(cdp, '新建参数化因子')}", flush=True)
made = eval_json(cdp, PREVIEW_READ, wait=2.4)
code, rows = api("GET", "/api/factors")
cat = {f["name"]: f for f in rows}
created = TMP_KEY in cat
report(created, "创建成功:API 目录里出现该参数化因子", str(sorted(cat)[-3:]))
if created:
row = cat[TMP_KEY]
report(
row.get("label") == "动量(窗口 90,越低越好)"
and row.get("lookback") == 90
and row.get("direction") == "lower_is_better"
and row.get("source") == "custom",
"新因子口径由引擎投影(中文名/回看/方向/来源)",
json.dumps({k: row.get(k) for k in ("label", "lookback", "direction", "source")},
ensure_ascii=False),
)
report(
"不变义" in (made["bodyText"] or "") or "新身份" in (made["bodyText"] or ""),
"成功提示里写明「旧因子不变义」",
)
# ---------- 3. 越界参数在界面被拒,且不留垃圾行 ----------
print("\n=== /factors:越界参数被拒(不静默截断) ===", flush=True)
probe(cdp, f"{WEB}/factors", 1500, 1200, 2.5)
set_input(cdp, "#factor-new-template", "momentum")
time.sleep(0.8)
set_input(cdp, "#factor-new-window", "999")
time.sleep(0.6)
click_by_text(cdp, "新建参数化因子")
bad = eval_json(cdp, PREVIEW_READ, wait=2.2)
btext = bad["bodyText"] or ""
report("2 ~ 500" in btext, "界面显示后端拒绝原因(含允许范围 2 ~ 500)", btext[:0])
_, rows2 = api("GET", "/api/factors")
report(BAD_KEY not in {f["name"] for f in rows2}, "越界请求没有在目录里留下垃圾行")
# 在目录里能看到新建的因子行(中文名含参数)
data3 = eval_json(cdp, FACTORS_READ.replace("__PROBE__", TMP_KEY), wait=1.0)
report(
bool(data3["probeCells"]) and "窗口 90" in " ".join(data3["probeCells"]),
"目录表格里出现新因子并显示参数",
str(data3["probeCells"])[:120],
)
# ---------- 4. 停用 / 启用(只影响能否被选中) ----------
print("\n=== /factors:停用/启用真的写库 ===", flush=True)
acts = data3["probeButtons"]
print(f" 该行按钮:{acts}", flush=True)
clicked = click_in_row(cdp, TMP_KEY, "停用")
time.sleep(1.6)
_, rows3 = api("GET", "/api/factors")
off = {f["name"]: f.get("enabled") for f in rows3}.get(TMP_KEY)
report(clicked == "clicked" and off is False, "界面停用 → 库里 enabled=false", f"{clicked}/{off}")
click_in_row(cdp, TMP_KEY, "启用")
time.sleep(1.6)
_, rows4 = api("GET", "/api/factors")
on = {f["name"]: f.get("enabled") for f in rows4}.get(TMP_KEY)
report(on is True, "界面启用 → 库里 enabled=true", str(on))
# 停用不影响引擎解析(历史不变义)
code, _ = api("GET", f"/api/factors?include_disabled=true")
report(code == 200, "目录接口在停用状态下仍正常(停用不是删除)")
# ---------- 5. 策略表单里能用(因子 + 条件两条路径) ----------
print("\n=== /strategies:参数化因子能当因子用,也能当条件用 ===", flush=True)
probe(cdp, f"{WEB}/strategies", 1500, 1200, 3.0)
print(f" 点「新建选股策略」→ {click_by_text(cdp, '新建选股策略')}", flush=True)
time.sleep(1.2)
click_by_text(cdp, "添加因子")
click_by_text(cdp, "添加条件")
time.sleep(1.2)
form = eval_json(cdp, STRATEGY_FORM_READ, wait=1.0)
fopts = form["factorOptions"]
print(f" 因子下拉选项:{fopts[:3]}", flush=True)
report(
any("动量(窗口 90,越低越好)" in o for o in fopts),
"因子下拉显示中文名(含参数)而不是裸键",
str([o for o in fopts if "90" in o][:1]),
)
report(
any(TMP_KEY not in o and "动量(窗口 20" in o for o in fopts),
"内置实例也显示中文名(含各自参数)",
str([o for o in fopts if "momentum_20" in o][:1]),
)
copts = form["condOptions"]
report(
any(TMP_KEY in o for o in copts),
"条件字段下拉并入参数化因子(因子目录 → 字段库)",
str([o for o in copts if "momentum" in o][:2]),
)
report("因子" in (form["condGroups"] or []), "条件字段下拉有「因子」分组",
str(form["condGroups"]))
# ---------- 5b. 停用后不该出现在任何「选因子」的地方 ----------
print("\n=== 停用 = 不出现在选择列表(各页一致,不是假开关) ===", flush=True)
api("PATCH", "/api/factors", {"name": TMP_KEY, "enabled": False})
time.sleep(0.6)
pages = [
("/selection", "select[aria-label=\"第 1 个因子的名称\"]", "添加因子"),
("/signals", "select[aria-label=\"评分因子\"]", None),
]
for path, selector, add_btn in pages:
probe(cdp, f"{WEB}{path}", 1500, 1200, 2.6)
if add_btn:
print(f" {path} 点「{add_btn}」→ {click_by_text(cdp, add_btn)}", flush=True)
time.sleep(0.8)
# /signals 的因子下拉是 <Field label> 包出来的(没有 aria-label),
# 所以按「选项里有 momentum_」来定位那一个 select —— 资产名比选择器稳。
finder = (
"(() => {const sels=[...document.querySelectorAll('select')];"
"const sel=sels.find(s=>[...s.options].some(o=>/momentum/.test(o.textContent||'')));"
"return JSON.stringify(sel?[...sel.options].map(o=>(o.textContent||'').trim()):[]);})()"
if add_btn is None
else "JSON.stringify([...document.querySelectorAll(%r + ' option')]"
".map((o) => (o.textContent || '').trim()))" % selector
)
opts = eval_json(cdp, finder, wait=0.8)
opts = opts or []
print(f" {path} 因子下拉 {len(opts)} 项", flush=True)
report(
not any(TMP_KEY in o for o in opts),
f"{path} 停用的因子不出现在候选里",
str([o for o in opts if "momentum" in o][:2]),
)
api("PATCH", "/api/factors", {"name": TMP_KEY, "enabled": True})
time.sleep(0.5)
probe(cdp, f"{WEB}/signals", 1500, 1200, 2.4)
# 这里连 value 一起读:显示文字是「中文名 · 短键」(direction 在提示里说),
# 但提交给引擎的 value 必须还是**完整键** —— 两者都要验,缺一个就是骗人
pairs = eval_json(
cdp,
"(() => {const sels=[...document.querySelectorAll('select')];"
"const sel=sels.find(s=>[...s.options].some(o=>/momentum/.test(o.textContent||'')));"
"return JSON.stringify(sel?[...sel.options].map(o=>[o.value,(o.textContent||'').trim()]):[]);})()",
wait=0.8,
) or []
hit = [t for v, t in pairs if v == TMP_KEY]
report(
bool(hit) and "动量(窗口 90,越低越好)" in hit[0] and "momentum(window=90)" in hit[0],
"启用后该页按「中文名 · 短键」显示,而 value 仍是完整引擎键",
str(hit[:1] or pairs[:1]),
)
# ---------- 6. 375px:无溢出 + 几何 ----------
print("\n=== /factors @375px:无横向溢出 ===", flush=True)
g = probe(cdp, f"{WEB}/factors", 375, 1200, 3.0)
small = eval_json(cdp, FACTORS_READ.replace("__PROBE__", TMP_KEY), wait=0.8)
over = [o for o in g["overflows"] if o["scrollW"] > o["clientW"] + 2]
print(f" 溢出元素:{over[:4]}", flush=True)
report(not over, "375px 无横向溢出", str(over[:2]))
report(bool(small["probeCells"]), "375px 表格仍渲染出因子行", str(len(small["probeCells"])))
geo = check_geometry(g)
bad_geo = [(n, d) for ok, n, d in geo if not ok]
report(not bad_geo, f"375px 几何检查 {len(geo)} 项全部通过", str(bad_geo[:2]))
finally:
# ---------- 7. 收尾:删掉临时因子,回到只有内置实例 ----------
print("\n=== 收尾 ===", flush=True)
try:
if created:
cleanup_factor(TMP_KEY)
print(f"[cleanup] 已删除临时参数化因子 {TMP_KEY}", flush=True)
_, rows = api("GET", "/api/factors")
names = [f["name"] for f in rows]
extra = [n for n in names if "(" in n]
report(not extra, "目录回到只有内置实例(无临时参数化行)", str(extra))
report(len(names) == 11, "目录行数回到 11(内置实例)", str(len(names)))
except Exception as e: # noqa: BLE001 - 清理失败要显式说出来
print(f" ⚠ 清理失败(请手工检查 factor_definition):{e}", flush=True)
failed += 1
proc.terminate()
print(f"\n{'✅ 全部通过' if failed == 0 else f'❌ {failed} 项失败'}", flush=True)
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main())