Add 230 Vitest unit tests covering the dependency-light, pure modules of packages/docmost-client/src/lib, imported directly from source: - node-ops: tree addressing, immutability/clone guarantees, table ops, throw-vs-noop contracts (87) - transforms: commentsToFootnotes reading-order renumbering, insertMarkerAfter mark-preserving split, setCalloutRange regex statefulness (43) - json-edit: applyTextEdits literal $&/$1, error distinction, immutability (17) - page-lock: async per-page mutex ordering and error isolation (6) - filters: filterPage/filterComment truthiness traps, filterSearchResult (19) - markdown-converter: per-node golden matrix + edge cases (41) - markdown-document envelope: round-trip, CRLF, malformed-JSON throws (17) No source files changed. The pre-existing test/markdown-document.test.ts is left intact; new envelope coverage lives in markdown-document-envelope.test.ts. Full suite: 16 files / 279 tests green.
562 lines
21 KiB
TypeScript
562 lines
21 KiB
TypeScript
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<T>(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);
|
|
});
|
|
});
|