import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { smartDiff, toFoldedRows } from "./smartDiff.ts"; /** 把结果压成便于断言的短格式:每行 "op:text" */ function flat(text: string, other: string): string[] { const r = smartDiff(text, other); return r.blocks.flatMap((b) => b.lines.map((l) => `${l.op}:${l.text}`)); } describe("smartDiff", () => { it("场景1:整段中文说明 → 整段 R 代码,判定 block-replace 而非碎片对齐", () => { // 两段各留一个空行作为「诱饵公共行」——朴素 LCS 会围绕它做对齐 const oldText = [ "本函数用于处理用户上传的数据。", "", "首先校验输入的合法性,", "然后将数据逐行写入临时表,", "最后清理缓存并返回结果。", ].join("\n"); const newText = [ "process_data <- function(df) {", "", " stopifnot(!is.null(df))", " out <- transform(df, total = sum(x))", ' write.csv(out, "out.csv")', " invisible(out)", "}", ].join("\n"); const r = smartDiff(oldText, newText); assert.equal(r.blocks.length, 1); assert.equal(r.blocks[0].type, "block-replace"); // 大删除块在前、大新增块在后,不出现 -/+ 交替碎片 const ops = r.blocks[0].lines.map((l) => l.op); assert.ok(ops.slice(0, 5).every((o) => o === "del")); assert.ok(ops.slice(5).every((o) => o === "ins")); assert.equal(r.stats.dels, 5); assert.equal(r.stats.adds, 7); // 整段替换不做行内细分 assert.ok(r.blocks[0].lines.every((l) => l.segments === undefined)); }); it("场景2:单行内改一个词,输出词级 segments 而非整行标红标绿", () => { const oldText = "const total = countItems(list);"; const newText = "const total = countUsers(list);"; const r = smartDiff(oldText, newText); assert.equal(r.blocks.length, 1); assert.equal(r.blocks[0].type, "replace"); const del = r.blocks[0].lines[0]; const ins = r.blocks[0].lines[1]; assert.ok(del.segments && del.segments.length > 0, "del 行应有 segments"); assert.ok(ins.segments && ins.segments.length > 0, "ins 行应有 segments"); // 删除行上被标记的正是 countItems,新增行上是 countUsers const delMarked = del.segments!.map((s) => del.text.slice(s.start, s.end)).join(""); const insMarked = ins.segments!.map((s) => ins.text.slice(s.start, s.end)).join(""); assert.equal(delMarked, "countItems"); assert.equal(insMarked, "countUsers"); // 片段不应包含未变化的部分 for (const s of del.segments!) { assert.ok(!del.text.slice(s.start, s.end).includes("const")); } }); it("场景3:仅行尾空白变化,不产生假改动", () => { const oldText = "第一行 \n第二行\t"; const newText = "第一行\n第二行"; const r = smartDiff(oldText, newText); assert.equal(r.blocks.length, 1); assert.equal(r.blocks[0].type, "equal"); assert.deepEqual(r.stats, { adds: 0, dels: 0 }); // 展示保留原文(含行尾空白),但比较视为相同 assert.equal(r.blocks[0].lines[0].text, "第一行 "); }); it("场景4:大量重复空行/花括号的代码,对齐不错位、变更块不碎", () => { const oldText = [ "function a() {", "", " if (x) {", "", " return 1;", "", " }", "", "}", ].join("\n"); const newText = [ "function a() {", "", " if (y) {", "", " return 2;", "", " }", "", "}", ].join("\n"); const r = smartDiff(oldText, newText); // 期望:equal → replace(if 行) → equal → replace(return 行) → equal assert.deepEqual( r.blocks.map((b) => b.type), ["equal", "replace", "equal", "replace", "equal"] ); // 两个 replace 块均有行内细分,且只标记真正变化的字符 const [b1, b2] = [r.blocks[1], r.blocks[3]]; assert.equal(b1.lines[0].text, " if (x) {"); assert.equal( b1.lines[0].segments!.map((s) => b1.lines[0].text.slice(s.start, s.end)).join(""), "x" ); assert.equal( b1.lines[1].segments!.map((s) => b1.lines[1].text.slice(s.start, s.end)).join(""), "y" ); assert.equal( b2.lines[0].segments!.map((s) => b2.lines[0].text.slice(s.start, s.end)).join(""), "1" ); // 空行不进入任何变更块 assert.ok(r.blocks.every((b) => b.type === "equal" || !b.lines.some((l) => l.text === ""))); }); it("场景5:完全相同的文本,只有一个 equal 块", () => { const text = "第一行\n第二行\n第三行"; const r = smartDiff(text, text); assert.equal(r.blocks.length, 1); assert.equal(r.blocks[0].type, "equal"); assert.equal(r.blocks[0].lines.length, 3); assert.deepEqual(r.stats, { adds: 0, dels: 0 }); }); it("CRLF / CR 行尾符差异不影响比较", () => { const r = smartDiff("a\r\nb\rc", "a\nb\nc"); assert.equal(r.blocks.length, 1); assert.equal(r.blocks[0].type, "equal"); assert.deepEqual(r.stats, { adds: 0, dels: 0 }); }); it("单侧为空:整体删除 / 整体新增", () => { const ins = smartDiff("", "新的一行\n再来一行"); assert.deepEqual(ins.blocks.map((b) => b.type), ["insert"]); assert.equal(ins.stats.adds, 2); const del = smartDiff("旧行一\n旧行二", ""); assert.deepEqual(del.blocks.map((b) => b.type), ["delete"]); assert.equal(del.stats.dels, 2); assert.deepEqual(smartDiff("", "").blocks, []); }); it("后处理不变式:相邻同向块必合并,replace 块内 del 全部在 ins 之前", () => { const oldText = Array.from({ length: 30 }, (_, i) => `common ${i}`).join("\n"); const newText = oldText .replace("common 5", "changed 5") .replace("common 6", "changed 6") .replace("common 20", "changed 20"); const r = smartDiff(oldText, newText); // 相邻块不允许同为变更块(equal 必然隔开两个变更区域) for (let i = 1; i < r.blocks.length; i++) { const prev = r.blocks[i - 1]; const cur = r.blocks[i]; assert.ok( !(prev.type !== "equal" && cur.type !== "equal"), "相邻变更块未被合并" ); } for (const b of r.blocks) { if (b.type !== "replace") continue; const firstIns = b.lines.findIndex((l) => l.op === "ins"); const lastDel = b.lines.map((l) => l.op).lastIndexOf("del"); assert.ok(lastDel < firstIns, "replace 块内应先删后增"); } }); it("上下文折叠:长 equal 块折叠为上下文 + 省略标记,头/尾单侧保留", () => { const lines = Array.from({ length: 40 }, (_, i) => `L${i}`); const oldText = lines.join("\n"); const newText = [...lines.slice(0, 20), "changed", ...lines.slice(21)].join("\n"); const rows = toFoldedRows(smartDiff(oldText, newText).blocks); // 头部 equal 块(L0..L19,isHead)只保留尾部 3 行上下文 L17/L18/L19 + 折叠 17 行; // 尾部 equal 块(L21..L39,isTail)只保留头部 3 行上下文 L21/L22/L23 + 折叠 16 行 const folds = rows.filter((r) => r.kind === "fold") as Array<{ kind: "fold"; count: number }>; assert.deepEqual( folds.map((f) => f.count), [17, 16] ); // 头部 equal 块(L0..L19,isHead):省略前 17 行 → 保留尾部上下文 L17/L18/L19; // 尾部 equal 块(L21..L39,isTail):保留头部上下文 L21/L22/L23 → 省略后 16 行 assert.deepEqual(rows.slice(0, 4).map((r) => (r.kind === "fold" ? "fold" : r.text)), [ "fold", "L17", "L18", "L19", ]); }); it("10 万行大文件在 2 秒内完成", () => { const n = 100_000; const oldLines = Array.from({ length: n }, (_, i) => `line-${i}`); const newLines = [...oldLines]; const changes = 20; for (let k = 0; k < changes; k++) { newLines[k * 4_000 + 7] = `line-${k * 4_000 + 7}-changed`; } const start = performance.now(); const r = smartDiff(oldLines.join("\n"), newLines.join("\n")); const elapsed = performance.now() - start; assert.equal(r.stats.dels, changes); assert.equal(r.stats.adds, changes); assert.ok(elapsed < 2_000, `应在 2 秒内完成,实际 ${elapsed.toFixed(0)}ms`); }); });