Files
WorkBuddy df56c2c137 初始化提交:contentm_agent 工作区全量快照
内容分四块:
1、产品规划产出 —— MCN 短视频整合营销工作台的①段五份(1a 需求/1b 竞品/1c 画像/1d 策略/1e 场景)、②段两份(2a 功能/2b 布局)、③段界面(DESIGN.md 契约与令牌表 + mcn-workbench.html 原型 + 实测/会诊/审查三份 + 23 张闸门截图)。
2、开源竞品调研 —— 5 个内容工作台项目的取证原始件与 1b 系列分析文档。
3、参考资料 —— 竞品视频抽帧 1145 张 + 2 个源视频 + 功能点截图。
4、机制侧 —— 协作脚本与状态台账、工作区记忆日志、抽帧/OCR 脚本。

.gitignore 只排运行时日志、脚本备份副本与一次性探针输出,其余按原样入库。
2026-10-08 08:13:02 +08:00

175 lines
7.9 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 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())