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 }); }); });