Files
pn-new-crm/tests/unit/tiptap-serializer.test.ts

222 lines
7.9 KiB
TypeScript
Raw Normal View History

import { describe, it, expect } from 'vitest';
import {
validateTipTapDocument,
tipTapToPdfmeTemplate,
substituteVariables,
buildContentInputsFromDoc,
type TipTapNode,
} from '@/lib/pdf/tiptap-to-pdfme';
// ─── Fixtures ─────────────────────────────────────────────────────────────────
function makeDoc(...children: TipTapNode[]): TipTapNode {
return { type: 'doc', content: children };
}
function paragraph(text: string): TipTapNode {
return {
type: 'paragraph',
content: [{ type: 'text', text }],
};
}
function heading(level: number, text: string): TipTapNode {
return {
type: 'heading',
attrs: { level },
content: [{ type: 'text', text }],
};
}
function bulletList(...items: string[]): TipTapNode {
return {
type: 'bulletList',
content: items.map((item) => ({
type: 'listItem',
content: [paragraph(item)],
})),
};
}
// ─── validateTipTapDocument ───────────────────────────────────────────────────
describe('validateTipTapDocument', () => {
it('returns empty array for a valid doc with only a paragraph', () => {
const doc = makeDoc(paragraph('Hello world'));
expect(validateTipTapDocument(doc)).toEqual([]);
});
it('returns empty array for a doc with heading + paragraph', () => {
const doc = makeDoc(heading(1, 'Title'), paragraph('Body text'));
expect(validateTipTapDocument(doc)).toEqual([]);
});
it('returns empty array for a doc with bulletList', () => {
const doc = makeDoc(bulletList('Item 1', 'Item 2'));
expect(validateTipTapDocument(doc)).toEqual([]);
});
it('returns ["blockquote"] when doc contains a blockquote', () => {
const doc = makeDoc(
paragraph('Before'),
{ type: 'blockquote', content: [paragraph('Quoted')] },
);
const errors = validateTipTapDocument(doc);
expect(errors).toContain('blockquote');
});
it('returns ["codeBlock"] when doc contains a codeBlock', () => {
const doc = makeDoc(
paragraph('Before'),
{ type: 'codeBlock', content: [{ type: 'text', text: 'const x = 1;' }] },
);
const errors = validateTipTapDocument(doc);
expect(errors).toContain('codeBlock');
});
it('returns multiple unsupported types without duplicates', () => {
const doc = makeDoc(
{ type: 'blockquote', content: [] },
{ type: 'codeBlock', content: [] },
{ type: 'blockquote', content: [] }, // duplicate — should only appear once
);
const errors = validateTipTapDocument(doc);
expect(errors).toContain('blockquote');
expect(errors).toContain('codeBlock');
expect(errors.filter((e) => e === 'blockquote')).toHaveLength(1);
});
it('detects unsupported nodes nested inside valid nodes', () => {
const doc = makeDoc({
type: 'paragraph',
content: [{ type: 'blockquote', content: [] }],
});
expect(validateTipTapDocument(doc)).toContain('blockquote');
});
});
// ─── tipTapToPdfmeTemplate ────────────────────────────────────────────────────
describe('tipTapToPdfmeTemplate', () => {
it('returns a template with a schemas array', () => {
const doc = makeDoc(paragraph('Hello'));
const template = tipTapToPdfmeTemplate(doc);
expect(template).toHaveProperty('schemas');
expect(Array.isArray(template.schemas)).toBe(true);
});
it('produces one schema field per paragraph', () => {
const doc = makeDoc(paragraph('One'), paragraph('Two'), paragraph('Three'));
const template = tipTapToPdfmeTemplate(doc);
const allFields = template.schemas.flat();
expect(allFields).toHaveLength(3);
});
it('heading + paragraph → 2 schema fields', () => {
const doc = makeDoc(heading(1, 'Title'), paragraph('Body'));
const template = tipTapToPdfmeTemplate(doc);
const allFields = template.schemas.flat();
expect(allFields).toHaveLength(2);
});
it('bulletList with 3 items → 3 schema fields', () => {
const doc = makeDoc(bulletList('A', 'B', 'C'));
const template = tipTapToPdfmeTemplate(doc);
const allFields = template.schemas.flat();
expect(allFields).toHaveLength(3);
});
it('all schema fields have type "text"', () => {
const doc = makeDoc(heading(2, 'Sub'), paragraph('Para'), bulletList('Item'));
const template = tipTapToPdfmeTemplate(doc);
const allFields = template.schemas.flat() as Array<{ type: string }>;
for (const field of allFields) {
expect(field.type).toBe('text');
}
});
it('all schema fields have a name property', () => {
const doc = makeDoc(paragraph('p1'), paragraph('p2'));
const template = tipTapToPdfmeTemplate(doc);
const allFields = template.schemas.flat() as Array<{ name: string }>;
for (const field of allFields) {
expect(typeof field.name).toBe('string');
expect(field.name.length).toBeGreaterThan(0);
}
});
it('field count matches content node count (round-trip check)', () => {
const nodeCount = 4;
const doc = makeDoc(
heading(1, 'H1'),
paragraph('Para 1'),
paragraph('Para 2'),
bulletList('Bullet'),
);
const template = tipTapToPdfmeTemplate(doc);
const allFields = template.schemas.flat();
expect(allFields).toHaveLength(nodeCount);
});
});
// ─── substituteVariables ──────────────────────────────────────────────────────
describe('substituteVariables', () => {
it('replaces a single variable token', () => {
const result = substituteVariables('Hello {{client.name}}', { 'client.name': 'Alice' });
expect(result).toBe('Hello Alice');
});
it('replaces multiple variable tokens', () => {
const result = substituteVariables('{{client.name}} at {{port.name}}', {
'client.name': 'Alice',
'port.name': 'Port Nimara',
});
expect(result).toBe('Alice at Port Nimara');
});
it('leaves unmatched tokens as-is', () => {
const result = substituteVariables('Hello {{client.name}}', {});
expect(result).toBe('Hello {{client.name}}');
});
it('handles whitespace inside token braces', () => {
const result = substituteVariables('Hello {{ client.name }}', { 'client.name': 'Bob' });
expect(result).toBe('Hello Bob');
});
it('replaces the same token multiple times', () => {
const result = substituteVariables('{{x}} and {{x}}', { x: 'yes' });
expect(result).toBe('yes and yes');
});
});
// ─── buildContentInputsFromDoc ────────────────────────────────────────────────
describe('buildContentInputsFromDoc', () => {
it('returns an array of records keyed by schema field names', () => {
const doc = makeDoc(paragraph('Hello'), paragraph('World'));
const template = tipTapToPdfmeTemplate(doc);
const inputs = buildContentInputsFromDoc(doc, template);
expect(Array.isArray(inputs)).toBe(true);
expect(inputs).toHaveLength(template.schemas.length);
const allFieldNames = (template.schemas.flat() as Array<{ name: string }>).map((f) => f.name);
const allInputKeys = inputs.flatMap((record) => Object.keys(record));
for (const name of allFieldNames) {
expect(allInputKeys).toContain(name);
}
});
it('input count matches schema field count', () => {
const doc = makeDoc(heading(1, 'H'), paragraph('P1'), paragraph('P2'));
const template = tipTapToPdfmeTemplate(doc);
const inputs = buildContentInputsFromDoc(doc, template);
const totalFields = template.schemas.reduce((acc, page) => acc + page.length, 0);
const totalInputs = inputs.reduce((acc, record) => acc + Object.keys(record).length, 0);
expect(totalInputs).toBe(totalFields);
});
});