import { describe, it, expect } from 'vitest'; /** * Tests for document diff utility. * * Pure logic tests for: * - Text extraction from TipTap JSON * - LCS-based word-level diff * - Diff block grouping * - HTML rendering of diffs */ import { extractText, diffWords, diffDocuments, renderDiffHtml, type DiffBlock, } from '../src/docs/doc-diff.js'; // --- extractText --- describe('extractText', () => { it('extracts text from a simple paragraph', () => { const doc = { type: 'doc', content: [ { type: 'paragraph', content: [ { type: 'text', text: 'Hello world' }, ], }, ], }; expect(extractText(doc)).toBe('Hello world'); }); it('extracts text from multiple paragraphs', () => { const doc = { type: 'doc', content: [ { type: 'paragraph', content: [{ type: 'text', text: 'First paragraph' }], }, { type: 'paragraph', content: [{ type: 'text', text: 'Second paragraph' }], }, ], }; expect(extractText(doc)).toBe('First paragraph\nSecond paragraph'); }); it('handles nested content (bold, italic)', () => { const doc = { type: 'doc', content: [ { type: 'paragraph', content: [ { type: 'text', text: 'Hello ' }, { type: 'text', text: 'bold', marks: [{ type: 'bold' }] }, { type: 'text', text: ' world' }, ], }, ], }; expect(extractText(doc)).toBe('Hello bold world'); }); it('returns empty string for empty document', () => { const doc = { type: 'doc', content: [] }; expect(extractText(doc)).toBe(''); }); it('returns empty string for null/undefined', () => { expect(extractText(null)).toBe(''); expect(extractText(undefined)).toBe(''); }); it('handles document with no content array', () => { const doc = { type: 'doc' }; expect(extractText(doc)).toBe(''); }); it('handles headings', () => { const doc = { type: 'doc', content: [ { type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Title' }], }, { type: 'paragraph', content: [{ type: 'text', text: 'Body text' }], }, ], }; expect(extractText(doc)).toBe('Title\nBody text'); }); it('handles bullet lists', () => { const doc = { type: 'doc', content: [ { type: 'bulletList', content: [ { type: 'listItem', content: [ { type: 'paragraph', content: [{ type: 'text', text: 'Item one' }], }, ], }, { type: 'listItem', content: [ { type: 'paragraph', content: [{ type: 'text', text: 'Item two' }], }, ], }, ], }, ], }; expect(extractText(doc)).toBe('Item one\nItem two'); }); }); // --- diffWords --- describe('diffWords', () => { it('returns equal blocks for identical text', () => { const result = diffWords('hello world', 'hello world'); expect(result).toEqual([ { type: 'equal', content: 'hello world' }, ]); }); it('detects inserted words', () => { const result = diffWords('hello world', 'hello beautiful world'); expect(result).toEqual([ { type: 'equal', content: 'hello' }, { type: 'insert', content: 'beautiful' }, { type: 'equal', content: 'world' }, ]); }); it('detects deleted words', () => { const result = diffWords('hello beautiful world', 'hello world'); expect(result).toEqual([ { type: 'equal', content: 'hello' }, { type: 'delete', content: 'beautiful' }, { type: 'equal', content: 'world' }, ]); }); it('handles complete replacement', () => { const result = diffWords('alpha beta', 'gamma delta'); expect(result).toEqual([ { type: 'delete', content: 'alpha beta' }, { type: 'insert', content: 'gamma delta' }, ]); }); it('handles empty old text', () => { const result = diffWords('', 'hello world'); expect(result).toEqual([ { type: 'insert', content: 'hello world' }, ]); }); it('handles empty new text', () => { const result = diffWords('hello world', ''); expect(result).toEqual([ { type: 'delete', content: 'hello world' }, ]); }); it('handles both empty', () => { const result = diffWords('', ''); expect(result).toEqual([]); }); it('handles single word change', () => { const result = diffWords('the cat sat', 'the dog sat'); expect(result).toEqual([ { type: 'equal', content: 'the' }, { type: 'delete', content: 'cat' }, { type: 'insert', content: 'dog' }, { type: 'equal', content: 'sat' }, ]); }); it('handles multiple scattered changes', () => { const result = diffWords('a b c d e', 'a x c y e'); expect(result).toEqual([ { type: 'equal', content: 'a' }, { type: 'delete', content: 'b' }, { type: 'insert', content: 'x' }, { type: 'equal', content: 'c' }, { type: 'delete', content: 'd' }, { type: 'insert', content: 'y' }, { type: 'equal', content: 'e' }, ]); }); it('groups consecutive same-type tokens', () => { const result = diffWords('a b c', 'a b c d e'); expect(result).toEqual([ { type: 'equal', content: 'a b c' }, { type: 'insert', content: 'd e' }, ]); }); }); // --- diffDocuments --- describe('diffDocuments', () => { const makeDoc = (text: string) => ({ type: 'doc', content: [ { type: 'paragraph', content: [{ type: 'text', text }], }, ], }); it('returns all equal for identical documents', () => { const doc = makeDoc('hello world'); const result = diffDocuments(doc, doc); expect(result).toEqual([ { type: 'equal', content: 'hello world' }, ]); }); it('detects added text', () => { const docA = makeDoc('hello world'); const docB = makeDoc('hello beautiful world'); const result = diffDocuments(docA, docB); expect(result).toEqual([ { type: 'equal', content: 'hello' }, { type: 'insert', content: 'beautiful' }, { type: 'equal', content: 'world' }, ]); }); it('detects removed text', () => { const docA = makeDoc('hello beautiful world'); const docB = makeDoc('hello world'); const result = diffDocuments(docA, docB); expect(result).toEqual([ { type: 'equal', content: 'hello' }, { type: 'delete', content: 'beautiful' }, { type: 'equal', content: 'world' }, ]); }); it('handles mixed changes', () => { const docA = makeDoc('the quick brown fox'); const docB = makeDoc('the slow brown dog'); const result = diffDocuments(docA, docB); expect(result).toContainEqual({ type: 'equal', content: 'the' }); expect(result).toContainEqual({ type: 'delete', content: 'quick' }); expect(result).toContainEqual({ type: 'insert', content: 'slow' }); expect(result).toContainEqual({ type: 'equal', content: 'brown' }); expect(result).toContainEqual({ type: 'delete', content: 'fox' }); expect(result).toContainEqual({ type: 'insert', content: 'dog' }); }); it('handles empty documents', () => { const emptyDoc = { type: 'doc', content: [] }; const result = diffDocuments(emptyDoc, emptyDoc); expect(result).toEqual([]); }); it('handles null documents', () => { const doc = makeDoc('hello'); expect(diffDocuments(null, doc)).toEqual([ { type: 'insert', content: 'hello' }, ]); expect(diffDocuments(doc, null)).toEqual([ { type: 'delete', content: 'hello' }, ]); }); }); // --- renderDiffHtml --- describe('renderDiffHtml', () => { it('renders equal blocks as plain spans', () => { const blocks: DiffBlock[] = [{ type: 'equal', content: 'hello' }]; const html = renderDiffHtml(blocks); expect(html).toContain('diff-equal'); expect(html).toContain('hello'); }); it('renders insert blocks with diff-insert class', () => { const blocks: DiffBlock[] = [{ type: 'insert', content: 'added' }]; const html = renderDiffHtml(blocks); expect(html).toContain('diff-insert'); expect(html).toContain('added'); }); it('renders delete blocks with diff-delete class', () => { const blocks: DiffBlock[] = [{ type: 'delete', content: 'removed' }]; const html = renderDiffHtml(blocks); expect(html).toContain('diff-delete'); expect(html).toContain('removed'); }); it('escapes HTML in content', () => { const blocks: DiffBlock[] = [ { type: 'equal', content: '' }, ]; const html = renderDiffHtml(blocks); expect(html).not.toContain('