Files
jiang13-bbs/frontend/lib/smartDiff.test.ts
freefire 5270fee2b7 feat: 评论/帖子编辑历史与编辑标记,内容锁定
- 新增 smartDiff 差异计算与 EditHistoryModal 编辑历史弹窗,替换原 CommentEditHistoryModal
- 新增 PostEditedMark 编辑标记展示
- 新增内容锁定机制(content_lock)防止并发编辑冲突
- 审核与通知服务适配
2026-09-25 23:41:45 +08:00

215 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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.
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`);
});
});