- 新增 smartDiff 差异计算与 EditHistoryModal 编辑历史弹窗,替换原 CommentEditHistoryModal - 新增 PostEditedMark 编辑标记展示 - 新增内容锁定机制(content_lock)防止并发编辑冲突 - 审核与通知服务适配
215 lines
8.2 KiB
TypeScript
215 lines
8.2 KiB
TypeScript
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`);
|
||
});
|
||
});
|