| 1 | // The op vocabulary over a Y.Doc (ops.mjs), one test per op family, plus |
| 2 | // the properties the core guarantees: a refused batch applies nothing, |
| 3 | // columns.children is validated, a layout switch round-trips, and the |
| 4 | // committed vectors are what generate.mjs would write today. |
| 5 | import test from 'node:test' |
| 6 | import assert from 'node:assert/strict' |
| 7 | import { readFileSync, readdirSync } from 'node:fs' |
| 8 | import { join, dirname } from 'node:path' |
| 9 | import { fileURLToPath } from 'node:url' |
| 10 | import * as Y from 'yjs' |
| 11 | import { applyOps, view, OpsError } from './ops.mjs' |
| 12 | import { buildVector, sortedJSON } from './generate.mjs' |
| 13 | |
| 14 | const here = dirname(fileURLToPath(import.meta.url)) |
| 15 | |
| 16 | function fresh () { |
| 17 | const doc = new Y.Doc({ guid: 'test' }) |
| 18 | doc.clientID = 1 |
| 19 | return doc |
| 20 | } |
| 21 | |
| 22 | function withSlide (layout = 'flow') { |
| 23 | const doc = fresh() |
| 24 | applyOps(doc, [{ op: 'add_slide', id: 's1', layout, template: 'title-body' }]) |
| 25 | return doc |
| 26 | } |
| 27 | |
| 28 | const heading = (id, md, extra = {}) => ({ |
| 29 | op: 'add_block', slide: 's1', id, type: 'heading', props: { level: 1 }, region: 'heading', markdown: md, ...extra |
| 30 | }) |
| 31 | const text = (id, md, extra = {}) => ({ |
| 32 | op: 'add_block', slide: 's1', id, type: 'text', props: {}, region: 'body', markdown: md, ...extra |
| 33 | }) |
| 34 | |
| 35 | test('empty_doc_views_as_defaults', () => { |
| 36 | assert.deepEqual(view(fresh()), { |
| 37 | meta: { title: '', theme: 'paper', accent: null, fonts: null, aspect: '16:9' }, |
| 38 | slides: [], |
| 39 | assets: {} |
| 40 | }) |
| 41 | }) |
| 42 | |
| 43 | test('set_meta', () => { |
| 44 | const doc = fresh() |
| 45 | applyOps(doc, [{ op: 'set_meta', title: 'T', theme: 'ink', accent: '#f00', fonts: { heading: 'A', body: 'B' } }]) |
| 46 | assert.deepEqual(view(doc).meta, { title: 'T', theme: 'ink', accent: '#f00', fonts: { heading: 'A', body: 'B' }, aspect: '16:9' }) |
| 47 | applyOps(doc, [{ op: 'set_meta', accent: null, fonts: null }]) |
| 48 | assert.deepEqual(view(doc).meta, { title: 'T', theme: 'ink', accent: null, fonts: null, aspect: '16:9' }) |
| 49 | assert.throws(() => applyOps(doc, [{ op: 'set_meta', title: 5 }]), OpsError) |
| 50 | assert.throws(() => applyOps(doc, [{ op: 'set_meta', fonts: { heading: 'A' } }]), OpsError) |
| 51 | }) |
| 52 | |
| 53 | test('slides: add, move, remove, set_slide, set_notes', () => { |
| 54 | const doc = fresh() |
| 55 | applyOps(doc, [ |
| 56 | { op: 'add_slide', id: 's1', layout: 'flow', template: 'title' }, |
| 57 | { op: 'add_slide', id: 's2', layout: 'flow' }, |
| 58 | { op: 'add_slide', id: 's0', layout: 'canvas', after: null }, |
| 59 | { op: 'add_slide', id: 's1b', layout: 'flow', after: 's1' } |
| 60 | ]) |
| 61 | let v = view(doc) |
| 62 | assert.deepEqual(v.slides.map(s => s.id), ['s0', 's1', 's1b', 's2']) |
| 63 | assert.deepEqual(v.slides[1], { |
| 64 | id: 's1', layout: 'flow', template: 'title', background: { kind: 'theme', value: '' }, |
| 65 | phone: 'inherit', transition: 'none', notes: [], blocks: [] |
| 66 | }) |
| 67 | assert.equal(v.slides[3].template, 'blank') |
| 68 | |
| 69 | applyOps(doc, [{ op: 'move_slide', id: 's2', after: null }, { op: 'move_slide', id: 's0', after: 's1b' }]) |
| 70 | assert.deepEqual(view(doc).slides.map(s => s.id), ['s2', 's1', 's1b', 's0']) |
| 71 | |
| 72 | applyOps(doc, [ |
| 73 | { op: 'remove_slide', id: 's1b' }, |
| 74 | { op: 'set_slide', id: 's1', template: 'quote', background: { kind: 'color', value: '#123' }, phone: 'reflow', transition: 'fade' }, |
| 75 | { op: 'set_notes', id: 's1', markdown: 'Say **hello**\n\n- one\n- two' } |
| 76 | ]) |
| 77 | v = view(doc) |
| 78 | assert.deepEqual(v.slides.map(s => s.id), ['s2', 's1', 's0']) |
| 79 | const s1 = v.slides[1] |
| 80 | assert.equal(s1.template, 'quote') |
| 81 | assert.deepEqual(s1.background, { kind: 'color', value: '#123' }) |
| 82 | assert.equal(s1.phone, 'reflow') |
| 83 | assert.equal(s1.transition, 'fade') |
| 84 | assert.deepEqual(s1.notes, [[{ t: 'Say ' }, { t: 'hello', b: true }], [{ t: 'one' }], [{ t: 'two' }]]) |
| 85 | |
| 86 | assert.throws(() => applyOps(doc, [{ op: 'add_slide', id: 's1', layout: 'flow' }]), /already exists/) |
| 87 | assert.throws(() => applyOps(doc, [{ op: 'add_slide', id: 'x', layout: 'grid' }]), /layout "grid" is not one of/) |
| 88 | assert.throws(() => applyOps(doc, [{ op: 'move_slide', id: 's1', after: 's1' }]), /after is the slide itself/) |
| 89 | assert.throws(() => applyOps(doc, [{ op: 'set_slide', id: 'nope' }]), /no slide "nope"/) |
| 90 | }) |
| 91 | |
| 92 | test('duplicate_slide', () => { |
| 93 | const doc = withSlide() |
| 94 | applyOps(doc, [ |
| 95 | heading('h1', 'Hi'), |
| 96 | text('t1', 'a'), |
| 97 | text('t2', 'b'), |
| 98 | { op: 'add_block', slide: 's1', id: 'c1', type: 'columns', props: { count: 2, children: ['t1', 't2'] } }, |
| 99 | { op: 'duplicate_slide', id: 's1', new_id: 's2', new_block_ids: ['h2', 't3', 't4', 'c2'] } |
| 100 | ]) |
| 101 | const v = view(doc) |
| 102 | assert.deepEqual(v.slides.map(s => s.id), ['s1', 's2']) |
| 103 | const copy = v.slides[1] |
| 104 | assert.deepEqual(copy.blocks.map(b => b.id), ['h2', 't3', 't4', 'c2']) |
| 105 | assert.deepEqual(copy.blocks[3].props, { count: 2, children: ['t3', 't4'] }) |
| 106 | assert.deepEqual(copy.blocks[0].text, v.slides[0].blocks[0].text) |
| 107 | assert.throws(() => applyOps(doc, [{ op: 'duplicate_slide', id: 's1', new_id: 's3', new_block_ids: ['a'] }]), /4 blocks but 1 new_block_ids/) |
| 108 | assert.throws(() => applyOps(doc, [{ op: 'duplicate_slide', id: 's1', new_id: 's3', new_block_ids: ['a', 'a', 'b', 'c'] }]), /distinct and non-empty/) |
| 109 | }) |
| 110 | |
| 111 | test('blocks: add, move, remove, set_block', () => { |
| 112 | const doc = withSlide() |
| 113 | applyOps(doc, [ |
| 114 | heading('h1', 'Hello **world**'), |
| 115 | text('t1', 'one'), |
| 116 | text('t0', 'zero', { after: null }), |
| 117 | text('t1b', 'x', { after: 't1' }), |
| 118 | { op: 'add_block', slide: 's1', id: 'img', type: 'image', props: { asset: 'a1', alt: '' } }, |
| 119 | { op: 'add_block', slide: 's1', id: 'div', type: 'divider' } |
| 120 | ]) |
| 121 | let v = view(doc) |
| 122 | assert.deepEqual(v.slides[0].blocks.map(b => b.id), ['t0', 'h1', 't1', 't1b', 'img', 'div']) |
| 123 | assert.deepEqual(v.slides[0].blocks[1], { |
| 124 | id: 'h1', type: 'heading', props: { level: 1 }, region: 'heading', box: null, z: 0, phone: {}, |
| 125 | text: [[{ t: 'Hello ' }, { t: 'world', b: true }]] |
| 126 | }) |
| 127 | assert.equal(v.slides[0].blocks[4].region, 'media') |
| 128 | assert.equal('text' in v.slides[0].blocks[4], false) |
| 129 | assert.equal(v.slides[0].blocks[5].region, 'body') |
| 130 | |
| 131 | applyOps(doc, [ |
| 132 | { op: 'move_block', slide: 's1', id: 'div', after: null }, |
| 133 | { op: 'move_block', slide: 's1', id: 't0', after: 'img' }, |
| 134 | { op: 'remove_block', slide: 's1', id: 't1b' }, |
| 135 | { op: 'set_block', slide: 's1', id: 'h1', props: { level: 2, extra: 'y' }, region: 'body' }, |
| 136 | { op: 'set_block', slide: 's1', id: 'h1', props: { extra: null } }, |
| 137 | { op: 'set_text', slide: 's1', id: 'h1', markdown: '*new*' } |
| 138 | ]) |
| 139 | v = view(doc) |
| 140 | assert.deepEqual(v.slides[0].blocks.map(b => b.id), ['div', 'h1', 't1', 'img', 't0']) |
| 141 | assert.deepEqual(v.slides[0].blocks[1].props, { level: 2 }) |
| 142 | assert.equal(v.slides[0].blocks[1].region, 'body') |
| 143 | assert.deepEqual(v.slides[0].blocks[1].text, [[{ t: 'new', i: true }]]) |
| 144 | |
| 145 | assert.throws(() => applyOps(doc, [{ op: 'add_block', slide: 's1', id: 'h1', type: 'text' }]), /already exists/) |
| 146 | assert.throws(() => applyOps(doc, [{ op: 'add_block', slide: 's1', id: 'n', type: 'video' }]), /type "video" is not one of/) |
| 147 | assert.throws(() => applyOps(doc, [{ op: 'set_text', slide: 's1', id: 'img', markdown: 'x' }]), /is a image and carries no text/) |
| 148 | assert.throws(() => applyOps(doc, [{ op: 'move_block', slide: 's1', id: 'h1', after: 'h1' }]), /after is the block itself/) |
| 149 | assert.throws(() => applyOps(doc, [{ op: 'remove_block', slide: 's1', id: 'zz' }]), /no block "zz" in slide "s1"/) |
| 150 | }) |
| 151 | |
| 152 | test('set_phone', () => { |
| 153 | const doc = withSlide() |
| 154 | applyOps(doc, [text('t1', 'x'), { op: 'set_phone', slide: 's1', id: 't1', phone: { hidden: true, order: 2, size: 's', box: { x: 0, y: 0.5, w: 1, h: 0.25 } } }]) |
| 155 | assert.deepEqual(view(doc).slides[0].blocks[0].phone, { hidden: true, order: 2, size: 's', box: { x: 0, y: 0.5, w: 1, h: 0.25 } }) |
| 156 | applyOps(doc, [{ op: 'set_phone', slide: 's1', id: 't1', phone: { size: 'l' } }]) |
| 157 | assert.deepEqual(view(doc).slides[0].blocks[0].phone, { size: 'l' }) |
| 158 | assert.throws(() => applyOps(doc, [{ op: 'set_phone', slide: 's1', id: 't1', phone: { size: 'xl' } }]), /phone.size "xl" is not one of/) |
| 159 | assert.throws(() => applyOps(doc, [{ op: 'set_phone', slide: 's1', id: 't1', phone: { colour: 'red' } }]), OpsError) |
| 160 | assert.throws(() => applyOps(doc, [{ op: 'set_phone', slide: 's1', id: 't1', phone: { order: 1.5 } }]), OpsError) |
| 161 | }) |
| 162 | |
| 163 | test('table cells follow rows and cols', () => { |
| 164 | const doc = withSlide() |
| 165 | applyOps(doc, [{ op: 'add_block', slide: 's1', id: 'tb', type: 'table', props: { header: true } }]) |
| 166 | let b = view(doc).slides[0].blocks[0] |
| 167 | assert.deepEqual(b.props, { header: true, rows: 2, cols: 2, cells: [[], [], [], []] }) |
| 168 | assert.equal('text' in b, false) |
| 169 | applyOps(doc, [{ op: 'set_block', slide: 's1', id: 'tb', props: { rows: 3 } }]) |
| 170 | b = view(doc).slides[0].blocks[0] |
| 171 | assert.equal(b.props.cells.length, 6) |
| 172 | applyOps(doc, [{ op: 'set_block', slide: 's1', id: 'tb', props: { rows: 1, cols: 1 } }]) |
| 173 | assert.equal(view(doc).slides[0].blocks[0].props.cells.length, 1) |
| 174 | assert.throws(() => applyOps(doc, [{ op: 'set_block', slide: 's1', id: 'tb', props: { rows: 0 } }]), /table rows must be a whole number from 1 to 100/) |
| 175 | assert.throws(() => applyOps(doc, [{ op: 'add_block', slide: 's1', id: 't2', type: 'table', props: { cols: 101 } }]), /table cols/) |
| 176 | }) |
| 177 | |
| 178 | test('canvas blocks: box defaults from the region, z from the index', () => { |
| 179 | const doc = withSlide('canvas') |
| 180 | applyOps(doc, [ |
| 181 | heading('h1', 'x'), |
| 182 | { op: 'add_block', slide: 's1', id: 't1', type: 'text', box: { x: 0.1, y: 0.2, w: 0.3, h: 0.44444 }, z: 7 }, |
| 183 | { op: 'set_block', slide: 's1', id: 'h1', box: { x: 0.5, y: 0.5, w: 0.1, h: 0.1 }, z: 3 } |
| 184 | ]) |
| 185 | const v = view(doc) |
| 186 | assert.deepEqual(v.slides[0].blocks[0], { |
| 187 | id: 'h1', type: 'heading', props: { level: 1 }, region: null, box: { x: 0.5, y: 0.5, w: 0.1, h: 0.1 }, z: 3, phone: {}, text: [[{ t: 'x' }]] |
| 188 | }) |
| 189 | assert.deepEqual(v.slides[0].blocks[1].box, { x: 0.1, y: 0.2, w: 0.3, h: 0.4444 }) |
| 190 | assert.equal(v.slides[0].blocks[1].z, 7) |
| 191 | applyOps(doc, [{ op: 'set_block', slide: 's1', id: 'h1', box: null }]) |
| 192 | assert.equal(view(doc).slides[0].blocks[0].box, null) |
| 193 | }) |
| 194 | |
| 195 | test('assets: register and remove', () => { |
| 196 | const doc = fresh() |
| 197 | applyOps(doc, [{ op: 'register_asset', id: 'a1', ref: 'blob/1', name: 'pic.png', width: 10, height: 20, alt: 'A' }, { op: 'register_asset', id: 'a2', ref: 'blob/2' }]) |
| 198 | assert.deepEqual(view(doc).assets, { |
| 199 | a1: { ref: 'blob/1', name: 'pic.png', width: 10, height: 20, alt: 'A' }, |
| 200 | a2: { ref: 'blob/2', name: '', width: 0, height: 0, alt: '' } |
| 201 | }) |
| 202 | applyOps(doc, [{ op: 'remove_asset', id: 'a1' }]) |
| 203 | assert.deepEqual(Object.keys(view(doc).assets), ['a2']) |
| 204 | assert.throws(() => applyOps(doc, [{ op: 'register_asset', id: 'a2', ref: 'x' }]), /already registered/) |
| 205 | assert.throws(() => applyOps(doc, [{ op: 'remove_asset', id: 'a1' }]), /no asset "a1"/) |
| 206 | assert.throws(() => applyOps(doc, [{ op: 'register_asset', id: 'a3', ref: '' }]), /ref is empty/) |
| 207 | }) |
| 208 | |
| 209 | test('markdown: every mark, code blocks are plain lines', () => { |
| 210 | const doc = withSlide() |
| 211 | applyOps(doc, [ |
| 212 | text('t1', '**b** *i* `c *x*` ==h== [t](u) \\* lone* **open'), |
| 213 | text('t2', '**a *b* c** [**x**](y)'), |
| 214 | { op: 'add_block', slide: 's1', id: 'c1', type: 'code', props: { language: 'go' }, markdown: 'x\n\n**y**' } |
| 215 | ]) |
| 216 | const [t1, t2, c1] = view(doc).slides[0].blocks |
| 217 | assert.deepEqual(t1.text, [[ |
| 218 | { t: 'b', b: true }, { t: ' ' }, { t: 'i', i: true }, { t: ' ' }, { t: 'c *x*', c: true }, { t: ' ' }, |
| 219 | { t: 'h', h: true }, { t: ' ' }, { t: 't', href: 'u' }, { t: ' * lone* **open' } |
| 220 | ]]) |
| 221 | assert.deepEqual(t2.text, [[{ t: 'a ', b: true }, { t: 'b', b: true, i: true }, { t: ' c', b: true }, { t: ' ' }, { t: 'x', b: true, href: 'y' }]]) |
| 222 | assert.deepEqual(c1.text, [[{ t: 'x' }], [], [{ t: '**y**' }]]) |
| 223 | }) |
| 224 | |
| 225 | test('batch_is_atomic', () => { |
| 226 | const doc = withSlide() |
| 227 | const before = Y.encodeStateAsUpdate(doc) |
| 228 | let origins = 0 |
| 229 | doc.on('afterTransaction', () => { origins++ }) |
| 230 | let err = null |
| 231 | try { |
| 232 | applyOps(doc, [ |
| 233 | heading('h1', 'x'), |
| 234 | { op: 'add_slide', id: 's2', layout: 'flow' }, |
| 235 | { op: 'remove_block', slide: 's1', id: 'missing' } |
| 236 | ]) |
| 237 | } catch (e) { err = e } |
| 238 | assert.ok(err instanceof OpsError) |
| 239 | assert.equal(err.index, 2) |
| 240 | assert.match(err.message, /^op 2: no block "missing" in slide "s1"/) |
| 241 | assert.deepEqual(Y.encodeStateAsUpdate(doc), before) |
| 242 | assert.equal(origins, 0) |
| 243 | assert.throws(() => applyOps(doc, [{ op: 'sing' }]), /op 0:/) |
| 244 | assert.throws(() => applyOps(doc, 'nope'), /ops must be a JSON array/) |
| 245 | }) |
| 246 | |
| 247 | test('a batch applies in one transaction with the given origin', () => { |
| 248 | const doc = withSlide() |
| 249 | const seen = [] |
| 250 | doc.on('afterTransaction', tr => { seen.push(tr.origin) }) |
| 251 | applyOps(doc, [heading('h1', 'x'), text('t1', 'y')], 'inspector') |
| 252 | assert.deepEqual(seen, ['inspector']) |
| 253 | }) |
| 254 | |
| 255 | test('columns_children_validated', () => { |
| 256 | const doc = withSlide() |
| 257 | applyOps(doc, [text('t1', 'a'), text('t2', 'b')]) |
| 258 | assert.throws(() => applyOps(doc, [{ op: 'add_block', slide: 's1', id: 'c1', type: 'columns', props: { children: ['t1', 'zz'] } }]), /names "zz", not a block on slide "s1"/) |
| 259 | assert.throws(() => applyOps(doc, [{ op: 'add_block', slide: 's1', id: 'c1', type: 'columns', props: { children: ['c1'] } }]), /cannot name itself/) |
| 260 | assert.throws(() => applyOps(doc, [{ op: 'add_block', slide: 's1', id: 'c1', type: 'columns', props: { children: 't1' } }]), /must be an array of block ids/) |
| 261 | applyOps(doc, [{ op: 'add_block', slide: 's1', id: 'c1', type: 'columns', props: { count: 2, children: ['t1', 't2'] } }]) |
| 262 | assert.throws(() => applyOps(doc, [{ op: 'set_block', slide: 's1', id: 'c1', props: { children: ['t1', 't9'] } }]), /names "t9"/) |
| 263 | // Removing a child prunes it from the columns block. |
| 264 | applyOps(doc, [{ op: 'remove_block', slide: 's1', id: 't1' }]) |
| 265 | assert.deepEqual(view(doc).slides[0].blocks.find(b => b.id === 'c1').props.children, ['t2']) |
| 266 | // The model follows the batch: a block removed earlier in the same batch is gone. |
| 267 | assert.throws(() => applyOps(doc, [ |
| 268 | { op: 'remove_block', slide: 's1', id: 't2' }, |
| 269 | { op: 'set_block', slide: 's1', id: 'c1', props: { children: ['t2'] } } |
| 270 | ]), /op 1: columns children names "t2"/) |
| 271 | applyOps(doc, [{ op: 'set_block', slide: 's1', id: 'c1', props: { children: null } }]) |
| 272 | assert.deepEqual(view(doc).slides[0].blocks.find(b => b.id === 'c1').props, { count: 2 }) |
| 273 | }) |
| 274 | |
| 275 | test('layout_switch_round_trip', () => { |
| 276 | const doc = withSlide() |
| 277 | applyOps(doc, [heading('h1', 'x'), text('t1', 'a'), text('t2', 'b'), { op: 'add_block', slide: 's1', id: 'as', type: 'text', region: 'aside' }]) |
| 278 | applyOps(doc, [{ op: 'set_slide', id: 's1', layout: 'canvas' }]) |
| 279 | let blocks = view(doc).slides[0].blocks |
| 280 | assert.deepEqual(blocks.map(b => [b.id, b.region, b.box, b.z]), [ |
| 281 | ['h1', null, { x: 0.06, y: 0.08, w: 0.88, h: 0.18 }, 0], |
| 282 | ['t1', null, { x: 0.06, y: 0.3, w: 0.44, h: 0.31 }, 1], |
| 283 | ['t2', null, { x: 0.06, y: 0.61, w: 0.44, h: 0.31 }, 2], |
| 284 | ['as', null, { x: 0.54, y: 0.3, w: 0.4, h: 0.62 }, 3] |
| 285 | ]) |
| 286 | // Back to flow: regions from position, order top to bottom then left to right. |
| 287 | applyOps(doc, [{ op: 'set_block', slide: 's1', id: 't2', box: { x: 0.5, y: 0.05, w: 0.2, h: 0.2 } }]) |
| 288 | applyOps(doc, [{ op: 'set_slide', id: 's1', layout: 'flow' }]) |
| 289 | blocks = view(doc).slides[0].blocks |
| 290 | assert.deepEqual(blocks.map(b => [b.id, b.region, b.box, b.z]), [ |
| 291 | ['t2', 'body', null, 0], |
| 292 | ['h1', 'heading', null, 0], |
| 293 | ['t1', 'body', null, 0], |
| 294 | ['as', 'body', null, 0] |
| 295 | ]) |
| 296 | // Setting the same layout again is a no-op. |
| 297 | const before = view(doc) |
| 298 | applyOps(doc, [{ op: 'set_slide', id: 's1', layout: 'flow' }]) |
| 299 | assert.deepEqual(view(doc), before) |
| 300 | }) |
| 301 | |
| 302 | test('view_matches_snapshot_fixtures', () => { |
| 303 | const dataDir = join(here, 'data') |
| 304 | const names = readdirSync(dataDir).filter(f => f.endsWith('.json')).map(f => f.slice(0, -5)) |
| 305 | assert.ok(names.length >= 5, 'five vectors at least') |
| 306 | const ops = new Set() |
| 307 | for (const name of names) { |
| 308 | const data = JSON.parse(readFileSync(join(dataDir, `${name}.json`), 'utf8')) |
| 309 | data.ops.forEach(o => ops.add(o.op)) |
| 310 | const { update, view: v } = buildVector(name, data) |
| 311 | const wantView = readFileSync(join(here, 'vectors', `${name}.view.json`), 'utf8') |
| 312 | assert.equal(sortedJSON(v), wantView, `${name}.view.json is stale: run node generate.mjs`) |
| 313 | const wantUpdate = readFileSync(join(here, 'vectors', `${name}.yjs`)) |
| 314 | assert.ok(Buffer.from(update).equals(wantUpdate), `${name}.yjs is stale: run node generate.mjs`) |
| 315 | } |
| 316 | const every = ['set_meta', 'add_slide', 'remove_slide', 'move_slide', 'duplicate_slide', 'set_slide', 'set_notes', |
| 317 | 'add_block', 'remove_block', 'move_block', 'set_block', 'set_text', 'set_phone', 'register_asset', 'remove_asset'] |
| 318 | assert.deepEqual(every.filter(o => !ops.has(o)), [], 'every op appears in some vector') |
| 319 | }) |
| 320 | |
| 321 | test('columns children have one parent and no cycles (codex 7)', () => { |
| 322 | const doc = withSlide() |
| 323 | applyOps(doc, [ |
| 324 | text('t1', 'a'), text('t2', 'b'), |
| 325 | { op: 'add_block', slide: 's1', id: 'a', type: 'columns', props: { children: ['t1'] } }, |
| 326 | { op: 'add_block', slide: 's1', id: 'b', type: 'columns', props: { children: ['a'] } }, |
| 327 | { op: 'add_block', slide: 's1', id: 'c', type: 'columns', props: { children: [] } } |
| 328 | ]) |
| 329 | const before = view(doc) |
| 330 | // A → B, B → A: the cycle codex reproduced, with the core's message. |
| 331 | assert.throws(() => applyOps(doc, [{ op: 'set_block', slide: 's1', id: 'a', props: { children: ['t1', 'b'] } }]), |
| 332 | /op 0: columns children names "b", which would put columns "a" inside itself$/) |
| 333 | // A shared child. |
| 334 | assert.throws(() => applyOps(doc, [{ op: 'set_block', slide: 's1', id: 'c', props: { children: ['t1'] } }]), |
| 335 | /op 0: columns children names "t1", already a child of columns "a"$/) |
| 336 | // The same child twice. |
| 337 | assert.throws(() => applyOps(doc, [{ op: 'set_block', slide: 's1', id: 'c', props: { children: ['t2', 't2'] } }]), |
| 338 | /op 0: columns children names "t2" twice$/) |
| 339 | assert.deepEqual(view(doc), before, 'nothing applied') |
| 340 | // A longer cycle: c → b → a → c. |
| 341 | applyOps(doc, [{ op: 'set_block', slide: 's1', id: 'c', props: { children: ['b'] } }]) |
| 342 | assert.throws(() => applyOps(doc, [{ op: 'set_block', slide: 's1', id: 'a', props: { children: ['t1', 'c'] } }]), /inside itself/) |
| 343 | // Reparenting inside one batch is fine: the model follows the batch. |
| 344 | applyOps(doc, [ |
| 345 | { op: 'set_block', slide: 's1', id: 'a', props: { children: [] } }, |
| 346 | { op: 'set_block', slide: 's1', id: 'c', props: { children: ['b', 't1'] } } |
| 347 | ]) |
| 348 | assert.deepEqual(view(doc).slides[0].blocks.find(b => b.id === 'c').props.children, ['b', 't1']) |
| 349 | }) |
| 350 | |
| 351 | /** Two replicas of one document, and the exchange of what each did since. */ |
| 352 | function replicas () { |
| 353 | const a = fresh() |
| 354 | const b = new Y.Doc({ guid: 'test' }) |
| 355 | b.clientID = 2 |
| 356 | const sync = () => { |
| 357 | const fromA = Y.encodeStateAsUpdate(a, Y.encodeStateVector(b)) |
| 358 | const fromB = Y.encodeStateAsUpdate(b, Y.encodeStateVector(a)) |
| 359 | Y.applyUpdate(a, fromB) |
| 360 | Y.applyUpdate(b, fromA) |
| 361 | } |
| 362 | return { a, b, sync } |
| 363 | } |
| 364 | |
| 365 | test('canvas-to-flow keeps a concurrent text edit (codex 4)', () => { |
| 366 | const { a, b, sync } = replicas() |
| 367 | applyOps(a, [ |
| 368 | { op: 'add_slide', id: 's1', layout: 'canvas' }, |
| 369 | { op: 'add_block', slide: 's1', id: 'low', type: 'text', markdown: 'below', box: { x: 0.1, y: 0.6, w: 0.3, h: 0.2 } }, |
| 370 | { op: 'add_block', slide: 's1', id: 'high', type: 'text', markdown: 'above', box: { x: 0.1, y: 0.1, w: 0.3, h: 0.2 } } |
| 371 | ]) |
| 372 | sync() |
| 373 | // A converts, which reorders: "high" reads first now. |
| 374 | applyOps(a, [{ op: 'set_slide', id: 's1', layout: 'flow' }]) |
| 375 | // B, concurrently, types into "low" the way a keystroke through |
| 376 | // y-prosemirror does: straight into the shared XmlText. |
| 377 | const low = b.getArray('slides').get(0).get('blocks').toArray().find(m => m.get('id') === 'low') |
| 378 | const t = low.get('text').get(0).get(0) |
| 379 | t.insert(t.length, ' and more') |
| 380 | sync() |
| 381 | for (const doc of [a, b]) { |
| 382 | const blocks = view(doc).slides[0].blocks |
| 383 | assert.deepEqual(blocks.map(x => x.id), ['high', 'low']) |
| 384 | assert.deepEqual(blocks[1].text, [[{ t: 'below and more' }]]) |
| 385 | assert.equal(blocks[1].region, 'body') |
| 386 | } |
| 387 | assert.deepEqual(view(a), view(b)) |
| 388 | // The array was never rebuilt: the maps are the ones the ops made. |
| 389 | assert.equal(a.getArray('slides').get(0).get('blocks').toArray()[0].get('id'), 'low') |
| 390 | }) |
| 391 | |
| 392 | test('move_block keeps a concurrent text edit, and later inserts land where after says (codex 4)', () => { |
| 393 | const { a, b, sync } = replicas() |
| 394 | applyOps(a, [ |
| 395 | { op: 'add_slide', id: 's1', layout: 'flow' }, |
| 396 | text('one', 'one'), text('two', 'two'), text('three', 'three') |
| 397 | ]) |
| 398 | sync() |
| 399 | applyOps(a, [{ op: 'move_block', slide: 's1', id: 'three' }]) |
| 400 | applyOps(b, [{ op: 'set_text', slide: 's1', id: 'three', markdown: 'three, edited' }]) |
| 401 | sync() |
| 402 | for (const doc of [a, b]) { |
| 403 | const blocks = view(doc).slides[0].blocks |
| 404 | assert.deepEqual(blocks.map(x => x.id), ['three', 'one', 'two']) |
| 405 | assert.deepEqual(blocks[0].text, [[{ t: 'three, edited' }]]) |
| 406 | } |
| 407 | applyOps(a, [ |
| 408 | text('four', 'four', { after: 'three' }), |
| 409 | text('five', 'five'), |
| 410 | text('zero', 'zero', { after: null }) |
| 411 | ]) |
| 412 | assert.deepEqual(view(a).slides[0].blocks.map(x => x.id), ['zero', 'three', 'four', 'one', 'two', 'five']) |
| 413 | // A duplicate pairs new ids with blocks in reading order. |
| 414 | applyOps(a, [{ op: 'duplicate_slide', id: 's1', new_id: 's2', new_block_ids: ['z', 't', 'f', 'o', 'w', 'v'] }]) |
| 415 | assert.deepEqual(view(a).slides[1].blocks.map(x => [x.id, x.text[0][0].t]), |
| 416 | [['z', 'zero'], ['t', 'three, edited'], ['f', 'four'], ['o', 'one'], ['w', 'two'], ['v', 'five']]) |
| 417 | }) |
| 418 | |
| 419 | test('move_slide keeps a concurrent text edit, and later inserts land where after says (codex 4 at slide level)', () => { |
| 420 | const { a, b, sync } = replicas() |
| 421 | applyOps(a, [ |
| 422 | { op: 'add_slide', id: 's1', layout: 'flow' }, |
| 423 | { op: 'add_slide', id: 's2', layout: 'flow' }, |
| 424 | { op: 'add_slide', id: 's3', layout: 'flow' }, |
| 425 | { op: 'add_block', slide: 's3', id: 't3', type: 'text', region: 'body', markdown: 'three' } |
| 426 | ]) |
| 427 | sync() |
| 428 | // A reorders the deck; B, who has not seen that, types into the |
| 429 | // slide being moved and into its notes. |
| 430 | applyOps(a, [{ op: 'move_slide', id: 's3' }]) |
| 431 | applyOps(b, [ |
| 432 | { op: 'set_text', slide: 's3', id: 't3', markdown: 'three, edited' }, |
| 433 | { op: 'set_notes', id: 's3', markdown: 'say this' } |
| 434 | ]) |
| 435 | sync() |
| 436 | for (const doc of [a, b]) { |
| 437 | const slides = view(doc).slides |
| 438 | assert.deepEqual(slides.map(s => s.id), ['s3', 's1', 's2']) |
| 439 | assert.deepEqual(slides[0].blocks[0].text, [[{ t: 'three, edited' }]]) |
| 440 | assert.deepEqual(slides[0].notes, [[{ t: 'say this' }]]) |
| 441 | // The order key is the document's, not the view's. |
| 442 | assert.equal(Object.hasOwn(slides[0], 'order'), false) |
| 443 | } |
| 444 | applyOps(a, [ |
| 445 | { op: 'add_slide', id: 's4', layout: 'flow', after: 's3' }, |
| 446 | { op: 'add_slide', id: 's5', layout: 'flow' }, |
| 447 | { op: 'add_slide', id: 's0', layout: 'flow', after: null }, |
| 448 | { op: 'duplicate_slide', id: 's3', new_id: 's3b', new_block_ids: ['t3b'] } |
| 449 | ]) |
| 450 | assert.deepEqual(view(a).slides.map(s => s.id), ['s0', 's3', 's3b', 's4', 's1', 's2', 's5']) |
| 451 | applyOps(a, [{ op: 'remove_slide', id: 's3' }]) |
| 452 | assert.deepEqual(view(a).slides.map(s => s.id), ['s0', 's3b', 's4', 's1', 's2', 's5']) |
| 453 | }) |
| 454 | |