test(docmost-client): add unit tests for pure lib modules
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.
This commit is contained in:
234
test/filters.test.ts
Normal file
234
test/filters.test.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
filterComment,
|
||||
filterGroup,
|
||||
filterPage,
|
||||
filterSearchResult,
|
||||
filterSpace,
|
||||
filterWorkspace,
|
||||
} from '../packages/docmost-client/src/lib/filters.js';
|
||||
|
||||
describe('filterPage', () => {
|
||||
const basePage = {
|
||||
id: 'pg1',
|
||||
slugId: 'slug-1',
|
||||
title: 'Title',
|
||||
parentPageId: 'parent-1',
|
||||
spaceId: 'space-1',
|
||||
isLocked: false,
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-01-02',
|
||||
deletedAt: null,
|
||||
// Extra fields that must be dropped by the filter.
|
||||
secret: 'should-not-leak',
|
||||
};
|
||||
|
||||
it('omits the content key when no content arg is given', () => {
|
||||
const result = filterPage(basePage);
|
||||
expect(result).not.toHaveProperty('content');
|
||||
expect(result).not.toHaveProperty('secret');
|
||||
expect(result.id).toBe('pg1');
|
||||
});
|
||||
|
||||
it('includes an empty-string content (truthiness trap: empty string is kept)', () => {
|
||||
const result = filterPage(basePage, '');
|
||||
expect(result).toHaveProperty('content');
|
||||
expect(result.content).toBe('');
|
||||
});
|
||||
|
||||
it('includes a non-empty content string', () => {
|
||||
const result = filterPage(basePage, '# Markdown');
|
||||
expect(result.content).toBe('# Markdown');
|
||||
});
|
||||
|
||||
it('omits content when it is not a string', () => {
|
||||
// A non-string (e.g. an object passed by mistake) must be dropped.
|
||||
const result = filterPage(basePage, { junk: true } as unknown as string);
|
||||
expect(result).not.toHaveProperty('content');
|
||||
});
|
||||
|
||||
it('omits subpages when undefined', () => {
|
||||
const result = filterPage(basePage);
|
||||
expect(result).not.toHaveProperty('subpages');
|
||||
});
|
||||
|
||||
it('omits subpages when empty array', () => {
|
||||
const result = filterPage(basePage, undefined, []);
|
||||
expect(result).not.toHaveProperty('subpages');
|
||||
});
|
||||
|
||||
it('maps non-empty subpages to { id, title } only', () => {
|
||||
const result = filterPage(basePage, undefined, [
|
||||
{ id: 's1', title: 'Sub 1', extra: 'drop-me' },
|
||||
{ id: 's2', title: 'Sub 2' },
|
||||
]);
|
||||
expect(result.subpages).toEqual([
|
||||
{ id: 's1', title: 'Sub 1' },
|
||||
{ id: 's2', title: 'Sub 2' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterComment', () => {
|
||||
const baseComment = {
|
||||
id: 'c1',
|
||||
pageId: 'pg1',
|
||||
content: 'original content',
|
||||
creatorId: 'u1',
|
||||
createdAt: '2024-01-01',
|
||||
};
|
||||
|
||||
it('overrides comment.content with markdownContent when provided', () => {
|
||||
const result = filterComment(baseComment, 'markdown version');
|
||||
expect(result.content).toBe('markdown version');
|
||||
});
|
||||
|
||||
it('falls back to comment.content when markdownContent is undefined', () => {
|
||||
const result = filterComment(baseComment, undefined);
|
||||
expect(result.content).toBe('original content');
|
||||
});
|
||||
|
||||
it('keeps an empty-string markdownContent (uses ?? not ||)', () => {
|
||||
// `??` only falls back on null/undefined, so "" must be preserved.
|
||||
const result = filterComment(baseComment, '');
|
||||
expect(result.content).toBe('');
|
||||
});
|
||||
|
||||
it('applies defaults for selection/type/parentCommentId/editedAt/resolvedAt/resolvedById', () => {
|
||||
const result = filterComment(baseComment);
|
||||
expect(result.selection).toBeNull();
|
||||
expect(result.type).toBe('page');
|
||||
expect(result.parentCommentId).toBeNull();
|
||||
expect(result.editedAt).toBeNull();
|
||||
expect(result.resolvedAt).toBeNull();
|
||||
expect(result.resolvedById).toBeNull();
|
||||
});
|
||||
|
||||
it('passes through provided optional values', () => {
|
||||
const result = filterComment({
|
||||
...baseComment,
|
||||
selection: 'some text',
|
||||
type: 'inline',
|
||||
parentCommentId: 'c0',
|
||||
editedAt: '2024-02-01',
|
||||
resolvedAt: '2024-03-01',
|
||||
resolvedById: 'u9',
|
||||
});
|
||||
expect(result.selection).toBe('some text');
|
||||
expect(result.type).toBe('inline');
|
||||
expect(result.parentCommentId).toBe('c0');
|
||||
expect(result.editedAt).toBe('2024-02-01');
|
||||
expect(result.resolvedAt).toBe('2024-03-01');
|
||||
expect(result.resolvedById).toBe('u9');
|
||||
});
|
||||
|
||||
it('returns null for creatorName when creator is absent', () => {
|
||||
const result = filterComment(baseComment);
|
||||
expect(result.creatorName).toBeNull();
|
||||
});
|
||||
|
||||
it('reads the nested creator?.name when present', () => {
|
||||
const result = filterComment({
|
||||
...baseComment,
|
||||
creator: { name: 'Alice' },
|
||||
});
|
||||
expect(result.creatorName).toBe('Alice');
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterSearchResult', () => {
|
||||
const baseResult = {
|
||||
id: 'r1',
|
||||
title: 'Result',
|
||||
parentPageId: 'p0',
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-01-02',
|
||||
rank: 0.9,
|
||||
highlight: '<em>Result</em>',
|
||||
};
|
||||
|
||||
it('reads nested space?.id and space?.name when space is present', () => {
|
||||
const result = filterSearchResult({
|
||||
...baseResult,
|
||||
space: { id: 'sp1', name: 'Space One' },
|
||||
});
|
||||
expect(result.spaceId).toBe('sp1');
|
||||
expect(result.spaceName).toBe('Space One');
|
||||
});
|
||||
|
||||
it('returns undefined for space fields when space is absent (no throw)', () => {
|
||||
const result = filterSearchResult(baseResult);
|
||||
expect(result.spaceId).toBeUndefined();
|
||||
expect(result.spaceName).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('flat pluckers (no branching)', () => {
|
||||
it('filterWorkspace plucks the expected shape', () => {
|
||||
const result = filterWorkspace({
|
||||
id: 'w1',
|
||||
name: 'WS',
|
||||
description: 'desc',
|
||||
defaultSpaceId: 'sp1',
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-01-02',
|
||||
deletedAt: null,
|
||||
extra: 'drop',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
id: 'w1',
|
||||
name: 'WS',
|
||||
description: 'desc',
|
||||
defaultSpaceId: 'sp1',
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-01-02',
|
||||
deletedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('filterSpace plucks the expected shape', () => {
|
||||
const result = filterSpace({
|
||||
id: 'sp1',
|
||||
name: 'Space',
|
||||
description: 'desc',
|
||||
slug: 'space',
|
||||
visibility: 'open',
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-01-02',
|
||||
deletedAt: null,
|
||||
extra: 'drop',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
id: 'sp1',
|
||||
name: 'Space',
|
||||
description: 'desc',
|
||||
slug: 'space',
|
||||
visibility: 'open',
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-01-02',
|
||||
deletedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('filterGroup plucks the expected shape', () => {
|
||||
const result = filterGroup({
|
||||
id: 'g1',
|
||||
name: 'Group',
|
||||
description: 'desc',
|
||||
workspaceId: 'w1',
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-01-02',
|
||||
deletedAt: null,
|
||||
extra: 'drop',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
id: 'g1',
|
||||
name: 'Group',
|
||||
description: 'desc',
|
||||
workspaceId: 'w1',
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-01-02',
|
||||
deletedAt: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
288
test/json-edit.test.ts
Normal file
288
test/json-edit.test.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { applyTextEdits } from '../packages/docmost-client/src/lib/json-edit.js';
|
||||
|
||||
// Helper: build a ProseMirror text node.
|
||||
function text(value: string, extra: Record<string, unknown> = {}) {
|
||||
return { type: 'text', text: value, ...extra };
|
||||
}
|
||||
|
||||
// Helper: a simple doc with a single paragraph containing one text node.
|
||||
function singleParagraph(value: string) {
|
||||
return {
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph', content: [text(value)] }],
|
||||
};
|
||||
}
|
||||
|
||||
describe('applyTextEdits', () => {
|
||||
describe('single match', () => {
|
||||
it('replaces an exact single match and reports one replacement', () => {
|
||||
const doc = singleParagraph('hello world');
|
||||
const { doc: result, results } = applyTextEdits(doc, [
|
||||
{ find: 'world', replace: 'there' },
|
||||
]);
|
||||
|
||||
expect(result.content[0].content[0].text).toBe('hello there');
|
||||
expect(results).toEqual([{ find: 'world', replacements: 1 }]);
|
||||
});
|
||||
|
||||
it('preserves node id/marks around the edited text', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
attrs: { id: 'p1' },
|
||||
content: [
|
||||
text('old value', { marks: [{ type: 'bold' }], custom: 42 }),
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const { doc: result } = applyTextEdits(doc, [
|
||||
{ find: 'old', replace: 'new' },
|
||||
]);
|
||||
|
||||
const node = result.content[0].content[0];
|
||||
expect(node.text).toBe('new value');
|
||||
expect(node.marks).toEqual([{ type: 'bold' }]);
|
||||
expect(node.custom).toBe(42);
|
||||
expect(result.content[0].attrs).toEqual({ id: 'p1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceAll', () => {
|
||||
it('replaces every occurrence inside a single node when replaceAll is set', () => {
|
||||
const doc = singleParagraph('a a a');
|
||||
const { doc: result, results } = applyTextEdits(doc, [
|
||||
{ find: 'a', replace: 'b', replaceAll: true },
|
||||
]);
|
||||
|
||||
expect(result.content[0].content[0].text).toBe('b b b');
|
||||
expect(results).toEqual([{ find: 'a', replacements: 3 }]);
|
||||
});
|
||||
|
||||
it('replaces occurrences across multiple separate text nodes', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [text('foo and foo')] },
|
||||
{ type: 'paragraph', content: [text('also foo')] },
|
||||
],
|
||||
};
|
||||
const { doc: result, results } = applyTextEdits(doc, [
|
||||
{ find: 'foo', replace: 'bar', replaceAll: true },
|
||||
]);
|
||||
|
||||
expect(result.content[0].content[0].text).toBe('bar and bar');
|
||||
expect(result.content[1].content[0].text).toBe('also bar');
|
||||
// 2 + 1 occurrences counted.
|
||||
expect(results).toEqual([{ find: 'foo', replacements: 3 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-match without replaceAll', () => {
|
||||
it('throws reporting the match count', () => {
|
||||
const doc = singleParagraph('x x x');
|
||||
expect(() => applyTextEdits(doc, [{ find: 'x', replace: 'y' }])).toThrow(
|
||||
/matches 3 times/,
|
||||
);
|
||||
});
|
||||
|
||||
it('counts matches across nodes when deciding to throw', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [text('dup')] },
|
||||
{ type: 'paragraph', content: [text('dup')] },
|
||||
],
|
||||
};
|
||||
expect(() => applyTextEdits(doc, [{ find: 'dup', replace: 'z' }])).toThrow(
|
||||
/matches 2 times/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('zero match', () => {
|
||||
it('throws "text not found" when text is absent entirely', () => {
|
||||
const doc = singleParagraph('hello world');
|
||||
expect(() =>
|
||||
applyTextEdits(doc, [{ find: 'absent', replace: 'x' }]),
|
||||
).toThrow(/text not found in the document/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('text split across formatting runs', () => {
|
||||
it('throws a distinct "spans multiple formatting runs" error', () => {
|
||||
// "hello world" exists in the concatenated text but no single text node
|
||||
// contains it: it is split across a bold run and a plain run.
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
text('hello ', { marks: [{ type: 'bold' }] }),
|
||||
text('world'),
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(() =>
|
||||
applyTextEdits(doc, [{ find: 'hello world', replace: 'x' }]),
|
||||
).toThrow(/spans multiple formatting runs/);
|
||||
});
|
||||
|
||||
it('does not raise the spans error when text is genuinely missing', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [text('hello ', { marks: [{ type: 'bold' }] }), text('world')],
|
||||
},
|
||||
],
|
||||
};
|
||||
// "nope" is in neither the runs nor the concatenated text.
|
||||
expect(() =>
|
||||
applyTextEdits(doc, [{ find: 'nope', replace: 'x' }]),
|
||||
).toThrow(/text not found in the document/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty find', () => {
|
||||
it('throws on an empty find string', () => {
|
||||
const doc = singleParagraph('hello');
|
||||
expect(() => applyTextEdits(doc, [{ find: '', replace: 'x' }])).toThrow(
|
||||
/edit\.find must be a non-empty string/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('literal replacement (regex foot-gun)', () => {
|
||||
it('inserts $& literally without regex expansion', () => {
|
||||
const doc = singleParagraph('price NUM here');
|
||||
const { doc: result } = applyTextEdits(doc, [
|
||||
{ find: 'NUM', replace: '$&100' },
|
||||
]);
|
||||
// If String.replace were used, "$&" would expand to the matched "NUM".
|
||||
expect(result.content[0].content[0].text).toBe('price $&100 here');
|
||||
});
|
||||
|
||||
it('inserts $1 literally without regex expansion', () => {
|
||||
const doc = singleParagraph('token TKN end');
|
||||
const { doc: result } = applyTextEdits(doc, [
|
||||
{ find: 'TKN', replace: 'a$1b$$c' },
|
||||
]);
|
||||
expect(result.content[0].content[0].text).toBe('token a$1b$$c end');
|
||||
});
|
||||
|
||||
it('inserts $& literally for replaceAll as well', () => {
|
||||
const doc = singleParagraph('K K');
|
||||
const { doc: result } = applyTextEdits(doc, [
|
||||
{ find: 'K', replace: '[$&]', replaceAll: true },
|
||||
]);
|
||||
expect(result.content[0].content[0].text).toBe('[$&] [$&]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pruning emptied nodes', () => {
|
||||
it('removes a text node that becomes empty after the edit', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [text('REMOVE'), text(' keep')],
|
||||
},
|
||||
],
|
||||
};
|
||||
const { doc: result } = applyTextEdits(doc, [
|
||||
{ find: 'REMOVE', replace: '' },
|
||||
]);
|
||||
|
||||
// The emptied node is pruned, leaving only the surviving node.
|
||||
expect(result.content[0].content).toHaveLength(1);
|
||||
expect(result.content[0].content[0].text).toBe(' keep');
|
||||
});
|
||||
});
|
||||
|
||||
describe('immutability', () => {
|
||||
it('does not mutate the input document', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
attrs: { id: 'p1' },
|
||||
content: [text('hello world', { marks: [{ type: 'bold' }] })],
|
||||
},
|
||||
],
|
||||
};
|
||||
const snapshot = structuredClone(doc);
|
||||
|
||||
applyTextEdits(doc, [{ find: 'world', replace: 'there' }]);
|
||||
|
||||
expect(doc).toEqual(snapshot);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deeply nested recursion', () => {
|
||||
it('finds and replaces text several levels deep', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'bulletList',
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'blockquote',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [text('deep target value')],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const { doc: result, results } = applyTextEdits(doc, [
|
||||
{ find: 'target', replace: 'goal' },
|
||||
]);
|
||||
|
||||
const leaf =
|
||||
result.content[0].content[0].content[0].content[0].content[0]
|
||||
.content[0];
|
||||
expect(leaf.text).toBe('deep goal value');
|
||||
expect(results).toEqual([{ find: 'target', replacements: 1 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple edits in sequence', () => {
|
||||
it('applies each edit and returns a result per edit in order', () => {
|
||||
const doc = singleParagraph('alpha beta gamma');
|
||||
const { doc: result, results } = applyTextEdits(doc, [
|
||||
{ find: 'alpha', replace: 'A' },
|
||||
{ find: 'gamma', replace: 'G' },
|
||||
]);
|
||||
|
||||
expect(result.content[0].content[0].text).toBe('A beta G');
|
||||
expect(results).toEqual([
|
||||
{ find: 'alpha', replacements: 1 },
|
||||
{ find: 'gamma', replacements: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
507
test/markdown-converter.test.ts
Normal file
507
test/markdown-converter.test.ts
Normal file
@@ -0,0 +1,507 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
// Import DIRECTLY from src (NOT the docmost-client barrel, which pulls in
|
||||
// collaboration.ts and mutates global DOM at import time).
|
||||
import { convertProseMirrorToMarkdown } from '../packages/docmost-client/src/lib/markdown-converter.js';
|
||||
|
||||
// Wrap a single node in a minimal ProseMirror doc. The top-level converter
|
||||
// joins doc children with "\n\n" and then .trim()s the whole output, so a
|
||||
// single-node doc yields exactly that node's rendered (and trimmed) string.
|
||||
const doc = (...nodes: any[]) => ({ type: 'doc', content: nodes });
|
||||
// Convenience: a text node, optionally with marks.
|
||||
const text = (t: string, marks?: any[]) =>
|
||||
marks ? { type: 'text', text: t, marks } : { type: 'text', text: t };
|
||||
// Convenience: a paragraph wrapping inline children.
|
||||
const para = (...inline: any[]) => ({ type: 'paragraph', content: inline });
|
||||
|
||||
describe('convertProseMirrorToMarkdown', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('headings', () => {
|
||||
it('emits the right number of "#" for levels 1-6', () => {
|
||||
for (let level = 1; level <= 6; level++) {
|
||||
const out = convertProseMirrorToMarkdown(
|
||||
doc({ type: 'heading', attrs: { level }, content: [text('H')] }),
|
||||
);
|
||||
expect(out).toBe('#'.repeat(level) + ' H');
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults to level 1 when level is missing', () => {
|
||||
const out = convertProseMirrorToMarkdown(
|
||||
doc({ type: 'heading', content: [text('NoLevel')] }),
|
||||
);
|
||||
expect(out).toBe('# NoLevel');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('text marks', () => {
|
||||
it('bold', () => {
|
||||
expect(
|
||||
convertProseMirrorToMarkdown(doc(para(text('x', [{ type: 'bold' }])))),
|
||||
).toBe('**x**');
|
||||
});
|
||||
|
||||
it('italic', () => {
|
||||
expect(
|
||||
convertProseMirrorToMarkdown(doc(para(text('x', [{ type: 'italic' }])))),
|
||||
).toBe('*x*');
|
||||
});
|
||||
|
||||
it('strike', () => {
|
||||
expect(
|
||||
convertProseMirrorToMarkdown(doc(para(text('x', [{ type: 'strike' }])))),
|
||||
).toBe('~~x~~');
|
||||
});
|
||||
|
||||
it('inline code (sole mark) uses backtick span', () => {
|
||||
expect(
|
||||
convertProseMirrorToMarkdown(doc(para(text('x', [{ type: 'code' }])))),
|
||||
).toBe('`x`');
|
||||
});
|
||||
|
||||
it('code + another mark switches to nested HTML (no backtick form)', () => {
|
||||
// marks array order drives nesting: bold first wraps, then code wraps that.
|
||||
const out = convertProseMirrorToMarkdown(
|
||||
doc(para(text('x', [{ type: 'bold' }, { type: 'code' }]))),
|
||||
);
|
||||
expect(out).toBe('<code><strong>x</strong></code>');
|
||||
});
|
||||
|
||||
it('code + strike combo emits <code> wrapping <s>', () => {
|
||||
const out = convertProseMirrorToMarkdown(
|
||||
doc(para(text('x', [{ type: 'strike' }, { type: 'code' }]))),
|
||||
);
|
||||
expect(out).toBe('<code><s>x</s></code>');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('links', () => {
|
||||
it('href only', () => {
|
||||
const out = convertProseMirrorToMarkdown(
|
||||
doc(para(text('site', [{ type: 'link', attrs: { href: 'https://e.com' } }]))),
|
||||
);
|
||||
expect(out).toBe('[site](https://e.com)');
|
||||
});
|
||||
|
||||
it('href + title with an embedded double quote is escaped', () => {
|
||||
const out = convertProseMirrorToMarkdown(
|
||||
doc(
|
||||
para(
|
||||
text('site', [
|
||||
{ type: 'link', attrs: { href: 'https://e.com', title: 'a "b" c' } },
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
// The markdown link-title form escapes the inner " as \".
|
||||
expect(out).toBe('[site](https://e.com "a \\"b\\" c")');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('image', () => {
|
||||
it('percent-encodes spaces and parentheses in src', () => {
|
||||
const out = convertProseMirrorToMarkdown(
|
||||
doc({
|
||||
type: 'image',
|
||||
attrs: { alt: 'cap', src: '/files/my pic (1).png' },
|
||||
}),
|
||||
);
|
||||
// space -> %20, ( -> %28, ) -> %29
|
||||
expect(out).toBe('');
|
||||
});
|
||||
|
||||
it('empty alt and missing src render harmlessly', () => {
|
||||
const out = convertProseMirrorToMarkdown(doc({ type: 'image', attrs: {} }));
|
||||
expect(out).toBe('![]()');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('codeBlock', () => {
|
||||
it('with language', () => {
|
||||
const out = convertProseMirrorToMarkdown(
|
||||
doc({
|
||||
type: 'codeBlock',
|
||||
attrs: { language: 'ts' },
|
||||
content: [text('const a = 1;')],
|
||||
}),
|
||||
);
|
||||
expect(out).toBe('```ts\nconst a = 1;\n```');
|
||||
});
|
||||
|
||||
it('without language emits empty info string', () => {
|
||||
const out = convertProseMirrorToMarkdown(
|
||||
doc({ type: 'codeBlock', content: [text('plain')] }),
|
||||
);
|
||||
expect(out).toBe('```\nplain\n```');
|
||||
});
|
||||
|
||||
it('strips ALL trailing newlines for idempotency', () => {
|
||||
const out = convertProseMirrorToMarkdown(
|
||||
doc({ type: 'codeBlock', content: [text('a\n\n\n')] }),
|
||||
);
|
||||
// Every trailing "\n" is removed, then exactly one is re-added by the fence.
|
||||
expect(out).toBe('```\na\n```');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('lists', () => {
|
||||
it('bullet list', () => {
|
||||
const out = convertProseMirrorToMarkdown(
|
||||
doc({
|
||||
type: 'bulletList',
|
||||
content: [
|
||||
{ type: 'listItem', content: [para(text('one'))] },
|
||||
{ type: 'listItem', content: [para(text('two'))] },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(out).toBe('- one\n- two');
|
||||
});
|
||||
|
||||
it('ordered list numbers items sequentially', () => {
|
||||
const out = convertProseMirrorToMarkdown(
|
||||
doc({
|
||||
type: 'orderedList',
|
||||
content: [
|
||||
{ type: 'listItem', content: [para(text('a'))] },
|
||||
{ type: 'listItem', content: [para(text('b'))] },
|
||||
{ type: 'listItem', content: [para(text('c'))] },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(out).toBe('1. a\n2. b\n3. c');
|
||||
});
|
||||
|
||||
it('nested bullet list indents the child by the 2-col marker width', () => {
|
||||
const out = convertProseMirrorToMarkdown(
|
||||
doc({
|
||||
type: 'bulletList',
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [
|
||||
para(text('parent')),
|
||||
{
|
||||
type: 'bulletList',
|
||||
content: [{ type: 'listItem', content: [para(text('child'))] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
// First line carries the marker; the nested list is indented 2 columns.
|
||||
expect(out).toBe('- parent\n - child');
|
||||
});
|
||||
|
||||
it('nested ordered list indents by the wider 3-col marker width', () => {
|
||||
const out = convertProseMirrorToMarkdown(
|
||||
doc({
|
||||
type: 'orderedList',
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [
|
||||
para(text('parent')),
|
||||
{
|
||||
type: 'orderedList',
|
||||
content: [{ type: 'listItem', content: [para(text('child'))] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
// "1. " is 3 columns wide, so the continuation indent is 3 spaces.
|
||||
expect(out).toBe('1. parent\n 1. child');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('task list', () => {
|
||||
it('unchecked and checked items', () => {
|
||||
const out = convertProseMirrorToMarkdown(
|
||||
doc({
|
||||
type: 'taskList',
|
||||
content: [
|
||||
{ type: 'taskItem', attrs: { checked: false }, content: [para(text('todo'))] },
|
||||
{ type: 'taskItem', attrs: { checked: true }, content: [para(text('done'))] },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(out).toBe('- [ ] todo\n- [x] done');
|
||||
});
|
||||
|
||||
it('empty task item keeps its marker', () => {
|
||||
const out = convertProseMirrorToMarkdown(
|
||||
doc({
|
||||
type: 'taskList',
|
||||
content: [{ type: 'taskItem', attrs: { checked: false }, content: [] }],
|
||||
}),
|
||||
);
|
||||
expect(out).toBe('- [ ]');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('blockquote', () => {
|
||||
it('single paragraph quote prefixes the line', () => {
|
||||
const out = convertProseMirrorToMarkdown(
|
||||
doc({ type: 'blockquote', content: [para(text('quoted'))] }),
|
||||
);
|
||||
expect(out).toBe('> quoted');
|
||||
});
|
||||
|
||||
it('multi-paragraph quote separates blocks with a bare ">" line', () => {
|
||||
const out = convertProseMirrorToMarkdown(
|
||||
doc({
|
||||
type: 'blockquote',
|
||||
content: [para(text('first')), para(text('second'))],
|
||||
}),
|
||||
);
|
||||
expect(out).toBe('> first\n>\n> second');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('breaks and rules', () => {
|
||||
it('horizontal rule', () => {
|
||||
expect(
|
||||
convertProseMirrorToMarkdown(doc({ type: 'horizontalRule' })),
|
||||
).toBe('---');
|
||||
});
|
||||
|
||||
it('hard break emits two trailing spaces then newline', () => {
|
||||
const out = convertProseMirrorToMarkdown(
|
||||
doc(para(text('a'), { type: 'hardBreak' }, text('b'))),
|
||||
);
|
||||
expect(out).toBe('a \nb');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('tables', () => {
|
||||
it('GFM table emits alignment markers derived from header cells', () => {
|
||||
const headerRow = {
|
||||
type: 'tableRow',
|
||||
content: [
|
||||
{ type: 'tableHeader', attrs: { align: 'left' }, content: [para(text('L'))] },
|
||||
{ type: 'tableHeader', attrs: { align: 'center' }, content: [para(text('C'))] },
|
||||
{ type: 'tableHeader', attrs: { align: 'right' }, content: [para(text('R'))] },
|
||||
{ type: 'tableHeader', content: [para(text('N'))] },
|
||||
],
|
||||
};
|
||||
const bodyRow = {
|
||||
type: 'tableRow',
|
||||
content: [
|
||||
{ type: 'tableCell', content: [para(text('1'))] },
|
||||
{ type: 'tableCell', content: [para(text('2'))] },
|
||||
{ type: 'tableCell', content: [para(text('3'))] },
|
||||
{ type: 'tableCell', content: [para(text('4'))] },
|
||||
],
|
||||
};
|
||||
const out = convertProseMirrorToMarkdown(
|
||||
doc({ type: 'table', content: [headerRow, bodyRow] }),
|
||||
);
|
||||
expect(out).toBe(
|
||||
[
|
||||
'| L | C | R | N |',
|
||||
'| :-- | :-: | --: | --- |',
|
||||
'| 1 | 2 | 3 | 4 |',
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
it('spanned table (colspan/rowspan) emits raw <table> HTML', () => {
|
||||
const out = convertProseMirrorToMarkdown(
|
||||
doc({
|
||||
type: 'table',
|
||||
content: [
|
||||
{
|
||||
type: 'tableRow',
|
||||
content: [
|
||||
{
|
||||
type: 'tableHeader',
|
||||
attrs: { colspan: 2 },
|
||||
content: [para(text('wide'))],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'tableRow',
|
||||
content: [
|
||||
{ type: 'tableCell', content: [para(text('a'))] },
|
||||
{ type: 'tableCell', content: [para(text('b'))] },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(out).toBe(
|
||||
'<table><tbody>' +
|
||||
'<tr><th colspan="2"><p>wide</p></th></tr>' +
|
||||
'<tr><td><p>a</p></td><td><p>b</p></td></tr>' +
|
||||
'</tbody></table>',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('callout and details', () => {
|
||||
it('callout uses lowercased type fence', () => {
|
||||
const out = convertProseMirrorToMarkdown(
|
||||
doc({
|
||||
type: 'callout',
|
||||
attrs: { type: 'WARNING' },
|
||||
content: [para(text('beware'))],
|
||||
}),
|
||||
);
|
||||
expect(out).toBe(':::warning\nbeware\n:::');
|
||||
});
|
||||
|
||||
it('callout defaults to info', () => {
|
||||
const out = convertProseMirrorToMarkdown(
|
||||
doc({ type: 'callout', content: [para(text('hi'))] }),
|
||||
);
|
||||
expect(out).toBe(':::info\nhi\n:::');
|
||||
});
|
||||
|
||||
it('details emits summary + content wrapped in <details>', () => {
|
||||
const out = convertProseMirrorToMarkdown(
|
||||
doc({
|
||||
type: 'details',
|
||||
content: [
|
||||
{ type: 'detailsSummary', content: [text('Title')] },
|
||||
{ type: 'detailsContent', content: [para(text('Body'))] },
|
||||
],
|
||||
}),
|
||||
);
|
||||
// details joins its children with "\n"; summary opens, content closes.
|
||||
expect(out).toBe('<details>\n<summary>Title</summary>\n\nBody\n</details>');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('math', () => {
|
||||
it('inline math carries LaTeX in a text attr WITHOUT escaping < or >', () => {
|
||||
const out = convertProseMirrorToMarkdown(
|
||||
doc(para({ type: 'mathInline', attrs: { text: 'a < b' } })),
|
||||
);
|
||||
// < and > must NOT be HTML-escaped (idempotency); only & and " would be.
|
||||
expect(out).toBe(
|
||||
'<span data-type="mathInline" data-katex="true" text="a < b"></span>',
|
||||
);
|
||||
expect(out).not.toContain('<');
|
||||
});
|
||||
|
||||
it('block math carries LaTeX in a text attr WITHOUT escaping < or >', () => {
|
||||
const out = convertProseMirrorToMarkdown(
|
||||
doc({ type: 'mathBlock', attrs: { text: 'x > y & z' } }),
|
||||
);
|
||||
// & IS escaped (entity-significant), but < and > are NOT.
|
||||
expect(out).toBe(
|
||||
'<div data-type="mathBlock" data-katex="true" text="x > y & z"></div>',
|
||||
);
|
||||
expect(out).not.toContain('<');
|
||||
expect(out).not.toContain('>');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('inline atoms and media', () => {
|
||||
it('mention emits schema span with data-* attrs and visible label', () => {
|
||||
const out = convertProseMirrorToMarkdown(
|
||||
doc(
|
||||
para({
|
||||
type: 'mention',
|
||||
attrs: { id: 'u1', label: 'Alice', entityType: 'user' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(out).toBe(
|
||||
'<span data-type="mention" data-id="u1" data-label="Alice" data-entity-type="user">@Alice</span>',
|
||||
);
|
||||
});
|
||||
|
||||
it('attachment emits div with schema data-attachment-* attrs', () => {
|
||||
const out = convertProseMirrorToMarkdown(
|
||||
doc({
|
||||
type: 'attachment',
|
||||
attrs: { url: '/files/x.zip', name: 'x.zip', mime: 'application/zip', size: 99 },
|
||||
}),
|
||||
);
|
||||
expect(out).toBe(
|
||||
'<div data-type="attachment" data-attachment-url="/files/x.zip" ' +
|
||||
'data-attachment-name="x.zip" data-attachment-mime="application/zip" ' +
|
||||
'data-attachment-size="99"></div>',
|
||||
);
|
||||
});
|
||||
|
||||
it('video emits a <div>-wrapped <video> with schema attrs', () => {
|
||||
const out = convertProseMirrorToMarkdown(
|
||||
doc({
|
||||
type: 'video',
|
||||
attrs: { src: '/v.mp4', alt: 'clip', width: 640 },
|
||||
}),
|
||||
);
|
||||
expect(out).toBe(
|
||||
'<div><video src="/v.mp4" aria-label="clip" width="640"></video></div>',
|
||||
);
|
||||
});
|
||||
|
||||
it('youtube emits a div[data-type="youtube"] with data-src', () => {
|
||||
const out = convertProseMirrorToMarkdown(
|
||||
doc({
|
||||
type: 'youtube',
|
||||
attrs: { src: 'https://youtu.be/abc', width: 560, height: 315 },
|
||||
}),
|
||||
);
|
||||
expect(out).toBe(
|
||||
'<div data-type="youtube" data-src="https://youtu.be/abc" ' +
|
||||
'data-width="560" data-height="315"></div>',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('edge cases', () => {
|
||||
it('null content returns ""', () => {
|
||||
expect(convertProseMirrorToMarkdown(null)).toBe('');
|
||||
});
|
||||
|
||||
it('empty object returns ""', () => {
|
||||
expect(convertProseMirrorToMarkdown({})).toBe('');
|
||||
});
|
||||
|
||||
it('doc with no content returns ""', () => {
|
||||
expect(convertProseMirrorToMarkdown({ type: 'doc' })).toBe('');
|
||||
});
|
||||
|
||||
it('unknown node type falls back to children-only (no throw, text preserved)', () => {
|
||||
const out = convertProseMirrorToMarkdown(
|
||||
doc({ type: 'totallyUnknownType', content: [text('kept')] }),
|
||||
);
|
||||
expect(out).toBe('kept');
|
||||
});
|
||||
|
||||
it('deeply nested structure does not stack-overflow', () => {
|
||||
// Build a deeply nested bullet list (each level holds one nested list).
|
||||
let node: any = { type: 'listItem', content: [para(text('leaf'))] };
|
||||
for (let i = 0; i < 200; i++) {
|
||||
node = {
|
||||
type: 'listItem',
|
||||
content: [para(text('lvl')), { type: 'bulletList', content: [node] }],
|
||||
};
|
||||
}
|
||||
const root = doc({ type: 'bulletList', content: [node] });
|
||||
expect(() => convertProseMirrorToMarkdown(root)).not.toThrow();
|
||||
const out = convertProseMirrorToMarkdown(root);
|
||||
expect(out).toContain('leaf');
|
||||
expect(out.startsWith('- lvl')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
199
test/markdown-document-envelope.test.ts
Normal file
199
test/markdown-document-envelope.test.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
// Import DIRECTLY from src (NOT the docmost-client barrel, which pulls in
|
||||
// collaboration.ts and mutates global DOM at import time).
|
||||
import {
|
||||
serializeDocmostMarkdown,
|
||||
parseDocmostMarkdown,
|
||||
serializeDocmostMarkdownBody,
|
||||
type DocmostMdMeta,
|
||||
} from '../packages/docmost-client/src/lib/markdown-document.js';
|
||||
|
||||
const meta: DocmostMdMeta = {
|
||||
version: 1,
|
||||
pageId: 'p1',
|
||||
slugId: 's1',
|
||||
title: 'Hello',
|
||||
spaceId: 'sp1',
|
||||
parentPageId: null,
|
||||
};
|
||||
|
||||
describe('serializeDocmostMarkdown / parseDocmostMarkdown', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('round-trip', () => {
|
||||
it('round-trips meta, body, and comments', () => {
|
||||
const body = '# Title\n\nSome **body** text.';
|
||||
const comments = [{ id: 'c1', text: 'a note' }];
|
||||
const full = serializeDocmostMarkdown(meta, body, comments);
|
||||
const parsed = parseDocmostMarkdown(full);
|
||||
expect(parsed.meta).toEqual(meta);
|
||||
expect(parsed.body).toBe(body);
|
||||
expect(parsed.comments).toEqual(comments);
|
||||
});
|
||||
|
||||
it('emits a comments block with [] even when there are no comments', () => {
|
||||
const full = serializeDocmostMarkdown(meta, 'body', []);
|
||||
expect(full).toContain('<!-- docmost:comments\n[]\n-->');
|
||||
const parsed = parseDocmostMarkdown(full);
|
||||
expect(parsed.comments).toEqual([]);
|
||||
expect(parsed.body).toBe('body');
|
||||
});
|
||||
|
||||
it('non-array comments arg is normalized to [] in the serialized output', () => {
|
||||
const full = serializeDocmostMarkdown(meta, 'body', null as any);
|
||||
expect(full).toContain('<!-- docmost:comments\n[]\n-->');
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace from the body on serialize', () => {
|
||||
const full = serializeDocmostMarkdown(meta, '\n\n body \n\n', []);
|
||||
const parsed = parseDocmostMarkdown(full);
|
||||
expect(parsed.body).toBe('body');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('missing blocks (tolerant parsing)', () => {
|
||||
it('missing meta block yields meta:null', () => {
|
||||
const input = 'Just a body.\n\n<!-- docmost:comments\n[]\n-->\n';
|
||||
const parsed = parseDocmostMarkdown(input);
|
||||
expect(parsed.meta).toBeNull();
|
||||
expect(parsed.body).toBe('Just a body.');
|
||||
expect(parsed.comments).toEqual([]);
|
||||
});
|
||||
|
||||
it('missing comments block yields comments:null and treats all as body', () => {
|
||||
const input =
|
||||
'<!-- docmost:meta\n' + JSON.stringify(meta) + '\n-->\n\nbody only';
|
||||
const parsed = parseDocmostMarkdown(input);
|
||||
expect(parsed.meta).toEqual(meta);
|
||||
expect(parsed.comments).toBeNull();
|
||||
expect(parsed.body).toBe('body only');
|
||||
});
|
||||
|
||||
it('plain markdown with neither block: meta and comments null, whole input is body', () => {
|
||||
const input = '# Plain\n\nNo envelope here.';
|
||||
const parsed = parseDocmostMarkdown(input);
|
||||
expect(parsed.meta).toBeNull();
|
||||
expect(parsed.comments).toBeNull();
|
||||
expect(parsed.body).toBe(input);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('CRLF normalization', () => {
|
||||
it('parses a CRLF-encoded document the same as LF', () => {
|
||||
const lf = serializeDocmostMarkdown(meta, 'line one\nline two', [
|
||||
{ id: 'c1' },
|
||||
]);
|
||||
const crlf = lf.replace(/\n/g, '\r\n');
|
||||
const parsed = parseDocmostMarkdown(crlf);
|
||||
expect(parsed.meta).toEqual(meta);
|
||||
expect(parsed.body).toBe('line one\nline two');
|
||||
expect(parsed.comments).toEqual([{ id: 'c1' }]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('only the final document-ending comments block is captured', () => {
|
||||
it('an earlier literal docmost:comments opener inside the body stays in the body', () => {
|
||||
// The body documents the format and contains a literal opener that does
|
||||
// NOT end the document. Only the trailing block is treated as metadata.
|
||||
const bodyWithLiteral =
|
||||
'Here is how the format looks:\n\n<!-- docmost:comments\n[{"fake":true}]\n-->\n\nand more prose after it.';
|
||||
const full = serializeDocmostMarkdown(meta, bodyWithLiteral, [
|
||||
{ id: 'real' },
|
||||
]);
|
||||
const parsed = parseDocmostMarkdown(full);
|
||||
// The real (final) block parses into the comments...
|
||||
expect(parsed.comments).toEqual([{ id: 'real' }]);
|
||||
// ...and the earlier literal opener is preserved verbatim in the body.
|
||||
expect(parsed.body).toContain(
|
||||
'<!-- docmost:comments\n[{"fake":true}]\n-->',
|
||||
);
|
||||
expect(parsed.body).toContain('and more prose after it.');
|
||||
});
|
||||
|
||||
it('a literal opener whose closer does NOT end the doc is left entirely in the body', () => {
|
||||
// No real trailing block: the opener is not document-ending, so comments
|
||||
// stays null and nothing is stripped.
|
||||
const input =
|
||||
'<!-- docmost:meta\n' +
|
||||
JSON.stringify(meta) +
|
||||
'\n-->\n\nbody start\n\n<!-- docmost:comments\n[]\n-->\n\ntrailing text not ending the doc';
|
||||
const parsed = parseDocmostMarkdown(input);
|
||||
expect(parsed.comments).toBeNull();
|
||||
expect(parsed.body).toContain('<!-- docmost:comments');
|
||||
expect(parsed.body).toContain('trailing text not ending the doc');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('malformed JSON throws a clear error', () => {
|
||||
it('throws on malformed meta JSON', () => {
|
||||
const input = '<!-- docmost:meta\n{not valid json}\n-->\n\nbody';
|
||||
expect(() => parseDocmostMarkdown(input)).toThrow(/docmost:meta JSON/);
|
||||
});
|
||||
|
||||
it('throws on malformed comments JSON', () => {
|
||||
const input = 'body\n\n<!-- docmost:comments\n[not, valid]\n-->\n';
|
||||
expect(() => parseDocmostMarkdown(input)).toThrow(/docmost:comments JSON/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('serializeDocmostMarkdownBody', () => {
|
||||
it('emits NO comments block', () => {
|
||||
const out = serializeDocmostMarkdownBody(meta, 'just the body');
|
||||
expect(out).not.toContain('docmost:comments');
|
||||
expect(out).toContain('<!-- docmost:meta');
|
||||
});
|
||||
|
||||
it('serialize -> parse preserves meta and the trimmed body, comments null (SPEC §3)', () => {
|
||||
const fullMeta: DocmostMdMeta = {
|
||||
version: 1,
|
||||
pageId: 'page-123',
|
||||
slugId: 'slug-abc',
|
||||
title: 'My Page',
|
||||
spaceId: 'space-1',
|
||||
parentPageId: 'parent-9',
|
||||
};
|
||||
const body = 'Hello\n\nWorld';
|
||||
const out = serializeDocmostMarkdownBody(fullMeta, body);
|
||||
const parsed = parseDocmostMarkdown(out);
|
||||
expect(parsed.meta).toEqual(fullMeta);
|
||||
expect(parsed.body).toBe(body);
|
||||
expect(parsed.comments).toBeNull();
|
||||
});
|
||||
|
||||
it('preserves a null parentPageId for a root page', () => {
|
||||
const out = serializeDocmostMarkdownBody(meta, 'body text');
|
||||
const parsed = parseDocmostMarkdown(out);
|
||||
expect(parsed.meta).toEqual(meta);
|
||||
expect(parsed.comments).toBeNull();
|
||||
});
|
||||
|
||||
it('produces a parseable file for an empty or missing body', () => {
|
||||
const minimal: DocmostMdMeta = { version: 1, pageId: 'p-empty' };
|
||||
|
||||
const emptyFile = serializeDocmostMarkdownBody(minimal, '');
|
||||
const parsedEmpty = parseDocmostMarkdown(emptyFile);
|
||||
expect(parsedEmpty.meta).toEqual(minimal);
|
||||
expect(parsedEmpty.body).toBe('');
|
||||
expect(parsedEmpty.comments).toBeNull();
|
||||
|
||||
// Missing body (undefined) — serializer coalesces to "".
|
||||
const missingFile = serializeDocmostMarkdownBody(
|
||||
minimal,
|
||||
undefined as unknown as string,
|
||||
);
|
||||
const parsedMissing = parseDocmostMarkdown(missingFile);
|
||||
expect(parsedMissing.meta).toEqual(minimal);
|
||||
expect(parsedMissing.body).toBe('');
|
||||
expect(parsedMissing.comments).toBeNull();
|
||||
});
|
||||
|
||||
it('trims the body', () => {
|
||||
const out = serializeDocmostMarkdownBody(meta, '\n\n hi \n');
|
||||
const parsed = parseDocmostMarkdown(out);
|
||||
expect(parsed.body).toBe('hi');
|
||||
});
|
||||
});
|
||||
908
test/node-ops.test.ts
Normal file
908
test/node-ops.test.ts
Normal file
@@ -0,0 +1,908 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
blockPlainText,
|
||||
buildOutline,
|
||||
getNodeByRef,
|
||||
replaceNodeById,
|
||||
deleteNodeById,
|
||||
sanitizeForYjs,
|
||||
findUnstorableAttr,
|
||||
insertNodeRelative,
|
||||
readTable,
|
||||
insertTableRow,
|
||||
deleteTableRow,
|
||||
updateTableCell,
|
||||
} from '../packages/docmost-client/src/lib/node-ops.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tiny ProseMirror/TipTap JSON fixture builders. These produce the exact plain
|
||||
// JSON shape Docmost uses: { type, attrs?, content?, text?, marks? }.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A text leaf node, optionally carrying marks. */
|
||||
function text(value: string, marks?: any[]): any {
|
||||
const node: any = { type: 'text', text: value };
|
||||
if (marks) node.marks = marks;
|
||||
return node;
|
||||
}
|
||||
|
||||
/** A paragraph block with an id and a single text child (or empty). */
|
||||
function para(id: string, value = ''): any {
|
||||
return {
|
||||
type: 'paragraph',
|
||||
attrs: { id, indent: 0 },
|
||||
content: value ? [text(value)] : [],
|
||||
};
|
||||
}
|
||||
|
||||
/** A heading block. */
|
||||
function heading(id: string, level: number, value: string): any {
|
||||
return {
|
||||
type: 'heading',
|
||||
attrs: { id, level },
|
||||
content: [text(value)],
|
||||
};
|
||||
}
|
||||
|
||||
/** A table cell (or header) wrapping a single paragraph; extra attrs merged in. */
|
||||
function cell(
|
||||
type: 'tableCell' | 'tableHeader',
|
||||
paraId: string | null,
|
||||
value = '',
|
||||
extraAttrs: Record<string, any> = {},
|
||||
): any {
|
||||
const attrs = { colspan: 1, rowspan: 1, ...extraAttrs };
|
||||
return {
|
||||
type,
|
||||
attrs,
|
||||
content: paraId == null ? [] : [para(paraId, value)],
|
||||
};
|
||||
}
|
||||
|
||||
/** A table row. */
|
||||
function row(cells: any[]): any {
|
||||
return { type: 'tableRow', content: cells };
|
||||
}
|
||||
|
||||
/** A doc root with the given top-level blocks. */
|
||||
function doc(...content: any[]): any {
|
||||
return { type: 'doc', content };
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// blockPlainText
|
||||
// ===========================================================================
|
||||
describe('blockPlainText', () => {
|
||||
it('returns the text of a plain text node', () => {
|
||||
expect(blockPlainText(text('hello'))).toBe('hello');
|
||||
});
|
||||
|
||||
it('concatenates text from nested containers', () => {
|
||||
const node = {
|
||||
type: 'paragraph',
|
||||
content: [text('foo'), text('bar'), { type: 'span', content: [text('baz')] }],
|
||||
};
|
||||
expect(blockPlainText(node)).toBe('foobarbaz');
|
||||
});
|
||||
|
||||
it('returns "" for nullish or non-object inputs', () => {
|
||||
expect(blockPlainText(null)).toBe('');
|
||||
expect(blockPlainText(undefined)).toBe('');
|
||||
expect(blockPlainText('a string')).toBe('');
|
||||
expect(blockPlainText(42)).toBe('');
|
||||
expect(blockPlainText([text('x')])).toBe(''); // arrays are not objects here
|
||||
});
|
||||
|
||||
it('uses BOTH text and nested content of a node, text first', () => {
|
||||
const node = { type: 'weird', text: 'A', content: [text('B'), text('C')] };
|
||||
expect(blockPlainText(node)).toBe('ABC');
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// buildOutline
|
||||
// ===========================================================================
|
||||
describe('buildOutline', () => {
|
||||
it('captures heading level, id and firstText', () => {
|
||||
const outline = buildOutline(doc(heading('h1', 2, 'Title')));
|
||||
expect(outline).toEqual([
|
||||
{ index: 0, type: 'heading', id: 'h1', firstText: 'Title', level: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports table rows/cols and header texts (cols from row 0)', () => {
|
||||
const table = {
|
||||
type: 'table',
|
||||
content: [
|
||||
row([cell('tableHeader', 'a', 'H1'), cell('tableHeader', 'b', 'H2')]),
|
||||
row([cell('tableCell', 'c', 'x'), cell('tableCell', 'd', 'y')]),
|
||||
],
|
||||
};
|
||||
const [entry] = buildOutline(doc(table));
|
||||
expect(entry.type).toBe('table');
|
||||
expect(entry.rows).toBe(2);
|
||||
expect(entry.cols).toBe(2);
|
||||
expect(entry.header).toEqual(['H1', 'H2']);
|
||||
});
|
||||
|
||||
it('derives cols from row 0 for a ragged table', () => {
|
||||
const table = {
|
||||
type: 'table',
|
||||
content: [
|
||||
row([cell('tableHeader', 'a', 'H1')]), // row 0 has 1 col
|
||||
row([cell('tableCell', 'b', 'x'), cell('tableCell', 'c', 'y')]), // 2 cols
|
||||
],
|
||||
};
|
||||
const [entry] = buildOutline(doc(table));
|
||||
expect(entry.rows).toBe(2);
|
||||
expect(entry.cols).toBe(1); // cols reflect ONLY row 0
|
||||
expect(entry.header).toEqual(['H1']);
|
||||
});
|
||||
|
||||
it('reports item count for any *List block', () => {
|
||||
const list = {
|
||||
type: 'bulletList',
|
||||
attrs: { id: 'l1' },
|
||||
content: [{ type: 'listItem' }, { type: 'listItem' }, { type: 'listItem' }],
|
||||
};
|
||||
const [entry] = buildOutline(doc(list));
|
||||
expect(entry.type).toBe('bulletList');
|
||||
expect(entry.items).toBe(3);
|
||||
});
|
||||
|
||||
it('returns [] for an empty or non-object doc', () => {
|
||||
expect(buildOutline(null)).toEqual([]);
|
||||
expect(buildOutline({ type: 'doc' })).toEqual([]); // no content array
|
||||
expect(buildOutline({ type: 'doc', content: [] })).toEqual([]);
|
||||
expect(buildOutline('nope')).toEqual([]);
|
||||
});
|
||||
|
||||
it('falls back to null id when a block has no attrs.id', () => {
|
||||
const [entry] = buildOutline(doc({ type: 'paragraph', content: [text('hi')] }));
|
||||
expect(entry.id).toBeNull();
|
||||
expect(entry.firstText).toBe('hi');
|
||||
});
|
||||
|
||||
it('truncates firstText to 100 chars with an ellipsis', () => {
|
||||
const long = 'x'.repeat(150);
|
||||
const [entry] = buildOutline(doc(para('p', long)));
|
||||
expect(entry.firstText).toBe('x'.repeat(100) + '…');
|
||||
expect(entry.firstText.length).toBe(101); // 100 chars + ellipsis
|
||||
});
|
||||
|
||||
it('truncates table header cell text to 40 chars', () => {
|
||||
const long = 'y'.repeat(60);
|
||||
const table = {
|
||||
type: 'table',
|
||||
content: [row([cell('tableHeader', 'a', long)])],
|
||||
};
|
||||
const [entry] = buildOutline(doc(table));
|
||||
expect(entry.header).toEqual(['y'.repeat(40) + '…']);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// getNodeByRef
|
||||
// ===========================================================================
|
||||
describe('getNodeByRef', () => {
|
||||
it('resolves a top-level block by #n', () => {
|
||||
const d = doc(para('p0', 'zero'), para('p1', 'one'));
|
||||
const hit = getNodeByRef(d, '#1');
|
||||
expect(hit).not.toBeNull();
|
||||
expect(hit!.path).toEqual([1]);
|
||||
expect(hit!.type).toBe('paragraph');
|
||||
expect(hit!.node.attrs.id).toBe('p1');
|
||||
});
|
||||
|
||||
it('returns null for #n out of range', () => {
|
||||
const d = doc(para('p0'));
|
||||
expect(getNodeByRef(d, '#5')).toBeNull();
|
||||
expect(getNodeByRef(d, '#1')).toBeNull();
|
||||
});
|
||||
|
||||
it('finds a nested node by id with the correct path', () => {
|
||||
const table = {
|
||||
type: 'table',
|
||||
content: [row([cell('tableCell', 'deep', 'found me')])],
|
||||
};
|
||||
const d = doc(para('p0'), table);
|
||||
const hit = getNodeByRef(d, 'deep');
|
||||
expect(hit).not.toBeNull();
|
||||
// doc.content[1] -> table.content[0] -> row.content[0] -> cell.content[0]
|
||||
expect(hit!.path).toEqual([1, 0, 0, 0]);
|
||||
expect(hit!.type).toBe('paragraph');
|
||||
});
|
||||
|
||||
it('returns null when the id is not found', () => {
|
||||
const d = doc(para('p0'));
|
||||
expect(getNodeByRef(d, 'missing')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the FIRST node for a duplicate id', () => {
|
||||
const d = doc(para('dup', 'first'), para('dup', 'second'));
|
||||
const hit = getNodeByRef(d, 'dup');
|
||||
expect(hit!.path).toEqual([0]);
|
||||
expect(blockPlainText(hit!.node)).toBe('first');
|
||||
});
|
||||
|
||||
it('returns null for a non-object doc', () => {
|
||||
expect(getNodeByRef(null, '#0')).toBeNull();
|
||||
expect(getNodeByRef('x', 'id')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns a CLONE — mutating it does not touch the input doc', () => {
|
||||
const d = doc(para('p0', 'orig'));
|
||||
const snapshot = structuredClone(d);
|
||||
const hit = getNodeByRef(d, 'p0');
|
||||
hit!.node.attrs.id = 'mutated';
|
||||
hit!.node.content.push(text('extra'));
|
||||
expect(d).toEqual(snapshot);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// replaceNodeById
|
||||
// ===========================================================================
|
||||
describe('replaceNodeById', () => {
|
||||
const newNode = () => ({ type: 'paragraph', attrs: { id: 'new' }, content: [text('NEW')] });
|
||||
|
||||
it('reports replaced:0 when nothing matches', () => {
|
||||
const d = doc(para('p0'));
|
||||
const res = replaceNodeById(d, 'missing', newNode());
|
||||
expect(res.replaced).toBe(0);
|
||||
expect(res.doc).toEqual(d);
|
||||
});
|
||||
|
||||
it('replaces a single match', () => {
|
||||
const d = doc(para('p0', 'old'), para('p1'));
|
||||
const res = replaceNodeById(d, 'p0', newNode());
|
||||
expect(res.replaced).toBe(1);
|
||||
expect(res.doc.content[0]).toEqual(newNode());
|
||||
expect(res.doc.content[1].attrs.id).toBe('p1');
|
||||
});
|
||||
|
||||
it('replaces N matches', () => {
|
||||
const d = doc(para('dup', 'a'), para('keep'), para('dup', 'b'));
|
||||
const res = replaceNodeById(d, 'dup', newNode());
|
||||
expect(res.replaced).toBe(2);
|
||||
expect(res.doc.content[0]).toEqual(newNode());
|
||||
expect(res.doc.content[1].attrs.id).toBe('keep');
|
||||
expect(res.doc.content[2]).toEqual(newNode());
|
||||
});
|
||||
|
||||
it('replaces a nested match inside a table cell', () => {
|
||||
const table = {
|
||||
type: 'table',
|
||||
content: [row([cell('tableCell', 'inner', 'x')])],
|
||||
};
|
||||
const d = doc(table);
|
||||
const res = replaceNodeById(d, 'inner', newNode());
|
||||
expect(res.replaced).toBe(1);
|
||||
expect(res.doc.content[0].content[0].content[0].content[0]).toEqual(newNode());
|
||||
});
|
||||
|
||||
it('does NOT recurse into the substituted node', () => {
|
||||
// The replacement itself carries the same id; it must not be re-replaced.
|
||||
const d = doc(para('target'));
|
||||
const replacement = { type: 'paragraph', attrs: { id: 'target' }, content: [text('R')] };
|
||||
const res = replaceNodeById(d, 'target', replacement);
|
||||
expect(res.replaced).toBe(1); // not 2 — no recursion into the new node
|
||||
});
|
||||
|
||||
it('gives each match a SEPARATE clone', () => {
|
||||
const d = doc(para('dup'), para('dup'));
|
||||
const res = replaceNodeById(d, 'dup', newNode());
|
||||
res.doc.content[0].content.push(text('mutated'));
|
||||
// The second replacement must be untouched.
|
||||
expect(res.doc.content[1]).toEqual(newNode());
|
||||
});
|
||||
|
||||
it('does not mutate the input doc', () => {
|
||||
const d = doc(para('p0', 'old'));
|
||||
const snapshot = structuredClone(d);
|
||||
replaceNodeById(d, 'p0', newNode());
|
||||
expect(d).toEqual(snapshot);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// deleteNodeById
|
||||
// ===========================================================================
|
||||
describe('deleteNodeById', () => {
|
||||
it('reports deleted:0 when nothing matches', () => {
|
||||
const d = doc(para('p0'));
|
||||
const res = deleteNodeById(d, 'missing');
|
||||
expect(res.deleted).toBe(0);
|
||||
expect(res.doc).toEqual(d);
|
||||
});
|
||||
|
||||
it('deletes a single match', () => {
|
||||
const d = doc(para('p0'), para('p1'), para('p2'));
|
||||
const res = deleteNodeById(d, 'p1');
|
||||
expect(res.deleted).toBe(1);
|
||||
expect(res.doc.content.map((c: any) => c.attrs.id)).toEqual(['p0', 'p2']);
|
||||
});
|
||||
|
||||
it('deletes N matches', () => {
|
||||
const d = doc(para('dup'), para('keep'), para('dup'));
|
||||
const res = deleteNodeById(d, 'dup');
|
||||
expect(res.deleted).toBe(2);
|
||||
expect(res.doc.content.map((c: any) => c.attrs.id)).toEqual(['keep']);
|
||||
});
|
||||
|
||||
it('deletes a nested node and preserves sibling order', () => {
|
||||
// A callout-style container holding three paragraph children; deleting the
|
||||
// middle one must leave the outer siblings in order.
|
||||
const callout = {
|
||||
type: 'callout',
|
||||
attrs: { id: 'cal' },
|
||||
content: [para('a', 'A'), para('b', 'B'), para('c', 'C')],
|
||||
};
|
||||
const d = doc(para('outer0'), callout, para('outer1'));
|
||||
const res = deleteNodeById(d, 'b');
|
||||
expect(res.deleted).toBe(1);
|
||||
// Inner siblings keep their order.
|
||||
const innerIds = res.doc.content[1].content.map((cl: any) => cl.attrs.id);
|
||||
expect(innerIds).toEqual(['a', 'c']);
|
||||
// Outer siblings are untouched and in order.
|
||||
const outerIds = res.doc.content.map((cl: any) => cl.attrs.id);
|
||||
expect(outerIds).toEqual(['outer0', 'cal', 'outer1']);
|
||||
});
|
||||
|
||||
it('does not mutate the input doc (deep-equal before/after)', () => {
|
||||
const d = doc(para('p0'), para('p1'));
|
||||
const snapshot = structuredClone(d);
|
||||
deleteNodeById(d, 'p0');
|
||||
expect(d).toEqual(snapshot);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// sanitizeForYjs
|
||||
// ===========================================================================
|
||||
describe('sanitizeForYjs', () => {
|
||||
it('strips undefined keys from node.attrs', () => {
|
||||
const d = doc({ type: 'paragraph', attrs: { id: 'p', gone: undefined, kept: 1 } });
|
||||
const res = sanitizeForYjs(d);
|
||||
expect('gone' in res.content[0].attrs).toBe(false);
|
||||
expect(res.content[0].attrs).toEqual({ id: 'p', kept: 1 });
|
||||
});
|
||||
|
||||
it('strips undefined keys from mark.attrs', () => {
|
||||
const d = doc({
|
||||
type: 'paragraph',
|
||||
attrs: { id: 'p' },
|
||||
content: [text('hi', [{ type: 'link', attrs: { href: 'u', gone: undefined } }])],
|
||||
});
|
||||
const res = sanitizeForYjs(d);
|
||||
expect('gone' in res.content[0].content[0].marks[0].attrs).toBe(false);
|
||||
expect(res.content[0].content[0].marks[0].attrs).toEqual({ href: 'u' });
|
||||
});
|
||||
|
||||
it('PRESERVES null, false, 0 and "" (only undefined is dropped)', () => {
|
||||
const d = doc({
|
||||
type: 'paragraph',
|
||||
attrs: { a: null, b: false, c: 0, d: '', e: undefined },
|
||||
});
|
||||
const res = sanitizeForYjs(d);
|
||||
expect(res.content[0].attrs).toEqual({ a: null, b: false, c: 0, d: '' });
|
||||
});
|
||||
|
||||
it('recurses into nested content', () => {
|
||||
const d = doc({
|
||||
type: 'table',
|
||||
content: [row([cell('tableCell', null, '', { gone: undefined, colwidth: null })])],
|
||||
});
|
||||
const res = sanitizeForYjs(d);
|
||||
const cellAttrs = res.content[0].content[0].content[0].attrs;
|
||||
expect('gone' in cellAttrs).toBe(false);
|
||||
expect(cellAttrs.colwidth).toBeNull();
|
||||
});
|
||||
|
||||
it('does not mutate the input doc', () => {
|
||||
const d = doc({ type: 'paragraph', attrs: { id: 'p', gone: undefined } });
|
||||
// structuredClone preserves an explicit `undefined` value key, so snapshot it.
|
||||
const snapshot = structuredClone(d);
|
||||
sanitizeForYjs(d);
|
||||
expect(d).toEqual(snapshot);
|
||||
expect('gone' in d.content[0].attrs).toBe(true); // still present on the input
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// findUnstorableAttr
|
||||
// ===========================================================================
|
||||
describe('findUnstorableAttr', () => {
|
||||
it('returns null for a fully storable doc', () => {
|
||||
const d = doc(para('p0', 'clean'));
|
||||
expect(findUnstorableAttr(d)).toBeNull();
|
||||
});
|
||||
|
||||
it('detects an undefined node attr with its path and kind', () => {
|
||||
const d = doc(para('a'), para('b'), { type: 'paragraph', attrs: { id: 'c', x: undefined } });
|
||||
expect(findUnstorableAttr(d)).toBe('content[2].attrs.x (undefined)');
|
||||
});
|
||||
|
||||
it('detects a function attr', () => {
|
||||
const d = doc({ type: 'paragraph', attrs: { fn: () => 1 } });
|
||||
expect(findUnstorableAttr(d)).toBe('content[0].attrs.fn (function)');
|
||||
});
|
||||
|
||||
it('detects a symbol attr', () => {
|
||||
const d = doc({ type: 'paragraph', attrs: { s: Symbol('x') } });
|
||||
expect(findUnstorableAttr(d)).toBe('content[0].attrs.s (symbol)');
|
||||
});
|
||||
|
||||
it('detects a bigint attr', () => {
|
||||
const d = doc({ type: 'paragraph', attrs: { big: 10n } });
|
||||
expect(findUnstorableAttr(d)).toBe('content[0].attrs.big (bigint)');
|
||||
});
|
||||
|
||||
it('detects an unstorable mark attr with the marks[i] path', () => {
|
||||
const d = doc({
|
||||
type: 'paragraph',
|
||||
attrs: { id: 'p' },
|
||||
content: [text('hi'), text('yo', [{ type: 'link', attrs: { x: undefined } }])],
|
||||
});
|
||||
expect(findUnstorableAttr(d)).toBe('content[0].content[1].marks[0].attrs.x (undefined)');
|
||||
});
|
||||
|
||||
it('returns the FIRST hit only', () => {
|
||||
const d = doc(
|
||||
{ type: 'paragraph', attrs: { first: undefined } },
|
||||
{ type: 'paragraph', attrs: { second: undefined } },
|
||||
);
|
||||
expect(findUnstorableAttr(d)).toBe('content[0].attrs.first (undefined)');
|
||||
});
|
||||
|
||||
it('returns null for a non-object doc', () => {
|
||||
expect(findUnstorableAttr(null)).toBeNull();
|
||||
expect(findUnstorableAttr('x')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// insertNodeRelative
|
||||
// ===========================================================================
|
||||
describe('insertNodeRelative', () => {
|
||||
const block = (id: string, value = '') => para(id, value);
|
||||
|
||||
it('appends a node to top-level content', () => {
|
||||
const d = doc(para('p0'));
|
||||
const res = insertNodeRelative(d, block('new', 'N'), { position: 'append' });
|
||||
expect(res.inserted).toBe(true);
|
||||
expect(res.doc.content.map((c: any) => c.attrs.id)).toEqual(['p0', 'new']);
|
||||
});
|
||||
|
||||
it('creates a content array when appending to a doc without one', () => {
|
||||
const res = insertNodeRelative({ type: 'doc' }, block('new'), { position: 'append' });
|
||||
expect(res.inserted).toBe(true);
|
||||
expect(res.doc.content.map((c: any) => c.attrs.id)).toEqual(['new']);
|
||||
});
|
||||
|
||||
it('inserts before a node by id (top level)', () => {
|
||||
const d = doc(para('p0'), para('p1'));
|
||||
const res = insertNodeRelative(d, block('new'), { position: 'before', anchorNodeId: 'p1' });
|
||||
expect(res.inserted).toBe(true);
|
||||
expect(res.doc.content.map((c: any) => c.attrs.id)).toEqual(['p0', 'new', 'p1']);
|
||||
});
|
||||
|
||||
it('inserts after a node by id (top level)', () => {
|
||||
const d = doc(para('p0'), para('p1'));
|
||||
const res = insertNodeRelative(d, block('new'), { position: 'after', anchorNodeId: 'p0' });
|
||||
expect(res.inserted).toBe(true);
|
||||
expect(res.doc.content.map((c: any) => c.attrs.id)).toEqual(['p0', 'new', 'p1']);
|
||||
});
|
||||
|
||||
it('inserts before a NESTED anchor by id, into its own parent content', () => {
|
||||
const table = {
|
||||
type: 'table',
|
||||
content: [row([cell('tableCell', 'inner', 'x')])],
|
||||
};
|
||||
const d = doc(table);
|
||||
const res = insertNodeRelative(d, block('new'), { position: 'before', anchorNodeId: 'inner' });
|
||||
expect(res.inserted).toBe(true);
|
||||
// The new (non-structural) node is spliced into the cell's content before the paragraph.
|
||||
const cellContent = res.doc.content[0].content[0].content[0].content;
|
||||
expect(cellContent.map((c: any) => c.attrs.id)).toEqual(['new', 'inner']);
|
||||
});
|
||||
|
||||
it('inserts by anchorText against top-level blocks (substring match)', () => {
|
||||
const d = doc(para('p0', 'hello world'), para('p1', 'other'));
|
||||
const res = insertNodeRelative(d, block('new'), { position: 'after', anchorText: 'world' });
|
||||
expect(res.inserted).toBe(true);
|
||||
expect(res.doc.content.map((c: any) => c.attrs.id)).toEqual(['p0', 'new', 'p1']);
|
||||
});
|
||||
|
||||
it('returns inserted:false when the anchor cannot be resolved', () => {
|
||||
const d = doc(para('p0'));
|
||||
const byId = insertNodeRelative(d, block('new'), { position: 'after', anchorNodeId: 'nope' });
|
||||
expect(byId.inserted).toBe(false);
|
||||
expect(byId.doc).toEqual(d);
|
||||
|
||||
const byText = insertNodeRelative(d, block('new'), { position: 'before', anchorText: 'zzz' });
|
||||
expect(byText.inserted).toBe(false);
|
||||
expect(byText.doc).toEqual(d);
|
||||
});
|
||||
|
||||
it('routes a structural tableRow to the nearest table container', () => {
|
||||
const table = {
|
||||
type: 'table',
|
||||
content: [
|
||||
row([cell('tableCell', 'r0c0', 'A')]),
|
||||
row([cell('tableCell', 'r1c0', 'B')]),
|
||||
],
|
||||
};
|
||||
const d = doc(table);
|
||||
const newRow = row([cell('tableCell', 'rNew', 'NEW')]);
|
||||
// Anchor on a cell paragraph inside row 0; "after" should put the row after row 0.
|
||||
const res = insertNodeRelative(d, newRow, { position: 'after', anchorNodeId: 'r0c0' });
|
||||
expect(res.inserted).toBe(true);
|
||||
const rowFirstCellId = (r: any) => r.content[0].content[0].attrs.id;
|
||||
expect(res.doc.content[0].content.map(rowFirstCellId)).toEqual(['r0c0', 'rNew', 'r1c0']);
|
||||
});
|
||||
|
||||
it('throws when appending a structural node at the top level', () => {
|
||||
const d = doc(para('p0'));
|
||||
const newRow = row([cell('tableCell', 'x', 'X')]);
|
||||
expect(() => insertNodeRelative(d, newRow, { position: 'append' })).toThrow(
|
||||
/cannot append a tableRow at the top level/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when a structural anchor is not inside the required container', () => {
|
||||
// Anchor resolves to a top-level paragraph that is not inside any table.
|
||||
const d = doc(para('p0', 'loose'));
|
||||
const newRow = row([cell('tableCell', 'x', 'X')]);
|
||||
expect(() =>
|
||||
insertNodeRelative(d, newRow, { position: 'after', anchorNodeId: 'p0' }),
|
||||
).toThrow(/the anchor is not inside a table/);
|
||||
});
|
||||
|
||||
it('honours offset: before vs after place the node on the correct side', () => {
|
||||
const d = doc(para('a'), para('b'), para('c'));
|
||||
const before = insertNodeRelative(d, block('N'), { position: 'before', anchorNodeId: 'b' });
|
||||
expect(before.doc.content.map((c: any) => c.attrs.id)).toEqual(['a', 'N', 'b', 'c']);
|
||||
const after = insertNodeRelative(d, block('N'), { position: 'after', anchorNodeId: 'b' });
|
||||
expect(after.doc.content.map((c: any) => c.attrs.id)).toEqual(['a', 'b', 'N', 'c']);
|
||||
});
|
||||
|
||||
it('does not mutate the input doc or the node argument', () => {
|
||||
const d = doc(para('p0'));
|
||||
const dSnapshot = structuredClone(d);
|
||||
const node = block('new', 'N');
|
||||
const nodeSnapshot = structuredClone(node);
|
||||
insertNodeRelative(d, node, { position: 'append' });
|
||||
expect(d).toEqual(dSnapshot);
|
||||
expect(node).toEqual(nodeSnapshot);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// readTable
|
||||
// ===========================================================================
|
||||
describe('readTable', () => {
|
||||
const makeTable = () => ({
|
||||
type: 'table',
|
||||
content: [
|
||||
row([cell('tableHeader', 'h0', 'H0'), cell('tableHeader', 'h1', 'H1')]),
|
||||
row([cell('tableCell', 'c0', 'A'), cell('tableCell', 'c1', 'B')]),
|
||||
],
|
||||
});
|
||||
|
||||
it('reads a table by #n', () => {
|
||||
const d = doc(para('p0'), makeTable());
|
||||
const res = readTable(d, '#1');
|
||||
expect(res).not.toBeNull();
|
||||
expect(res!.rows).toBe(2);
|
||||
expect(res!.cols).toBe(2);
|
||||
expect(res!.cells).toEqual([['H0', 'H1'], ['A', 'B']]);
|
||||
expect(res!.cellIds).toEqual([['h0', 'h1'], ['c0', 'c1']]);
|
||||
expect(res!.path).toEqual([1]);
|
||||
});
|
||||
|
||||
it('climbs from an inner paragraph id up to the table', () => {
|
||||
const d = doc(makeTable());
|
||||
const res = readTable(d, 'c1'); // id of a paragraph inside a data cell
|
||||
expect(res).not.toBeNull();
|
||||
expect(res!.path).toEqual([0]);
|
||||
expect(res!.cells).toEqual([['H0', 'H1'], ['A', 'B']]);
|
||||
});
|
||||
|
||||
it('reports per-row widths via cells for a ragged table', () => {
|
||||
const table = {
|
||||
type: 'table',
|
||||
content: [
|
||||
row([cell('tableHeader', 'h0', 'H0')]),
|
||||
row([cell('tableCell', 'c0', 'A'), cell('tableCell', 'c1', 'B')]),
|
||||
],
|
||||
};
|
||||
const res = readTable(doc(table), '#0');
|
||||
expect(res!.cols).toBe(1); // cols comes from row 0
|
||||
expect(res!.cells).toEqual([['H0'], ['A', 'B']]); // actual per-row widths preserved
|
||||
expect(res!.cellIds).toEqual([['h0'], ['c0', 'c1']]);
|
||||
});
|
||||
|
||||
it('reports null cellId for an empty cell with no paragraph', () => {
|
||||
const table = {
|
||||
type: 'table',
|
||||
content: [row([cell('tableCell', null), cell('tableCell', 'c1', 'B')])],
|
||||
};
|
||||
const res = readTable(doc(table), '#0');
|
||||
expect(res!.cells).toEqual([['', 'B']]);
|
||||
expect(res!.cellIds).toEqual([[null, 'c1']]);
|
||||
});
|
||||
|
||||
it('returns null when the ref matches no table', () => {
|
||||
const d = doc(para('p0'));
|
||||
expect(readTable(d, '#0')).toBeNull(); // #0 is a paragraph, not a table
|
||||
expect(readTable(d, 'missing')).toBeNull();
|
||||
expect(readTable(d, 'p0')).toBeNull(); // id found but no enclosing table
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// insertTableRow
|
||||
// ===========================================================================
|
||||
describe('insertTableRow', () => {
|
||||
const makeTable = () => ({
|
||||
type: 'table',
|
||||
content: [
|
||||
row([
|
||||
cell('tableHeader', 'h0', 'H0', { colwidth: [120] }),
|
||||
cell('tableHeader', 'h1', 'H1', { colwidth: [240] }),
|
||||
]),
|
||||
row([cell('tableCell', 'c0', 'A'), cell('tableCell', 'c1', 'B')]),
|
||||
],
|
||||
});
|
||||
|
||||
/** First-paragraph ids of every cell in a row, for ordering assertions. */
|
||||
const rowCellParaIds = (r: any): (string | undefined)[] =>
|
||||
r.content.map((c: any) => c.content[0]?.attrs?.id);
|
||||
/** Cell text of a row. */
|
||||
const rowTexts = (r: any): string[] =>
|
||||
r.content.map((c: any) => blockPlainText(c));
|
||||
|
||||
it('appends a row when index is omitted', () => {
|
||||
const d = doc(makeTable());
|
||||
const res = insertTableRow(d, '#0', ['X', 'Y']);
|
||||
expect(res.inserted).toBe(true);
|
||||
const rows = res.doc.content[0].content;
|
||||
expect(rows.length).toBe(3);
|
||||
expect(rowTexts(rows[2])).toEqual(['X', 'Y']);
|
||||
});
|
||||
|
||||
it('splices at a middle index', () => {
|
||||
const d = doc(makeTable());
|
||||
const res = insertTableRow(d, '#0', ['X', 'Y'], 1);
|
||||
const rows = res.doc.content[0].content;
|
||||
expect(rows.length).toBe(3);
|
||||
expect(rowTexts(rows[1])).toEqual(['X', 'Y']); // new row at index 1
|
||||
expect(rowTexts(rows[2])).toEqual(['A', 'B']); // old data row pushed down
|
||||
});
|
||||
|
||||
it('splices at the end index', () => {
|
||||
const d = doc(makeTable());
|
||||
const res = insertTableRow(d, '#0', ['X', 'Y'], 2); // rows == 2, valid end index
|
||||
const rows = res.doc.content[0].content;
|
||||
expect(rows.length).toBe(3);
|
||||
expect(rowTexts(rows[2])).toEqual(['X', 'Y']);
|
||||
});
|
||||
|
||||
it('APPENDS (does not throw) for an out-of-range index', () => {
|
||||
const d = doc(makeTable());
|
||||
const res = insertTableRow(d, '#0', ['X', 'Y'], 99);
|
||||
const rows = res.doc.content[0].content;
|
||||
expect(res.inserted).toBe(true);
|
||||
expect(rows.length).toBe(3);
|
||||
expect(rowTexts(rows[2])).toEqual(['X', 'Y']); // appended at the end
|
||||
});
|
||||
|
||||
it('throws when given more cells than columns', () => {
|
||||
const d = doc(makeTable());
|
||||
expect(() => insertTableRow(d, '#0', ['X', 'Y', 'Z'])).toThrow(
|
||||
/got 3 cell\(s\) but the table has 2 column\(s\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it('pads a short row to the column count', () => {
|
||||
const d = doc(makeTable());
|
||||
const res = insertTableRow(d, '#0', ['only']);
|
||||
const rows = res.doc.content[0].content;
|
||||
expect(rowTexts(rows[2])).toEqual(['only', '']); // padded with empty cell
|
||||
});
|
||||
|
||||
it('copies colwidth from the header row for each column', () => {
|
||||
const d = doc(makeTable());
|
||||
const res = insertTableRow(d, '#0', ['X', 'Y']);
|
||||
const newRow = res.doc.content[0].content[2];
|
||||
expect(newRow.content[0].attrs.colwidth).toEqual([120]);
|
||||
expect(newRow.content[1].attrs.colwidth).toEqual([240]);
|
||||
expect(newRow.content[0].attrs).toMatchObject({ colspan: 1, rowspan: 1 });
|
||||
});
|
||||
|
||||
it('index 0 inherits the header cell TYPE', () => {
|
||||
const d = doc(makeTable());
|
||||
const res = insertTableRow(d, '#0', ['X', 'Y'], 0);
|
||||
const newRow = res.doc.content[0].content[0];
|
||||
expect(newRow.content.every((c: any) => c.type === 'tableHeader')).toBe(true);
|
||||
// A non-zero index produces plain data cells instead.
|
||||
const res2 = insertTableRow(d, '#0', ['X', 'Y'], 1);
|
||||
const dataRow = res2.doc.content[0].content[1];
|
||||
expect(dataRow.content.every((c: any) => c.type === 'tableCell')).toBe(true);
|
||||
});
|
||||
|
||||
it('mints unique, well-formed paragraph ids for new cells', () => {
|
||||
const d = doc(makeTable());
|
||||
const existing = new Set(['h0', 'h1', 'c0', 'c1']);
|
||||
const res = insertTableRow(d, '#0', ['X', 'Y']);
|
||||
const newRow = res.doc.content[0].content[2];
|
||||
const ids = rowCellParaIds(newRow) as string[];
|
||||
for (const id of ids) {
|
||||
expect(typeof id).toBe('string');
|
||||
expect(id).toMatch(/^[a-z0-9]{12}$/); // Docmost-style 12-char id
|
||||
expect(existing.has(id)).toBe(false); // unique vs pre-existing ids
|
||||
}
|
||||
expect(new Set(ids).size).toBe(ids.length); // unique within the row
|
||||
});
|
||||
|
||||
it('returns inserted:false when the table cannot be located', () => {
|
||||
const d = doc(para('p0'));
|
||||
const res = insertTableRow(d, 'missing', ['X']);
|
||||
expect(res.inserted).toBe(false);
|
||||
expect(res.doc).toEqual(d);
|
||||
});
|
||||
|
||||
it('does not mutate the input doc', () => {
|
||||
const d = doc(makeTable());
|
||||
const snapshot = structuredClone(d);
|
||||
insertTableRow(d, '#0', ['X', 'Y'], 1);
|
||||
expect(d).toEqual(snapshot);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// deleteTableRow
|
||||
// ===========================================================================
|
||||
describe('deleteTableRow', () => {
|
||||
const makeTable = () => ({
|
||||
type: 'table',
|
||||
content: [
|
||||
row([cell('tableHeader', 'h0', 'H')]),
|
||||
row([cell('tableCell', 'c0', 'A')]),
|
||||
row([cell('tableCell', 'c1', 'B')]),
|
||||
],
|
||||
});
|
||||
const firstId = (r: any) => r.content[0].content[0].attrs.id;
|
||||
|
||||
it('deletes a middle row and preserves siblings', () => {
|
||||
const d = doc(makeTable());
|
||||
const res = deleteTableRow(d, '#0', 1);
|
||||
expect(res.deleted).toBe(true);
|
||||
expect(res.doc.content[0].content.map(firstId)).toEqual(['h0', 'c1']);
|
||||
});
|
||||
|
||||
it('deletes the first row', () => {
|
||||
const d = doc(makeTable());
|
||||
const res = deleteTableRow(d, '#0', 0);
|
||||
expect(res.doc.content[0].content.map(firstId)).toEqual(['c0', 'c1']);
|
||||
});
|
||||
|
||||
it('deletes the last row', () => {
|
||||
const d = doc(makeTable());
|
||||
const res = deleteTableRow(d, '#0', 2);
|
||||
expect(res.doc.content[0].content.map(firstId)).toEqual(['h0', 'c0']);
|
||||
});
|
||||
|
||||
it('throws on an out-of-range index', () => {
|
||||
const d = doc(makeTable());
|
||||
expect(() => deleteTableRow(d, '#0', 99)).toThrow(/out of range/);
|
||||
expect(() => deleteTableRow(d, '#0', -1)).toThrow(/out of range/);
|
||||
});
|
||||
|
||||
it('throws when asked to delete the only row', () => {
|
||||
const single = {
|
||||
type: 'table',
|
||||
content: [row([cell('tableCell', 'c0', 'A')])],
|
||||
};
|
||||
expect(() => deleteTableRow(doc(single), '#0', 0)).toThrow(
|
||||
/refusing to delete the only row/,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns deleted:false when the table cannot be located', () => {
|
||||
const d = doc(para('p0'));
|
||||
const res = deleteTableRow(d, 'missing', 0);
|
||||
expect(res.deleted).toBe(false);
|
||||
expect(res.doc).toEqual(d);
|
||||
});
|
||||
|
||||
it('does not mutate the input doc', () => {
|
||||
const d = doc(makeTable());
|
||||
const snapshot = structuredClone(d);
|
||||
deleteTableRow(d, '#0', 1);
|
||||
expect(d).toEqual(snapshot);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// updateTableCell
|
||||
// ===========================================================================
|
||||
describe('updateTableCell', () => {
|
||||
const makeTable = () => ({
|
||||
type: 'table',
|
||||
content: [
|
||||
row([cell('tableHeader', 'h0', 'H0'), cell('tableHeader', 'h1', 'H1')]),
|
||||
row([
|
||||
cell('tableCell', 'c0', 'A', { colspan: 2, rowspan: 3, colwidth: [200] }),
|
||||
cell('tableCell', 'c1', 'B'),
|
||||
]),
|
||||
],
|
||||
});
|
||||
|
||||
it('sets the cell text', () => {
|
||||
const d = doc(makeTable());
|
||||
const res = updateTableCell(d, '#0', 1, 1, 'NEW');
|
||||
expect(res.updated).toBe(true);
|
||||
expect(blockPlainText(res.doc.content[0].content[1].content[1])).toBe('NEW');
|
||||
});
|
||||
|
||||
it('REUSES the existing first-paragraph id', () => {
|
||||
const d = doc(makeTable());
|
||||
const res = updateTableCell(d, '#0', 1, 0, 'changed');
|
||||
const para0 = res.doc.content[0].content[1].content[0].content[0];
|
||||
expect(para0.attrs.id).toBe('c0'); // critical: id reused, not regenerated
|
||||
expect(para0.content[0].text).toBe('changed');
|
||||
});
|
||||
|
||||
it('mints a fresh id when the cell had no paragraph', () => {
|
||||
const table = {
|
||||
type: 'table',
|
||||
content: [row([cell('tableCell', null), cell('tableCell', 'c1', 'B')])],
|
||||
};
|
||||
const d = doc(table);
|
||||
const res = updateTableCell(d, '#0', 0, 0, 'now has text');
|
||||
const newPara = res.doc.content[0].content[0].content[0].content[0];
|
||||
expect(typeof newPara.attrs.id).toBe('string');
|
||||
expect(newPara.attrs.id).toMatch(/^[a-z0-9]{12}$/);
|
||||
expect(newPara.attrs.id).not.toBe('c1'); // unique vs existing ids
|
||||
expect(newPara.content[0].text).toBe('now has text');
|
||||
});
|
||||
|
||||
it('PRESERVES the cell colspan/rowspan/colwidth (only content replaced)', () => {
|
||||
const d = doc(makeTable());
|
||||
const res = updateTableCell(d, '#0', 1, 0, 'x');
|
||||
const cellNode = res.doc.content[0].content[1].content[0];
|
||||
expect(cellNode.attrs).toEqual({ colspan: 2, rowspan: 3, colwidth: [200] });
|
||||
});
|
||||
|
||||
it('throws when row or col is out of range', () => {
|
||||
const d = doc(makeTable());
|
||||
expect(() => updateTableCell(d, '#0', 5, 0, 'x')).toThrow(/out of range/);
|
||||
expect(() => updateTableCell(d, '#0', 0, 5, 'x')).toThrow(/out of range/);
|
||||
expect(() => updateTableCell(d, '#0', -1, 0, 'x')).toThrow(/out of range/);
|
||||
});
|
||||
|
||||
it('an empty string yields an empty paragraph content array', () => {
|
||||
const d = doc(makeTable());
|
||||
const res = updateTableCell(d, '#0', 1, 1, '');
|
||||
const cellPara = res.doc.content[0].content[1].content[1].content[0];
|
||||
expect(cellPara.type).toBe('paragraph');
|
||||
expect(cellPara.content).toEqual([]); // empty string -> empty content
|
||||
expect(cellPara.attrs.id).toBe('c1'); // id still reused
|
||||
});
|
||||
|
||||
it('returns updated:false when the table cannot be located', () => {
|
||||
const d = doc(para('p0'));
|
||||
const res = updateTableCell(d, 'missing', 0, 0, 'x');
|
||||
expect(res.updated).toBe(false);
|
||||
expect(res.doc).toEqual(d);
|
||||
});
|
||||
|
||||
it('does not mutate the input doc', () => {
|
||||
const d = doc(makeTable());
|
||||
const snapshot = structuredClone(d);
|
||||
updateTableCell(d, '#0', 1, 1, 'NEW');
|
||||
expect(d).toEqual(snapshot);
|
||||
});
|
||||
});
|
||||
170
test/page-lock.test.ts
Normal file
170
test/page-lock.test.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { withPageLock } from '../packages/docmost-client/src/lib/page-lock.js';
|
||||
|
||||
// A manually-resolvable promise so ordering is fully deterministic without
|
||||
// any timers. `resolve`/`reject` are pulled out of the executor.
|
||||
function deferred<T = void>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
// A unique pageId per test: the module-level Map is process-global and shared
|
||||
// across every test in a worker, so reusing ids would couple tests together.
|
||||
let pageCounter = 0;
|
||||
function uniquePageId() {
|
||||
return `page-${process.pid}-${Date.now()}-${pageCounter++}`;
|
||||
}
|
||||
|
||||
// Drain the microtask queue several times. withPageLock chains through
|
||||
// `Promise.resolve().catch().then(fn)`, so `fn` only starts after a few
|
||||
// microtask hops — a single `await Promise.resolve()` is not enough. We never
|
||||
// use timers, so this stays fully deterministic.
|
||||
async function flushMicrotasks() {
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
describe('withPageLock', () => {
|
||||
it('serializes two ops on the same pageId (second waits for the first to settle)', async () => {
|
||||
const pageId = uniquePageId();
|
||||
const order: string[] = [];
|
||||
|
||||
const gate1 = deferred();
|
||||
|
||||
const op1 = withPageLock(pageId, async () => {
|
||||
order.push('op1-start');
|
||||
await gate1.promise;
|
||||
order.push('op1-end');
|
||||
return 'one';
|
||||
});
|
||||
|
||||
const op2 = withPageLock(pageId, async () => {
|
||||
order.push('op2-start');
|
||||
return 'two';
|
||||
});
|
||||
|
||||
// Let microtasks flush: op1 has started, op2 must not have started yet.
|
||||
await flushMicrotasks();
|
||||
expect(order).toEqual(['op1-start']);
|
||||
|
||||
// Release op1; op2 may only begin after op1 fully settles.
|
||||
gate1.resolve();
|
||||
await Promise.all([op1, op2]);
|
||||
|
||||
expect(order).toEqual(['op1-start', 'op1-end', 'op2-start']);
|
||||
});
|
||||
|
||||
it('does not poison the queue when the first op rejects (second still runs)', async () => {
|
||||
const pageId = uniquePageId();
|
||||
const order: string[] = [];
|
||||
|
||||
const gate1 = deferred();
|
||||
|
||||
const op1 = withPageLock(pageId, async () => {
|
||||
order.push('op1-start');
|
||||
await gate1.promise;
|
||||
throw new Error('boom');
|
||||
});
|
||||
|
||||
const op2 = withPageLock(pageId, async () => {
|
||||
order.push('op2-start');
|
||||
return 'survived';
|
||||
});
|
||||
|
||||
await flushMicrotasks();
|
||||
expect(order).toEqual(['op1-start']);
|
||||
|
||||
gate1.resolve();
|
||||
|
||||
// op1 rejects, but op2 still runs afterwards.
|
||||
await expect(op1).rejects.toThrow('boom');
|
||||
await expect(op2).resolves.toBe('survived');
|
||||
expect(order).toEqual(['op1-start', 'op2-start']);
|
||||
});
|
||||
|
||||
it('runs ops on different pageIds concurrently (no cross-page blocking)', async () => {
|
||||
const pageIdA = uniquePageId();
|
||||
const pageIdB = uniquePageId();
|
||||
const order: string[] = [];
|
||||
|
||||
const gateA = deferred();
|
||||
|
||||
const opA = withPageLock(pageIdA, async () => {
|
||||
order.push('A-start');
|
||||
await gateA.promise;
|
||||
order.push('A-end');
|
||||
return 'a';
|
||||
});
|
||||
|
||||
const opB = withPageLock(pageIdB, async () => {
|
||||
order.push('B-start');
|
||||
return 'b';
|
||||
});
|
||||
|
||||
// B is on a different page and must start without waiting for A.
|
||||
await flushMicrotasks();
|
||||
expect(order).toContain('A-start');
|
||||
expect(order).toContain('B-start');
|
||||
|
||||
gateA.resolve();
|
||||
await Promise.all([opA, opB]);
|
||||
|
||||
// B finished while A was still gated.
|
||||
expect(order).toEqual(['A-start', 'B-start', 'A-end']);
|
||||
});
|
||||
|
||||
it('returns the real resolved value to the caller', async () => {
|
||||
const pageId = uniquePageId();
|
||||
const value = { ok: true, n: 7 };
|
||||
|
||||
await expect(withPageLock(pageId, async () => value)).resolves.toBe(value);
|
||||
});
|
||||
|
||||
it('propagates the real rejection to the caller (not swallowed)', async () => {
|
||||
const pageId = uniquePageId();
|
||||
const err = new Error('real failure');
|
||||
|
||||
await expect(
|
||||
withPageLock(pageId, async () => {
|
||||
throw err;
|
||||
}),
|
||||
).rejects.toBe(err);
|
||||
});
|
||||
|
||||
it('serializes a third op behind the chain even after a rejection mid-chain', async () => {
|
||||
const pageId = uniquePageId();
|
||||
const order: string[] = [];
|
||||
|
||||
const gate1 = deferred();
|
||||
|
||||
const op1 = withPageLock(pageId, async () => {
|
||||
order.push('1-start');
|
||||
await gate1.promise;
|
||||
throw new Error('mid-fail');
|
||||
});
|
||||
|
||||
const op2 = withPageLock(pageId, async () => {
|
||||
order.push('2');
|
||||
return 2;
|
||||
});
|
||||
|
||||
const op3 = withPageLock(pageId, async () => {
|
||||
order.push('3');
|
||||
return 3;
|
||||
});
|
||||
|
||||
await flushMicrotasks();
|
||||
expect(order).toEqual(['1-start']);
|
||||
|
||||
gate1.resolve();
|
||||
|
||||
await expect(op1).rejects.toThrow('mid-fail');
|
||||
await expect(op2).resolves.toBe(2);
|
||||
await expect(op3).resolves.toBe(3);
|
||||
expect(order).toEqual(['1-start', '2', '3']);
|
||||
});
|
||||
});
|
||||
561
test/transforms.test.ts
Normal file
561
test/transforms.test.ts
Normal file
@@ -0,0 +1,561 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user