首页
/ career-ops CV 视觉回归测试实战:像素基线、几何断言、页数与 ATS 文本提取的四重质量门

career-ops CV 视觉回归测试实战:像素基线、几何断言、页数与 ATS 文本提取的四重质量门

2026-09-05 22:45:58作者:盛欣凯Ernestine

本文基于 career-ops 仓库中的 CV 视觉回归测试文档 展开,完整介绍该仓库如何为 HTML 简历模板建立「截图基线 + 几何校验 + PDF 页数 + ATS 文本提取」四层回归防线:包括本地环境准备与运行命令、四个双语夹具的设计、Playwright 的确定性渲染配置、每一项断言的源码级实现,以及基线更新与 CI 字体保障的操作规范。读完后你可以完整理解该测试套件的工作原理,并能在自研的 HTML→PDF 渲染管线中复用这套多门控验证思路。

standard 模板 zh-long-photo 夹具的截图基线,展示密集版中文简历布局

standard 模板 en-long-photo 夹具的截图基线,展示带照片的密集版英文简历布局

一、测试定位:为什么简历渲染需要视觉回归

career-ops 的核心工作流是:AI 编码 CLI(Claude Code、Codex、OpenCode 等)读取候选人的 cv.mdconfig/profile.yml,生成一份结构化的 JSON payload,再由渲染脚本把 payload 合并进 CV 模板,最终导出 PDF。这条管线中,HTML 模板承载了「ATS 安全排版」的全部细节——浮动的照片、换行、分页断点、孤行保护等。模板一旦改动就可能悄悄破坏版式,而这类问题靠常规单元测试很难发现。

因此仓库提供了第一片视觉回归切片(visual-regression slice):针对 standard HTML CV 模板,用四份经过脱敏的夹具(英文/简体中文 × 短内容/密集内容 × 带照片/无照片)驱动真实渲染管线,同时施加四类门控:

  1. 截图基线对比(像素级视觉回归);
  2. 几何校验(溢出、裁切、照片与文字重叠、标题数量与孤行保护);
  3. PDF 页数门(防止意外的分页膨胀);
  4. ATS 文本提取门(保证 PDF 中的文字可被 ATS 系统正确抽取)。

文档中特别说明,这套测试是当前稳定下来的首个切片,其余模板会在该框架稳定后以独立评审的后续提交逐步加入(见 docs/CV_VISUAL_TESTING.md)。

二、本地运行:依赖与命令

2.1 前置依赖

需要安装 Chromium 和 Poppler(提供 pdftotext):

npx playwright install chromium

2.2 运行测试套件

npm run test:cv-visual

该脚本在 package.json 中定义为 playwright test --config=playwright.cv.config.mjs,指向专用配置文件而非项目默认 Playwright 配置,从而与主测试套件隔离。

产物落盘位置(文档原文约定):

  • 渲染出的 PDF 与 PNG 预览 写入 test-results/cv-visual-artifacts/
  • 失败时,Playwright 会把 expected / actual / pixel-diff 三张对比图写入 test-results/cv-visual-results/(对应配置中的 outputDir)。

三、确定性渲染配置:playwright.cv.config.mjs

视觉回归的前提是渲染确定性,配置文件完整体现了文档中「单一 Chromium worker、固定视口、UTC 时区、浅色配色」的约定:

export default defineConfig({
  testDir: './tests/cv-visual',
  testMatch: '**/*.spec.mjs',
  fullyParallel: false,      // 串行执行
  workers: 1,                // 单一 worker
  retries: 0,
  timeout: 60_000,
  expect: { timeout: 10_000 },
  outputDir: 'test-results/cv-visual-results',
  snapshotPathTemplate: '{testDir}/__screenshots__/{arg}{ext}',
  use: {
    browserName: 'chromium',
    viewport: { width: 1050, height: 1485 },  // 固定视口
    deviceScaleFactor: 1,
    locale: 'en-US',
    timezoneId: 'UTC',                       // UTC 时区
    colorScheme: 'light',                     // 浅色配色
  },
});

几个要点:

  • snapshotPathTemplate 把基线截图固化到 tests/cv-visual/__screenshots__/ 并随仓库提交,这就是文档所指的「screenshot baselines」的物理载体(4 张基线 PNG 已在仓库中);
  • 固定 1050x1485 视口与 deviceScaleFactor: 1,保证不同机器上像素坐标一致;
  • 时区固定为 UTC、locale 固定为 en-US,避免日期本地化差异污染截图与 PDF 文本。

