test(git-sync): exhaustive converter coverage + fix 3 round-trip data-loss bugs
Coder↔reviewer design loop (9 rounds, reviewer verdict: exhaustive) produced 92 specs; implemented +123 tests (465 -> 588 passing). The new round-trip coverage exposed three genuine data-loss bugs in the Markdown<->ProseMirror converter, all now FIXED (round-trip is lossless for these): 1. pageBreak was lost on export (no converter case -> rendered to "" and the node vanished). Now emits <div data-type="pageBreak"></div>, which the schema parses back -> round-trips. 2. A block image between blocks left an empty <p> artifact after import-hoisting, producing a phantom blank-gap diff on every sync. markdownToProseMirror now strips content-less paragraphs after generateJSON — with a schema-validity guard that keeps the obligatory single empty paragraph in `content: "block+"` containers (tableCell/tableHeader/blockquote/column/callout/doc), so empty cells/quotes never become an invalid `content: []`. 3. The `code` mark combined with another mark was not byte-stable (emitted nested HTML that the schema's `code` `excludes:"_"` collapsed on import). The converter now emits code-only when `code` co-occurs, matching the editor. New coverage spans media/diagram/details/columns/math/mention attribute round-trips, converter emission branches, git error paths, and engine decision branches. A dedicated test pins the empty-container schema validity (the review catch on the bug-2 fix). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -59,19 +59,22 @@ describe('convertProseMirrorToMarkdown', () => {
|
||||
).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.
|
||||
it('code + another mark emits the backtick code form (code wins)', () => {
|
||||
// The schema's `code` mark excludes all other marks, so the editor can
|
||||
// never produce code+bold on one run and import always drops the co-mark.
|
||||
// The lossless, byte-stable behavior is to emit ONLY the backtick code
|
||||
// span and ignore the co-occurring mark.
|
||||
const out = convertProseMirrorToMarkdown(
|
||||
doc(para(text('x', [{ type: 'bold' }, { type: 'code' }]))),
|
||||
);
|
||||
expect(out).toBe('<code><strong>x</strong></code>');
|
||||
expect(out).toBe('`x`');
|
||||
});
|
||||
|
||||
it('code + strike combo emits <code> wrapping <s>', () => {
|
||||
it('code + strike combo emits the backtick code form (code wins)', () => {
|
||||
const out = convertProseMirrorToMarkdown(
|
||||
doc(para(text('x', [{ type: 'strike' }, { type: 'code' }]))),
|
||||
);
|
||||
expect(out).toBe('<code><s>x</s></code>');
|
||||
expect(out).toBe('`x`');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -504,4 +507,145 @@ describe('convertProseMirrorToMarkdown', () => {
|
||||
expect(out.startsWith('- lvl')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Targeted coverage for marker-width-scaled list indent, the markdown
|
||||
// link-title escape branch, the markdown callout fence, and the blockquote
|
||||
// per-line prefixer over a multi-line nested-block child. Grounded against
|
||||
// the real converter output (verified empirically) — see processListItem /
|
||||
// indentItemChildren (src 812-843), the link mark branch (src 117-121), the
|
||||
// callout case (src 373-376), and the blockquote prefixer (src 210-221).
|
||||
describe('marker-width / link-title / callout / blockquote-nested', () => {
|
||||
// Spec 1 — two-digit ordered marker scales the continuation indent to 4.
|
||||
it('indents a nested ordered sublist under item 10 by 4 spaces (marker "10. ")', () => {
|
||||
// Items 1..10 ("a".."j"); the 10th additionally holds a nested
|
||||
// orderedList with one paragraph "x".
|
||||
const items: any[] = [];
|
||||
for (let i = 0; i < 9; i++) {
|
||||
items.push({
|
||||
type: 'listItem',
|
||||
content: [para(text(String.fromCharCode(97 + i)))], // 'a'..'i'
|
||||
});
|
||||
}
|
||||
items.push({
|
||||
type: 'listItem',
|
||||
content: [
|
||||
para(text('j')),
|
||||
{
|
||||
type: 'orderedList',
|
||||
content: [{ type: 'listItem', content: [para(text('x'))] }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const out = convertProseMirrorToMarkdown(
|
||||
doc({ type: 'orderedList', content: items }),
|
||||
);
|
||||
|
||||
// The 10th marker is the 4-column "10. "; the nested sublist line must be
|
||||
// indented exactly 4 spaces (prefix.length 3 + 1), NOT 3.
|
||||
expect(out).toContain('10. j\n 1. x');
|
||||
// Guard against the off-by-one (3-space) regression that would re-parse
|
||||
// the sublist as loose/sibling content on import.
|
||||
expect(out).not.toContain('10. j\n 1. x');
|
||||
// And the single-digit items keep the narrower 3-column marker (no body
|
||||
// continuation here, but the marker itself must stay "1. ".."9. ").
|
||||
expect(out.startsWith('1. a\n2. b\n')).toBe(true);
|
||||
expect(out).toContain('\n9. i\n10. j');
|
||||
});
|
||||
|
||||
// Spec 2 — markdown link-title branch escapes an embedded double quote and
|
||||
// emits the href raw.
|
||||
it('escapes an embedded double-quote in a markdown link title and emits href raw', () => {
|
||||
const out = convertProseMirrorToMarkdown(
|
||||
doc(
|
||||
para(
|
||||
text('lbl', [
|
||||
{
|
||||
type: 'link',
|
||||
attrs: { href: 'http://a', title: 'he said "hi"' },
|
||||
},
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
// The title's " is backslash-escaped (.replace(/"/g,'\\"')) so it cannot
|
||||
// terminate the (url "title") syntax early; the href is RAW (not escaped).
|
||||
expect(out).toBe('[lbl](http://a "he said \\"hi\\"")');
|
||||
});
|
||||
|
||||
// Spec 3 — markdown callout fence lowercases the type and joins multiple
|
||||
// paragraph children.
|
||||
it('lowercases an uppercase callout type and joins its paragraphs', () => {
|
||||
const out = convertProseMirrorToMarkdown(
|
||||
doc({
|
||||
type: 'callout',
|
||||
attrs: { type: 'WARNING' },
|
||||
content: [para(text('line1')), para(text('line2'))],
|
||||
}),
|
||||
);
|
||||
// NOTE(review): the spec predicted ':::warning\nline1\n\nline2\n:::' (a
|
||||
// blank line between paragraphs, attributed to "marked's paragraph
|
||||
// blank-line"). The real converter does NOT route callout bodies through
|
||||
// marked — the callout case (src 374-376) joins its rendered children
|
||||
// with a single '\n' (calloutContent = nodeContent.map(processNode)
|
||||
// .join('\n')), and each paragraph renders to just its text. So the ACTUAL
|
||||
// (and correct-per-source) body is 'line1\nline2' with ONE newline. We
|
||||
// still pin the two behaviors the spec cares about: the .toLowerCase()
|
||||
// (WARNING -> warning) and the multi-child join.
|
||||
expect(out).toBe(':::warning\nline1\nline2\n:::');
|
||||
// The fence type is lowercased (regression to ':::WARNING' breaks import).
|
||||
expect(out.startsWith(':::warning\n')).toBe(true);
|
||||
expect(out).not.toContain(':::WARNING');
|
||||
// Both paragraph children are present and joined inside the fence.
|
||||
expect(out).toContain('line1\nline2');
|
||||
});
|
||||
|
||||
// Spec 4 — blockquote per-line prefixer over a multi-line nested callout.
|
||||
it('prefixes every line of a nested callout child with "> "', () => {
|
||||
const out = convertProseMirrorToMarkdown(
|
||||
doc({
|
||||
type: 'blockquote',
|
||||
content: [
|
||||
{
|
||||
type: 'callout',
|
||||
attrs: { type: 'INFO' },
|
||||
content: [para(text('a')), para(text('b'))],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
// NOTE(review): the spec predicted '> :::info\n> a\n>\n> b\n> :::',
|
||||
// assuming the nested callout body contains a blank line between 'a' and
|
||||
// 'b' (which would exercise the line.length?'> ':'>' empty-line branch).
|
||||
// But per Spec 3's finding the callout joins paragraphs with a SINGLE
|
||||
// '\n', so its rendered output ':::info\na\nb\n:::' has NO blank line.
|
||||
// The blockquote prefixer (src 214-221) therefore prefixes each of the
|
||||
// four non-empty lines with '> ', yielding the ACTUAL output below — the
|
||||
// realistic per-line-prefix loop over a multi-line nested-block child.
|
||||
expect(out).toBe('> :::info\n> a\n> b\n> :::');
|
||||
// Every produced line carries the '> ' prefix (no line escapes to col 0).
|
||||
for (const line of out.split('\n')) {
|
||||
expect(line.startsWith('>')).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
// The empty-line '>' branch from Spec 4's intent IS reachable — just not via
|
||||
// the nested callout (whose body has no blank line). A two-paragraph
|
||||
// blockquote DOES separate its block children with a bare '>' line, which is
|
||||
// the branch the spec wanted to protect. Pin it directly so the
|
||||
// (line.length ? '> ' : '>') empty-line path stays covered.
|
||||
it('maps an internal blank line to a bare ">" (not "> ") in a multi-block quote', () => {
|
||||
const out = convertProseMirrorToMarkdown(
|
||||
doc({
|
||||
type: 'blockquote',
|
||||
content: [para(text('p1')), para(text('p2'))],
|
||||
}),
|
||||
);
|
||||
expect(out).toBe('> p1\n>\n> p2');
|
||||
// The separator line is exactly '>' with NO trailing space.
|
||||
expect(out.split('\n')).toContain('>');
|
||||
expect(out).not.toContain('> \n');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user