2026-10-08 22:21:42 +08:00
|
|
|
|
#!/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())
|