Files
Notes/tests/unit/markdown-diff.test.js
2026-09-12 14:15:26 +08:00

365 lines
16 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Stage 8 tests: shared/markdown-diff.js
//
// 覆盖:
// - computeFullMarkdownDiff:纯增 / 纯删 / 改 / 跨多 region / 空内容
// - applyDiffRegionSafely:成功路径 + conflict(找不到 / 多个匹配)
// - endsWithNewline 保留
// - 行级 MAX_LINE_DIFF_CELLS 守卫(过大输入不爆炸)
// - tokenDiff 选项生效路径(仅断言结构,不验证具体分词)
//
// shared/ 是 CommonJS,直接 require 即可。
import { describe, it, expect } from 'vitest';
const { computeFullMarkdownDiff, applyDiffRegionSafely } = require('../../shared/markdown-diff.js');
describe('computeFullMarkdownDiff', () => {
it('identical content → 空 regions(rows 只剩 context)', () => {
const out = computeFullMarkdownDiff('a\nb\nc', 'a\nb\nc');
expect(out.regions).toEqual([]);
// 没有 regionId 的行就是纯 context
const regionsInRows = out.rows.filter((r) => r.regionId).length;
expect(regionsInRows).toBe(0);
});
it('空内容 + 空内容 → 无 rows / 无 regions', () => {
const out = computeFullMarkdownDiff('', '');
expect(out.rows).toEqual([]);
expect(out.regions).toEqual([]);
});
it('BOM 仅出现在文件头 → 被剥掉,不影响 diff 结果(Phase 8 fix)', () => {
// Windows Notepad / PowerShell pipeline 写 UTF-8 BOM (U+FEFF) 在文件头。
// 不剥 → 首行变成 "# Title",LCS 看到 oldLines[0] !== newLines[0],
// 每次「应用全部」都把首行当成「被改」渲染。
const base = '# Title\nline2';
const next = '# Title\nline2'; // 一字未动
const out = computeFullMarkdownDiff(base, next);
expect(out.regions).toEqual([]);
// rows 只剩 context,无任何 regionId
expect(out.rows.filter((r) => r.regionId)).toEqual([]);
});
it('BOM 只剥头部一次,正文中残留的 BOM 保留', () => {
// intra-content 的 BOM 不剥:可能是有意为之(极少),且剥错会破坏内容。
const base = 'line1\nmiddle\nline3';
const next = 'line1\nmiddle\nline3';
const out = computeFullMarkdownDiff(base, next);
expect(out.regions).toEqual([]);
});
it('BOM + 真改动:diff 只反映真改动,BOM 不计入 regions', () => {
const base = '# Title\nold';
const next = '# Title\nnew';
const out = computeFullMarkdownDiff(base, next);
expect(out.regions).toHaveLength(1);
// region 应只包含改动的行,不包含被剥的 BOM
const changed = out.regions[0];
expect(changed.newLines).toEqual(['new']);
expect(changed.oldLines).toEqual(['old']);
});
it('纯新增一行:产生 1 个 region + 1 个 added row', () => {
const out = computeFullMarkdownDiff('a\nb', 'a\nx\nb');
expect(out.regions).toHaveLength(1);
const region = out.regions[0];
expect(region.oldLines).toEqual([]);
expect(region.newLines).toEqual(['x']);
// 新增行的 type 应是 'added'
const addedRows = out.rows.filter((r) => r.type === 'added' && r.regionId === region.id);
expect(addedRows).toHaveLength(1);
expect(addedRows[0].segments[0].text).toBe('x');
});
it('纯删除一行:oldLines 有内容,newLines 为空', () => {
const out = computeFullMarkdownDiff('a\nx\nb', 'a\nb');
expect(out.regions).toHaveLength(1);
const region = out.regions[0];
expect(region.oldLines).toEqual(['x']);
expect(region.newLines).toEqual([]);
const removedRows = out.rows.filter((r) => r.type === 'removed' && r.regionId === region.id);
expect(removedRows).toHaveLength(1);
expect(removedRows[0].segments[0].text).toBe('x');
});
it('修改一行:产生 removed + added 一对,oldLines/newLines 各 1', () => {
const out = computeFullMarkdownDiff('a\nfoo\nb', 'a\nbar\nb');
expect(out.regions).toHaveLength(1);
const region = out.regions[0];
expect(region.oldLines).toEqual(['foo']);
expect(region.newLines).toEqual(['bar']);
});
it('多处独立修改:产生多个 region', () => {
const out = computeFullMarkdownDiff(
'a\nfoo1\nb\nfoo2\nc',
'a\nbar1\nb\nbar2\nc'
);
expect(out.regions).toHaveLength(2);
});
it('行级 MAX_LINE_DIFF_CELLS 守卫:超大输入不爆炸(fallback 到行级 diff)', () => {
// MAX_LINE_DIFF_CELLS = 200_000;构造 n*m > 200_000 但内容仍合法的输入
// 期望:返回结果仍合理(rows / regions 数量与内容一致),不会抛错或返回 undefined
const n = 1000;
const oldLines = Array.from({ length: n }, (_, i) => `old-${i}`);
const newLines = oldLines.map((l, i) => (i === 500 ? `NEW-${i}` : l));
const oldContent = oldLines.join('\n');
const newContent = newLines.join('\n');
// 1000 * 1000 = 1_000_000 cells >> 200_000 守卫
const out = computeFullMarkdownDiff(oldContent, newContent);
expect(out).toBeTruthy();
expect(Array.isArray(out.rows)).toBe(true);
expect(Array.isArray(out.regions)).toBe(true);
// 至少能识别出那 1 处修改
expect(out.regions.length).toBeGreaterThanOrEqual(1);
});
it('H3:行级 fallback 触发时返回 warnings 字段(audit H3 修复)', () => {
// MAX_LINE_DIFF_CELLS = 200_000。需要构造一个 oldMiddle × newMiddle > 200_000
// 的输入。如果两批行毫无公共前后缀,prefix/suffix trim 起不到作用,
// middle 长度就是 n,必然走 fallback。
const n = 1000;
const oldLines = Array.from({ length: n }, (_, i) => `old-${i}`);
// 完全打乱:所有 new 行都不等于任何 old 行 → LCS 退化为 O(n²)
const newLines = Array.from({ length: n }, (_, i) => `new-${i}`);
// sanity:1000 * 1000 = 1_000_000 >> 200_000
const out = computeFullMarkdownDiff(oldLines.join('\n'), newLines.join('\n'));
expect(Array.isArray(out.warnings)).toBe(true);
expect(out.warnings.length).toBeGreaterThanOrEqual(1);
// 至少一条提示「行级 diff 超过 ... cells 上限」
expect(out.warnings.some((w) => /行级 diff 超过/.test(w))).toBe(true);
});
it('H3:正常 diff 不带 warnings 字段(保持 return shape 稳定)', () => {
const out = computeFullMarkdownDiff('a\nfoo\nb', 'a\nbar\nb');
expect(out.warnings).toBeUndefined();
});
it('tokenDiff 选项不破坏结构(接口兼容性)', () => {
const out = computeFullMarkdownDiff('hello world', 'hello there', { tokenDiff: true });
expect(out).toBeTruthy();
expect(Array.isArray(out.rows)).toBe(true);
expect(Array.isArray(out.regions)).toBe(true);
});
});
describe('applyDiffRegionSafely', () => {
it('成功替换:唯一匹配', () => {
const diff = computeFullMarkdownDiff('a\nfoo\nb', 'a\nbar\nb');
const region = diff.regions[0];
const r = applyDiffRegionSafely('a\nfoo\nb', region);
expect(r.ok).toBe(true);
expect(r.content).toBe('a\nbar\nb');
});
it('保留末尾换行(endsWithNewline)', () => {
const diff = computeFullMarkdownDiff('a\nfoo\nb\n', 'a\nbar\nb\n');
const region = diff.regions[0];
const r = applyDiffRegionSafely('a\nfoo\nb\n', region);
expect(r.ok).toBe(true);
expect(r.content.endsWith('\n')).toBe(true);
expect(r.content).toBe('a\nbar\nb\n');
});
it('成功替换 + 没有末尾换行:不补 \\n', () => {
const diff = computeFullMarkdownDiff('a\nfoo\nb', 'a\nbar\nb');
const region = diff.regions[0];
const r = applyDiffRegionSafely('a\nfoo\nb', region);
expect(r.ok).toBe(true);
expect(r.content.endsWith('\n')).toBe(false);
});
it('CRLF 文件:apply 后行尾仍是 CRLF(不被改成 LF)', () => {
const before = 'a\r\nfoo\r\nb\r\n';
const after = 'a\r\nbar\r\nb\r\n';
// 用 LF 描述 diff(算法内部按 LF 算 region),但实际磁盘上是 CRLF。
const diff = computeFullMarkdownDiff('a\nfoo\nb\n', 'a\nbar\nb\n');
const r = applyDiffRegionSafely(before, diff.regions[0]);
expect(r.ok).toBe(true);
expect(r.content).toBe(after);
expect(r.content.includes('\r\n')).toBe(true);
expect(r.content.includes('\nfoo')).toBe(false); // 行内不含裸 LF
});
it('LF 文件:apply 后行尾仍是 LF(不被改成 CRLF)', () => {
const before = 'a\nfoo\nb\n';
const after = 'a\nbar\nb\n';
const diff = computeFullMarkdownDiff(before, after);
const r = applyDiffRegionSafely(before, diff.regions[0]);
expect(r.ok).toBe(true);
expect(r.content).toBe(after);
expect(r.content.includes('\r\n')).toBe(false);
});
it('CRLF 文件 + 末尾无换行:apply 后仍无末尾换行', () => {
const before = 'a\r\nfoo\r\nb';
const after = 'a\r\nbar\r\nb';
const diff = computeFullMarkdownDiff('a\nfoo\nb', 'a\nbar\nb');
const r = applyDiffRegionSafely(before, diff.regions[0]);
expect(r.ok).toBe(true);
expect(r.content).toBe(after);
expect(r.content.endsWith('\r\n')).toBe(false);
expect(r.content.endsWith('\n')).toBe(false);
});
it('conflict:旧行已被删除 → 0 匹配 → ok:false', () => {
const diff = computeFullMarkdownDiff('a\nfoo\nb', 'a\nbar\nb');
const region = diff.regions[0];
// 当前内容里 "foo" 已经被改成别的
const r = applyDiffRegionSafely('a\nbaz\nb', region);
expect(r.ok).toBe(false);
expect(typeof r.reason).toBe('string');
expect(r.reason.length).toBeGreaterThan(0);
});
it('conflict:多处重复 + oldStart 失效 → 多匹配 → ok:false', () => {
// region.oldStart 是算法给出的「最佳位置」hint —— 当 preferredIndex 处的行
// 跟 oldLines[0] 对不上时,findSequence 才退回去扫描全部匹配。
// 构造场景:用户编辑后,原 region 指向的"foo"被改成别的,
// 而文件其它位置仍出现 "foo"(多次匹配)—— 算法无法确定应替换哪一处。
const diff = computeFullMarkdownDiff('a\nfoo\nb', 'a\nbar\nb');
const region = diff.regions[0];
// 当前内容:原 region 位置的 "foo" 已被改成 "X",但文件其它两处仍有 "foo"
// findSequence 找不到 preferredIndex 命中 → 扫描全文 → 2 处匹配 → conflict
const r = applyDiffRegionSafely('foo\nX\nfoo\nb', region);
expect(r.ok).toBe(false);
expect(typeof r.reason).toBe('string');
});
it('纯新增 + 唯一上下文:能安全插入', () => {
const diff = computeFullMarkdownDiff('a\nb', 'a\nx\nb');
expect(diff.regions).toHaveLength(1);
const region = diff.regions[0];
// region.oldLines=[],但 beforeContext=['a'], afterContext=['b']
expect(region.oldLines).toEqual([]);
expect(region.newLines).toEqual(['x']);
expect(region.beforeContext).toEqual(['a']);
expect(region.afterContext).toEqual(['b']);
const r = applyDiffRegionSafely('a\nb', region);
expect(r.ok).toBe(true);
expect(r.content).toBe('a\nx\nb');
});
it('apply 完一轮后内容应当再次能 compute(idempotent 内容一致)', () => {
const oldContent = 'a\nfoo\nb';
const newContent = 'a\nbar\nb';
const diff = computeFullMarkdownDiff(oldContent, newContent);
const region = diff.regions[0];
const applied = applyDiffRegionSafely(oldContent, region);
expect(applied.ok).toBe(true);
// 应用之后再算一次 diff,regions 应为空
const secondDiff = computeFullMarkdownDiff(applied.content, newContent);
expect(secondDiff.regions).toEqual([]);
});
});
describe('C1 整篇替换 / 无上下文 region(audit C1 fix)', () => {
it('整篇替换:region 无上下文 → 标 conflict 引导走应用全部', () => {
// 场景:AI 把整段换成 X/Y,前后没保留任何 context 行
const out = computeFullMarkdownDiff('A\nB', 'X\nY');
expect(out.regions).toHaveLength(1);
const region = out.regions[0];
expect(region.beforeContext).toEqual([]);
expect(region.afterContext).toEqual([]);
expect(region.conflict).toMatch(/无上下文/);
});
it('在空文档插入新内容:region 无上下文 → 标 conflict', () => {
const out = computeFullMarkdownDiff('', 'X\nY');
expect(out.regions).toHaveLength(1);
expect(out.regions[0].beforeContext).toEqual([]);
expect(out.regions[0].afterContext).toEqual([]);
expect(out.regions[0].conflict).toBeTruthy();
});
it('局部修改(保留 context):region 不应被标 conflict', () => {
const out = computeFullMarkdownDiff('A\nfoo\nB', 'A\nbar\nB');
expect(out.regions).toHaveLength(1);
expect(out.regions[0].conflict).toBeUndefined();
});
});
describe('C3 歧义匹配二次校验(audit C3 fix)', () => {
it('oldLines 唯一匹配但上下文漂移 → 拒绝应用', () => {
// region 算出来 oldStart=1,前后 context 期望为 ["a"]/["b"]
const diff = computeFullMarkdownDiff('a\nfoo\nb', 'a\nbar\nb');
const region = diff.regions[0];
// 用户在前面又加了一个完全相同的 "foo"——oldLines 还能匹配到 preferredIndex 那个,
// 但**该位置的前后文**与 region 算出的不同(前面是 "X" 不是 "a",后面是 "foo" 不是 "b")。
// 这就是「复制粘贴造成歧义」场景——必须靠 beforeContext/afterContext 二次校验兜底。
const drifted = 'X\nfoo\nfoo\nfoo';
const r = applyDiffRegionSafely(drifted, region);
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/上下文漂移/);
});
it('oldLines 唯一匹配 + 上下文一致 → 正常替换', () => {
// 控制组:完全没漂移的常规 case
const diff = computeFullMarkdownDiff('a\nfoo\nb', 'a\nbar\nb');
const region = diff.regions[0];
const r = applyDiffRegionSafely('a\nfoo\nb', region);
expect(r.ok).toBe(true);
expect(r.content).toBe('a\nbar\nb');
});
// fix(audit 2026-08):多匹配但 context 唯一 → 仍能安全应用。
// 场景:用户在其它位置粘了相同行,oldLines 匹配多个下标,但只有一个位置的
// 前后文与 region 一致 —— 用 context 过滤掉歧义,应用 context 唯一的那处。
it('多匹配 + 唯一 context → 应用 context 匹配位置', () => {
// region 期望 oldLines=['foo'], beforeContext=['a'], afterContext=['b']
const diff = computeFullMarkdownDiff('a\nfoo\nb', 'a\nbar\nb');
const region = diff.regions[0];
// drifted: 有 2 个 foo,但只有 index 1 处的 context 是 a...b;index 3 上下文是 c...d
const drifted = 'a\nfoo\nb\nc\nfoo\nd';
const r = applyDiffRegionSafely(drifted, region);
expect(r.ok).toBe(true);
expect(r.content).toBe('a\nbar\nb\nc\nfoo\nd');
});
// fix(audit 2026-08):多匹配 + 多处 context 都一致 → 真歧义,refuse。
it('多匹配 + 多处 context 都一致 → 拒绝', () => {
const diff = computeFullMarkdownDiff('a\nfoo\nb', 'a\nbar\nb');
const region = diff.regions[0];
// 两段都是 a...foo...b 上下文 + foo 行重复 —— region context 与两处都一致
const duplicated = 'a\nfoo\nb\n...\na\nfoo\nb';
const r = applyDiffRegionSafely(duplicated, region);
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/多个相同位置/);
});
// fix(audit 2026-08):preferredIndex 命中 ≠ 唯一匹配时,必须扫描全文。
// 旧版在 preferredIndex 命中时直接 early-return [preferredIndex],跳过扫描 →
// 其它位置的重复匹配被静默忽略,apply 会改错行。
// 新行为:扫描全文 → context 过滤 → 唯一 context 匹配位置 → apply。
it('preferredIndex 命中 + 其它位置重复 + context 唯一 → 应用正确位置', () => {
const diff = computeFullMarkdownDiff('a\nfoo\nb', 'a\nbar\nb');
const region = diff.regions[0]; // oldStart=1, context ['a']/['b']
// preferredIndex=1 处仍是 'a\nfoo\nb'(context 匹配),index 4 也匹配 foo
// 但 index 4 处上下文是 ['X']/['Y'],不匹配 region
const drifted = 'a\nfoo\nb\nX\nfoo\nY';
const r = applyDiffRegionSafely(drifted, region);
expect(r.ok).toBe(true);
expect(r.content).toBe('a\nbar\nb\nX\nfoo\nY');
});
});
describe('C4 整段删除不被空防线误伤(audit C4 fix)', () => {
// 注意:纯 region 删除合法性的拦截逻辑在 ai-controller.js(handlesApplyRegion
// 空内容防线)。shared/markdown-diff.js 自身允许 appliedContent 为空字符串——
// 这部分测试在 ai-controller 层覆盖。这里只验证算法层"整段删除"算出来就是 ""。
it('整段删除:apply 后内容为 ""', () => {
const diff = computeFullMarkdownDiff(' \n ', '');
expect(diff.regions).toHaveLength(1);
const region = diff.regions[0];
expect(region.oldLines.length).toBeGreaterThan(0);
expect(region.newLines).toEqual([]);
const r = applyDiffRegionSafely(' \n ', region);
expect(r.ok).toBe(true);
expect(r.content).toBe('');
});
});