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 → 空 regionsrows 只剩 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}`);
// sanity1000 * 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 完一轮后内容应当再次能 computeidempotent 内容一致)', () => {
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);
// 应用之后再算一次 diffregions 应为空
const secondDiff = computeFullMarkdownDiff(applied.content, newContent);
expect(secondDiff.regions).toEqual([]);
});
});
describe('C1 整篇替换 / 无上下文 regionaudit 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('局部修改(保留 contextregion 不应被标 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...bindex 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.jshandlesApplyRegion
// 空内容防线。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('');
});
});