Files
sm-journal-plugin/tests/blogeditor/serializer.test.ts
Jakub Zych a578ddabda test(16.2-06): Blog Editor schema, serializer and embed coverage
- schema: every raw HTML tag rule, figure shapes, span mark, unmodelled
  attributes, handler attributes dropped, links, tables, no trailing node
- serializer: block reuse after an edit, a deletion and an insertion,
  separators and comments kept, captureBlocks fallbacks
- embeds: parseEmbedUrl table, placeholder round trip, isPolicyUrl table,
  upload id matching and timeout, labels, disabled and invalid
2026-10-10 16:03:07 +02:00

156 lines
6.1 KiB
TypeScript

// Top-level block reuse (Phase 16.2 D-03): after an edit, every top-level
// block the edit did not touch is emitted as its original source bytes,
// with the white space and comments between blocks, whatever the edit was
// (one changed block, a deletion, an insertion).
import { afterEach, describe, expect, it } from 'vitest'
import { getSchema } from '@tiptap/core'
import { preservationExtensions } from '../../assets/src/blogeditor/extensions'
import { captureBlocks } from '../../assets/src/blogeditor/serializer'
import { insertAtBlock, mount } from './helpers'
afterEach(() => {
document.body.replaceChildren()
})
// Source formatting the schema serializer would not reproduce: upper-case
// tags, single quotes, unquoted and reordered attributes, entities,
// self-closing void tags and odd white space.
const blocks = [
"<P CLASS='lead'>Pierwszy&nbsp;akapit &amp; więcej</P>",
'<h2 id=intro >Wstęp</h2>',
'<p>Drugi <a title="t" href="/x">link</a></p>',
'<ul>\n <li>jeden</li>\n <li>dwa</li>\n</ul>',
'<p>Trzeci<br/>wiersz</p>',
'<footer class="author-footer"><p>Autor</p></footer>',
]
const separators = ['\n\n', '\n<!-- komentarz -->\n', '\r\n', '\n \n', '\n']
const source = blocks.map((block, i) => block + (separators[i] ?? '')).join('') + '\n<!-- end -->\n'
/** topLevel returns the editor document's top-level node types. */
function topLevel(el: ReturnType<typeof mount>['editor']): string[] {
const out: string[] = []
el.state.doc.forEach((node) => out.push(node.type.name))
return out
}
describe('block reuse', () => {
it('returns the source unchanged before any edit', () => {
const { el, editor } = mount(source)
expect(topLevel(editor)).toEqual(['paragraph', 'heading', 'paragraph', 'bulletList', 'paragraph', 'rawHtml'])
expect(el.value).toBe(source)
})
it('keeps every other block, separator and comment byte for byte after one edit', () => {
const { el, editor } = mount(source)
insertAtBlock(editor, 2, 'X')
const value = el.value
expect(value).not.toBe(source)
for (const [i, block] of blocks.entries()) {
if (i === 2) continue
expect(value).toContain(block)
}
for (const separator of separators) expect(value).toContain(separator)
expect(value).toContain('<p>XDrugi')
expect(value.endsWith('\n<!-- end -->\n')).toBe(true)
// Only the edited block differs from the source.
expect(value.replace(/<p>XDrugi[^]*?<\/p>/, blocks[2]!)).toBe(source)
})
it('keeps the remaining blocks after a deletion', () => {
const { el, editor } = mount(source)
let from = 0
let to = 0
editor.state.doc.forEach((node, offset, index) => {
if (index === 1) {
from = offset
to = offset + node.nodeSize
}
})
editor.view.dispatch(editor.state.tr.delete(from, to))
const value = el.value
expect(value).not.toContain('Wstęp')
for (const [i, block] of blocks.entries()) {
if (i !== 1) expect(value).toContain(block)
}
expect(value.startsWith(blocks[0]!)).toBe(true)
})
it('keeps every block around an insertion and serializes only the new one', () => {
const { el, editor } = mount(source)
let at = 0
editor.state.doc.forEach((_node, offset, index) => {
if (index === 3) at = offset
})
const paragraph = editor.schema.nodes.paragraph!.create(null, editor.schema.text('Nowy'))
editor.view.dispatch(editor.state.tr.insert(at, paragraph))
const value = el.value
for (const block of blocks) expect(value).toContain(block)
const inserted = value.indexOf('<p>Nowy</p>')
expect(inserted).toBeGreaterThan(value.indexOf(blocks[2]!))
expect(inserted).toBeLessThan(value.indexOf(blocks[3]!))
})
it('keeps reused blocks after several edits and an undo returns the exact source', () => {
const { el, editor } = mount(source)
insertAtBlock(editor, 0, 'A')
insertAtBlock(editor, 4, 'B')
const value = el.value
for (const i of [1, 2, 3, 5]) expect(value).toContain(blocks[i]!)
editor.commands.undo()
editor.commands.undo()
expect(el.value).toBe(source)
})
it('serializes every block when the document is replaced entirely', () => {
const { el, editor } = mount(source)
editor.commands.setContent('<p>Inny</p>')
expect(el.value).toBe('<p>Inny</p>')
})
})
describe('captureBlocks', () => {
const schema = getSchema(preservationExtensions())
it('records each block with the text before it and the trailing text', () => {
const captured = captureBlocks('\n<p>a</p>\n<!-- c -->\n<h2>b</h2> ', schema)!
expect(captured.blocks.map((b) => [b.before, b.html])).toEqual([
['\n', '<p>a</p>'],
['\n<!-- c -->\n', '<h2>b</h2>'],
])
expect(captured.trailing).toBe(' ')
expect(captured.blocks.map((b) => b.nodes.map((n) => n.type.name))).toEqual([['paragraph'], ['heading']])
})
it('splits raw text elements and nested elements by source position', () => {
const html = '<style>p > a { x: "</p>" }</style><div><div>a</div><p>b</p></div><p>c<!-- </p> --></p>'
const captured = captureBlocks(html, schema)!
expect(captured.blocks.map((b) => b.html)).toEqual(['<style>p > a { x: "</p>" }</style>', '<div><div>a</div><p>b</p></div>', '<p>c<!-- </p> --></p>'])
})
it.each([
['an unclosed comment', '<p>a</p><!-- open'],
['an unclosed element', '<p>a</p><div><p>b'],
['an unclosed tag', '<p>a</p><p class="x'],
])('gives up on %s, and the element still returns the untouched source', (_name, html) => {
expect(captureBlocks(html, schema)).toBeNull()
const { el } = mount(html)
expect(el.value).toBe(html)
})
it('gives up on implied end tags it cannot split by source position', () => {
// The second <p> closes the first only in the HTML parser's context.
expect(captureBlocks('<p>a<p>b', schema)).toBeNull()
const { el, editor } = mount('<p>a<p>b')
expect(el.value).toBe('<p>a<p>b')
// An edit serializes the whole document from the model.
insertAtBlock(editor, 0, 'X')
expect(el.value).toBe('<p>Xa</p><p>b</p>')
})
it('handles an empty value', () => {
const captured = captureBlocks('', schema)!
expect(captured.blocks).toEqual([])
expect(captured.trailing).toBe('')
})
})