188 lines
6.5 KiB
TypeScript
188 lines
6.5 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import { computeDiffLines, diffStats, type DiffLine } from "./structuredDiff.js";
|
|
|
|
function texts(lines: DiffLine[]): Array<[string, string]> {
|
|
return lines.map((l) => [l.kind, l.text]);
|
|
}
|
|
|
|
describe("computeDiffLines", () => {
|
|
it("renders a brand new file (no oldText) as all additions", () => {
|
|
const lines = computeDiffLines(null, "a\nb\n");
|
|
expect(texts(lines)).toEqual([
|
|
["add", "a"],
|
|
["add", "b"],
|
|
]);
|
|
});
|
|
|
|
it("renders a full deletion as all removals", () => {
|
|
const lines = computeDiffLines("a\nb\n", "");
|
|
expect(texts(lines)).toEqual([
|
|
["del", "a"],
|
|
["del", "b"],
|
|
]);
|
|
});
|
|
|
|
it("shows a small replace as context around del/add", () => {
|
|
const lines = computeDiffLines("one\ntwo\nthree\n", "one\ntwx\nthree\n");
|
|
expect(texts(lines)).toEqual([
|
|
["ctx", "one"],
|
|
["del", "two"],
|
|
["add", "twx"],
|
|
["ctx", "three"],
|
|
]);
|
|
});
|
|
|
|
it("leaves identical text as pure context, uncollapsed when short", () => {
|
|
const lines = computeDiffLines("a\nb\nc\n", "a\nb\nc\n");
|
|
expect(texts(lines)).toEqual([
|
|
["ctx", "a"],
|
|
["ctx", "b"],
|
|
["ctx", "c"],
|
|
]);
|
|
});
|
|
|
|
it("handles an insertion without changing surrounding lines", () => {
|
|
expect(texts(computeDiffLines("a\nc\n", "a\nb\nc\n"))).toEqual([
|
|
["ctx", "a"],
|
|
["add", "b"],
|
|
["ctx", "c"],
|
|
]);
|
|
});
|
|
|
|
it("handles a deletion without changing surrounding lines", () => {
|
|
expect(texts(computeDiffLines("a\nb\nc\n", "a\nc\n"))).toEqual([
|
|
["ctx", "a"],
|
|
["del", "b"],
|
|
["ctx", "c"],
|
|
]);
|
|
});
|
|
|
|
it("renders two empty files without invented lines", () => {
|
|
expect(computeDiffLines("", "")).toEqual([]);
|
|
});
|
|
|
|
it("shows when a final newline is added", () => {
|
|
expect(texts(computeDiffLines("a", "a\n"))).toEqual([
|
|
["del", "a"],
|
|
["eof", "\\ No newline at end of file"],
|
|
["add", "a"],
|
|
]);
|
|
});
|
|
|
|
it("shows when a final newline is removed", () => {
|
|
expect(texts(computeDiffLines("a\n", "a"))).toEqual([
|
|
["del", "a"],
|
|
["add", "a"],
|
|
["eof", "\\ No newline at end of file"],
|
|
]);
|
|
});
|
|
|
|
it("marks an unchanged unterminated final context line", () => {
|
|
expect(texts(computeDiffLines("a\nb\nc", "a\nX\nc"))).toEqual([
|
|
["ctx", "a"],
|
|
["del", "b"],
|
|
["add", "X"],
|
|
["ctx", "c"],
|
|
["eof", "\\ No newline at end of file"],
|
|
]);
|
|
});
|
|
|
|
it("marks an unterminated new file only once", () => {
|
|
const lines = computeDiffLines(null, "a");
|
|
expect(texts(lines)).toEqual([
|
|
["add", "a"],
|
|
["eof", "\\ No newline at end of file"],
|
|
]);
|
|
expect(lines.filter((line) => line.kind === "eof")).toHaveLength(1);
|
|
});
|
|
|
|
it("collapses a long unchanged interior run, keeping 3 lines of context each side", () => {
|
|
const common = Array.from({ length: 20 }, (_, i) => `line${i}`);
|
|
const oldText = [...common.slice(0, 10), "OLD", ...common.slice(10)].join("\n");
|
|
const newText = [...common.slice(0, 10), "NEW", ...common.slice(10)].join("\n");
|
|
const lines = computeDiffLines(oldText, newText);
|
|
// The leading run keeps only its last 3 lines (adjacent to the change),
|
|
// behind a marker for the rest; the trailing run mirrors that.
|
|
expect(lines[0].kind).toBe("hunk");
|
|
expect(lines[1]).toEqual({ kind: "ctx", text: "line7" });
|
|
expect(lines[2]).toEqual({ kind: "ctx", text: "line8" });
|
|
expect(lines[3]).toEqual({ kind: "ctx", text: "line9" });
|
|
expect(lines[4].kind).toBe("del");
|
|
expect(lines[5].kind).toBe("add");
|
|
expect(lines[6]).toEqual({ kind: "ctx", text: "line10" });
|
|
expect(lines.filter((l) => l.kind === "hunk")).toHaveLength(2);
|
|
});
|
|
|
|
it("preserves the EOF marker when a long unterminated trailing run collapses", () => {
|
|
const trailing = Array.from({ length: 10 }, (_, i) => `t${i}`);
|
|
const oldText = ["OLD", ...trailing].join("\n");
|
|
const newText = ["NEW", ...trailing].join("\n");
|
|
|
|
expect(texts(computeDiffLines(oldText, newText))).toEqual([
|
|
["del", "OLD"],
|
|
["add", "NEW"],
|
|
["ctx", "t0"],
|
|
["ctx", "t1"],
|
|
["ctx", "t2"],
|
|
["hunk", "⋯ 7 unchanged lines ⋯"],
|
|
["eof", "\\ No newline at end of file"],
|
|
]);
|
|
});
|
|
|
|
it("produces one hunk for the long middle run, none for the short edges", () => {
|
|
// Edge runs (2 lines) fit within their one-sided 3-line context budget;
|
|
// only the interior run (10 lines, budgeted 3+3=6) needs to collapse.
|
|
const middle = Array.from({ length: 10 }, (_, i) => `c${i}`);
|
|
const oldText = ["lead0", "lead1", "OLD1", ...middle, "OLD2", "trail0", "trail1"].join("\n");
|
|
const newText = ["lead0", "lead1", "NEW1", ...middle, "NEW2", "trail0", "trail1"].join("\n");
|
|
const lines = computeDiffLines(oldText, newText);
|
|
const dels = lines.filter((l) => l.kind === "del").map((l) => l.text);
|
|
const adds = lines.filter((l) => l.kind === "add").map((l) => l.text);
|
|
expect(dels).toEqual(["OLD1", "OLD2"]);
|
|
expect(adds).toEqual(["NEW1", "NEW2"]);
|
|
expect(lines.filter((l) => l.kind === "hunk")).toHaveLength(1);
|
|
expect(texts(lines).slice(0, 2)).toEqual([
|
|
["ctx", "lead0"],
|
|
["ctx", "lead1"],
|
|
]);
|
|
expect(texts(lines).slice(-3)).toEqual([
|
|
["ctx", "trail0"],
|
|
["ctx", "trail1"],
|
|
["eof", "\\ No newline at end of file"],
|
|
]);
|
|
});
|
|
|
|
it("handles an old file with no trailing newline", () => {
|
|
const lines = computeDiffLines("a\nb", "a\nb\nc");
|
|
expect(texts(lines)).toEqual([
|
|
["ctx", "a"],
|
|
["del", "b"],
|
|
["eof", "\\ No newline at end of file"],
|
|
["add", "b"],
|
|
["add", "c"],
|
|
["eof", "\\ No newline at end of file"],
|
|
]);
|
|
});
|
|
|
|
it("falls back to a bounded coarse diff for a pathological changed middle", () => {
|
|
const oldLines = Array.from({ length: 501 }, (_, index) => `line${index}`);
|
|
const newLines = [...oldLines.slice(1), oldLines[0]];
|
|
const lines = computeDiffLines(`${oldLines.join("\n")}\n`, `${newLines.join("\n")}\n`);
|
|
expect(lines).toHaveLength(1_002);
|
|
expect(lines.slice(0, 501).every((line) => line.kind === "del")).toBe(true);
|
|
expect(lines.slice(501).every((line) => line.kind === "add")).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("diffStats", () => {
|
|
it("counts add/del lines, ignoring context and hunk markers", () => {
|
|
const lines = computeDiffLines("a\nb\nc\n", "a\nx\ny\nc\n");
|
|
expect(diffStats(lines)).toEqual({ added: 2, removed: 1 });
|
|
});
|
|
|
|
it("is zero for identical text", () => {
|
|
expect(diffStats(computeDiffLines("same\n", "same\n"))).toEqual({ added: 0, removed: 0 });
|
|
});
|
|
});
|