File size: 5,874 Bytes
fae625a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
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(
			`<span class="diff-line diff-del"><span class="diff-sign">- </span>b</span>`
		);
		expect(lines[2]).toBe(
			`<span class="diff-line diff-add"><span class="diff-sign">+ </span>B</span>`
		);
	});

	it("escapes HTML in line content", () => {
		const html = renderDiffHtml([{ type: "add", text: `<img & y>` }]);
		expect(html).toContain("&lt;img &amp; y&gt;");
		expect(html).not.toContain("<img");
	});

	it("keeps syntax highlighting spans that cross line boundaries", () => {
		// Fake highlighter wrapping the whole document in one token span
		const highlight = (text: string) => `<span class="hljs-string">${text}</span>`;
		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(`  <span class="hljs-string">a</span>`);
		expect(lines[1]).toBe(
			`<span class="diff-line diff-add"><span class="diff-sign">+ </span>` +
				`<span class="hljs-string">b</span></span>`
		);
	});

	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(`<span class="diff-emph">div</span>`);
		expect(lines[1]).toContain(`<span class="diff-emph">span</span>`);
	});

	it("counts entities as single characters when emphasizing", () => {
		// The "<" before the change escapes to &lt; (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(`&lt;`);
		expect(lines[0]).toContain(`<span class="diff-emph">x</span>`);
		expect(lines[1]).toContain(`<span class="diff-emph">y</span>`);
	});

	it("splits the emphasis chip around token tags instead of crossing them", () => {
		const highlight = (text: string) => text.replace(/\+/g, `<span class="hljs-operator">+</span>`);
		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(`<span class="diff-emph">2 </span>`);
		expect(addLine).toContain(
			`<span class="hljs-operator"><span class="diff-emph">+</span></span>`
		);
		expect(addLine).toContain(`<span class="diff-emph"> y</span>`);
	});

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