10 · 验证工作流
本次抓到的问题里,大多数靠看代码发现不了。这份文档是那套"能真的抓到问题"的验证手段。 配套脚本在仓库的
src/verify/,可直接改路径复用。
0. 决策表:什么该用什么手段验
| 要验的东西 | 手段 | 能不能靠看代码 |
|---|---|---|
| 配色对比度 | 精算(WCAG 相对亮度) | ❌ 完全不能 |
| 布局 / 构图 / 溢出 / 遮挡 | 无头浏览器截图 | ❌ 不能 |
| 交互链路(点击、键盘、历史) | --dump-dom 功能测试 |
⚠️ 只能看逻辑,不能看是否真的触发 |
| 路由 / 解析 / 状态机纯逻辑 | 抽取函数做单元测试 | ✅ 可以 |
| HTML 结构 / CSS 语法 / JS 语法 | 静态检查 | ✅ 可以 |
| 字体是否覆盖某语言 | 查元数据 / 解析 cmap |
❌ 不能(气质会骗人) |
| 无障碍(焦点、读屏、降级) | ⚠️ 静态检查只能覆盖一部分;必须真机 / 真读屏 | ❌ 基本不能 |
结论:一张截图 + 一次精算,抵得上一小时读代码。
1. 静态检查(每次改完都跑)
src/verify/static-check.ps1 一次跑完四项:
- HTML 标签栈式配平 —— 比正则计数可靠(正则会被
<p>/<path>前缀搞混) - CSS 大括号配平 + 未定义变量检测
node --check校验 JS 语法(前提:本机有 node)- JS 里
getElementById引用的 id 是否都真实存在 —— 这条能抓到"改了 HTML 忘了改 JS"
$proj = 'C:\...\视频工作流\soviet-press'
powershell -ExecutionPolicy Bypass -File .\verify\static-check.ps1 -Dir $proj
2. 无头浏览器截图(判"长相对不对")
powershell -ExecutionPolicy Bypass -File .\verify\shot.ps1 -Page "$proj\index.html" -Out "$proj\_shots"
要点:
- 逐个状态用 URL 直接落到该状态再截(
index.html#/p/1/a),不要靠脚本驱动后截图 —— 时机不可控 --window-size=1440,900给足;长页面会被裁,需要更高的窗口- 必须逐张看。本次的"巨型字被 multiply 抹掉"、"红字压红底"、"形状画成平行四边形",都是看图才发现的
$edge = 'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe'
Start-Process -FilePath $edge -ArgumentList @(
'--headless=new','--disable-gpu','--no-sandbox','--hide-scrollbars',
'--window-size=1440,900','--virtual-time-budget=7000',
"--screenshot=$out", $url
) -Wait -NoNewWindow
3. 功能测试(判"逻辑对不对")
用 --dump-dom 读文本,不要用截图。 截图受网络/虚拟时钟影响,时机不可控。
3.1 关键:测试页必须和被测页同目录
🔴 这是本次踩过的坑(见 09-踩坑清单.md 的 F1)。
被测页如果用 <script src="press.js"> 这种相对路径,测试页就必须生成在同一个目录里。放到 %TEMP% 会导致所有资源静默不加载,然后你会看到一堆"什么都没发生"的假失败。
3.2 模式
# 1) 把测试脚本注入到 </body> 之前,并把测试页写进【被测页所在目录】
$patched = $html.Replace('</body>', $testScript + "`n</body>")
$tp = Join-Path $dir '_test.html' # ← 同目录,不是 TEMP
[IO.File]::WriteAllText($tp, $patched, (New-Object Text.UTF8Encoding($false)))
# 2) --dump-dom 的输出【重定向到文件】再读(管道拿不到)
$outF = Join-Path $env:TEMP 'dom.txt'
Start-Process -FilePath $edge -ArgumentList @(
'--headless=new','--disable-gpu','--no-sandbox',
'--virtual-time-budget=20000','--dump-dom', $url
) -Wait -NoNewWindow -RedirectStandardOutput $outF -RedirectStandardError $errF
# 3) 结果通过 body 属性回传,正则取出
$c = [IO.File]::ReadAllText($outF, [Text.Encoding]::UTF8)
$m = [regex]::Match($c, 'data-test-result="([^"]*)"')
测试脚本骨架(写进 data-test-result 属性):
(async function () {
const out = [];
const sleep = ms => new Promise(r => setTimeout(r, ms));
const snap = () => document.body.dataset.view + '/' + document.body.dataset.drawer;
const chk = (label, expected) => {
const got = snap();
out.push((got.indexOf(expected) === 0 ? 'OK:' : 'XX:') + label + '=' + got);
};
try {
await sleep(400);
chk('1init', 'paper/closed');
document.querySelector('[data-goto="0"]').click();
await sleep(1600);
chk('2poster', 'poster/closed');
/* … */
} catch (e) { out.push('THROWN=' + e.message); } // ← 必须捕获,否则静默空白
document.body.setAttribute('data-test-result', out.join(' ;; '));
})();
三个必备要素:
1. try/catch —— 否则脚本一抛异常,页面就一片空白,你连"失败了"都看不出来
2. 结果写进 DOM 属性,不要写 console.log(拿不到)
3. 上报 window.onerror,方便区分"逻辑错"和"脚本崩了"
3.3 整屏覆盖层必须验"归位"
任何全屏转场/幕布,最后一条断言必须是它回到了看不见的状态:
const cp = getComputedStyle(document.getElementById('wipe')).clipPath;
out.push((cp.indexOf('100% 100%') < 0 ? 'OK:' : 'XX:') + '6overlay-parked=' + cp);
3.4 用 reduced-motion 桩把状态机和动画分开验
动画路径和状态机路径混在一起测,失败时分不清是谁的问题。注入一个 matchMedia 桩强制走降级快路径,就能单独验状态机:
// 注入在业务脚本【之前】
(function(){
var orig = window.matchMedia.bind(window);
window.matchMedia = function (q) {
if (String(q).indexOf('prefers-reduced-motion') >= 0)
return { matches: true, media: q, addEventListener(){}, removeEventListener(){} };
return orig(q);
};
})();
两种都跑一遍: - 加强制桩 → 验状态机(快、确定) - 不加桩 → 验真实动画收敛(慢、但验的是真实路径)
4. 纯逻辑单元测试(最便宜的一种验证)
把状态机里的纯函数抽出来单独跑,不需要浏览器。本次的路由函数跑了 12 个用例(含畸形输入):
const MAX_INDEX = 2;
const hashFor = (s) => (s.view !== 'poster' ? '#/' : `#/p/${s.index}${s.drawer ? '/a' : ''}`);
function stateFromHash(hash) {
const m = /^#\/p\/(\d)(\/a)?$/.exec(hash || '');
if (m) { const i = Number(m[1]);
if (i >= 0 && i <= MAX_INDEX) return { view:'poster', index:i, drawer:Boolean(m[2]) }; }
return { view:'paper', index:null, drawer:false };
}
// 用例:[输入, 期望];外加"往返一致性 hashFor(stateFromHash(h)) === h"
重点测畸形输入:#/p/9(越界)、#/p/0/x、#/garbage、#/p/1/a/extra、""。
& node .\verify\route-test.js ; "退出码 $LASTEXITCODE"
5. 字体覆盖验证(不要凭气质猜)
5.1 Google Fonts 官方元数据(权威、一次性拿全)
$m = Invoke-RestMethod 'https://fonts.google.com/metadata/fonts' # 约 1950 个字族
$m.familyMetadataList | Where-Object { $_.subsets -contains 'cyrillic' } | Select-Object family
# 某几个字的字重轴:
$m.familyMetadataList | Where-Object { $_.family -eq 'Oswald' } |
ForEach-Object { $_.fonts.PSObject.Properties.Name }
5.2 快速判定单款字体(用 text= 参数)
# 若字体不含该字形,Google Fonts 会报错;成功返回 @font-face 即【有】覆盖
Invoke-WebRequest 'https://fonts.googleapis.com/css2?family=Oswald&text=%D0%9F%D1%80%D0%B8%D0%B2%D0%B5%D1%82'
5.3 最硬一级:解析 cmap
下载字体二进制 → 解析 cmap 表 → 逐字形判定。适合"必须确定"的场合(例如商业项目要保证俄文/乌克兰文全覆盖)。本方法能发现元数据看不出的差异,例如某字体俄文完整但乌克兰文缺 ұ / є / і / ї。
6. 对比度精算
见 07-设计规则速查.md 第 1.1 节的脚本(无依赖,PowerShell 直接跑)。
把项目里所有前景/背景配对标成一张表,逐对跑,而不是只验你怀疑的那几个。本次就是靠全量跑才发现 #8E1B14 on 黑 = 2.79 这条。
7. 交付前清单
静态
- [ ] HTML 标签配平、无重复属性
- [ ] CSS 大括号配平、无未定义变量
- [ ] JS node --check 通过
- [ ] JS 引用的 id / class 都真实存在
- [ ] 文档内相对链接全部可解析
视觉(必须逐张看图) - [ ] 桌面 + 移动两个宽度 - [ ] 每个可达状态各一张 - [ ] 文字没有被几何图形遮挡 - [ ] 没有元素溢出 / 被裁
功能(--dump-dom)
- [ ] 主要交互链路端到端通过
- [ ] 键盘路径(Tab / Enter / ESC)通过
- [ ] 整屏覆盖层结束时归位
- [ ] 无 window.onerror
无障碍
- [ ] 焦点环在所有背景上都可见
- [ ] 可交互元素身上没有 clip-path
- [ ] prefers-reduced-motion / prefers-contrast / forced-colors 三套降级都在
- [ ] 触控目标 ≥ 44px
- [ ] 所有前景/背景配对都算过对比度