Files
qlib/scripts/verify_ui_alignment.py
T
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

502 lines
22 KiB
Python
Raw 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.
#!/usr/bin/env python3
"""UI 对齐与控件一致性自检(无头 Chrome + 原生 CDP,仅用标准库)。
为什么需要这个脚本
------------------
「控件没对齐 / 尺寸和内容不匹配 / 输入不友好」这类问题**肉眼能看出,但无法回归**:
截图不能进版本库比较,人工点页面又会漏。所以把「对齐」变成可测量的不变量,
每次改样式后跑一遍,用数字证明没有退化。
为什么自己实现 CDP
------------------
不依赖 playwright / selenium / websocket-client(本项目零额外依赖原则)。
只用标准库 socket 做 WebSocket 握手与帧编解码,连本机 Chrome 的调试端口;
用**独占端口 + 临时 profile**,不碰用户正在用的浏览器(共享端口曾导致误开标签页)。
检查项(每条都对应一种真实可感知的缺陷)
----------------------------------------
1. 同排等高 同一横向视觉行内的输入框/下拉/按钮高度必须一致(容差 1.5px)
2. 高度归一 单页控件高度取值 ≤ 3 种(输入 / 标准按钮 / 小按钮),
杜绝「每个组件各写一套 padding」导致的 33/34/35px 参差
3. 点击目标 ≥ 28px 高;图标按钮 ≥ 24×24
4. 标签可见 每个 input/select/textarea 都有可见标签或 aria-label
(placeholder 不能当唯一标签)
5. 图标按钮命名 仅图标的按钮必须有 aria-label / title(否则读屏只会念「按钮」)
6. 字号 / 圆角一致 控件字号取值 ≤ 3 种、圆角取值 ≤ 2 种
7. 尺寸匹配内容 number/date 输入不得占满整行、文本输入不得窄于 120px
8. 无横向溢出 1500px 与 375px 两种视口下页面都不得出现横向滚动
9. 提示不裁切 字段提示/错误文本不得溢出或裁切
用法
----
python3 scripts/verify_ui_alignment.py # 检查默认页面
python3 scripts/verify_ui_alignment.py --dump # 额外打印几何明细
python3 scripts/verify_ui_alignment.py --pages /experiments
python3 scripts/verify_ui_alignment.py --width 375 # 小屏复核
"""
from __future__ import annotations
import argparse
import base64
import contextlib
import glob
import json
import os
import shutil
import socket
import struct
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
WEB = os.environ.get("QLIB_WEB", "http://127.0.0.1:3000")
DEFAULT_PAGES = ["/", "/backtest", "/strategies", "/selection", "/fields", "/experiments", "/stocks", "/factors"]
CHROME_CANDIDATES = [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
"/usr/bin/google-chrome",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
]
CHROME_GLOBS = [
os.path.expanduser(
"~/Library/Caches/ms-playwright/chromium-*/chrome-mac*/"
"Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"
),
os.path.expanduser("~/.cache/ms-playwright/chromium-*/chrome-linux/chrome"),
]
# ---------------------------------------------------------------- CDP(标准库)
class _WS:
"""最小 WebSocket 客户端(RFC6455 文本帧、客户端掩码、ping 自动回 pong)。"""
def __init__(self, url: str, timeout: float = 60.0) -> None:
u = urllib.parse.urlparse(url)
self.sock = socket.create_connection((u.hostname, u.port), timeout=timeout)
self.sock.settimeout(timeout)
key = base64.b64encode(os.urandom(16)).decode()
path = u.path + (f"?{u.query}" if u.query else "")
req = (
f"GET {path} HTTP/1.1\r\nHost: {u.hostname}:{u.port}\r\n"
"Upgrade: websocket\r\nConnection: Upgrade\r\n"
f"Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n"
)
self.sock.sendall(req.encode())
self._buf = b""
while b"\r\n\r\n" not in self._buf:
chunk = self.sock.recv(65536)
if not chunk:
raise RuntimeError("CDP 握手失败:连接被关闭")
self._buf += chunk
head, self._buf = self._buf.split(b"\r\n\r\n", 1)
if b" 101 " not in head.split(b"\r\n")[0]:
raise RuntimeError(f"CDP 握手失败:{head.split(b'\r\n')[0]!r}")
def _read(self, n: int) -> bytes:
while len(self._buf) < n:
chunk = self.sock.recv(65536)
if not chunk:
raise RuntimeError("CDP 连接已关闭")
self._buf += chunk
out, self._buf = self._buf[:n], self._buf[n:]
return out
def _frame(self, opcode: int, payload: bytes) -> bytes:
head = bytearray([0x80 | opcode])
n = len(payload)
if n < 126:
head.append(0x80 | n)
elif n < 65536:
head.append(0x80 | 126)
head += struct.pack(">H", n)
else:
head.append(0x80 | 127)
head += struct.pack(">Q", n)
mask = os.urandom(4)
head += mask
return bytes(head) + bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
def send_text(self, text: str) -> None:
self.sock.sendall(self._frame(0x1, text.encode()))
def recv_text(self) -> str:
while True:
b1, b2 = self._read(2)
opcode, n, masked = b1 & 0x0F, b2 & 0x7F, bool(b2 & 0x80)
if n == 126:
n = struct.unpack(">H", self._read(2))[0]
elif n == 127:
n = struct.unpack(">Q", self._read(8))[0]
mask = self._read(4) if masked else None
data = self._read(n)
if mask:
data = bytes(b ^ mask[i % 4] for i, b in enumerate(data))
if opcode == 0x9: # ping
self.sock.sendall(self._frame(0xA, data))
continue
if opcode == 0x8:
raise RuntimeError("CDP 连接被对端关闭")
if opcode in (0x1, 0x2):
return data.decode("utf-8", "ignore")
def close(self) -> None:
with contextlib.suppress(OSError):
self.sock.close()
class CDP:
def __init__(self, ws_url: str) -> None:
self.ws = _WS(ws_url)
self._id = 0
def call(self, method: str, params: dict | None = None, timeout: float = 90.0):
self._id += 1
mid = self._id
self.ws.send_text(json.dumps({"id": mid, "method": method, "params": params or {}}))
deadline = time.time() + timeout
while time.time() < deadline:
msg = json.loads(self.ws.recv_text())
if msg.get("id") == mid:
if "error" in msg:
raise RuntimeError(f"{method} 失败:{msg['error']}")
return msg.get("result", {})
raise TimeoutError(f"{method} 超时")
def find_chrome() -> str | None:
for p in CHROME_CANDIDATES:
if os.path.exists(p):
return p
for pat in CHROME_GLOBS:
hit = sorted(glob.glob(pat))
if hit:
return hit[-1]
return shutil.which("google-chrome") or shutil.which("chromium")
def free_port() -> int:
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return int(s.getsockname()[1])
# ---------------------------------------------------------------- 页面几何探针
PROBE = r"""
(() => {
const rect = (el) => { const b = el.getBoundingClientRect();
return { x: +b.x.toFixed(1), y: +b.y.toFixed(1), w: +b.width.toFixed(1), h: +b.height.toFixed(1) }; };
const txt = (s) => (s || "").replace(/\s+/g, " ").trim();
const css = (el) => getComputedStyle(el);
const nameOf = (el) => txt(el.getAttribute("aria-label") || el.getAttribute("title") ||
el.innerText || el.value || el.placeholder || el.getAttribute("name") || el.tagName);
const ctrlSel = "input:not([type=hidden]), select, textarea, button, a.btn, .btn, .icon-btn";
const controls = [...document.querySelectorAll(ctrlSel)].filter((e) => {
const r = e.getBoundingClientRect();
const s = css(e);
return r.width > 0 && r.height > 0 && s.visibility !== "hidden" && s.display !== "none";
}).map((e) => {
const r = rect(e), s = css(e);
// 搜索框/字段内嵌的图标按钮:天然比输入框小且垂直居中,不参与「同排等高」
const nested = !!e.closest(".searchbox, .field, .input-group");
return { tag: e.tagName.toLowerCase(), type: e.getAttribute("type") || "",
cls: txt(e.className).slice(0, 40), label: nameOf(e).slice(0, 30),
aria: !!(e.getAttribute("aria-label") || e.getAttribute("title")),
iconOnlyBtn: e.tagName === "BUTTON" && !txt(e.innerText) && !e.getAttribute("data-label"),
nestedIcon: nested && e.classList.contains("icon-btn"),
// 多行文本域:高度随行数变化,是「内容决定高度」的控件,
// 不参与「同排等高」(它本来就该比单行输入高)与「高度取值归一」比较
isTextarea: e.tagName === "TEXTAREA",
// 卡片式多选项(整张卡可点):高度由文案决定,不计入「控件高度归一」
cardPick: !!(e.closest && e.closest(".strategy-pick__item")),
x: r.x, y: r.y, w: r.w, h: r.h, fs: parseFloat(s.fontSize),
radius: s.borderRadius, padL: parseFloat(s.paddingLeft), padR: parseFloat(s.paddingRight),
placeholder: txt(e.getAttribute("placeholder")).slice(0, 30),
hasVisibleLabel: (() => {
const id = e.getAttribute("id");
if (id && document.querySelector(`label[for="${id}"]`)) return true;
if (e.closest("label")) return true;
const f = e.closest(".field, .form-field, .input-group");
if (f && f.querySelector(".field__label, label")) return true;
return false;
})(),
required: e.hasAttribute("required"), disabled: e.hasAttribute("disabled") || e.disabled === true,
// 勾选/单选控件的真实点击目标是外层 label(.check / .radio-row),
// 16px 的方块永远达不到 28px;量 label 才是量「用户能不能点到」。
// 但「卡片式」多选 label(.strategy-pick__item:内含名称+说明+因子,
// 高度由文案决定,可达上百 px)不是「控件高度」概念,排除出归一比较。
hitH: (() => {
if (e.type !== "checkbox" && e.type !== "radio") return r.h;
const lab = e.closest("label");
if (lab && lab.classList.contains("strategy-pick__item")) {
// 整张策略卡都是热区:用卡片高度(远大于 28px),而非 16px 方块
return +lab.getBoundingClientRect().height.toFixed(1);
}
return lab ? +lab.getBoundingClientRect().height.toFixed(1) : r.h;
})(),
// pill / chip 是刻意的胶囊形控件,不参与圆角统一
intentionalPill: !!e.closest(".pill, .chip, .tabs, .seg"),
// 多行选项行(.radio-row:标题 + 说明会换行):高度由文案决定,
// 属于「选项热区」而不是「尺寸令牌控件」,不参与高度归一比较
optionRow: !!e.closest(".radio-row") };
});
// 横向溢出(含被压出的子元素)
const overflows = [];
for (const el of document.querySelectorAll("body *")) {
const s = css(el);
if (s.display === "none" || s.visibility === "hidden" || s.position === "fixed") continue;
if (el.scrollWidth > el.clientWidth + 2 && el.clientWidth > 0 && s.overflowX !== "auto" && s.overflowX !== "scroll") {
const tag = el.tagName.toLowerCase() + (el.className && typeof el.className === "string" ? "." + txt(el.className).split(" ")[0] : "");
overflows.push({ el: tag, scrollW: el.scrollWidth, clientW: el.clientWidth, text: txt(el.innerText).slice(0, 40) });
}
if (overflows.length > 25) break;
}
// 提示 / 错误文本是否被裁切
const clipped = [];
for (const el of document.querySelectorAll(".field__hint, .hint, .field__error, .err")) {
const r = el.getBoundingClientRect(), s = css(el);
if (r.width === 0) continue;
if (el.scrollHeight > el.clientHeight + 2 || (s.textOverflow === "ellipsis" && el.scrollWidth > el.clientWidth + 2)) {
clipped.push({ el: txt(el.className).split(" ")[0], text: txt(el.innerText).slice(0, 40),
scrollH: el.scrollHeight, clientH: el.clientHeight });
}
}
return {
path: location.pathname, viewport: { w: innerWidth, h: innerHeight },
docScrollW: document.documentElement.scrollWidth,
bodyScrollW: document.body.scrollWidth,
controls, overflows, clipped,
fontSizes: [...new Set(controls.map((c) => c.fs))].sort((a, b) => a - b),
radii: [...new Set(controls.map((c) => c.radius))].sort(),
};
})()
"""
def probe(cdp: CDP, url: str, width: int, height: int, settle: float) -> dict:
cdp.call("Emulation.setDeviceMetricsOverride", {
"width": width, "height": height, "deviceScaleFactor": 1, "mobile": width < 700,
})
cdp.call("Page.navigate", {"url": url})
deadline = time.time() + 45
while time.time() < deadline:
st = cdp.call("Runtime.evaluate", {
"expression": "document.readyState", "returnByValue": True}).get("result", {}).get("value")
if st == "complete":
break
time.sleep(0.3)
time.sleep(settle)
res = cdp.call("Runtime.evaluate", {"expression": PROBE, "returnByValue": True, "awaitPromise": True})
return res["result"]["value"]
# ---------------------------------------------------------------- 检查逻辑
def group_rows(controls: list[dict]) -> list[list[dict]]:
"""按垂直重叠把控件分成视觉行(重叠超过较矮者一半即视为同一行)。"""
rows: list[list[dict]] = []
for c in sorted(controls, key=lambda c: (c["y"], c["x"])):
placed = False
for row in rows:
ref = row[0]
top, bot = max(ref["y"], c["y"]), min(ref["y"] + ref["h"], c["y"] + c["h"])
if bot - top > 0.5 * min(ref["h"], c["h"]):
row.append(c)
placed = True
break
if not placed:
rows.append([c])
return rows
def check_geometry(g: dict) -> list[tuple[bool, str, str]]:
out: list[tuple[bool, str, str]] = []
ctrls = g["controls"]
rows = group_rows(ctrls)
# 1 同排等高
mixed = []
for row in rows:
heights = [c["hitH"] for c in row if not c["nestedIcon"] and not c["isTextarea"]]
if len(heights) < 2:
continue
if max(heights) - min(heights) > 1.5:
mixed.append((row, heights))
detail = "; ".join(
f"y={row[0]['y']:.0f} " + " / ".join(f"{c['tag']}{'[' + c['type'] + ']' if c['type'] else ''}「{c['label']}」{c['hitH']}px" for c in row)
for row, _ in mixed[:6]
)
out.append((not mixed, "同排控件等高", detail or f"{len(rows)} 行全部等高"))
# 2 高度取值归一
uniq = sorted({round(c["hitH"], 1) for c in ctrls
if not c["optionRow"] and not c["isTextarea"] and not c.get("cardPick")})
out.append((len(uniq) <= 3, "控件高度取值归一(≤3 种)", f"实际 {len(uniq)} 种:{uniq}"))
# 3 点击目标
small = [c for c in ctrls if c["hitH"] < 28 and not c["nestedIcon"]]
small_icon = [c for c in ctrls if c["nestedIcon"] and (c["h"] < 22 or c["w"] < 22)]
det = "; ".join(f"{c['tag']}「{c['label']}」{c['w']}×{c['hitH']}" for c in small[:6]) or "-"
if small_icon:
det += " | 图标按钮过小:" + "; ".join(f"「{c['label']}」{c['w']}×{c['h']}" for c in small_icon[:4])
out.append((not small and not small_icon, "点击目标 ≥28px(嵌内图标 ≥22px)", det))
# 4 标签可见
fields = [c for c in ctrls if c["tag"] in ("input", "select", "textarea")
and c["type"] not in ("checkbox", "radio", "submit", "button")]
nolabel = [c for c in fields if not c["hasVisibleLabel"] and not c["aria"]]
aria_only = [c for c in fields if not c["hasVisibleLabel"] and c["aria"]]
det = "; ".join(f"{c['tag']}[{c['type']}] placeholder=「{c['placeholder']}」" for c in nolabel[:8]) or "全部有可见标签"
if aria_only:
det += f"(另 {len(aria_only)} 个用 aria-label:{', '.join('「' + c['label'] + '」' for c in aria_only[:4])})"
out.append((not nolabel, "输入控件有可见标签或 aria-label", det))
# 5 图标按钮无障碍名
noname = [c for c in ctrls if c["tag"] == "button" and c["iconOnlyBtn"] and not c["aria"]]
det = "; ".join(f"「{c['label']}」" for c in noname[:8]) or "全部有无障碍名"
out.append((not noname, "图标按钮有无障碍名", det))
# 6 字号 / 圆角一致
fs = g["fontSizes"]
# 原生 checkbox/radio 的 0px 圆角由浏览器绘制,不是本项目的样式选择
radii = sorted({c["radius"] for c in ctrls
if not c["intentionalPill"] and c["type"] not in ("checkbox", "radio")})
out.append((len(fs) <= 3, "控件字号 ≤3 种", f"{fs}"))
out.append((len(radii) <= 2, "控件圆角 ≤2 种", f"{radii}"))
# 7 尺寸匹配内容
# 阈值 240:参数表单约束到 ~1100px 后每列约 210px,数字输入填满单元格属正常;
# 真正失调的是早先 flex:1 拉到 359px 那种(截图实测)。
wide_num = [c for c in ctrls if c["tag"] == "input" and c["type"] in ("number", "date") and c["w"] > 240]
narrow = [c for c in ctrls if c["tag"] == "input" and c["type"] in ("text", "search", "")
and c["w"] < 120 and not c["nestedIcon"]]
det = []
if wide_num:
det.append("数字/日期输入过宽:" + "; ".join(f"「{c['label']}」{c['w']}" for c in wide_num[:4]))
if narrow:
det.append("文本输入过窄:" + "; ".join(f"「{c['label']}」{c['w']}" for c in narrow[:4]))
out.append((not det, "控件尺寸匹配内容", " | ".join(det) or "无异常"))
# 8 无横向溢出
over = g["bodyScrollW"] > g["viewport"]["w"] + 1
det = f"body scrollWidth={g['bodyScrollW']} vs viewport {g['viewport']['w']}"
if over and g["overflows"]:
det += " | 溢出元素:" + "; ".join(f"{o['el']}({o['scrollW']}>{o['clientW']})" for o in g["overflows"][:4])
out.append((not over, "无横向滚动", det))
# 9 提示不裁切
clipped = g["clipped"]
det = "; ".join(f"{c['el']}「{c['text']}」" for c in clipped[:5]) or "无裁切"
out.append((not clipped, "提示/错误文本不被裁切", det))
return out
def main() -> int:
ap = argparse.ArgumentParser(description="UI 对齐与控件一致性自检")
ap.add_argument("--pages", nargs="*", default=DEFAULT_PAGES)
ap.add_argument("--base", default=WEB, help="前端地址(默认 %(default)s)")
ap.add_argument("--width", type=int, default=1500)
ap.add_argument("--height", type=int, default=1200)
ap.add_argument("--settle", type=float, default=3.0, help="导航后再等多久(懒加载/接口回填)")
ap.add_argument("--dump", action="store_true", help="打印几何明细")
ap.add_argument("--json", dest="json_out", help="把原始几何写入该文件")
args = ap.parse_args()
chrome = find_chrome()
if not chrome:
print("未找到 Chrome / Chromium,可用 QLIB_CHROME 指定可执行文件路径", file=sys.stderr)
return 2
chrome = os.environ.get("QLIB_CHROME", chrome)
port = free_port()
profile = tempfile.mkdtemp(prefix="qlib-ui-check-")
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}",
f"--window-size={args.width},{args.height}", "about:blank"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
ok = bad = 0
raw: dict[str, dict] = {}
try:
ws_url = None
for _ in range(80):
try:
with urllib.request.urlopen(f"http://127.0.0.1:{port}/json/list", timeout=2) as r:
for t in json.loads(r.read()):
if t.get("type") == "page":
ws_url = t["webSocketDebuggerUrl"]
break
if ws_url:
break
except (urllib.error.URLError, TimeoutError, OSError):
pass
time.sleep(0.3)
if not ws_url:
print("无法连接 Chrome 调试端口", file=sys.stderr)
return 2
cdp = CDP(ws_url)
cdp.call("Page.enable")
cdp.call("Runtime.enable")
widths = [args.width]
if args.width != 375:
widths.append(375) # 小屏复核(UX 规范要求 375px 验证)
for path in args.pages:
for w in widths:
url = args.base.rstrip("/") + path
label = f"{path} @{w}px"
print(f"\n[{label}]", flush=True)
try:
g = probe(cdp, url, w, args.height, args.settle)
except Exception as e: # noqa: BLE001 - 自检脚本要把失败讲清楚而不是崩掉
print(f" ❌ 探针失败:{e}", flush=True)
bad += 1
continue
raw[f"{path}@{w}"] = g
if args.dump:
for row in group_rows(g["controls"]):
box = " | ".join(
f"{c['tag']}{'[' + c['type'] + ']' if c['type'] else ''}「{c['label']}」"
f"{c['w']}×{c['h']}@y{c['y']:.0f} fs{c['fs']}" for c in row)
print(f" {box}", flush=True)
for good, name, detail in check_geometry(g):
mark = "✅" if good else "❌"
if good:
ok += 1
else:
bad += 1
print(f" {mark} {name} — {detail}", flush=True)
if args.json_out:
with open(args.json_out, "w", encoding="utf-8") as f:
json.dump(raw, f, ensure_ascii=False, indent=1)
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
shutil.rmtree(profile, ignore_errors=True)
print(f"\n结果:{ok} 项通过 / {bad} 项失败", flush=True)
return 1 if bad else 0
if __name__ == "__main__":
raise SystemExit(main())