四、夹具设计:tests/cv-visual/fixtures.mjs

四份夹具由 fixture(id, lang, dense, withPhoto) 工厂组装:

export const fixtures = [
  fixture('en-short-no-photo', 'en', false, false),
  fixture('en-long-photo', 'en', true, true),
  fixture('zh-short-no-photo', 'zh-CN', false, false),
  fixture('zh-long-photo', 'zh-CN', true, true),
];

夹具内容全部为脱敏示例数据(如候选人 Jordan Lee / 林知远、示例组织与占位技能),文档中「sanitized fixtures」即指这一点。几个实现细节值得注意:

  • 照片占位:带照片的夹具使用一个 1×1 像素的 base64 PNG(PIXEL 常量)作为 candidate.photo,配合 photo_style: 'circle';无照片夹具该字段为空字符串。这样测试覆盖照片浮动的几何逻辑,又不依赖真实图片资产;
  • 密度维度dense 夹具包含 5 段工作经历(每段 5 条 bullet)与 4 个项目,用于在密集版中「覆盖分页边界附近的布局表现」(源码注释原文);短夹具只有 1 段经历(2 条 bullet)与 1 个项目;
  • 中文夹具额外提供中文章节标题映射(个人简介核心能力 等),验证 CJK 字形排版路径。

五、测试主流程:tests/cv-visual/cv-visual.spec.mjs

每个夹具对应一个 test,完整走了一遍真实生产渲染管线:

5.1 构建 HTML:与生产同一条代码路径

const TEMPLATE = { name: 'standard', path: resolveTemplate('cv', 'standard') };
// ...
writeFileSync(input, JSON.stringify(fixture.payload));
execFileSync(process.execPath, ['build-cv-html.mjs', input, html, TEMPLATE.path], { cwd: ROOT });

模板路径通过 cv-templates.mjs 中的 resolveTemplate('cv', 'standard') 解析,然后以子进程方式调用 build-cv-html.mjs——这正是生产管线使用的确定性 HTML 渲染器(其文件头注释明确定位为 build-cv-latex.mjs 的 HTML 对应物,脚本负责全部标签与 HTML 转义)。测试因此不是在测「测试专用渲染器」,而是在测真实产物。

5.2 打印媒体仿真

await page.goto(pathToFileURL(html).href, { waitUntil: 'load' });
await page.emulateMedia({ media: 'print' });
await page.evaluate(() => document.fonts.ready);

切换到 print 媒体类型让 @media print 规则生效,并等待 document.fonts.ready 消除字体加载时序带来的截图抖动。

5.3 几何门(geometry gate)

page.evaluate 在页面内采集一组几何信号并逐一断言(tests/cv-visual/cv-visual.spec.mjs):

断言 检查内容
bodyOverflow === falseoverflowing 为空 任意元素的 scrollWidth 不得超过 clientWidth + 1,即不允许水平溢出
clipped 为空 .page 内任何元素的边界框不得超出视口左右边缘 ±1px,即不允许被裁切
photoOverlap === false 照片框不得与头部具体文字/链接的重排框相交
headings >= 6 至少渲染出 6 个 .section-title 章节
orphanGuardMissing 为空 每个章节标题的计算样式 break-after 必须是 avoidavoid-page(孤行保护)

其中照片重叠检测有一段值得借鉴的注释:浮动照片故意.contact-row 的容器重叠以便文字环绕,因此断言不使用容器盒子,而是对 .header h1.contact-row > * 逐节点建立 Range 并取 getClientRects(),对具体的文字/链接盒子做相交判断——避免了容器级误报。

5.4 截图基线门

await expect(page).toHaveScreenshot(`${artifactBase}.png`, {
  fullPage: true,
  animations: 'disabled',
  maxDiffPixelRatio: 0.04,
});

全页截图与仓库内基线对比,maxDiffPixelRatio: 0.04 即文档所述的 4% 像素容差:足以吸收不同平台字体光栅化器(font-rasterizer)的细微差异,又不会掩盖结构性变化。截图同时另存一份到 test-results/cv-visual-artifacts/ 供人工查看。

5.5 PDF 页数门

