③段第八轮收口:功能入口卡按参照物对齐 + 导航按 tiaoyue 源站重建 + 密度 A 方案
原型(执行会话/目标-…-d85687/ui/mcn-workbench.html) - 功能入口卡改三段纵向(图标/标题/描述各一行),68 → 111.19px,对齐 mcn-work-shop 参考卡(同视口 1249×1277 实测 133px),补 --sh-L1 投影;主因是排布不是字号 - 左侧导航重建:由「纯图标 + 悬停提示」改为 64px 药丸浮栏,图标 24 在上、名字常驻在下 (名字一律 2 字,说明走 data-tip);真值取自 tiaoyue 源站实测 (栏 64/内衬 3/圆角 100/项 56×78/字 12px-500-行高 16) - 密度 A 方案:≤1000 卡最小宽 200→160、≤820 统计条最小宽 132→96 (两处 auto-fit 只在真装不下时才缩)⇒ 768/700/560 三处转绿,其余 10 个视口未动 - 移动档隐藏导航条横向滚动轨(16px 轨道曾吃掉 52px 条高的三成,还白吃 16px 主区) 取证通道 - _tools/cap.py 改为「只连不起」:只连 9223 常驻实例,视口走 Emulation.setDeviceMetricsOverride, 不再自起 headless Chrome(顺带解除 500px 视口下限,390×844 可测) - _tools/check_js.py 新增第 5 道门禁(node --check + 模板内注释反引号扫描) 文档 - 复盘-为什么第一版做得很差.md(新建):13 轮时间轴/四类反作用/三重归因/已修与仍欠 - 3d-审查报告.md:更正 §九 那条假 ✓(把「§5.5 五条逐条过完」记成机核,实际零覆盖, 正是「五道闸门全绿却难看」的开关),补 §十二 功能入口卡高差、§十三 tiaoyue 导航逐条对照 - 3b-实测记录.md/DESIGN.md/3c-GPT 参考版与会诊/布局排版参考 ×2 机制与记忆 - .workbuddy/collab/collabctl.py:off --ws 不再全机扫描(原先一次杀掉他区+看板共 5 个进程) - .workbuddy/memory/:本轮纪律与实测真值 - .gitignore:补排运行时噪音(ui/_scratch/__pycache__/浏览器取证临时目录/*.bak-*/一次性脚本) 闸门:语法全过/骨架八页 39 板块/变异对照 15-15/逐视口 14 绿(500×844 唯一红,缺口待拍板)/命名 2060-0-27
This commit is contained in:
1 parent
df56c2c137
commit
eee042c855
48 files changed
+8222
-1752
No files matched your search
@@ -1,41 +1,74 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""无头 Chrome 取证小工具(③段 3b/3d 用)。
|
||||
"""取证小工具(③段 3b/3d 用)。
|
||||
|
||||
--mode dom : 取渲染后的 DOM(自检输出、数值取证都从这里读)
|
||||
--mode shot : 截图(视口帧或整页长图)
|
||||
--mode dom : 取渲染后的 DOM(自检输出、数值取证都从这里读)
|
||||
--mode shot : 截图(视口帧)
|
||||
|
||||
用法示例:
|
||||
python _tools/cap.py --mode dom --route selftest --out _scratch/selftest.html
|
||||
python _tools/cap.py --mode shot --route p/p1 --viewport 1440x900 --out _gate_shots/P1-1440.png
|
||||
python _tools/cap.py --mode shot --route p/p1 --viewport 1440x900 --out _gate_shots/P1-1440.png
|
||||
|
||||
⚠️ 本机实测(Chrome 154 · headless=new)——**两条通道对 --window-size 的解释不一样**,
|
||||
同一台机器、同一个窗口尺寸,拿到的是两个不同视口(探针页实测记录见 3b §五):
|
||||
|
||||
--window-size=656,995 ─┬─ --dump-dom ⇒ 视口 640 × 900 (mq<=640 true)
|
||||
└─ --screenshot ⇒ 视口 656 × 995 (mq<=640 false)
|
||||
|
||||
即:dump-dom 通道会从窗口里扣掉滚动条与标题栏(16 / 95),截图通道不扣。
|
||||
⇒ 想拿到「视口 W × H」,dom 模式要传 (W+16, H+95),shot 模式**原样传 (W, H)**。
|
||||
⚠️ 视口宽度下限 500(窗口最小 516):`--window-size=390,844` 拿到的仍是 500×844。
|
||||
要 390px 视口,本机 CLI 做不到(需要 CDP 的 setDeviceMetricsOverride),
|
||||
所以窄视口只测到 500×844 —— 报告里⛔ 不许写成 390。
|
||||
🔴 2026-10-08 改:**连已有浏览器实例,不再自己起 headless Chrome**。
|
||||
旧写法是 `chrome.exe --headless=new --dump-dom/--screenshot`,两个后果:
|
||||
① 违反本机明令(见 `skills/browser-harness/SKILL.md` 开头「先探测、能复用就复用」);
|
||||
② 只能靠 `--window-size` 猜视口 —— 而且**两条通道对同一个 window-size 的解释不同**
|
||||
(dom 通道会扣掉 16 / 95,截图通道不扣),还得在调用侧补偏移量;
|
||||
更糟的是它有**视口宽度下限 500**(Chrome 窗口最小 516),390px 根本量不了。
|
||||
现在改用 CDP 的 `Emulation.setDeviceMetricsOverride`:视口是**声明式的,要多少是多少**,
|
||||
上下限与偏移量的坑一并消失(390px 现在能量了)。
|
||||
实例地址默认 `http://127.0.0.1:9223`(可用环境变量 `BU_CDP_URL` 覆盖)。
|
||||
⚠️ 实例没起来时**直接报错退出**,⛔ 不自动拉起浏览器 —— 拉起谁、拉几个,那是人的决定。
|
||||
"""
|
||||
import argparse, os, subprocess, sys
|
||||
from urllib.parse import quote
|
||||
import argparse, os, socket, subprocess, sys, tempfile
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
CHROME = r"C:\Program Files\Google\Chrome\Application\chrome.exe"
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
PROTO = os.path.abspath(os.path.join(HERE, "..", "mcn-workbench.html"))
|
||||
FRAME_W, FRAME_H = 16, 95 # window-size 与视口的实测差值
|
||||
MIN_VP_W = 500 # 视口宽度下限(Chrome 窗口最小 516)
|
||||
HARNESS = os.path.expanduser("~/.local/bin/browser-harness.exe")
|
||||
BU_URL = os.environ.get("BU_CDP_URL", "http://127.0.0.1:9223")
|
||||
|
||||
SCRIPT = r"""
|
||||
import io, json
|
||||
BU = {bu!r}
|
||||
url = {url!r}
|
||||
out = {out!r}
|
||||
mode = {mode!r}
|
||||
W, H = {w}, {h}
|
||||
t = new_tab(url)
|
||||
cdp("Emulation.setDeviceMetricsOverride", width=W, height=H, deviceScaleFactor=1, mobile=False)
|
||||
wait_for_load()
|
||||
wait(0.8)
|
||||
if mode == 'dom':
|
||||
vw = js("innerWidth"); vh = js("innerHeight")
|
||||
assert (vw, vh) == (W, H), "视口不符:要 %dx%d,实到 %dx%d" % (W, H, vw, vh)
|
||||
html = js("document.documentElement.outerHTML")
|
||||
io.open(out, 'w', encoding='utf-8').write("<!DOCTYPE html>\n" + html)
|
||||
print("VIEWPORT %dx%d" % (vw, vh))
|
||||
else:
|
||||
vw = js("innerWidth"); vh = js("innerHeight")
|
||||
assert (vw, vh) == (W, H), "视口不符:要 %dx%d,实到 %dx%d" % (W, H, vw, vh)
|
||||
capture_screenshot(path=out, full=False)
|
||||
print("VIEWPORT %dx%d" % (vw, vh))
|
||||
cdp("Emulation.clearDeviceMetricsOverride")
|
||||
close_tab()
|
||||
"""
|
||||
|
||||
|
||||
def _probe(host="127.0.0.1", port=9223):
|
||||
s = socket.socket()
|
||||
s.settimeout(1.5)
|
||||
try:
|
||||
s.connect((host, port))
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def url(route, proto=None):
|
||||
# Windows 盘符路径 -> file:///E:/...
|
||||
p = (proto or PROTO).replace("\\", "/")
|
||||
# ⚠️ 路由不带前导 #/ :本机 bash 是 Git Bash(MSYS),会把 "#/xxx" 当成路径改写成
|
||||
# "#C:/.../PortableGit/xxx"(实测踩过)⇒ 这里由脚本自己拼 "#/"。
|
||||
h = route.strip().lstrip("#").lstrip("/")
|
||||
return "file:///" + quote(p, safe="/:") + ("#/" + h if h else "")
|
||||
|
||||
@@ -45,34 +78,44 @@ def main():
|
||||
ap.add_argument("--mode", choices=["dom", "shot"], required=True)
|
||||
ap.add_argument("--route", default="p/p1/ok", help="例:selftest / p/p4/empty(⛔ 不带 #/)")
|
||||
ap.add_argument("--hash", default=None, help="兼容旧参数(不带 #/)")
|
||||
ap.add_argument("--viewport", default="1440x900", help="目标 CSS 视口,例 1440x900 / 500x844")
|
||||
ap.add_argument("--viewport", default="1440x900", help="目标 CSS 视口,例 1440x900 / 500x844 / 390x844")
|
||||
ap.add_argument("--proto", default=None, help="要渲染的文件(默认本目录的 mcn-workbench.html;变异对照用)")
|
||||
ap.add_argument("--out", required=True)
|
||||
ap.add_argument("--budget", default="6000")
|
||||
a = ap.parse_args()
|
||||
route = a.hash or a.route
|
||||
|
||||
vw, vh = [int(x) for x in a.viewport.lower().replace(",", "x").split("x")]
|
||||
vw = max(vw, MIN_VP_W)
|
||||
# 🔴 dom 与 shot 两条通道对 --window-size 的解释不同(见文件头实测):截图通道原样等于视口,
|
||||
# dump-dom 通道要补回被扣掉的 16 / 95。这里分开算,⛔ 别再用同一个偏移量。
|
||||
win = ("%d,%d" % (vw + FRAME_W, vh + FRAME_H)) if a.mode == "dom" else ("%d,%d" % (vw, vh))
|
||||
|
||||
out = os.path.abspath(a.out)
|
||||
os.makedirs(os.path.dirname(out), exist_ok=True)
|
||||
cmd = [CHROME, "--headless=new", "--no-sandbox", "--disable-gpu", "--hide-scrollbars",
|
||||
"--window-size=" + win, "--virtual-time-budget=" + a.budget,
|
||||
"--run-all-compositor-stages-before-draw"]
|
||||
if a.mode == "shot":
|
||||
cmd += ["--screenshot=" + out]
|
||||
else:
|
||||
cmd += ["--dump-dom"]
|
||||
cmd += [url(route, a.proto)]
|
||||
r = subprocess.run(cmd, capture_output=True)
|
||||
|
||||
host = urlparse(BU_URL).hostname or "127.0.0.1"
|
||||
port = urlparse(BU_URL).port or 9223
|
||||
if not _probe(host, port):
|
||||
sys.stderr.write("⛔ %s 上没有浏览器实例在跑。\n"
|
||||
" 本工具**只连不起**(本机明令)。先把常驻实例起来,或者\n"
|
||||
" 用 browser-harness 自带的 daemon 流程,再重跑本命令。\n" % BU_URL)
|
||||
return 2
|
||||
|
||||
script = SCRIPT.format(bu=BU_URL, url=url(route, a.proto), out=out, mode=a.mode, w=vw, h=vh)
|
||||
tf = tempfile.NamedTemporaryFile("w", suffix=".py", delete=False, encoding="utf-8")
|
||||
tf.write(script)
|
||||
tf.close()
|
||||
|
||||
env = dict(os.environ)
|
||||
for k in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"):
|
||||
env.pop(k, None)
|
||||
env["NO_PROXY"] = "127.0.0.1,localhost"
|
||||
env["BU_CDP_URL"] = BU_URL
|
||||
|
||||
r = subprocess.run([HARNESS], stdin=None, capture_output=True, env=env,
|
||||
input=open(tf.name, "rb").read())
|
||||
os.unlink(tf.name)
|
||||
err = r.stderr.decode("utf-8", "replace")
|
||||
ok = r.stdout.decode("utf-8", "replace")
|
||||
|
||||
if a.mode == "dom":
|
||||
with open(out, "wb") as f:
|
||||
f.write(r.stdout)
|
||||
sys.stderr.write("viewport %dx%d → window %s | dom bytes=%d\n" % (vw, vh, win, len(r.stdout)))
|
||||
sys.stderr.write("dom → %s | %s | bytes=%d\n"
|
||||
% (out, ok.strip() or "(没说视口)", os.path.getsize(out) if os.path.exists(out) else -1))
|
||||
else:
|
||||
# 🔴 截图出来必须**当面对尺寸**:这条通道曾经因为偏移量算错,把 1456×995 当成
|
||||
# 「1440×900」交付出去(同一份报告里 dump-dom 又按 640 说话 ⇒ 两份证据互相打架)。
|
||||
@@ -89,10 +132,11 @@ def main():
|
||||
else:
|
||||
sys.stderr.write("shot ⛔ 尺寸不符:目标视口 %dx%d,PNG 实际 %dx%d ⇒ 别拿它当 %dx%d 的证据\n"
|
||||
% (vw, vh, pw, ph, vw, vh))
|
||||
err = r.stderr.decode("utf-8", "replace")
|
||||
if "ERROR" in err.upper():
|
||||
if "Traceback (most recent call last)" in err:
|
||||
sys.stderr.write(err[-1500:] + "\n")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""原型 JS 与模板字面量的**语法门禁**(③段 3b/3d 用,只读原型,⛔ 不写盘)。
|
||||
|
||||
为什么需要它(不是"为了严谨",是同一个坑踩了 **4 次**的真实事故):
|
||||
原型是单文件自包含,页面结构写在一堆 JS **模板字面量**里(反引号包起来的字符串)。
|
||||
只要在模板里的 **HTML 注释**(`<!-- ... -->`)中写出一个反引号,模板字面量就会被提前闭合,
|
||||
而后面那段被当成 JS 代码解析 —— 症状是**页面静默坏掉**(白屏 / `xxx is not defined` /
|
||||
`Unexpected identifier`),而 `node --check 整份 html` 是**报不出来**的(html 不是 js),
|
||||
于是它一路混到浏览器里才炸。
|
||||
|
||||
已经踩到的 4 次(每次都是「HTML 注释里带反引号」):
|
||||
1. P1 的 `.grid.cols2` 注释 → 页面白屏
|
||||
2. P2 的 `.home-stats` / `.stat-item` 注释 → `stats is not defined`,自检没跑完
|
||||
3. P2 的 `.grid-cards` / `font-size` 注释 → `Unexpected identifier 'style'`
|
||||
4. P1 的 `.qa-block` / `.func-card .qa{...}` 注释 → `Unexpected token '{'`
|
||||
|
||||
本脚本查两件事:
|
||||
① **语法**:把 `<script>` 段抠出来交给 `node --check`(⛔ 不是查整份 html)。
|
||||
② **模板里的反引号**:多行扫描 script 区内的每个 `<!-- ... -->` 块,块内出现反引号即报红。
|
||||
⚠️ 必须**多行**扫:反引号常在注释的**续行**上(单行 grep 抓不到)。
|
||||
|
||||
用法:
|
||||
python _tools/check_js.py # 查同目录的 mcn-workbench.html
|
||||
python _tools/check_js.py --proto X.html
|
||||
退出码:0 = 全过;1 = 有 FAIL。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
DEFAULT_PROTO = os.path.abspath(os.path.join(HERE, '..', 'mcn-workbench.html'))
|
||||
NODE = r"E:\ProgramData\.workbuddy\binaries\node\versions\22.22.2-6\node.exe"
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('--proto', default=DEFAULT_PROTO)
|
||||
a = ap.parse_args()
|
||||
|
||||
src = io.open(a.proto, encoding='utf-8').read()
|
||||
fails = []
|
||||
|
||||
i = src.index('<script>') + len('<script>')
|
||||
j = src.rindex('</script>')
|
||||
js = src[i:j]
|
||||
|
||||
# ① 语法:把 script 抠出来给 node --check
|
||||
node = NODE if os.path.exists(NODE) else 'node'
|
||||
with tempfile.NamedTemporaryFile('w', suffix='.js', delete=False, encoding='utf-8', newline='') as f:
|
||||
f.write(js)
|
||||
tmp = f.name
|
||||
try:
|
||||
r = subprocess.run([node, '--check', tmp], capture_output=True)
|
||||
if r.returncode == 0:
|
||||
print('PASS 语法:<script> 段 ' + str(js.count(chr(10))) + ' 行,node --check 通过')
|
||||
else:
|
||||
err = r.stderr.decode('utf-8', 'replace').strip().split('\n')
|
||||
# 把临时文件里的行号换算回原文件行号
|
||||
base = src[:i].count('\n') + 1
|
||||
head = [l for l in err if l.strip()][:4]
|
||||
fails.append('语法不过:' + ' / '.join(head))
|
||||
print('FAIL 语法:node --check 报错(下面的行号是 script 段内的,原文件约 +%d 行)' % base)
|
||||
for l in head:
|
||||
print(' ' + l)
|
||||
finally:
|
||||
os.unlink(tmp)
|
||||
|
||||
# ② 模板里的反引号(多行扫描每个 HTML 注释块)
|
||||
bad = []
|
||||
for m in re.finditer(r'<!--[\s\S]*?-->', js):
|
||||
blk = m.group(0)
|
||||
if '`' in blk:
|
||||
line = js[:m.start()].count('\n') + src[:i].count('\n') + 2
|
||||
first = blk.split('\n')[0][:70]
|
||||
n_tick = blk.count('`')
|
||||
bad.append('原文件第 %d 行附近:模板里的 HTML 注释含 %d 个反引号 —— 「%s」' % (line, n_tick, first))
|
||||
if bad:
|
||||
fails.extend(bad)
|
||||
print('FAIL 模板里的反引号:')
|
||||
for b in bad:
|
||||
print(' ' + b)
|
||||
else:
|
||||
print('PASS 模板里的反引号:script 区内所有 <!-- --> 块(多行扫描)都不含反引号')
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print('结论:FAIL %d 项 —— 上面每一条都会让**页面在浏览器里才炸**(node --check 整份 html 看不到)。' % len(fails))
|
||||
print('修法:注释里的反引号一律去掉(用 .cls 或 「cls」代替 `.cls`)。')
|
||||
return 1
|
||||
print('结论:全过。')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -1,174 +1,201 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""变异对照:证明自检的判据不是「恒绿」。
|
||||
|
||||
做法:把原型复制一份,往里注入一处**已知的假病**,再跑一次自检,
|
||||
要求它必须报红,并且红在**预期的那一条**上。测完即删临时件。
|
||||
|
||||
为什么要这个:一个永远 PASS 的自检等于没有自检。只有「注病 → 报红 → 复原 → 回绿」
|
||||
这条链走通了,前面那串 PASS 才算证据。
|
||||
|
||||
用法:python _tools/mutate_test.py
|
||||
"""
|
||||
import os, re, subprocess, sys, tempfile, html
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
UI = os.path.abspath(os.path.join(HERE, ".."))
|
||||
PROTO = os.path.join(UI, "mcn-workbench.html")
|
||||
PY = sys.executable
|
||||
|
||||
# (名称, 说明, 查找串, 替换串, 期望出现的报红片段, 视口[可选,默认 1440x900])
|
||||
MUTANTS = [
|
||||
("M1 死控件",
|
||||
"把 P2-01「低粉爆款」的实现掏空 —— 点它不再有任何反馈",
|
||||
"'P2-01':()=>{ S.benchFilter='低粉爆款'; paint(); },",
|
||||
"'P2-01':()=>{},",
|
||||
"P2-01"),
|
||||
|
||||
("M2 未登记的可点元素",
|
||||
"在页面上塞一个没写进交互清单的 data-act",
|
||||
"<span class=\"action-cell\"><button class=\"btn btn-sm\" data-act=\"P2-03\"",
|
||||
"<span class=\"action-cell\"><button class=\"btn btn-sm\" data-act=\"ZZ-99\">野按钮</button><button class=\"btn btn-sm\" data-act=\"P2-03\"",
|
||||
"未登记的 data-act"),
|
||||
|
||||
("M3 悬空声明",
|
||||
"清单里声明了一个页面上根本不存在的 ID",
|
||||
"'CHECKOUT-OK':()=>{",
|
||||
"'ZZ-98':()=>{},\n 'CHECKOUT-OK':()=>{",
|
||||
"声明了但页面里没有"),
|
||||
|
||||
("M4 骨架漂移",
|
||||
"给 P3 多塞一个板块 —— ②段那份骨架是冻结的,这属于越界",
|
||||
"${head('分区切换',",
|
||||
"${head('多出来的板块', '')}<div class=\"panel\"><p class=\"meta\">越界加的块</p></div>${head('分区切换',",
|
||||
"骨架与②段不一致"),
|
||||
|
||||
("M5 对比度越界",
|
||||
"把 --ink-faint 换成偏白的灰,压到契约线以下",
|
||||
"--t-ink-faint:#8a887f;",
|
||||
"--t-ink-faint:#c9c7c0;",
|
||||
"对比度 --ink-faint"),
|
||||
|
||||
("M6 点击无反馈(跳页不同步)",
|
||||
"把 go() 退回「只改 hash」的写法 —— 点完状态不动,自检应判它没反应",
|
||||
" S.route=page; S.pageState=st||'ok'; S.layer=null;\n const want='#/p/'+page+'/'+(st||'ok');\n if(location.hash!==want){ try{ location.hash=want; }catch(e){} }\n paint();",
|
||||
" const want='#/p/'+page+'/'+(st||'ok');\n if(location.hash!==want){ try{ location.hash=want; }catch(e){} }",
|
||||
"驱动了没反应"),
|
||||
|
||||
("M7 行高不齐(放开换行)",
|
||||
"删掉「行内每段不许换行」这条规则 —— 窄视口里文字一折行,行高就随文字长短乱跳",
|
||||
".list-row:not(.is-dense) .primary-cell > *,\n"
|
||||
".list-row:not(.is-dense) .secondary-cell > *{\n"
|
||||
" min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;\n}",
|
||||
"/* 变异:去掉不许换行 */",
|
||||
"行高不齐", "768x900"),
|
||||
|
||||
("M8 密度退化(内衬变肥)",
|
||||
"把列表行内衬从 12px 加到 36px —— 行变肥,一屏就装不下 6 行了",
|
||||
" padding:var(--mg-12) var(--mg-8); border:0; border-top:1px solid var(--line-soft);",
|
||||
" padding:36px var(--mg-8); border:0; border-top:1px solid var(--line-soft);",
|
||||
"低于契约 6 行"),
|
||||
|
||||
("M9 量尺寸时页面不在零点",
|
||||
"量 P1 之前先把页面滚下去 —— rect 取到的是相对视口的坐标,滚了之后 top 全变负,"
|
||||
"「一屏装得下几行」的减法就会凭空变大(这条守卫盯的就是它)",
|
||||
" guardScroll('P1');",
|
||||
" window.scrollTo(0,300);\n guardScroll('P1');",
|
||||
"被滚到", "768x900"),
|
||||
|
||||
("M10 行分隔线被关掉",
|
||||
"把选择器退回成 `.list-row:first-child` —— 每行都包在 <li> 里,于是每行都是自己 <li> 的 "
|
||||
"first-child,整张表的分隔线一起消失(原型里修掉过的真缺陷,原样退回)",
|
||||
"li:first-child > .list-row{ border-top:0; }",
|
||||
".list-row:first-child{ border-top:0; }",
|
||||
"行分隔线根本没画出来"),
|
||||
|
||||
("M11 浮层没有遮罩",
|
||||
"去掉遮罩层 —— 「点层外空白关掉浮层」这条通道就没了",
|
||||
"const lh=layerHTML();\n $('#layer-root').innerHTML = lh ? `<div class=\"layer-mask\" data-act=\"X-mask\" aria-hidden=\"true\"></div>`+lh : '';",
|
||||
"const lh=layerHTML();\n $('#layer-root').innerHTML = lh;",
|
||||
"没有遮罩"),
|
||||
|
||||
("M12 关掉浮层后焦点不还回去",
|
||||
"把 closeLayer 退回「只清状态」的写法 —— 焦点会掉回 body,键盘用户就断了",
|
||||
" const back=layerTrigger;\n S.layer=null; paint();\n if(back){ const el=document.querySelector('[data-act=\"'+back+'\"]'); if(el) el.focus(); }",
|
||||
" S.layer=null; paint();",
|
||||
"关掉后焦点没回到"),
|
||||
|
||||
("M13 浮层多出一个中性关闭出口",
|
||||
"给「随手记」再加一颗 ×(多一个 X-close)—— 一个层两个中性出口会让用户找不着北,"
|
||||
"这条判据原先只数 .layer-close 类名,多出来的是同类,应当报红",
|
||||
" const close=`<button class=\"layer-close\" data-act=\"X-close\" aria-label=\"关闭\">×</button>`;",
|
||||
" const close=`<button class=\"layer-close\" data-act=\"X-close\" aria-label=\"关闭\">×</button><button class=\"layer-close\" data-act=\"X-close\" aria-label=\"再关一次\">×</button>`;",
|
||||
"中性关闭出口 = 2"),
|
||||
]
|
||||
|
||||
|
||||
def read_out(dom_path):
|
||||
s = open(dom_path, encoding="utf-8", errors="replace").read()
|
||||
m = re.search(r'<div class="st-out" id="selftest-out">(.*?)</div>', s, re.S)
|
||||
if not m:
|
||||
return None
|
||||
return html.unescape(re.sub(r"<[^>]+>", "", m.group(1)))
|
||||
|
||||
|
||||
def run(proto, vp="1440x900"):
|
||||
out = os.path.join(tempfile.gettempdir(), "mut-selftest.html")
|
||||
r = subprocess.run([PY, os.path.join(HERE, "cap.py"), "--mode", "dom", "--route", "selftest",
|
||||
"--viewport", vp, "--proto", proto, "--out", out],
|
||||
capture_output=True, text=True)
|
||||
if not os.path.exists(out):
|
||||
return None
|
||||
return read_out(out)
|
||||
|
||||
|
||||
def headline(text):
|
||||
m = re.search(r"自检结果:PASS (\d+) / FAIL (\d+)", text or "")
|
||||
return (int(m.group(1)), int(m.group(2))) if m else None
|
||||
|
||||
|
||||
def main():
|
||||
src = open(PROTO, encoding="utf-8").read()
|
||||
|
||||
base = run(PROTO)
|
||||
hb = headline(base)
|
||||
print("基线(未注入):PASS %s / FAIL %s" % hb if hb else "基线读不到自检输出")
|
||||
if not hb or hb[1] != 0:
|
||||
print("⛔ 基线就不是全绿 ⇒ 先修原型,别急着做变异对照。")
|
||||
return 2
|
||||
|
||||
bad = 0
|
||||
for row in MUTANTS:
|
||||
name, why, find, repl, expect = row[:5]
|
||||
vp = row[5] if len(row) > 5 else "1440x900"
|
||||
if find not in src:
|
||||
print("⚠️ %s:找不到注入点,跳过(注入点失效本身也要修)" % name)
|
||||
bad += 1
|
||||
continue
|
||||
tmp = os.path.join(tempfile.gettempdir(), "mut-proto.html")
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
f.write(src.replace(find, repl, 1))
|
||||
text = run(tmp, vp)
|
||||
h = headline(text)
|
||||
os.remove(tmp)
|
||||
if not h:
|
||||
print("❌ %s:注入后自检跑不出来(连输出都没有)" % name)
|
||||
bad += 1
|
||||
continue
|
||||
caught = h[1] > 0 and expect in (text or "")
|
||||
print("%s %s:注入(%s) ⇒ PASS %d / FAIL %d | %s" %
|
||||
("✅" if caught else "❌", name, why, h[0], h[1],
|
||||
"报红且红在预期处(%s,视口 %s)" % (expect, vp) if caught
|
||||
else "**没在预期处报红(期望含「%s」,视口 %s)**" % (expect, vp)))
|
||||
if not caught:
|
||||
bad += 1
|
||||
|
||||
print()
|
||||
print("变异对照:%d / %d 通过" % (len(MUTANTS) - bad, len(MUTANTS)))
|
||||
if bad == 0:
|
||||
print("⇒ %d 处假病全部被自检抓住,且基线回绿:这套判据不是恒绿的。" % len(MUTANTS))
|
||||
return 0 if bad == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""变异对照:证明自检的判据不是「恒绿」。
|
||||
|
||||
做法:把原型复制一份,往里注入一处**已知的假病**,再跑一次自检,
|
||||
要求它必须报红,并且红在**预期的那一条**上。测完即删临时件。
|
||||
|
||||
为什么要这个:一个永远 PASS 的自检等于没有自检。只有「注病 → 报红 → 复原 → 回绿」
|
||||
这条链走通了,前面那串 PASS 才算证据。
|
||||
|
||||
用法:python _tools/mutate_test.py
|
||||
"""
|
||||
import os, re, subprocess, sys, tempfile, html
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
UI = os.path.abspath(os.path.join(HERE, ".."))
|
||||
PROTO = os.path.join(UI, "mcn-workbench.html")
|
||||
PY = sys.executable
|
||||
|
||||
# (名称, 说明, 查找串, 替换串, 期望出现的报红片段, 视口[可选,默认 1440x900])
|
||||
MUTANTS = [
|
||||
("M1 死控件",
|
||||
"把 P2-01「低粉爆款」的实现掏空 —— 点它不再有任何反馈",
|
||||
"'P2-01':()=>{ S.benchFilter='低粉爆款'; paint(); },",
|
||||
"'P2-01':()=>{},",
|
||||
"P2-01"),
|
||||
|
||||
("M2 未登记的可点元素",
|
||||
"在页面上塞一个没写进交互清单的 data-act。"
|
||||
"⚠️ 2026-10-08 改:P2 的「对标内容列表」已由行式列表改成满宽表格,"
|
||||
"原来那个注入点(.action-cell 里的 P2-03)**在文件里已经不存在了** —— 脚本本来会直接"
|
||||
"判「找不到注入点、跳过」,那等于这条变异悄悄失效 ⇒ 注入点跟着形态换到表格那一行。",
|
||||
"<td class=\"cell-act\"><button class=\"btn-micro\" data-act=\"P2-03\"",
|
||||
"<td class=\"cell-act\"><button class=\"btn-micro\" data-act=\"ZZ-99\">野按钮</button><button class=\"btn-micro\" data-act=\"P2-03\"",
|
||||
"未登记的 data-act"),
|
||||
|
||||
("M3 悬空声明",
|
||||
"清单里声明了一个页面上根本不存在的 ID",
|
||||
"'CHECKOUT-OK':()=>{",
|
||||
"'ZZ-98':()=>{},\n 'CHECKOUT-OK':()=>{",
|
||||
"声明了但页面里没有"),
|
||||
|
||||
("M4 骨架漂移",
|
||||
"给 P3 多塞一个板块 —— ②段那份骨架是冻结的,这属于越界",
|
||||
"${head('分区切换',",
|
||||
"${head('多出来的板块', '')}<div class=\"panel\"><p class=\"meta\">越界加的块</p></div>${head('分区切换',",
|
||||
"骨架与②段不一致"),
|
||||
|
||||
("M5 对比度越界",
|
||||
"把 --ink-faint 换成偏白的灰,压到契约线以下",
|
||||
"--t-ink-faint:#8a887f;",
|
||||
"--t-ink-faint:#c9c7c0;",
|
||||
"对比度 --ink-faint"),
|
||||
|
||||
("M6 点击无反馈(跳页不同步)",
|
||||
"把 go() 退回「只改 hash」的写法 —— 点完状态不动,自检应判它没反应",
|
||||
" S.route=page; S.pageState=st||'ok'; S.layer=null;\n const want='#/p/'+page+'/'+(st||'ok');\n if(location.hash!==want){ try{ location.hash=want; }catch(e){} }\n paint();",
|
||||
" const want='#/p/'+page+'/'+(st||'ok');\n if(location.hash!==want){ try{ location.hash=want; }catch(e){} }",
|
||||
"驱动了没反应"),
|
||||
|
||||
("M7 行高不齐(把某一行撑高)",
|
||||
"只把表里**第一行**的单元格内衬撑大 —— 模拟「某一行被状态标或长文字撑高」这件事:"
|
||||
"一旦行高不齐,扫表时节奏就乱了。"
|
||||
"⚠️ 2026-10-08 改:P1 两块已由行式列表改成表格,原来那条「放开换行」的注入对表格无效"
|
||||
"(表格列宽自适应、根本不折行,是假对照)⇒ 换成直接撑高一行。",
|
||||
"table.ds-table.tbl-dense .cell-lead{ font-weight:600; }",
|
||||
"table.ds-table.tbl-dense tbody tr:first-child td{ padding:20px var(--mg-8); }\ntable.ds-table.tbl-dense .cell-lead{ font-weight:600; }",
|
||||
"行高不齐", "1440x900"),
|
||||
|
||||
("M8 密度退化(内衬变肥)",
|
||||
"把表格行的内衬从 4px 加到 36px —— 行变肥,一屏就装不下 6 行了。"
|
||||
"⚠️ 2026-10-08 改:密度判据量的已是 P1 那张满宽表格(.tbl-dense 的 td 内衬),"
|
||||
"原来注在 .list-row 内衬上、对表格无效(假对照)⇒ 注入点换到表格的 td 内衬。",
|
||||
"table.ds-table.tbl-dense td{ padding:var(--mg-4) var(--mg-8); vertical-align:middle; white-space:nowrap; }",
|
||||
"table.ds-table.tbl-dense td{ padding:36px var(--mg-8); vertical-align:middle; white-space:nowrap; }",
|
||||
"低于契约 6 行"),
|
||||
|
||||
("M9 量尺寸时页面不在零点",
|
||||
"量 P1 之前先把主区滚下去 —— rect 取到的是相对视口的坐标,滚了之后 top 全变负,"
|
||||
"「一屏装得下几行」的减法就会凭空变大(这条守卫盯的就是它)。"
|
||||
"⚠️ 2026-10-08 改:注入点从 window.scrollTo 换成 .view.scrollTop —— 外壳修过之后"
|
||||
"整页的滚动容器就是 .view,window 根本滚不动,拿 window 注入等于没注入(假对照)。",
|
||||
" guardScroll('P1');",
|
||||
" document.querySelector('.view').scrollTop=300;\n guardScroll('P1');",
|
||||
"没回零点", "768x900"),
|
||||
|
||||
("M10 行分隔线被关掉",
|
||||
"把表格行的分隔线(td 的 border-top)关掉 —— 一格一格的表读起来就没有行界了。"
|
||||
"⚠️ 2026-10-08 改:判据现在量的是 P1 那张表的 td 边框,注入点也跟着从 `.list-row` 换过来"
|
||||
"(原来那条注在 `.list-row:first-child` 上,对表格无效,是假对照)。",
|
||||
"table.ds-table td{ padding:var(--mg-8); border-top:1px solid var(--line-soft); vertical-align:top; }",
|
||||
"table.ds-table td{ padding:var(--mg-8); vertical-align:top; }",
|
||||
"行分隔线根本没画出来", "1440x900"),
|
||||
|
||||
("M11 浮层没有遮罩",
|
||||
"去掉遮罩层 —— 「点层外空白关掉浮层」这条通道就没了",
|
||||
"const lh=layerHTML();\n $('#layer-root').innerHTML = lh ? `<div class=\"layer-mask\" data-act=\"X-mask\" aria-hidden=\"true\"></div>`+lh : '';",
|
||||
"const lh=layerHTML();\n $('#layer-root').innerHTML = lh;",
|
||||
"没有遮罩"),
|
||||
|
||||
("M12 关掉浮层后焦点不还回去",
|
||||
"把 closeLayer 退回「只清状态」的写法 —— 焦点会掉回 body,键盘用户就断了",
|
||||
" const back=layerTrigger;\n S.layer=null; paint();\n if(back){ const el=document.querySelector('[data-act=\"'+back+'\"]'); if(el) el.focus(); }",
|
||||
" S.layer=null; paint();",
|
||||
"关掉后焦点没回到"),
|
||||
|
||||
("M13 浮层多出一个中性关闭出口",
|
||||
"给「随手记」再加一颗 ×(多一个 X-close)—— 一个层两个中性出口会让用户找不着北,"
|
||||
"这条判据原先只数 .layer-close 类名,多出来的是同类,应当报红",
|
||||
" const close=`<button class=\"layer-close\" data-act=\"X-close\" aria-label=\"关闭\">×</button>`;",
|
||||
" const close=`<button class=\"layer-close\" data-act=\"X-close\" aria-label=\"关闭\">×</button><button class=\"layer-close\" data-act=\"X-close\" aria-label=\"再关一次\">×</button>`;",
|
||||
"中性关闭出口 = 2"),
|
||||
|
||||
("M14 外壳高度链没约束住(整页滚不动、折叠线以下拿不到)",
|
||||
"把 .app 的 height:100vh 退回 min-height:100vh —— 就是 2026-10-08 修掉的那个真缺陷的原样:"
|
||||
"主区跟着内容长高、自己不滚,滚动落回 <body>,而 body 是 overflow:hidden,"
|
||||
"于是内容掉到折叠线以下就永远拿不到",
|
||||
"grid-template-rows:minmax(0,1fr); height:100vh; }",
|
||||
"grid-template-rows:minmax(0,1fr); min-height:100vh; }",
|
||||
"高度链没约束住"),
|
||||
|
||||
("M15 用内联 style 表达选中态(组件口径被破坏)",
|
||||
"把 P2 选题库分组那个分段控件改回「`.btn btn-sm` + 内联 border-color:var(--ink)」的写法 ——"
|
||||
"这正是 2026-10-08 统一掉的那套旧画法:选中态一旦能被内联样式随便盖,"
|
||||
"全站就会出现第三套、第四套画法,而「选中态只有一套」这件事就没人看着了。"
|
||||
"⚠️ 这条变异是给新增判据「组件口径」(自检第 84 项)配的对照 —— "
|
||||
"没有它,那条判据是不是恒绿就无从证明。",
|
||||
"<button class=\"seg-item ${S.topicGroup===g?'is-on':''}\" data-act=\"P2-08\"",
|
||||
"<button class=\"seg-item ${S.topicGroup===g?'is-on':''}\" style=\"border-color:var(--ink)\" data-act=\"P2-08\"",
|
||||
"内联 style 越权"),
|
||||
]
|
||||
|
||||
|
||||
def read_out(dom_path):
|
||||
s = open(dom_path, encoding="utf-8", errors="replace").read()
|
||||
m = re.search(r'<div class="st-out" id="selftest-out">(.*?)</div>', s, re.S)
|
||||
if not m:
|
||||
return None
|
||||
return html.unescape(re.sub(r"<[^>]+>", "", m.group(1)))
|
||||
|
||||
|
||||
def run(proto, vp="1440x900"):
|
||||
out = os.path.join(tempfile.gettempdir(), "mut-selftest.html")
|
||||
r = subprocess.run([PY, os.path.join(HERE, "cap.py"), "--mode", "dom", "--route", "selftest",
|
||||
"--viewport", vp, "--proto", proto, "--out", out],
|
||||
capture_output=True, text=True)
|
||||
if not os.path.exists(out):
|
||||
return None
|
||||
return read_out(out)
|
||||
|
||||
|
||||
def headline(text):
|
||||
m = re.search(r"自检结果:PASS (\d+) / FAIL (\d+)", text or "")
|
||||
return (int(m.group(1)), int(m.group(2))) if m else None
|
||||
|
||||
|
||||
def main():
|
||||
src = open(PROTO, encoding="utf-8").read()
|
||||
|
||||
base = run(PROTO)
|
||||
hb = headline(base)
|
||||
print("基线(未注入):PASS %s / FAIL %s" % hb if hb else "基线读不到自检输出")
|
||||
if not hb or hb[1] != 0:
|
||||
print("⛔ 基线就不是全绿 ⇒ 先修原型,别急着做变异对照。")
|
||||
return 2
|
||||
|
||||
bad = 0
|
||||
for row in MUTANTS:
|
||||
name, why, find, repl, expect = row[:5]
|
||||
vp = row[5] if len(row) > 5 else "1440x900"
|
||||
if find not in src:
|
||||
print("⚠️ %s:找不到注入点,跳过(注入点失效本身也要修)" % name)
|
||||
bad += 1
|
||||
continue
|
||||
tmp = os.path.join(tempfile.gettempdir(), "mut-proto.html")
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
f.write(src.replace(find, repl, 1))
|
||||
text = run(tmp, vp)
|
||||
h = headline(text)
|
||||
os.remove(tmp)
|
||||
if not h:
|
||||
print("❌ %s:注入后自检跑不出来(连输出都没有)" % name)
|
||||
bad += 1
|
||||
continue
|
||||
caught = h[1] > 0 and expect in (text or "")
|
||||
print("%s %s:注入(%s) ⇒ PASS %d / FAIL %d | %s" %
|
||||
("✅" if caught else "❌", name, why, h[0], h[1],
|
||||
"报红且红在预期处(%s,视口 %s)" % (expect, vp) if caught
|
||||
else "**没在预期处报红(期望含「%s」,视口 %s)**" % (expect, vp)))
|
||||
if not caught:
|
||||
bad += 1
|
||||
|
||||
print()
|
||||
print("变异对照:%d / %d 通过" % (len(MUTANTS) - bad, len(MUTANTS)))
|
||||
if bad == 0:
|
||||
print("⇒ %d 处假病全部被自检抓住,且基线回绿:这套判据不是恒绿的。" % len(MUTANTS))
|
||||
return 0 if bad == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user