career-ops CV 视觉回归测试实战:像素基线、几何断言、页数与 ATS 文本提取的四重质量门
本文基于 career-ops 仓库中的 CV 视觉回归测试文档 展开,完整介绍该仓库如何为 HTML 简历模板建立「截图基线 + 几何校验 + PDF 页数 + ATS 文本提取」四层回归防线:包括本地环境准备与运行命令、四个双语夹具的设计、Playwright 的确定性渲染配置、每一项断言的源码级实现,以及基线更新与 CI 字体保障的操作规范。读完后你可以完整理解该测试套件的工作原理,并能在自研的 HTML→PDF 渲染管线中复用这套多门控验证思路。
一、测试定位:为什么简历渲染需要视觉回归
career-ops 的核心工作流是:AI 编码 CLI(Claude Code、Codex、OpenCode 等)读取候选人的 cv.md 与 config/profile.yml,生成一份结构化的 JSON payload,再由渲染脚本把 payload 合并进 CV 模板,最终导出 PDF。这条管线中,HTML 模板承载了「ATS 安全排版」的全部细节——浮动的照片、换行、分页断点、孤行保护等。模板一旦改动就可能悄悄破坏版式,而这类问题靠常规单元测试很难发现。
因此仓库提供了第一片视觉回归切片(visual-regression slice):针对 standard HTML CV 模板,用四份经过脱敏的夹具(英文/简体中文 × 短内容/密集内容 × 带照片/无照片)驱动真实渲染管线,同时施加四类门控:
- 截图基线对比(像素级视觉回归);
- 几何校验(溢出、裁切、照片与文字重叠、标题数量与孤行保护);
- PDF 页数门(防止意外的分页膨胀);
- 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 === false 且 overflowing 为空 |
任意元素的 scrollWidth 不得超过 clientWidth + 1,即不允许水平溢出 |
clipped 为空 |
.page 内任何元素的边界框不得超出视口左右边缘 ±1px,即不允许被裁切 |
photoOverlap === false |
照片框不得与头部具体文字/链接的重排框相交 |
headings >= 6 |
至少渲染出 6 个 .section-title 章节 |
orphanGuardMissing 为空 |
每个章节标题的计算样式 break-after 必须是 avoid 或 avoid-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 # 重新跑全部门控确认
三条纪律必须遵守:
- 逐张人工审查每一个发生变化的 PNG,而不是机械接受快照刷新("Review every changed PNG rather than accepting snapshots mechanically");
- 只有当预期 PDF 页数区间变化时才更新 tests/cv-visual/baselines.json——截图刷新与页数区间是两类独立的基线;
- 意外的页数增长即回归:即使截图看起来「视觉上可以接受」,只要页数超出既有区间,在新增密度被显式批准之前都必须按回归处理。
这条规则体现了文档的核心工程立场:截图能捕捉「看起来变了」,页数门能捕捉「密度悄悄膨胀」,两者互相独立、互为补充。
七、CI 的字体与 Poppler 保障
文档指出,CI 环境额外安装 Noto CJK 与 Poppler:前者保证中文夹具的字形覆盖(zh-long-photo 夹具 + [□�] 缺字断言构成闭环),后者提供 pdftotext 支撑 ATS 提取门。结合配置文件可见整套确定性的来源:单一 Chromium worker、固定视口、UTC 时区、浅色配色与 4% 像素容差共同保证——跨平台噪声被容差吸收,结构性改动则一定会触发基线失败。
八、小结与可复用要点
| 门控 | 载体 | 捕捉的问题 |
|---|---|---|
| 几何断言 | cv-visual.spec.mjs 内 page.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)。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00

