import { describe, expect, it } from 'vitest'; import { walk, getList, insertMarkerAfter, setCalloutRange, noteItem, mdToInlineNodes, commentsToFootnotes, } from '../packages/docmost-client/src/lib/transforms.js'; // --------------------------------------------------------------------------- // Small inline fixture builders. A ProseMirror node is a plain JSON object of // shape { type, attrs?, content?, text?, marks? }. // --------------------------------------------------------------------------- /** A plain text run, optionally with marks. */ function text(t: string, marks?: any[]): any { return marks ? { type: 'text', text: t, marks } : { type: 'text', text: t }; } /** A paragraph holding the given inline runs. */ function para(...runs: any[]): any { return { type: 'paragraph', attrs: { id: 'p' }, content: runs }; } /** A callout holding the given child blocks. */ function callout(...children: any[]): any { return { type: 'callout', content: children }; } /** A document with the given top-level blocks. */ function doc(...blocks: any[]): any { return { type: 'doc', content: blocks }; } /** * Recursively strip every `attrs.id` so docs containing freshId()-generated ids * can be deep-compared structurally. Mutates a clone, returns it. */ function stripIds(value: T): T { const v: any = structuredClone(value); const recur = (n: any): void => { if (Array.isArray(n)) { n.forEach(recur); return; } if (n && typeof n === 'object') { if (n.attrs && typeof n.attrs === 'object' && 'id' in n.attrs) { delete n.attrs.id; } for (const k of Object.keys(n)) recur(n[k]); } }; recur(v); return v; } // =========================================================================== describe('walk', () => { it('is a no-op for a nullish or non-object root', () => { const seen: any[] = []; walk(null, (n) => seen.push(n)); walk(undefined, (n) => seen.push(n)); walk('string', (n) => seen.push(n)); walk(42, (n) => seen.push(n)); walk([1, 2, 3], (n) => seen.push(n)); // array is not an object root expect(seen).toEqual([]); }); it('visits the root itself and all nested nodes (callout/table/list)', () => { const tree = { type: 'doc', content: [ callout(para(text('a'))), { type: 'table', content: [ { type: 'tableRow', content: [{ type: 'tableCell', content: [para(text('b'))] }] }, ], }, { type: 'orderedList', content: [{ type: 'listItem', content: [para(text('c'))] }], }, ], }; const types: string[] = []; walk(tree, (n) => types.push(n.type)); // Root first, then DFS into every nested container. expect(types[0]).toBe('doc'); expect(types).toContain('callout'); expect(types).toContain('table'); expect(types).toContain('tableRow'); expect(types).toContain('tableCell'); expect(types).toContain('orderedList'); expect(types).toContain('listItem'); expect(types).toContain('paragraph'); expect(types).toContain('text'); }); it('ignores a non-array content field', () => { const node = { type: 'weird', content: { not: 'an array' } }; const seen: any[] = []; walk(node, (n) => seen.push(n)); // Only the root is visited; the object content is never recursed into. expect(seen).toEqual([node]); }); }); // =========================================================================== describe('getList', () => { it('returns the FIRST match in depth-first order', () => { const first = { type: 'orderedList', attrs: { id: 'L1' }, content: [] }; const second = { type: 'orderedList', attrs: { id: 'L2' }, content: [] }; const tree = doc(callout(first), second); const found = getList(tree, (n) => n.type === 'orderedList'); expect(found).toBe(first); // DFS reaches the callout's child before the sibling expect(found.attrs.id).toBe('L1'); }); it('returns null when nothing matches', () => { const tree = doc(para(text('x'))); expect(getList(tree, (n) => n.type === 'orderedList')).toBeNull(); }); it('returns a LIVE reference, not a clone', () => { const list = { type: 'orderedList', content: [] }; const tree = doc(list); const found = getList(tree, (n) => n.type === 'orderedList'); expect(found).toBe(list); // same object identity found.marker = 'mutated'; expect(list.marker).toBe('mutated'); // mutation visible on the original }); it('matches a node lacking attrs.id', () => { const noId = { type: 'orderedList', content: [] }; // no attrs at all const tree = doc(para(text('x')), noId); const found = getList(tree, (n) => n.type === 'orderedList'); expect(found).toBe(noId); }); }); // =========================================================================== describe('insertMarkerAfter', () => { it('returns inserted:false when the anchor is not found', () => { const d = doc(para(text('hello world'))); const r = insertMarkerAfter(d, 'absent text', '[1]'); expect(r.inserted).toBe(false); // Returned doc is a clone of the unchanged input. expect(r.doc).toEqual(d); expect(r.doc).not.toBe(d); }); it('inserts a plain marker run after the anchor in a single text run', () => { const d = doc(para(text('see here for details'))); const r = insertMarkerAfter(d, 'see here', '[1]'); expect(r.inserted).toBe(true); expect(r.doc.content[0].content).toEqual([ { type: 'text', text: 'see here', marks: [] }, { type: 'text', text: ' [1]' }, { type: 'text', text: ' for details', marks: [] }, ]); }); it('preserves marks across runs and emits a PLAIN marker, no empty runs', () => { // Anchor "foo bar" spans a plain run "foo " and a bold run "bar baz". const d = doc( para( text('foo '), text('bar baz', [{ type: 'bold' }]), ), ); const r = insertMarkerAfter(d, 'foo bar', '[1]'); expect(r.inserted).toBe(true); // The bold run "bar baz" is split at the anchor end (after "bar"); the // leading "foo " run is untouched, the marker is plain, surrounding marks // are preserved verbatim, and no empty text run is emitted. expect(r.doc.content[0].content).toEqual([ { type: 'text', text: 'foo ' }, { type: 'text', text: 'bar', marks: [{ type: 'bold' }] }, { type: 'text', text: ' [1]' }, { type: 'text', text: ' baz', marks: [{ type: 'bold' }] }, ]); }); it('splits exactly at a run boundary without emitting an empty run', () => { // Anchor ends exactly at the end of the first run "alpha". const d = doc(para(text('alpha'), text('beta'))); const r = insertMarkerAfter(d, 'alpha', '[1]'); expect(r.inserted).toBe(true); // "before" == whole first run, "after" is empty -> no empty run pushed. expect(r.doc.content[0].content).toEqual([ { type: 'text', text: 'alpha', marks: [] }, { type: 'text', text: ' [1]' }, { type: 'text', text: 'beta' }, ]); }); it('beforeBlock scope excludes blocks at/after the boundary', () => { const d = doc( para(text('body anchor')), // index 0 (in scope when beforeBlock=1) para(text('notes anchor')), // index 1 (out of scope) ); // Anchor only exists in the out-of-scope block -> not inserted. const r = insertMarkerAfter(d, 'notes anchor', '[1]', { beforeBlock: 1 }); expect(r.inserted).toBe(false); // The in-scope anchor still inserts when limited. const r2 = insertMarkerAfter(d, 'body anchor', '[1]', { beforeBlock: 1 }); expect(r2.inserted).toBe(true); }); it('does not mutate the input document', () => { const d = doc(para(text('keep me intact please'))); const snapshot = structuredClone(d); insertMarkerAfter(d, 'keep me', '[1]'); expect(d).toEqual(snapshot); }); it('returns inserted:false for an empty anchor', () => { const d = doc(para(text('anything'))); const r = insertMarkerAfter(d, '', '[1]'); expect(r.inserted).toBe(false); expect(r.doc).toEqual(d); }); }); // =========================================================================== describe('setCalloutRange', () => { it('rewrites a Unicode-ellipsis [1]…[K] range inside a callout', () => { const d = doc(callout(para(text('Footnotes [1]…[5] follow')))); const r = setCalloutRange(d, 7); expect(r.changed).toBe(1); expect(r.doc.content[0].content[0].content[0].text).toBe('Footnotes [1]…[7] follow'); }); it('rewrites an ASCII-ellipsis [1]...[K] range inside a callout', () => { const d = doc(callout(para(text('range [1]...[3] here')))); const r = setCalloutRange(d, 9); expect(r.changed).toBe(1); expect(r.doc.content[0].content[0].content[0].text).toBe('range [1]...[9] here'); }); it('leaves a paragraph [1]…[K] (outside any callout) untouched', () => { const d = doc(para(text('not a callout [1]…[5]'))); const r = setCalloutRange(d, 9); expect(r.changed).toBe(0); expect(r.doc.content[0].content[0].text).toBe('not a callout [1]…[5]'); }); it('rewrites across multiple callouts and reports the changed count', () => { const d = doc( callout(para(text('a [1]…[2] b'))), para(text('skip [1]…[2]')), callout(para(text('c [1]...[4] d'))), ); const r = setCalloutRange(d, 10); expect(r.changed).toBe(2); expect(r.doc.content[0].content[0].content[0].text).toBe('a [1]…[10] b'); expect(r.doc.content[2].content[0].content[0].text).toBe('c [1]...[10] d'); }); it('reports changed:0 when no range matches', () => { const d = doc(callout(para(text('no range here')))); const r = setCalloutRange(d, 4); expect(r.changed).toBe(0); }); it('does not mutate the input document', () => { const d = doc(callout(para(text('x [1]…[5] y')))); const snapshot = structuredClone(d); setCalloutRange(d, 99); expect(d).toEqual(snapshot); }); it('handles TWO matching text nodes in one callout (regex lastIndex reset)', () => { // Two separate text nodes, each carrying a range, inside one callout. const d = doc( callout( para(text('first [1]…[2]')), para(text('second [1]…[3]')), ), ); const r = setCalloutRange(d, 6); expect(r.changed).toBe(2); expect(r.doc.content[0].content[0].content[0].text).toBe('first [1]…[6]'); expect(r.doc.content[0].content[1].content[0].text).toBe('second [1]…[6]'); }); }); // =========================================================================== describe('noteItem', () => { it('wraps inline nodes in listItem > paragraph', () => { const inline = [text('hello')]; const item = noteItem(inline); expect(item.type).toBe('listItem'); expect(item.content).toHaveLength(1); const p = item.content[0]; expect(p.type).toBe('paragraph'); expect(p.content).toEqual([text('hello')]); // The paragraph carries a string id from Math.random()-based freshId(). expect(typeof p.attrs.id).toBe('string'); expect(p.attrs.id.length).toBeGreaterThan(0); }); it('produces empty content for non-array input', () => { expect(noteItem(undefined as any).content[0].content).toEqual([]); expect(noteItem(null as any).content[0].content).toEqual([]); expect(noteItem('nope' as any).content[0].content).toEqual([]); }); it('clones the input so the result shares no references', () => { const inline = [text('mutable')]; const item = noteItem(inline); inline[0].text = 'changed'; expect(item.content[0].content[0].text).toBe('mutable'); // unaffected expect(item.content[0].content[0]).not.toBe(inline[0]); }); it('matches the expected structure (ignoring the random id)', () => { const item = noteItem([text('body', [{ type: 'bold' }])]); expect(stripIds(item)).toEqual({ type: 'listItem', content: [ { type: 'paragraph', attrs: {}, content: [{ type: 'text', text: 'body', marks: [{ type: 'bold' }] }], }, ], }); }); }); // =========================================================================== describe('mdToInlineNodes', () => { it('returns [] for empty or non-string input', () => { expect(mdToInlineNodes('')).toEqual([]); expect(mdToInlineNodes(' ')).toEqual([]); expect(mdToInlineNodes(undefined as any)).toEqual([]); expect(mdToInlineNodes(null as any)).toEqual([]); expect(mdToInlineNodes(123 as any)).toEqual([]); }); it('strips a case-insensitive "комментарий:" prefix', () => { expect(mdToInlineNodes('Комментарий: hello')).toEqual([{ type: 'text', text: 'hello' }]); expect(mdToInlineNodes('комментарий : hi')).toEqual([{ type: 'text', text: 'hi' }]); }); it('strips a leading "N. " numeric prefix', () => { expect(mdToInlineNodes('3. some note')).toEqual([{ type: 'text', text: 'some note' }]); }); it('turns a leading **bold lead** into a bold node + plain remainder, space preserved', () => { const nodes = mdToInlineNodes('**Lead** rest of text'); expect(nodes).toEqual([ { type: 'text', text: 'Lead', marks: [{ type: 'bold' }] }, { type: 'text', text: ' rest of text' }, // separating space preserved ]); }); it('splits an inline **bold** mid-text', () => { const nodes = mdToInlineNodes('start **mid** end'); expect(nodes).toEqual([ { type: 'text', text: 'start ' }, { type: 'text', text: 'mid', marks: [{ type: 'bold' }] }, { type: 'text', text: ' end' }, ]); }); it('passes plain text through unchanged when there is no bold', () => { expect(mdToInlineNodes('just plain text')).toEqual([{ type: 'text', text: 'just plain text' }]); }); it('handles a bold-only string', () => { // A bold-only string is treated as a leading bold lead with empty remainder. expect(mdToInlineNodes('**only**')).toEqual([ { type: 'text', text: 'only', marks: [{ type: 'bold' }] }, ]); }); }); // =========================================================================== describe('commentsToFootnotes', () => { const HEADING = 'Примечания переводчика'; /** * Build a realistic doc: body paragraphs, then the notes heading, then the * notes orderedList. `notes` is an array of inline-text strings for existing * list items. */ function buildDoc(opts: { body: any[]; notes?: string[]; omitHeading?: boolean; omitList?: boolean; disclaimer?: any; }): any { const blocks: any[] = []; if (opts.disclaimer) blocks.push(opts.disclaimer); blocks.push(...opts.body); if (!opts.omitHeading) { blocks.push({ type: 'heading', attrs: { id: 'h', level: 2 }, content: [text(HEADING)] }); } if (!opts.omitList) { const items = (opts.notes ?? []).map((t, i) => ({ type: 'listItem', attrs: { id: `li${i}` }, content: [para(text(t))], })); blocks.push({ type: 'orderedList', attrs: { id: 'ol' }, content: items }); } return doc(...blocks); } function findNotesList(d: any): any { return d.content.find((n: any) => n.type === 'orderedList'); } it('is identity (renumber pass only) when there are zero comments', () => { const d = buildDoc({ body: [para(text('plain body'))], notes: [] }); const r = commentsToFootnotes(d, []); expect(r.consumed).toEqual([]); expect(r.doc.content[0]).toEqual(para(text('plain body'))); }); it('inserts a marker and appends one note for one comment with a selection', () => { const d = buildDoc({ body: [para(text('the quick brown fox'))], notes: [] }); const r = commentsToFootnotes(d, [ { id: 'c1', content: 'A note', selection: 'quick brown' }, ]); expect(r.consumed).toEqual(['c1']); // Body now carries "[1]" right after the selection. const bodyText = r.doc.content[0].content.map((n: any) => n.text).join(''); expect(bodyText).toBe('the quick brown [1] fox'); // The notes list holds exactly one note built from the comment content. const list = findNotesList(r.doc); expect(list.content).toHaveLength(1); expect(stripIds(list.content[0])).toEqual( stripIds(noteItem(mdToInlineNodes('A note'))), ); }); it('numbers many comments by BODY reading order, not comment-array order', () => { // Body order: "alpha" then "omega". Comments are given out of order. const d = buildDoc({ body: [para(text('alpha then omega here'))], notes: [], }); const r = commentsToFootnotes(d, [ { id: 'cOmega', content: 'note for omega', selection: 'omega' }, { id: 'cAlpha', content: 'note for alpha', selection: 'alpha' }, ]); // Both consumed, in comment-array processing order (NOT reading order, NOT sorted). expect(r.consumed).toEqual(['cOmega', 'cAlpha']); const bodyText = r.doc.content[0].content.map((n: any) => n.text).join(''); // "alpha" precedes "omega" in reading order => [1] then [2]. expect(bodyText).toBe('alpha [1] then omega [2] here'); const list = findNotesList(r.doc); // Note list reordered to reading order: alpha-note first, omega-note second. expect(list.content[0].content[0].content[0].text).toBe('note for alpha'); expect(list.content[1].content[0].content[0].text).toBe('note for omega'); }); it('skips a comment with no selection without consuming it', () => { const d = buildDoc({ body: [para(text('body text here'))], notes: [] }); const r = commentsToFootnotes(d, [ { id: 'c1', content: 'no anchor', selection: null }, { id: 'c2', content: 'anchored', selection: 'body text' }, ]); expect(r.consumed).toEqual(['c2']); const list = findNotesList(r.doc); expect(list.content).toHaveLength(1); }); it('skips a comment whose selection is absent (no orphan note)', () => { const d = buildDoc({ body: [para(text('present text'))], notes: [] }); const r = commentsToFootnotes(d, [ { id: 'c1', content: 'orphan', selection: 'this string is not in the body' }, ]); expect(r.consumed).toEqual([]); // nothing anchored const list = findNotesList(r.doc); expect(list.content).toHaveLength(0); // no orphan note appended const bodyText = r.doc.content[0].content.map((n: any) => n.text).join(''); expect(bodyText).toBe('present text'); // body unchanged }); it('renumbers existing [N] markers mixed with new placeholders by reading order', () => { // Body already has an existing "[1]" marker after "first"; a new comment // anchors before it in reading order at "intro". const d = buildDoc({ body: [para(text('intro and first [1] then more'))], notes: ['existing note one'], }); const r = commentsToFootnotes(d, [ { id: 'cNew', content: 'fresh note', selection: 'intro' }, ]); expect(r.consumed).toEqual(['cNew']); const bodyText = r.doc.content[0].content.map((n: any) => n.text).join(''); // Reading order: "intro" placeholder -> [1]; existing "[1]" -> [2]. expect(bodyText).toBe('intro [1] and first [2] then more'); const list = findNotesList(r.doc); // Notes reordered: the new note (for "intro") first, the existing note second. expect(list.content).toHaveLength(2); expect(list.content[0].content[0].content[0].text).toBe('fresh note'); expect(list.content[1].content[0].content[0].text).toBe('existing note one'); }); it('throws "document is inconsistent" when a body [N] has no matching note', () => { // Body references [9] but the notes list has only 3 items. const d = buildDoc({ body: [para(text('see footnote [9] here'))], notes: ['n1', 'n2', 'n3'], }); expect(() => commentsToFootnotes(d, [])).toThrow(/document is inconsistent/); }); it('throws when the notes heading is missing', () => { const d = buildDoc({ body: [para(text('x'))], notes: [], omitHeading: true }); expect(() => commentsToFootnotes(d, [])).toThrow(/not found/); }); it('throws when the notes orderedList is missing', () => { const d = buildDoc({ body: [para(text('x'))], omitList: true }); expect(() => commentsToFootnotes(d, [])).toThrow(/orderedList not found/); }); it('does not mutate the input document', () => { const d = buildDoc({ body: [para(text('the quick brown fox'))], notes: [] }); const snapshot = structuredClone(d); commentsToFootnotes(d, [{ id: 'c1', content: 'A note', selection: 'quick brown' }]); expect(d).toEqual(snapshot); }); it('does not renumber a top-level disclaimer callout but syncs its range', () => { // A disclaimer callout carries "[1]…[K]"; it must be preserved (not consumed // as a footnote marker) and its range synced to the final note count. const disclaimer = callout(para(text('Notes range [1]…[1] applies'))); const d = buildDoc({ body: [para(text('alpha and beta here'))], notes: [], disclaimer, }); const r = commentsToFootnotes(d, [ { id: 'c1', content: 'note a', selection: 'alpha' }, { id: 'c2', content: 'note b', selection: 'beta' }, ]); expect(r.consumed.sort()).toEqual(['c1', 'c2']); // Disclaimer callout is at index 0; its body must NOT have been renumbered // into [1][2], it remains a "[1]…[n]" range synced to 2 notes. const calloutText = r.doc.content[0].content[0].content .map((n: any) => n.text) .join(''); expect(calloutText).toBe('Notes range [1]…[2] applies'); // Body markers (index 1) are the real footnotes. const bodyText = r.doc.content[1].content.map((n: any) => n.text).join(''); expect(bodyText).toBe('alpha [1] and beta [2] here'); const list = findNotesList(r.doc); expect(list.content).toHaveLength(2); }); });