|
| 1 | +/** |
| 2 | + * @vitest-environment node |
| 3 | + */ |
| 4 | +import { describe, expect, it } from 'vitest' |
| 5 | +import * as Y from 'yjs' |
| 6 | +import { COLLAB_DOC_FIELD, stripEmptyTopLevelParagraphs } from './normalize' |
| 7 | + |
| 8 | +/** Build a top-level element with the given tag and optional text content. */ |
| 9 | +function element(tag: string, text?: string): Y.XmlElement { |
| 10 | + const el = new Y.XmlElement(tag) |
| 11 | + if (text !== undefined) el.insert(0, [new Y.XmlText(text)]) |
| 12 | + return el |
| 13 | +} |
| 14 | + |
| 15 | +/** Recursively concatenate the visible text of a Yjs XML node. */ |
| 16 | +function textOf(node: Y.XmlElement | Y.XmlText | Y.XmlHook): string { |
| 17 | + if (node instanceof Y.XmlText) return node.toString() |
| 18 | + if (node instanceof Y.XmlElement) { |
| 19 | + let text = '' |
| 20 | + for (let i = 0; i < node.length; i++) text += textOf(node.get(i)) |
| 21 | + return text |
| 22 | + } |
| 23 | + return '' |
| 24 | +} |
| 25 | + |
| 26 | +/** The ordered list of top-level `[tag, text]` pairs currently in a doc's body fragment. */ |
| 27 | +function structure(doc: Y.Doc): Array<[string, string]> { |
| 28 | + const fragment = doc.getXmlFragment(COLLAB_DOC_FIELD) |
| 29 | + const out: Array<[string, string]> = [] |
| 30 | + for (let i = 0; i < fragment.length; i++) { |
| 31 | + const node = fragment.get(i) |
| 32 | + out.push([node instanceof Y.XmlElement ? node.nodeName! : 'text', textOf(node)]) |
| 33 | + } |
| 34 | + return out |
| 35 | +} |
| 36 | + |
| 37 | +describe('stripEmptyTopLevelParagraphs', () => { |
| 38 | + it('removes interior empty paragraphs while preserving content and order (production repro)', () => { |
| 39 | + // Mirrors the persisted snapshot for random_data.md: a description paragraph, TWO consecutive empty |
| 40 | + // paragraphs (the reported "two spaces"), then a bullet list, then another interior empty paragraph. |
| 41 | + const doc = new Y.Doc() |
| 42 | + const fragment = doc.getXmlFragment(COLLAB_DOC_FIELD) |
| 43 | + fragment.insert(0, [ |
| 44 | + element('paragraph', 'A small collection of sample data.'), |
| 45 | + element('paragraph'), |
| 46 | + element('paragraph'), |
| 47 | + element('bulletList', 'list'), |
| 48 | + element('paragraph'), |
| 49 | + element('paragraph', 'trailing content'), |
| 50 | + ]) |
| 51 | + |
| 52 | + expect(stripEmptyTopLevelParagraphs(doc)).toBe(true) |
| 53 | + expect(structure(doc)).toEqual([ |
| 54 | + ['paragraph', 'A small collection of sample data.'], |
| 55 | + ['bulletList', 'list'], |
| 56 | + ['paragraph', 'trailing content'], |
| 57 | + ]) |
| 58 | + doc.destroy() |
| 59 | + }) |
| 60 | + |
| 61 | + it('is idempotent — a second pass finds nothing to remove', () => { |
| 62 | + const doc = new Y.Doc() |
| 63 | + doc |
| 64 | + .getXmlFragment(COLLAB_DOC_FIELD) |
| 65 | + .insert(0, [element('paragraph'), element('paragraph', 'body')]) |
| 66 | + |
| 67 | + expect(stripEmptyTopLevelParagraphs(doc)).toBe(true) |
| 68 | + expect(stripEmptyTopLevelParagraphs(doc)).toBe(false) |
| 69 | + expect(structure(doc)).toEqual([['paragraph', 'body']]) |
| 70 | + doc.destroy() |
| 71 | + }) |
| 72 | + |
| 73 | + it('returns false and mutates nothing when there are no top-level empty paragraphs', () => { |
| 74 | + const doc = new Y.Doc() |
| 75 | + doc |
| 76 | + .getXmlFragment(COLLAB_DOC_FIELD) |
| 77 | + .insert(0, [element('heading', 'Title'), element('paragraph', 'body')]) |
| 78 | + |
| 79 | + expect(stripEmptyTopLevelParagraphs(doc)).toBe(false) |
| 80 | + expect(structure(doc)).toEqual([ |
| 81 | + ['heading', 'Title'], |
| 82 | + ['paragraph', 'body'], |
| 83 | + ]) |
| 84 | + doc.destroy() |
| 85 | + }) |
| 86 | + |
| 87 | + it('leaves an empty paragraph nested inside another block untouched (only top-level is stripped)', () => { |
| 88 | + const doc = new Y.Doc() |
| 89 | + const listItem = new Y.XmlElement('listItem') |
| 90 | + listItem.insert(0, [new Y.XmlElement('paragraph')]) // an empty paragraph BELOW the fragment root |
| 91 | + const list = new Y.XmlElement('bulletList') |
| 92 | + list.insert(0, [listItem]) |
| 93 | + doc.getXmlFragment(COLLAB_DOC_FIELD).insert(0, [list]) |
| 94 | + |
| 95 | + expect(stripEmptyTopLevelParagraphs(doc)).toBe(false) |
| 96 | + const nestedList = doc.getXmlFragment(COLLAB_DOC_FIELD).get(0) as Y.XmlElement |
| 97 | + const nestedItem = nestedList.get(0) as Y.XmlElement |
| 98 | + expect(nestedItem.get(0)).toBeInstanceOf(Y.XmlElement) |
| 99 | + expect((nestedItem.get(0) as Y.XmlElement).nodeName).toBe('paragraph') |
| 100 | + doc.destroy() |
| 101 | + }) |
| 102 | + |
| 103 | + it('survives an encode/decode round-trip preserving CRDT ids and the config map (seed-repair path)', () => { |
| 104 | + const original = new Y.Doc() |
| 105 | + original |
| 106 | + .getXmlFragment(COLLAB_DOC_FIELD) |
| 107 | + .insert(0, [element('paragraph', 'kept'), element('paragraph')]) |
| 108 | + original.getMap('config').set('initialContentLoaded', true) |
| 109 | + original.getMap('config').set('frontmatter', 'title: x') |
| 110 | + const before = Y.encodeStateAsUpdate(original) |
| 111 | + original.destroy() |
| 112 | + |
| 113 | + // Repair exactly as normalizeSeedUpdate does: apply → strip → re-encode. |
| 114 | + const repair = new Y.Doc() |
| 115 | + Y.applyUpdate(repair, before) |
| 116 | + expect(stripEmptyTopLevelParagraphs(repair)).toBe(true) |
| 117 | + const after = Y.encodeStateAsUpdate(repair) |
| 118 | + repair.destroy() |
| 119 | + |
| 120 | + const seeded = new Y.Doc() |
| 121 | + Y.applyUpdate(seeded, after) |
| 122 | + expect(structure(seeded)).toEqual([['paragraph', 'kept']]) |
| 123 | + expect(seeded.getMap('config').get('initialContentLoaded')).toBe(true) |
| 124 | + expect(seeded.getMap('config').get('frontmatter')).toBe('title: x') |
| 125 | + seeded.destroy() |
| 126 | + }) |
| 127 | +}) |
0 commit comments