渲染 A4 PDF(0.6in 四边距、printBackground: true)后,用轻量正则统计具体页面对象:

function countPdfPages(pdf) {
  const matches = pdf.toString('latin1').match(/\/Type\s*\/Page\b/g);
  return matches ? matches.length : 0;
}

\b 边界确保不会把 /Pages 根对象误计为页面。页数必须落在 baselines.json 的区间内:

{
  "standard": {
    "en-short-no-photo": { "minPages": 1, "maxPages": 1 },
    "en-long-photo":   { "minPages": 2, "maxPages": 3 },
    "zh-short-no-photo": { "minPages": 1, "maxPages": 1 },
    "zh-long-photo":   { "minPages": 2, "maxPages": 3 }
  }
}

tests/cv-visual/baselines.json)短内容必须严格单页;密集内容允许 2–3 页。

5.6 ATS 文本提取门

最后调用 Poppler 的 pdftotext 做「ATS 可读性」断言:

return execFileSync('pdftotext', ['-layout', pdfPath, '-'], { encoding: 'utf8' });
// ...
expect(text).toContain(fixture.payload.candidate.name);
expect(text).toContain(fixture.payload.skills[0].items[0]);
expect(text).not.toMatch(/[□�]/);

三层含义:-layout 保留版式地抽取文本(模拟 ATS 解析器视角);姓名与第一个技能项必须可被提取(证明 PDF 里是真实文本而非图像);不得出现 / 等缺字形占位符——这正是 CI 安装 Noto CJK 字体的配套断言(见下节)。若本机没有 pdftotext,该门会直接抛出带明确提示的错误而非静默跳过。

六、有意的基线更新:何时动 PNG,何时动 JSON

当一次经过评审的模板改动确实要改变版式时,文档给出两步流程:

npm run test:cv-visual:update   # playwright --update-snapshots,仅刷新截图基线
npm run test:cv-visual         # 重新跑全部门控确认

三条纪律必须遵守:

  1. 逐张人工审查每一个发生变化的 PNG,而不是机械接受快照刷新("Review every changed PNG rather than accepting snapshots mechanically");
  2. 只有当预期 PDF 页数区间变化时才更新 tests/cv-visual/baselines.json——截图刷新与页数区间是两类独立的基线;
  3. 意外的页数增长即回归:即使截图看起来「视觉上可以接受」,只要页数超出既有区间,在新增密度被显式批准之前都必须按回归处理。

这条规则体现了文档的核心工程立场:截图能捕捉「看起来变了」,页数门能捕捉「密度悄悄膨胀」,两者互相独立、互为补充。

七、CI 的字体与 Poppler 保障

文档指出,CI 环境额外安装 Noto CJKPoppler:前者保证中文夹具的字形覆盖(zh-long-photo 夹具 + [□�] 缺字断言构成闭环),后者提供 pdftotext 支撑 ATS 提取门。结合配置文件可见整套确定性的来源:单一 Chromium worker、固定视口、UTC 时区、浅色配色与 4% 像素容差共同保证——跨平台噪声被容差吸收,结构性改动则一定会触发基线失败。

八、小结与可复用要点

门控 载体 捕捉的问题
几何断言 cv-visual.spec.mjspage.evaluate 溢出、裁切、照片压字、孤行保护缺失
截图基线 tests/cv-visual/__screenshots__/ + 4% 容差 任何视觉回归(配色、间距、字体回退)
页数区间 baselines.json 意外的分页膨胀
ATS 文本提取 pdftotext -layout + 缺字正则 PDF 文本不可抽取、CJK 缺字形

对自研「HTML 模板 → PDF 交付物」渲染管线的团队,可直接借鉴的四点:用与生产同一条构建命令驱动测试(本例即复用 build-cv-html.mjs);用双语文本密度矩阵(语言 × 长度 × 照片)压缩夹具数量;把「截图容差」与「页数区间」拆成两类基线并分开维护;以及把 ATS 视角的文本可抽取性当作一等断言,而不是只验证视觉外观。

适用范围说明:以上命令与行为基于当前仓库快照(package.json 中的 test:cv-visual 脚本、playwright.cv.config.mjs 配置及 tests/cv-visual/ 目录内容);本地运行需 Node + npm 环境及可安装 Chromium 的平台,ATS 门额外要求系统级 Poppler(pdftotext)。

登录后查看全文
热门项目推荐
相关项目推荐