Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 359 lines 10 kB view raw
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('&lt;script&gt;'); 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});