Full document, spreadsheet, slideshow, and diagram tooling
1import { describe, it, expect } from 'vitest';
2
3/**
4 * Tests for document diff utility.
5 *
6 * Pure logic tests for:
7 * - Text extraction from TipTap JSON
8 * - LCS-based word-level diff
9 * - Diff block grouping
10 * - HTML rendering of diffs
11 */
12
13import {
14 extractText,
15 diffWords,
16 diffDocuments,
17 renderDiffHtml,
18 type DiffBlock,
19} from '../src/docs/doc-diff.js';
20
21// --- extractText ---
22
23describe('extractText', () => {
24 it('extracts text from a simple paragraph', () => {
25 const doc = {
26 type: 'doc',
27 content: [
28 {
29 type: 'paragraph',
30 content: [
31 { type: 'text', text: 'Hello world' },
32 ],
33 },
34 ],
35 };
36 expect(extractText(doc)).toBe('Hello world');
37 });
38
39 it('extracts text from multiple paragraphs', () => {
40 const doc = {
41 type: 'doc',
42 content: [
43 {
44 type: 'paragraph',
45 content: [{ type: 'text', text: 'First paragraph' }],
46 },
47 {
48 type: 'paragraph',
49 content: [{ type: 'text', text: 'Second paragraph' }],
50 },
51 ],
52 };
53 expect(extractText(doc)).toBe('First paragraph\nSecond paragraph');
54 });
55
56 it('handles nested content (bold, italic)', () => {
57 const doc = {
58 type: 'doc',
59 content: [
60 {
61 type: 'paragraph',
62 content: [
63 { type: 'text', text: 'Hello ' },
64 { type: 'text', text: 'bold', marks: [{ type: 'bold' }] },
65 { type: 'text', text: ' world' },
66 ],
67 },
68 ],
69 };
70 expect(extractText(doc)).toBe('Hello bold world');
71 });
72
73 it('returns empty string for empty document', () => {
74 const doc = { type: 'doc', content: [] };
75 expect(extractText(doc)).toBe('');
76 });
77
78 it('returns empty string for null/undefined', () => {
79 expect(extractText(null)).toBe('');
80 expect(extractText(undefined)).toBe('');
81 });
82
83 it('handles document with no content array', () => {
84 const doc = { type: 'doc' };
85 expect(extractText(doc)).toBe('');
86 });
87
88 it('handles headings', () => {
89 const doc = {
90 type: 'doc',
91 content: [
92 {
93 type: 'heading',
94 attrs: { level: 1 },
95 content: [{ type: 'text', text: 'Title' }],
96 },
97 {
98 type: 'paragraph',
99 content: [{ type: 'text', text: 'Body text' }],
100 },
101 ],
102 };
103 expect(extractText(doc)).toBe('Title\nBody text');
104 });
105
106 it('handles bullet lists', () => {
107 const doc = {
108 type: 'doc',
109 content: [
110 {
111 type: 'bulletList',
112 content: [
113 {
114 type: 'listItem',
115 content: [
116 {
117 type: 'paragraph',
118 content: [{ type: 'text', text: 'Item one' }],
119 },
120 ],
121 },
122 {
123 type: 'listItem',
124 content: [
125 {
126 type: 'paragraph',
127 content: [{ type: 'text', text: 'Item two' }],
128 },
129 ],
130 },
131 ],
132 },
133 ],
134 };
135 expect(extractText(doc)).toBe('Item one\nItem two');
136 });
137});
138
139// --- diffWords ---
140
141describe('diffWords', () => {
142 it('returns equal blocks for identical text', () => {
143 const result = diffWords('hello world', 'hello world');
144 expect(result).toEqual([
145 { type: 'equal', content: 'hello world' },
146 ]);
147 });
148
149 it('detects inserted words', () => {
150 const result = diffWords('hello world', 'hello beautiful world');
151 expect(result).toEqual([
152 { type: 'equal', content: 'hello' },
153 { type: 'insert', content: 'beautiful' },
154 { type: 'equal', content: 'world' },
155 ]);
156 });
157
158 it('detects deleted words', () => {
159 const result = diffWords('hello beautiful world', 'hello world');
160 expect(result).toEqual([
161 { type: 'equal', content: 'hello' },
162 { type: 'delete', content: 'beautiful' },
163 { type: 'equal', content: 'world' },
164 ]);
165 });
166
167 it('handles complete replacement', () => {
168 const result = diffWords('alpha beta', 'gamma delta');
169 expect(result).toEqual([
170 { type: 'delete', content: 'alpha beta' },
171 { type: 'insert', content: 'gamma delta' },
172 ]);
173 });
174
175 it('handles empty old text', () => {
176 const result = diffWords('', 'hello world');
177 expect(result).toEqual([
178 { type: 'insert', content: 'hello world' },
179 ]);
180 });
181
182 it('handles empty new text', () => {
183 const result = diffWords('hello world', '');
184 expect(result).toEqual([
185 { type: 'delete', content: 'hello world' },
186 ]);
187 });
188
189 it('handles both empty', () => {
190 const result = diffWords('', '');
191 expect(result).toEqual([]);
192 });
193
194 it('handles single word change', () => {
195 const result = diffWords('the cat sat', 'the dog sat');
196 expect(result).toEqual([
197 { type: 'equal', content: 'the' },
198 { type: 'delete', content: 'cat' },
199 { type: 'insert', content: 'dog' },
200 { type: 'equal', content: 'sat' },
201 ]);
202 });
203
204 it('handles multiple scattered changes', () => {
205 const result = diffWords('a b c d e', 'a x c y e');
206 expect(result).toEqual([
207 { type: 'equal', content: 'a' },
208 { type: 'delete', content: 'b' },
209 { type: 'insert', content: 'x' },
210 { type: 'equal', content: 'c' },
211 { type: 'delete', content: 'd' },
212 { type: 'insert', content: 'y' },
213 { type: 'equal', content: 'e' },
214 ]);
215 });
216
217 it('groups consecutive same-type tokens', () => {
218 const result = diffWords('a b c', 'a b c d e');
219 expect(result).toEqual([
220 { type: 'equal', content: 'a b c' },
221 { type: 'insert', content: 'd e' },
222 ]);
223 });
224});
225
226// --- diffDocuments ---
227
228describe('diffDocuments', () => {
229 const makeDoc = (text: string) => ({
230 type: 'doc',
231 content: [
232 {
233 type: 'paragraph',
234 content: [{ type: 'text', text }],
235 },
236 ],
237 });
238
239 it('returns all equal for identical documents', () => {
240 const doc = makeDoc('hello world');
241 const result = diffDocuments(doc, doc);
242 expect(result).toEqual([
243 { type: 'equal', content: 'hello world' },
244 ]);
245 });
246
247 it('detects added text', () => {
248 const docA = makeDoc('hello world');
249 const docB = makeDoc('hello beautiful world');
250 const result = diffDocuments(docA, docB);
251 expect(result).toEqual([
252 { type: 'equal', content: 'hello' },
253 { type: 'insert', content: 'beautiful' },
254 { type: 'equal', content: 'world' },
255 ]);
256 });
257
258 it('detects removed text', () => {
259 const docA = makeDoc('hello beautiful world');
260 const docB = makeDoc('hello world');
261 const result = diffDocuments(docA, docB);
262 expect(result).toEqual([
263 { type: 'equal', content: 'hello' },
264 { type: 'delete', content: 'beautiful' },
265 { type: 'equal', content: 'world' },
266 ]);
267 });
268
269 it('handles mixed changes', () => {
270 const docA = makeDoc('the quick brown fox');
271 const docB = makeDoc('the slow brown dog');
272 const result = diffDocuments(docA, docB);
273 expect(result).toContainEqual({ type: 'equal', content: 'the' });
274 expect(result).toContainEqual({ type: 'delete', content: 'quick' });
275 expect(result).toContainEqual({ type: 'insert', content: 'slow' });
276 expect(result).toContainEqual({ type: 'equal', content: 'brown' });
277 expect(result).toContainEqual({ type: 'delete', content: 'fox' });
278 expect(result).toContainEqual({ type: 'insert', content: 'dog' });
279 });
280
281 it('handles empty documents', () => {
282 const emptyDoc = { type: 'doc', content: [] };
283 const result = diffDocuments(emptyDoc, emptyDoc);
284 expect(result).toEqual([]);
285 });
286
287 it('handles null documents', () => {
288 const doc = makeDoc('hello');
289 expect(diffDocuments(null, doc)).toEqual([
290 { type: 'insert', content: 'hello' },
291 ]);
292 expect(diffDocuments(doc, null)).toEqual([
293 { type: 'delete', content: 'hello' },
294 ]);
295 });
296});
297
298// --- renderDiffHtml ---
299
300describe('renderDiffHtml', () => {
301 it('renders equal blocks as plain spans', () => {
302 const blocks: DiffBlock[] = [{ type: 'equal', content: 'hello' }];
303 const html = renderDiffHtml(blocks);
304 expect(html).toContain('diff-equal');
305 expect(html).toContain('hello');
306 });
307
308 it('renders insert blocks with diff-insert class', () => {
309 const blocks: DiffBlock[] = [{ type: 'insert', content: 'added' }];
310 const html = renderDiffHtml(blocks);
311 expect(html).toContain('diff-insert');
312 expect(html).toContain('added');
313 });
314
315 it('renders delete blocks with diff-delete class', () => {
316 const blocks: DiffBlock[] = [{ type: 'delete', content: 'removed' }];
317 const html = renderDiffHtml(blocks);
318 expect(html).toContain('diff-delete');
319 expect(html).toContain('removed');
320 });
321
322 it('escapes HTML in content', () => {
323 const blocks: DiffBlock[] = [
324 { type: 'equal', content: '<script>alert("xss")</script>' },
325 ];
326 const html = renderDiffHtml(blocks);
327 expect(html).not.toContain('<script>');
328 expect(html).toContain('<script>');
329 });
330
331 it('renders mixed blocks in order', () => {
332 const blocks: DiffBlock[] = [
333 { type: 'equal', content: 'hello' },
334 { type: 'delete', content: 'old' },
335 { type: 'insert', content: 'new' },
336 { type: 'equal', content: 'world' },
337 ];
338 const html = renderDiffHtml(blocks);
339 const eqPos = html.indexOf('hello');
340 const delPos = html.indexOf('old');
341 const insPos = html.indexOf('new');
342 const eq2Pos = html.indexOf('world');
343 expect(eqPos).toBeLessThan(delPos);
344 expect(delPos).toBeLessThan(insPos);
345 expect(insPos).toBeLessThan(eq2Pos);
346 });
347
348 it('returns empty string for empty blocks array', () => {
349 expect(renderDiffHtml([])).toBe('');
350 });
351
352 it('preserves newlines as <br> tags', () => {
353 const blocks: DiffBlock[] = [
354 { type: 'equal', content: 'line one\nline two' },
355 ];
356 const html = renderDiffHtml(blocks);
357 expect(html).toContain('<br>');
358 });
359});