import { describe, it, expect } from 'vitest'; import { deserialize, serialize } from './mentionSerializer'; import type { PersonMention } from '$lib/types'; // ─── deserialize ───────────────────────────────────────────────────────────── describe('deserialize', () => { it('returns a valid Tiptap doc for an empty string', () => { const doc = deserialize('', []); expect(doc.type).toBe('doc'); expect(doc.content).toHaveLength(1); expect(doc.content![0].type).toBe('paragraph'); }); it('returns plain text node for text with no mentions', () => { const doc = deserialize('Hallo Welt', []); const para = doc.content![0]; expect(para.content).toHaveLength(1); expect(para.content![0]).toEqual({ type: 'text', text: 'Hallo Welt' }); }); it('places a mention node at the correct position in the paragraph', () => { const sidecar: PersonMention[] = [{ personId: 'uuid-x', displayName: 'Clara' }]; const doc = deserialize('Hallo @Clara Welt', sidecar); const nodes = doc.content![0].content!; expect(nodes).toHaveLength(3); expect(nodes[0]).toEqual({ type: 'text', text: 'Hallo ' }); expect(nodes[1]).toMatchObject({ type: 'mention', attrs: { displayName: 'Clara', personId: 'uuid-x' } }); expect(nodes[2]).toEqual({ type: 'text', text: ' Welt' }); }); it('prefers the longer displayName when two sidecar entries share a prefix', () => { const sidecar: PersonMention[] = [ { personId: 'uuid-short', displayName: 'Auguste' }, { personId: 'uuid-long', displayName: 'Auguste Raddatz' } ]; const doc = deserialize('@Auguste Raddatz', sidecar); const nodes = doc.content![0].content!; expect(nodes).toHaveLength(1); expect(nodes[0]).toMatchObject({ type: 'mention', attrs: { personId: 'uuid-long', displayName: 'Auguste Raddatz' } }); }); it('splits multiple paragraphs on newline', () => { const doc = deserialize('Zeile 1\nZeile 2', []); expect(doc.content).toHaveLength(2); expect(doc.content![0].content![0]).toEqual({ type: 'text', text: 'Zeile 1' }); expect(doc.content![1].content![0]).toEqual({ type: 'text', text: 'Zeile 2' }); }); }); // ─── serialize ─────────────────────────────────────────────────────────────── describe('serialize', () => { it('serializes plain text unchanged', () => { const doc = deserialize('Hallo Welt', []); const { text, mentionedPersons } = serialize(doc); expect(text).toBe('Hallo Welt'); expect(mentionedPersons).toEqual([]); }); it('serializes mention node back to @displayName', () => { const sidecar: PersonMention[] = [{ personId: 'uuid-x', displayName: 'Clara' }]; const doc = deserialize('Hallo @Clara Welt', sidecar); const { text, mentionedPersons } = serialize(doc); expect(text).toBe('Hallo @Clara Welt'); expect(mentionedPersons).toEqual([{ personId: 'uuid-x', displayName: 'Clara' }]); }); it('joins multi-paragraph doc back with newlines', () => { const doc = deserialize('Zeile 1\nZeile 2', []); const { text } = serialize(doc); expect(text).toBe('Zeile 1\nZeile 2'); }); it('de-duplicates repeated mentions in the sidecar', () => { const sidecar: PersonMention[] = [{ personId: 'uuid-x', displayName: 'Clara' }]; const doc = deserialize('@Clara und @Clara', sidecar); const { mentionedPersons } = serialize(doc); expect(mentionedPersons).toHaveLength(1); expect(mentionedPersons[0].personId).toBe('uuid-x'); }); }); // ─── Round-trip invariant ───────────────────────────────────────────────────── describe('round-trip invariant', () => { it('text is preserved exactly through deserialize → serialize', () => { const cases = [ ['', []], ['Hallo Welt', []], ['@Clara schreibt', [{ personId: 'uuid-x', displayName: 'Clara' }]], ['Zeile 1\nZeile 2', []], ['Sehr geehrte @Auguste Raddatz,', [{ personId: 'uuid-aug', displayName: 'Auguste Raddatz' }]] ] as const; for (const [text, sidecar] of cases) { const doc = deserialize(text, sidecar as PersonMention[]); const { text: out } = serialize(doc); expect(out, `round-trip failed for: "${text}"`).toBe(text); } }); }); // ─── Backward compatibility (AC-6) ──────────────────────────────────────────── describe('backward compatibility', () => { it('old-format full-name sidecar entry still round-trips correctly', () => { // Before this issue, displayName stored the person's full DB name. // renderTranscriptionBody already handles this — so does the serializer. const oldSidecar: PersonMention[] = [{ personId: 'uuid-aug', displayName: 'Auguste Raddatz' }]; const text = 'Brief von @Auguste Raddatz'; const doc = deserialize(text, oldSidecar); const { text: out, mentionedPersons } = serialize(doc); expect(out).toBe(text); expect(mentionedPersons).toEqual(oldSidecar); }); }); // ─── Security ───────────────────────────────────────────────────────────────── describe('security', () => { it('displayName containing HTML special chars is preserved as a string, not injected', () => { const sidecar: PersonMention[] = [ { personId: 'uuid-x', displayName: '' } ]; const text = '@'; const doc = deserialize(text, sidecar); const { mentionedPersons } = serialize(doc); // The displayName is stored verbatim — HTML escaping is the renderer's job expect(mentionedPersons[0].displayName).toBe(''); }); });