import { describe, expect, it } from "vitest";
import { diffLines, diffStats, renderDiffHtml } from "./artifactDiff";
describe("diffLines", () => {
it("returns only context lines for identical content", () => {
const lines = diffLines("a\nb\nc", "a\nb\nc");
expect(lines).toEqual([
{ type: "context", text: "a" },
{ type: "context", text: "b" },
{ type: "context", text: "c" },
]);
expect(diffStats(lines)).toEqual({ added: 0, removed: 0 });
});
it("emits deletion before addition for a replaced line", () => {
expect(diffLines("a\nb\nc", "a\nB\nc")).toEqual([
{ type: "context", text: "a" },
{ type: "del", text: "b" },
{ type: "add", text: "B" },
{ type: "context", text: "c" },
]);
});
it("handles pure insertions and deletions", () => {
expect(diffLines("a\nc", "a\nb\nc")).toEqual([
{ type: "context", text: "a" },
{ type: "add", text: "b" },
{ type: "context", text: "c" },
]);
expect(diffLines("a\nb\nc", "a\nc")).toEqual([
{ type: "context", text: "a" },
{ type: "del", text: "b" },
{ type: "context", text: "c" },
]);
});
it("handles changes at the very start and end", () => {
expect(diffLines("x\nb", "y\nb")).toEqual([
{ type: "del", text: "x" },
{ type: "add", text: "y" },
{ type: "context", text: "b" },
]);
expect(diffLines("a\nx", "a\ny")).toEqual([
{ type: "context", text: "a" },
{ type: "del", text: "x" },
{ type: "add", text: "y" },
]);
});
it("keeps unchanged lines between multiple change regions", () => {
expect(diffLines("a\nb\nc\nd\ne", "a\nB\nc\nd\nE")).toEqual([
{ type: "context", text: "a" },
{ type: "del", text: "b" },
{ type: "add", text: "B" },
{ type: "context", text: "c" },
{ type: "context", text: "d" },
{ type: "del", text: "e" },
{ type: "add", text: "E" },
]);
});
it("ignores a trailing newline difference", () => {
expect(diffLines("a\nb\n", "a\nb")).toEqual([
{ type: "context", text: "a" },
{ type: "context", text: "b" },
]);
});
it("falls back to delete-all/add-all when the changed region is huge", () => {
const oldText = Array.from({ length: 1100 }, (_, i) => `old ${i}`).join("\n");
const newText = Array.from({ length: 1100 }, (_, i) => `new ${i}`).join("\n");
const lines = diffLines(oldText, newText);
expect(lines).toHaveLength(2200);
expect(lines.slice(0, 1100).every((l) => l.type === "del")).toBe(true);
expect(lines.slice(1100).every((l) => l.type === "add")).toBe(true);
});
it("counts added and removed lines", () => {
const lines = diffLines("a\nb", "a\nB\nc");
expect(diffStats(lines)).toEqual({ added: 2, removed: 1 });
});
});
describe("renderDiffHtml", () => {
it("tints changed lines and keeps context plain", () => {
const html = renderDiffHtml([
{ type: "context", text: "a" },
{ type: "del", text: "b" },
{ type: "add", text: "B" },
]);
const lines = html.split("\n");
expect(lines[0]).toBe(" a");
expect(lines[1]).toBe(
`- b`
);
expect(lines[2]).toBe(
`+ B`
);
});
it("escapes HTML in line content", () => {
const html = renderDiffHtml([{ type: "add", text: `
` }]);
expect(html).toContain("<img & y>");
expect(html).not.toContain("
{
// Fake highlighter wrapping the whole document in one token span
const highlight = (text: string) => `${text}`;
const html = renderDiffHtml(
[
{ type: "context", text: "a" },
{ type: "add", text: "b" },
],
highlight
);
const lines = html.split("\n");
// The span is closed and reopened so each rendered line is self-contained
expect(lines[0]).toBe(` a`);
expect(lines[1]).toBe(
`+ ` +
`b`
);
});
it("emphasizes only the changed segment of a replaced line", () => {
const html = renderDiffHtml(
diffLines(`const el = create('div');`, `const el = create('span');`)
);
const lines = html.split("\n");
expect(lines[0]).toContain(`div`);
expect(lines[1]).toContain(`span`);
});
it("counts entities as single characters when emphasizing", () => {
// The "<" before the change escapes to < (one source character)
const html = renderDiffHtml(diffLines(`if (a < b) return x;`, `if (a < b) return y;`));
const lines = html.split("\n");
expect(lines[0]).toContain(`<`);
expect(lines[0]).toContain(`x`);
expect(lines[1]).toContain(`y`);
});
it("splits the emphasis chip around token tags instead of crossing them", () => {
const highlight = (text: string) => text.replace(/\+/g, `+`);
const html = renderDiffHtml(diffLines(`foo = bar1 + x;`, `foo = bar2 + y;`), highlight);
const addLine = html.split("\n")[1];
// The changed segment crosses the operator token: the chip closes before
// the tag and reopens inside it, so nesting stays valid
expect(addLine).toContain(`2 `);
expect(addLine).toContain(
`+`
);
expect(addLine).toContain(` y`);
});
it("skips emphasis when the paired lines share almost nothing", () => {
const html = renderDiffHtml([
{ type: "del", text: "aaaaaaaaaa" },
{ type: "add", text: "zzzzzzzzzz" },
]);
expect(html).not.toContain("diff-emph");
expect(html).toContain("diff-del");
expect(html).toContain("diff-add");
});
});