"""端到端验证:字段库单位「只能在给定范围内选」+ 界面单位 ⇄ 基准单位换算(2026-10)。 为什么值得单独一条契约脚本:单位换算写错**不会报错**,只会让策略静默按错的量级筛选 (把「总市值 ≥ 5 亿元」跑成「≥ 5 万元」)。所以这里从界面一路查到库里: 1. `/fields`:total_mv 的单位是下拉,且只有注册表给的 万元/亿元;用界面把单位改成亿元 → 保存 → 回读 API 生效,且 base_unit 仍是 万元;显示模式注明「亿元(基准 万元,×10000)」; 375px 刷新后仍是亿元(持久化)且无横向溢出 / 几何不退化。 2. `/fields`:新增字段时单位也是受限下拉(总股本 = 万股/亿股,选项里带换算系数)。 3. `/strategies`:新建策略加条件「总市值 ≥ 5」,取值框后缀显示「亿元」、说明写明 ×10000; 保存后回读 API —— 库里必须存 **50000(基准单位万元)**,卡片显示「总市值 ≥ 5 亿元」。 4. 收尾复原现场:删掉临时策略,把 total_mv 的界面单位改回 万元。 需要有浏览器可用的 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.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_NAME = "单位验证-临时(可删)" FIELDS_READ = r""" (() => { const t = (el) => (el ? el.innerText.replace(/\s+/g, " ").trim() : null); const rows = [...document.querySelectorAll("table.tbl tbody tr")]; const mv = rows.find((r) => (r.innerText || "").includes("total_mv")); const unitCell = mv ? mv.querySelectorAll("td")[2] : null; const sel = unitCell ? unitCell.querySelector("select") : null; return JSON.stringify({ rowFound: !!mv, unitCellText: t(unitCell), isSelect: !!sel, options: sel ? [...sel.options].map((o) => o.textContent.trim()) : [], selected: sel && sel.selectedOptions[0] ? sel.selectedOptions[0].textContent.trim() : null, hint: unitCell ? t(unitCell.querySelector(".cell-hint")) : null, }); })() """ def click_by_text(cdp: CDP, text: str, nth: int = 0) -> str: expr = ( "(() => { const bs=[...document.querySelectorAll('button,a')];" f"const m=bs.filter(x=>x.innerText.replace(/\\s+/g,'').trim()==='{text}');" f"const b=m[{nth}];" "if(!b) return 'not-found'; b.click(); return 'clicked'; })()" ) return cdp.call("Runtime.evaluate", {"expression": expr, "returnByValue": True})["result"]["value"] def click_in_total_mv_row(cdp: CDP, label: str) -> str: expr = ( "(() => {const rows=[...document.querySelectorAll('table.tbl tbody tr')];" "const r=rows.find(x=>x.innerText.includes('total_mv'));if(!r)return 'no-row';" f"const b=[...r.querySelectorAll('button')].find(x=>x.innerText.includes('{label}'));" "if(!b)return 'no-btn';b.click();return 'clicked';})()" ) return cdp.call("Runtime.evaluate", {"expression": expr, "returnByValue": True})["result"]["value"] def eval_json(cdp: CDP, expr: str, wait: float = 1.2): time.sleep(wait) r = cdp.call("Runtime.evaluate", {"expression": expr, "returnByValue": True, "awaitPromise": True}) val = r.get("result", {}).get("value") return json.loads(val) if isinstance(val, str) else val 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:" "el.tagName==='TEXTAREA'?HTMLTextAreaElement.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"") def api(method: str, path: str, body: dict | None = None) -> dict: 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 {}, ) with urllib.request.urlopen(req, timeout=20) as r: return json.loads(r.read() or "{}") 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-units-") 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_id = 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") # ---------- 1. /fields:单位是受限下拉,只能选注册表给的档 ---------- print("\n=== /fields @1500px:单位只能在给定范围内选 ===", flush=True) probe(cdp, f"{WEB}/fields", 1500, 1200, 3.0) before = eval_json(cdp, FIELDS_READ, wait=0.4) print(f" 显示模式单位单元格:{before['unitCellText']}", flush=True) report("万元" in (before["unitCellText"] or ""), "未改时显示当前单位 万元(= 基准,无需额外标注)") report(click_in_total_mv_row(cdp, "编辑") == "clicked", "点 total_mv 行的「编辑」") data = eval_json(cdp, FIELDS_READ) print(f" 编辑模式单位单元格:{data['unitCellText']}", flush=True) report(data["isSelect"], "单位是下拉(不是自由文本框)") report(data["options"] == ["万元", "亿元"], "下拉只有注册表给的 万元/亿元", str(data["options"])) report(data["selected"] == "万元", "当前界面单位 = 万元(基准)") print(f" 换算提示:{data['hint']}", flush=True) report((data["hint"] or "").startswith("1 万元 = 1 万元"), "显示 1 万元 = 1 万元(基准档)") changed = set_input(cdp, 'select[aria-label$="的界面单位"]', "亿元") print(f" 下拉改选 → {changed}", flush=True) time.sleep(0.5) print(f" 点「保存」→ {click_by_text(cdp, '保存')}", flush=True) time.sleep(1.8) rows = api("GET", "/api/condition-fields") mv = next((r for r in rows if r["name"] == "total_mv"), {}) report(mv.get("unit") == "亿元", "界面上改单位后保存 → 库里 unit=亿元", f"unit={mv.get('unit')}") report(mv.get("base_unit") == "万元", "基准单位仍是 万元(引擎口径不变)", f"base={mv.get('base_unit')}") after = eval_json(cdp, FIELDS_READ, wait=0.6) print(f" 保存后显示模式:{after['unitCellText']}", flush=True) report("亿元" in (after["unitCellText"] or "") and "基准 万元" in (after["unitCellText"] or ""), "显示模式注明「亿元(基准 万元,×10000)」", after["unitCellText"] or "") # ---------- 1b. 刷新后仍生效(持久化),375px 也不溢出 ---------- print("\n=== /fields @375px:刷新后仍是亿元 + 无溢出 ===", flush=True) g = probe(cdp, f"{WEB}/fields", 375, 1200, 2.8) report(click_in_total_mv_row(cdp, "编辑") == "clicked", "点 total_mv 行的「编辑」(375px)") small = eval_json(cdp, FIELDS_READ, wait=0.6) report(small["selected"] == "亿元", "刷新后仍是 亿元(持久化)", str(small["selected"])) report(small["options"] == ["万元", "亿元"], "375px 下拉仍是 万元/亿元", str(small["options"])) 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])) geo = check_geometry(g) bad = [(n, d) for ok, n, d in geo if not ok] report(not bad, f"375px 几何检查 {len(geo)} 项全部通过", str(bad[:2])) # ---------- 2. 新增字段的单位也是受限下拉 ---------- print("\n=== /fields @1500px:新增字段(总股本)的单位下拉 ===", flush=True) probe(cdp, f"{WEB}/fields", 1500, 1200, 2.5) picked = set_input(cdp, 'select[aria-label="可新增字段"]', "float_share") print(f" 选 float_share → {picked}", flush=True) time.sleep(0.8) add = eval_json( cdp, "JSON.stringify({sel:(()=>{const s=[...document.querySelectorAll('select')]" ".find(x=>x.getAttribute('aria-label')==='新增字段的界面单位');" "return s?{options:[...s.options].map(o=>o.textContent.trim())," "selected:s.selectedOptions[0].textContent.trim()}:null;})()})", wait=0.4, ) print(f" 单位下拉:{add['sel']}", flush=True) opts = " ".join((add["sel"] or {}).get("options", [])) report(bool(add["sel"]) and "万股" in opts and "亿股" in opts and "10000" in opts, "新增字段时单位只能是 万股/亿股(含换算系数)", opts) # ---------- 3. /strategies:按亿元输入,库里存万元 ---------- print("\n=== /strategies @1500px:总市值 ≥ 5 亿元 ===", flush=True) probe(cdp, f"{WEB}/strategies", 1500, 1200, 3.0) print(f" 点「新建选股策略」→ {click_by_text(cdp, '新建选股策略')}", flush=True) time.sleep(1.0) set_input(cdp, ".params-meta__name input", TMP_NAME) set_input(cdp, ".params-meta__desc textarea", "临时验证:单位换算(跑完即删)") print(f" 点「添加因子」→ {click_by_text(cdp, '添加因子')}", flush=True) time.sleep(0.6) set_input(cdp, 'select[aria-label$="因子的名称"]', "dividend_yield") time.sleep(0.4) print(f" 点「添加条件」→ {click_by_text(cdp, '添加条件')}", flush=True) time.sleep(0.8) set_input(cdp, '.cond-row:last-child select[aria-label$="的字段"]', "total_mv") time.sleep(0.6) form = eval_json( cdp, "JSON.stringify({rows:document.querySelectorAll('.cond-row').length," "suffix:(()=>{const e=document.querySelector('.cond-row:last-child .unit-suffix');" "return e?e.innerText.trim():null;})()," "hint:(()=>{const e=document.querySelector('.cond-row:last-child .cell-hint');" "return e?e.innerText.replace(/\\s+/g,' ').trim():null;})()})", wait=0.4, ) print(f" 条件行:{form['rows']};单位后缀:{form['suffix']}", flush=True) print(f" 字段说明:{form['hint']}", flush=True) report(form["suffix"] == "亿元", "取值框显示界面单位后缀「亿元」", str(form["suffix"])) hint = form["hint"] or "" report("界面单位 亿元" in hint and "×10000" in hint and "万元" in hint, "字段说明写明 ×10000 换算成基准单位 万元", hint[:60]) typed = set_input(cdp, '.cond-row:last-child input[aria-label$="的取值"]', "5") print(f" 输入 5(亿元)→ {typed}", flush=True) print(f" 点「保存到策略库」→ {click_by_text(cdp, '保存到策略库')}", flush=True) time.sleep(2.5) card = cdp.call( "Runtime.evaluate", {"expression": "(() => {const c=[...document.querySelectorAll('.chip')].find(x=>x.innerText.includes('条件'));return c?c.innerText.replace(/\\s+/g,' ').trim():null;})()", "returnByValue": True}, )["result"].get("value") print(f" 策略卡片条件文本:{card}", flush=True) report(bool(card) and "总市值" in card and "亿元" in card and "50000" not in card, "卡片显示「总市值 ≥ 5 亿元」(中文名+界面单位,不露基准数值)", card or "") strategies = api("GET", "/api/strategies") mine = [s for s in strategies if s.get("name") == TMP_NAME] if not mine: errs = eval_json(cdp, "JSON.stringify({banner:[...document.querySelectorAll('.banner')].map(b=>b.innerText.replace(/\\s+/g,' ').trim()),errs:[...document.querySelectorAll('.field-err')].map(e=>e.innerText.trim())})", wait=0.2) report(False, "保存后的临时策略可在 API 查到", f"现有:{[s.get('name') for s in strategies]};页面:{errs}") else: created_id = mine[0]["id"] conds = mine[0].get("conditions") or [] print(f" 回读 API:{json.dumps(conds, ensure_ascii=False)}", flush=True) got = conds[0] if conds else {} report(got.get("field") == "total_mv", "条件字段 = total_mv") report(got.get("op") == "gte", "比较符 = ≥") report(got.get("value") == 50000, "库里存的是 50000(基准单位万元),不是 5", str(got.get("value"))) return 1 if failed else 0 finally: if created_id: try: api("DELETE", f"/api/strategies/{created_id}") print(f"\n已清理临时策略 {created_id}", flush=True) except Exception as exc: # noqa: BLE001 print(f"\n⚠ 清理临时策略失败:{exc}", flush=True) try: api("PUT", "/api/condition-fields/total_mv", {"unit": "万元"}) print("已把 total_mv 的界面单位改回 万元(复原现场)", flush=True) except Exception as exc: # noqa: BLE001 print(f"⚠ 复原单位失败:{exc}", flush=True) proc.terminate() print(f"\n总计失败:{failed}", flush=True) if __name__ == "__main__": raise SystemExit(main())