← 目录 10 / 11

10 · 验证工作流

本次抓到的问题里,大多数靠看代码发现不了。这份文档是那套"能真的抓到问题"的验证手段。 配套脚本在仓库的 src/verify/,可直接改路径复用。


0. 决策表:什么该用什么手段验

要验的东西 手段 能不能靠看代码
配色对比度 精算(WCAG 相对亮度) ❌ 完全不能
布局 / 构图 / 溢出 / 遮挡 无头浏览器截图 ❌ 不能
交互链路(点击、键盘、历史) --dump-dom 功能测试 ⚠️ 只能看逻辑,不能看是否真的触发
路由 / 解析 / 状态机纯逻辑 抽取函数做单元测试 ✅ 可以
HTML 结构 / CSS 语法 / JS 语法 静态检查 ✅ 可以
字体是否覆盖某语言 查元数据 / 解析 cmap ❌ 不能(气质会骗人)
无障碍(焦点、读屏、降级) ⚠️ 静态检查只能覆盖一部分;必须真机 / 真读屏 ❌ 基本不能

结论:一张截图 + 一次精算,抵得上一小时读代码。


1. 静态检查(每次改完都跑)

src/verify/static-check.ps1 一次跑完四项:

  1. HTML 标签栈式配平 —— 比正则计数可靠(正则会被 <p> / <path> 前缀搞混)
  2. CSS 大括号配平 + 未定义变量检测
  3. node --check 校验 JS 语法(前提:本机有 node)
  4. 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"

要点:

$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 - [ ] 所有前景/背景配对都算过对比度