oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

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.
5import test from 'node:test'
6import assert from 'node:assert/strict'
7import { readFileSync, readdirSync } from 'node:fs'
8import { join, dirname } from 'node:path'
9import { fileURLToPath } from 'node:url'
10import * as Y from 'yjs'
11import { applyOps, view, OpsError } from './ops.mjs'
12import { buildVector, sortedJSON } from './generate.mjs'
13
14const here = dirname(fileURLToPath(import.meta.url))
15
16function fresh () {
17 const doc = new Y.Doc({ guid: 'test' })
18 doc.clientID = 1
19 return doc
20}
21
22function withSlide (layout = 'flow') {
23 const doc = fresh()
24 applyOps(doc, [{ op: 'add_slide', id: 's1', layout, template: 'title-body' }])
25 return doc
26}
27
28const heading = (id, md, extra = {}) => ({
29 op: 'add_block', slide: 's1', id, type: 'heading', props: { level: 1 }, region: 'heading', markdown: md, ...extra
30})
31const text = (id, md, extra = {}) => ({
32 op: 'add_block', slide: 's1', id, type: 'text', props: {}, region: 'body', markdown: md, ...extra
33})
34
35test('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
43test('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
53test('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
92test('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
111test('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
152test('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
163test('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
178test('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
195test('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
209test('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
225test('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
247test('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
255test('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
275test('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
302test('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
321test('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. */
352function 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
365test('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
392test('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
419test('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