From cc13c94f5322ac0a6141c530435799977b129702 Mon Sep 17 00:00:00 2001 From: vvzvlad Date: Tue, 16 Jun 2026 22:10:06 +0300 Subject: [PATCH] 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. --- test/filters.test.ts | 234 ++++++ test/json-edit.test.ts | 288 ++++++++ test/markdown-converter.test.ts | 507 +++++++++++++ test/markdown-document-envelope.test.ts | 199 ++++++ test/node-ops.test.ts | 908 ++++++++++++++++++++++++ test/page-lock.test.ts | 170 +++++ test/transforms.test.ts | 561 +++++++++++++++ 7 files changed, 2867 insertions(+) create mode 100644 test/filters.test.ts create mode 100644 test/json-edit.test.ts create mode 100644 test/markdown-converter.test.ts create mode 100644 test/markdown-document-envelope.test.ts create mode 100644 test/node-ops.test.ts create mode 100644 test/page-lock.test.ts create mode 100644 test/transforms.test.ts diff --git a/test/filters.test.ts b/test/filters.test.ts new file mode 100644 index 0000000..020dc9d --- /dev/null +++ b/test/filters.test.ts @@ -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: 'Result', + }; + + 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, + }); + }); +}); diff --git a/test/json-edit.test.ts b/test/json-edit.test.ts new file mode 100644 index 0000000..95a1383 --- /dev/null +++ b/test/json-edit.test.ts @@ -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 = {}) { + 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 }, + ]); + }); + }); +}); diff --git a/test/markdown-converter.test.ts b/test/markdown-converter.test.ts new file mode 100644 index 0000000..51c34b1 --- /dev/null +++ b/test/markdown-converter.test.ts @@ -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('x'); + }); + + it('code + strike combo emits wrapping ', () => { + const out = convertProseMirrorToMarkdown( + doc(para(text('x', [{ type: 'strike' }, { type: 'code' }]))), + ); + expect(out).toBe('x'); + }); + }); + + // --------------------------------------------------------------------------- + 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('![cap](/files/my%20pic%20%281%29.png)'); + }); + + 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 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( + '
' + + '' + + '' + + '

wide

a

b

', + ); + }); + }); + + // --------------------------------------------------------------------------- + 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
', () => { + 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('
\nTitle\n\nBody\n
'); + }); + }); + + // --------------------------------------------------------------------------- + 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( + '', + ); + 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( + '
', + ); + 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( + '@Alice', + ); + }); + + 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( + '
', + ); + }); + + it('video emits a
-wrapped