void 编辑器 Diffing Fixture 测试机制全解:从期望文件自愈到 diff 算法回归保障
【免费下载链接】void开源AI代码编辑器,Cursor的替代方案。项目地址: https://gitcode.com/GitHub_Trending/void2/void
在 void(基于 VS Code 的开源 AI 代码编辑器)中,行级 diff(Lines Diff)算法是编辑器差异视图、合并编辑器(Merge Editor)与代码评审体验的核心基础。本文以仓库内 diffing fixture 测试说明 为骨架,结合 fixtures.test.ts 与 DefaultLinesDiffComputer 的源码实现,系统讲解这套"黄金样本(golden fixtures)"式回归测试的工作机制、目录约定、期望文件自愈流程,以及修改 diff 算法时必须遵循的验证工作流。读完本文,你将掌握如何为 diff 算法新增测试用例、理解*.expected.diff.json与*.invalid.diff.json的协作逻辑,并能在不破坏既有行为的前提下安全修改 diff 算法。
一、什么是 Diffing Fixture 测试
Fixture 测试(也称为黄金样本测试 / golden file test)是 void 编辑器对 diff 计算器(Lines Diff Computer)进行回归保障的主要手段。其核心思想是:为每对输入文件预先固化一份"期望 diff 输出",算法改动后只需比对实际输出与期望输出是否一致,即可快速捕获行为漂移。
这套测试位于 src/vs/editor/test/node/diffing 目录,包含:
README.md:测试机制的说明文档(本文主题);fixtures/:全部测试样本与期望输出;- fixtures.test.ts:驱动 fixture 测试的 Mocha 测试套件;
- defaultLinesDiffComputer.test.ts:针对算法组件的单元测试。
二、目录结构与命名约定
fixtures/下每个文件夹代表一个测试用例,例如trivial、move-1、bracket-aligning、deletion、import-shifting等。每个测试文件夹遵循严格的命名规则:
1. 输入文件:1.*与2.*
- 以
1.开头的文件是原始版本(original); - 以
2.开头的文件是修改后版本(modified); - 测试会计算
1.*→2.*的 diff。
文件扩展名可以是任意类型(.txt、.js、.json、.tsx、.tst),测试套件通过startsWith('1.')/startsWith('2.')定位输入文件(见 fixtures.test.ts)。
2. 为什么 TypeScript 样本用.tst而不是.ts
对于故意包含语法错误、半成品代码或非规范缩进的 diff 样本(例如move-1/1.tst中CHEAP_TOKENIZATION_LENGTH_LIMIT常量从2048改成了1024这类"真实但非编译通过"的代码),如果使用.ts扩展名,会被 TypeScript 编译器与 linter 扫描并产生报错,污染构建。因此约定改用.tst扩展名,既让编辑器/编译器将其视为普通文本,又能通过文件名一眼识别其 TypeScript 语义。README 原文也明确指出:"Usetstinstead oftsto avoid compiler/linter errors for typescript diff files."
3. 期望输出:*.expected.diff.json
每个测试文件夹同时包含两类期望文件:
| 文件 | 含义 |
|---|---|
advanced.expected.diff.json | 先进算法(DefaultLinesDiffComputer)的期望 diff 输出 |
legacy.expected.diff.json | 旧算法(LegacyLinesDiffComputer)的期望 diff 输出 |
测试套件会对每一个 fixture 文件夹 × 两种算法分别执行测试(见 fixtures.test.ts 的双重循环),从而同时守护新旧两条算法路径。
部分文件夹还带有*.human.diff.json文件(如class-replacement、ts-class、penalize-fragmentation),这是人工评审后确认的权威期望结果,作为维护者审查机器生成期望文件时的参照基准。
三、期望文件的内容结构
一个典型的*.expected.diff.json长这样(以 trivial/advanced.expected.diff.json 为例,输入为"空文件 → 一行x"):
{ "original": { "content": "", "fileName": "./1.txt" }, "modified": { "content": "x", "fileName": "./2.txt" }, "diffs": [ { "originalRange": "[1,2)", "modifiedRange": "[1,2)", "innerChanges": [ { "originalRange": "[1,1 -> 1,1 EOL]", "modifiedRange": "[1,1 -> 1,2 EOL]" } ] } ] }对照 fixtures.test.ts 中的DiffingResult接口可以理解每个字段:
original/modified:记录两侧输入内容与文件名,保证期望文件可回溯、可复核;diffs:顶层差异块(IDetailedDiff)数组,每个块含originalRange/modifiedRange([startLineNumber, endLineNumberExclusive)半开区间)以及innerChanges——即块内部的字符级细粒度映射(如[1,1 -> 1,1 EOL],其中EOL表示终点恰好落在行尾);moves(可选):检测到的代码移动块(IMoveInfo),每个移动块包含被移动文本的原始/修改区间及其内部变更。当没有任何移动时,该字段会被删除(见 fixtures.test.ts)。
以 move-1/advanced.expected.diff.json 为例:它识别出原始第[24,28)行被整体移动到修改后的[70,74)位置,并记录该移动块内部的 4 个字符改动([26,36 -> 26,40 EOL]→[72,36 -> 72,40 EOL]),这正是 diff 视图里"代码块整体搬移并高亮内部变更"功能的测试依据。
四、期望文件的自愈机制(核心工作流)
README 用三条规则概括了这套机制的灵魂,结合 fixtures.test.ts 的源码可以精确还原每一步:
规则 1:缺失的期望文件自动创建
如果某个 fixture 目录缺少*.expected.diff.json(通常是新加的测试用例),测试运行时会:
- 用当前算法输出写入期望文件(fixtures.test.ts);
- 同时写入一个空的
*.invalid.diff.json文件(fixtures.test.ts); - 抛出错误
No expected file! Expected and invalid files were written. Delete the invalid file to make the test pass.
这样,首次运行即固化基线,且保证测试必然失败一次,强制开发者主动确认新期望文件的内容。
规则 2:实际输出与期望不一致时自动更新期望文件
如果*.expected.diff.json已存在,但实际 diff 与期望不一致:
- 先将旧的期望内容备份写入
*.invalid.diff.json(fixtures.test.ts); - 再用实际输出覆盖更新期望文件(fixtures.test.ts);
- 抛出异常导致测试失败。
规则 3:存在*.invalid.diff.json时测试必然失败
这是整个机制中防止"假绿"的关键设计。只要目录里残留任何*.invalid.diff.json,测试就会失败,即使期望文件已被更新:
- 若 invalid 文件为空(代表新基线尚未确认),测试要求开发者手动删除它(fixtures.test.ts);
- 若 invalid 文件非空(代表上次算法变更备份的旧期望),测试会先验证当前输出与该 invalid 内容一致;若一致,则说明发生了可确认的行为回退,从 invalid 文件恢复期望文件并删除 invalid;若不一致则抛错(fixtures.test.ts)。
三者的协作可以用下面的流程概括:
运行 fixture 测试 ├─ 无 expected 文件 → 写入 expected + 空 invalid → 失败(等待确认) ├─ 有 invalid 文件 → 必然失败(等待人工清理/确认) └─ 有 expected 且无 invalid ├─ 输出一致 → 通过 └─ 输出不一致 → 旧期望备份为 invalid + 更新 expected → 失败这套设计保证了:算法变更后,即使期望文件被自动改写,测试也不会在第二次运行时悄然变绿——必须由开发者审查 diff、删除 invalid 文件,回归才算真正完成。
五、测试驱动与算法调用链
理解 fixture 测试还需要知道它背后调用的算法。测试套件在 fixtures.test.ts 中这样构造输入:
const diffingAlgo = diffingAlgoName === 'legacy' ? new LegacyLinesDiffComputer() : new DefaultLinesDiffComputer(); const ignoreTrimWhitespace = folder.indexOf('trimws') >= 0; const diff = diffingAlgo.computeDiff(firstContentLines, secondContentLines, { ignoreTrimWhitespace, maxComputationTimeMs: Number.MAX_SAFE_INTEGER, computeMoves: true });几个值得注意的实现细节:
- 输入统一规范化:读取的文本会先做
\r\n、\r→\n的换行归一化(fixtures.test.ts),避免平台差异干扰期望文件; trimws命名即配置:只要文件夹名包含trimws子串(如invalid-diff-trimws、issue-202147-trimws),就自动启用ignoreTrimWhitespace(忽略行首尾空白比较),这是 fixture 命名约定驱动测试行为的巧妙范例;- 超时设置为无限:fixture 场景规模可控,测试目标是确定性输出而非性能;
- 始终开启移动检测:
computeMoves: true,确保moves字段被持续验证。
ILinesDiffComputerOptions的完整定义位于 linesDiffComputer.ts:ignoreTrimWhitespace、maxComputationTimeMs、computeMoves,以及可选的extendToSubwords。
正确性断言
对 advanced 算法且非 trimws 场景,测试还会额外调用assertDiffCorrectness(fixtures.test.ts):把 diff 产生的所有字符级映射构造成一次TextEdit,应用到原始文本后必须能精确还原出修改后文本。这是一条比"与期望文件一致"更强的性质约束,从根上杜绝"diff 结果自洽但对不齐"的缺陷。
底层算法实现
先进算法 DefaultLinesDiffComputer 的computeDiff会依据输入规模选择不同策略(defaultLinesDiffComputer.ts):
- 当两侧行数之和小于 1700 时,使用动态规划算法(
DynamicProgrammingDiffing),并对完全相同的行按1 + Math.log(1 + 行长度)加权,兼顾匹配质量; - 大文件场景切换到Myers 算法(
MyersDiffAlgorithm)以控制复杂度; - 之后依次经过
optimizeSequenceDiffs、removeVeryShortMatchingLinesBetweenDiffs等启发式优化(heuristicSequenceOptimizations.ts),最后通过 computeMovedLines.ts 完成移动块识别。
算法组件的单测位于 defaultLinesDiffComputer.test.ts,例如直接用LinesSliceCharSequence(['hello world'], ...)与LinesSliceCharSequence(['hallo welt'], ...)验证 Myers 算法行为,或用getLineRangeMapping验证行区间映射的归一化结果。
六、fixture 测试用例家族
当前仓库fixtures/下包含约 60 组场景,是 diff 算法行为边界的"活字典",按主题大致可分为:
| 类别 | 代表用例 | 覆盖的行为点 |
|---|---|---|
| 基础行为 | trivial、equals、deletion、indentation | 空文件、完全相等、整段删除、缩进变化 |
| 代码移动 | move-1、difficult-move、noisy-move1、false-positive-move、shifting-twice | 移动块识别、防误报、移动中的噪声容忍 |
| 括号/结构对齐 | bracket-aligning、intra-block-align、json-brackets、ws-alignment | 括号对齐与空白感知 |
| 类型语言专项 | ts-class、ts-methods、ts-strings、ts-comments、ts-import-ws-affinity、ts-advanced-bug等数十个ts-* | TypeScript 重构场景下的 diff 质量 |
| 回归缺陷 | invalid-diff-bug、invalid-diff-trimws、invalid-ranges、issue-131091、issue-185779、issue-201713、issue-204948、issue-214049等 | 历史上修复过的 diff 缺陷,防止复发 |
| 算法质量 | fuzzy-matching、subword、word-shared-letters、minimal-diff-character、random-match-1/2/3、noise-1/2、penalize-fragmentation | 模糊匹配、子词切分、碎片化惩罚、噪声鲁棒性 |
以bracket-aligning为例,其1.tst与2.tst分别是同一段 Merge Editor 源码在重构前后的版本,期望 diff 精确到[24,1 -> 28,1 EOL]这样的字符列级别,用于守护"括号自动对齐"这一差异渲染体验。而issue-*系列则把真实用户上报的缺陷固化为回归样本,一旦算法回归即可在 CI 中立刻暴露。
七、修改 diff 算法时的标准操作流程
README 最后一段给出了维护者修改 diff 算法时的明确操作规范,这也是贡献者最需要遵循的流程:
- 运行 fixture 测试:修改算法实现后,执行
fixtures.test.ts所属测试套件(仓库根目录运行测试脚本,如scripts/test.sh对应的完整测试流程,fixture 测试位于 node 侧测试中); - 审查 diff:重点检查被自动更新的
*.expected.diff.json文件,逐一确认每一处行为变化都是有意且正确的,尤其关注innerChanges的字符级映射与moves的移动识别是否合理; - 删除
*.invalid.diff.json:在所有期望文件确认无误后,清理残留的 invalid 文件,使测试恢复绿色。
只要某次测试运行产生了*.invalid.diff.json,就意味着测试保持失败状态——这正是对"算法改动必须经过人工确认"这一纪律的强制保证。若你的改动被判定为不应保留,则只需用 invalid 文件中的旧内容恢复对应的 expected 文件即可。
八、相关源码路径速查
- 测试说明:src/vs/editor/test/node/diffing/README.md
- Fixture 驱动测试:src/vs/editor/test/node/diffing/fixtures.test.ts
- 算法组件单元测试:src/vs/editor/test/node/diffing/defaultLinesDiffComputer.test.ts
- 全部测试样本:src/vs/editor/test/node/diffing/fixtures
- 先进算法实现:src/vs/editor/common/diff/defaultLinesDiffComputer/defaultLinesDiffComputer.ts
- 算法接口与选项:src/vs/editor/common/diff/linesDiffComputer.ts
- 旧算法实现:src/vs/editor/common/diff/legacyLinesDiffComputer.ts
- 启发式优化:src/vs/editor/common/diff/defaultLinesDiffComputer/heuristicSequenceOptimizations.ts
- 移动行计算:src/vs/editor/common/diff/defaultLinesDiffComputer/computeMovedLines.ts
九、总结
void 的 Diffing Fixture 测试是一个设计精良的自愈式黄金样本测试体系:用1.*/2.*输入文件固化场景,用advanced/legacy双算法期望文件守护两条实现路径,用*.invalid.diff.json充当"强制人工确认"的闸门,杜绝算法改动在无人审查的情况下悄然通过 CI。对于任何计划修改 diff 算法、新增 diff 场景或排查差异视图缺陷的开发者,理解这套机制——尤其是期望文件的自愈流程与trimws命名约定——都是高效工作的前提。
【免费下载链接】void开源AI代码编辑器,Cursor的替代方案。项目地址: https://gitcode.com/GitHub_Trending/void2/void
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考