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("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("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(-2)).toEqual([ ["ctx", "trail0"], ["ctx", "trail1"], ]); }); it("handles an old file with no trailing newline", () => { const lines = computeDiffLines("a\nb", "a\nb\nc"); expect(texts(lines)).toEqual([ ["ctx", "a"], ["ctx", "b"], ["add", "c"], ]); }); }); 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 }); }); });