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('