import { describe, expect, it } from 'vitest'; import fc from 'fast-check'; import { setCalloutRange, insertMarkerAfter, mdToInlineNodes, commentsToFootnotes, } from '../packages/docmost-client/src/lib/transforms.js'; // Gaps NOT covered by transforms.test.ts (test-strategy report §2). The base // file covers the happy paths and basic edges; these add the foot-gun cases: // TWO ranges in ONE text node (static /g lastIndex), multi-occurrence + >2-run // offset accumulation, prefix-strip ordering, orderedList-before-heading throw, // NUL-sentinel non-collision, empty note, and the renumber property. const text = (t: string, marks?: any[]): any => marks ? { type: 'text', text: t, marks } : { type: 'text', text: t }; const para = (...runs: any[]): any => ({ type: 'paragraph', attrs: { id: 'p' }, content: runs }); const callout = (...children: any[]): any => ({ type: 'callout', content: children }); const doc = (...blocks: any[]): any => ({ type: 'doc', content: blocks }); // =========================================================================== describe('setCalloutRange — two ranges in ONE text node (static /g lastIndex)', () => { it('rewrites BOTH [1]…[K] ranges that share a single text node', () => { // The base file covers two ranges across two SEPARATE text nodes; this pins // the harder case where the static /g regex must rewrite both ranges within // a single `.replace()` call on one string (lastIndex foot-gun). const d = doc(callout(para(text('a [1]…[2] and b [1]…[3] end')))); const r = setCalloutRange(d, 7); // One text node touched, but BOTH ranges rewritten by the global replace. expect(r.changed).toBe(1); expect(r.doc.content[0].content[0].content[0].text).toBe('a [1]…[7] and b [1]…[7] end'); }); }); // =========================================================================== describe('insertMarkerAfter — multi-occurrence, multi-run, block fall-through', () => { it('inserts after the FIRST occurrence when the anchor appears twice', () => { const d = doc(para(text('foo here and foo there'))); const r = insertMarkerAfter(d, 'foo', '[1]'); expect(r.inserted).toBe(true); expect(r.doc.content[0].content.map((n: any) => n.text)).toEqual([ 'foo', ' [1]', ' here and foo there', ]); }); it('accumulates the offset across >2 runs and splits inside the correct run, preserving marks', () => { // "aa "(plain) + "bb "(bold) + "cc dd"(italic); anchor "aa bb cc" ends inside // the third run, so offset must accumulate across the first two runs. const d = doc( para(text('aa '), text('bb ', [{ type: 'bold' }]), text('cc dd', [{ type: 'italic' }])), ); const r = insertMarkerAfter(d, 'aa bb cc', '[9]'); expect(r.doc.content[0].content).toEqual([ { type: 'text', text: 'aa ' }, { type: 'text', text: 'bb ', marks: [{ type: 'bold' }] }, { type: 'text', text: 'cc', marks: [{ type: 'italic' }] }, { type: 'text', text: ' [9]' }, { type: 'text', text: ' dd', marks: [{ type: 'italic' }] }, ]); }); it('falls through a block that lacks the anchor and inserts in the next matching block', () => { const d = doc(para(text('nothing here')), para(text('target word follows'))); const r = insertMarkerAfter(d, 'target word', '[1]'); expect(r.inserted).toBe(true); expect(r.doc.content[1].content.map((n: any) => n.text)).toEqual([ 'target word', ' [1]', ' follows', ]); // The first block stays untouched. expect(r.doc.content[0].content).toEqual([text('nothing here')]); }); }); // =========================================================================== describe('mdToInlineNodes — prefix-strip ordering', () => { it('strips a leading "комментарий:" THEN a "N." prefix (both, in that order)', () => { // The "комментарий:" strip runs first, then the "N." strip; with both leading // prefixes present in that order, both are removed. expect(mdToInlineNodes('комментарий: 3. real note')).toEqual([ { type: 'text', text: 'real note' }, ]); }); it('does NOT strip "комментарий:" when it follows the numeric prefix (order matters)', () => { // Reversed order: only the leading "3." is at the very start, so the // "комментарий:" (no longer leading) is preserved in the body text. expect(mdToInlineNodes('3. комментарий: real note')).toEqual([ { type: 'text', text: 'комментарий: real note' }, ]); }); }); // =========================================================================== describe('commentsToFootnotes — additional edges', () => { const HEADING = 'Примечания переводчика'; const heading = (): any => ({ type: 'heading', attrs: { id: 'h', level: 2 }, content: [text(HEADING)] }); const ol = (items: any[] = []): any => ({ type: 'orderedList', attrs: { id: 'ol' }, content: items }); const findNotesList = (d: any): any => d.content.find((n: any) => n.type === 'orderedList'); it('throws when the orderedList sits BEFORE the notes heading (not at/after it)', () => { // The notes list must live at or after the heading; a list before the heading // is not the notes list, so it is reported missing. const d = doc(ol([]), para(text('body')), heading()); expect(() => commentsToFootnotes(d, [])).toThrow(/orderedList not found/); }); it('does NOT mistake a literal "FN0" in body text for the NUL-delimited placeholder', () => { // The placeholder is "FN"; a literal "FN0" in prose must NOT be // renumbered or consumed. Only the real anchored selection becomes "[1]". const d = doc(para(text('press FN0 button then anchor here')), heading(), ol([])); const r = commentsToFootnotes(d, [{ id: 'c1', content: 'note', selection: 'anchor here' }]); const bodyText = r.doc.content[0].content.map((n: any) => n.text).join(''); expect(bodyText).toBe('press FN0 button then anchor here [1]'); expect(r.consumed).toEqual(['c1']); }); it('builds a note item with EMPTY inline content for an empty comment body', () => { const d = doc(para(text('anchor word here')), heading(), ol([])); const r = commentsToFootnotes(d, [{ id: 'c1', content: '', selection: 'anchor word' }]); expect(r.consumed).toEqual(['c1']); const list = findNotesList(r.doc); expect(list.content).toHaveLength(1); // mdToInlineNodes('') -> [], so the note paragraph has empty content. expect(list.content[0].content[0].content).toEqual([]); }); }); // =========================================================================== describe('commentsToFootnotes — renumber property ([1]..[K] in reading order)', () => { const HEADING = 'Примечания переводчика'; const heading = (): any => ({ type: 'heading', attrs: { id: 'h', level: 2 }, content: [text(HEADING)] }); const ol = (): any => ({ type: 'orderedList', attrs: { id: 'ol' }, content: [] }); it('K distinct anchored comments produce body markers reading exactly [1]..[K], notes length == K', () => { // Distinct lowercase words so each selection is unique and order is the word // order in the body. Permuting the comment array must not change the result. const wordArb = fc .uniqueArray(fc.stringMatching(/^[a-z]{3,6}$/), { minLength: 1, maxLength: 6 }) .filter((ws) => ws.length >= 1); fc.assert( fc.property(wordArb, fc.boolean(), (words, reverse) => { const body = { type: 'paragraph', attrs: { id: 'p' }, content: [text(words.join(' ') + ' end')] }; const d = doc(body, heading(), ol()); const comments = words.map((w, i) => ({ id: 'c' + i, content: 'note ' + i, selection: w })); if (reverse) comments.reverse(); // comment-array order must not matter const r = commentsToFootnotes(d, comments); const bodyText = r.doc.content[0].content.map((n: any) => n.text ?? '').join(''); const markers = [...bodyText.matchAll(/\[(\d+)\]/g)].map((m) => Number(m[1])); const K = words.length; // Markers read 1..K in reading order regardless of comment-array order. expect(markers).toEqual(Array.from({ length: K }, (_, i) => i + 1)); const notes = r.doc.content.find((n: any) => n.type === 'orderedList'); expect(notes.content).toHaveLength(K); expect(r.consumed).toHaveLength(K); }), { numRuns: 100 }, ); }); });