// Each inspector control produces the one op it stands for // (inspector-ops.mjs), and every op it produces is one the ops module // accepts: each is applied to a real deck built through ops.mjs, so a // field that named a prop the core refuses would fail here. import test from 'node:test' import assert from 'node:assert/strict' import * as Y from 'yjs' import { applyOps, view } from './ops.mjs' import { deckOp, slideOp, moveSlideOp, propValue, propOp, regionOp, labelOp, boxFieldOp, moveBlockOp, zOps, addBlockOp, blockLabel, DEFAULT_BOX } from './inspector-ops.mjs' import { fillBlocks } from './inspector.mjs' import { el as stub, withDocument } from './dom-stub.mjs' import { blockChange } from './inspector-block.mjs' function deck () { const doc = new Y.Doc() applyOps(doc, [ { op: 'set_meta', title: 'T', theme: 'paper' }, { op: 'add_slide', id: 'slideaaaaaaa', layout: 'flow', template: 'title-body' }, { op: 'add_block', slide: 'slideaaaaaaa', id: 'blockheading', type: 'heading', region: 'heading', props: { level: 1 }, markdown: 'Hello world of slides' }, { op: 'add_block', slide: 'slideaaaaaaa', id: 'blocktexttxt', type: 'text', region: 'body', markdown: 'Body' }, { op: 'add_block', slide: 'slideaaaaaaa', id: 'blockshapeee', type: 'shape', region: 'body', props: { kind: 'rect' } }, { op: 'add_slide', id: 'slidebbbbbbb', layout: 'canvas' }, { op: 'add_block', slide: 'slidebbbbbbb', id: 'canvasblock1', type: 'text', box: { x: 0.1, y: 0.1, w: 0.3, h: 0.2 }, z: 0, markdown: 'One' }, { op: 'add_block', slide: 'slidebbbbbbb', id: 'canvasblock2', type: 'shape', box: { x: 0.5, y: 0.5, w: 0.3, h: 0.2 }, z: 1 }, { op: 'add_block', slide: 'slidebbbbbbb', id: 'canvasblock3', type: 'image', box: { x: 0.2, y: 0.6, w: 0.3, h: 0.2 }, z: 2 }, { op: 'add_slide', id: 'slideccccccc', layout: 'flow', template: 'blank' } ], 'test') return doc } const accepts = (doc, ops) => assert.doesNotThrow(() => applyOps(doc, Array.isArray(ops) ? ops : [ops], 'test')) test('deck controls are set_meta', () => { const doc = deck() const meta = view(doc).meta assert.deepEqual(deckOp('title', 'New', meta), { op: 'set_meta', title: 'New' }) assert.deepEqual(deckOp('theme', 'ink', meta), { op: 'set_meta', theme: 'ink' }) assert.deepEqual(deckOp('accent', '#ff0000', meta), { op: 'set_meta', accent: '#ff0000' }) assert.deepEqual(deckOp('accent-reset', undefined, meta), { op: 'set_meta', accent: null }) assert.deepEqual(deckOp('font-heading', 'Georgia, serif', meta), { op: 'set_meta', fonts: { heading: 'Georgia, serif', body: '' } }) for (const f of ['title', 'theme', 'accent', 'accent-reset']) accepts(doc, deckOp(f, f === 'theme' ? 'ink' : '#123456', meta)) accepts(doc, deckOp('font-heading', 'Georgia, serif', meta)) const after = view(doc).meta assert.deepEqual(deckOp('font-body', 'monospace', after), { op: 'set_meta', fonts: { heading: 'Georgia, serif', body: 'monospace' } }) // Both back to the theme's clears the map rather than storing two empties. assert.deepEqual(deckOp('font-heading', '', { fonts: { heading: 'x', body: '' } }), { op: 'set_meta', fonts: null }) accepts(doc, deckOp('font-heading', '', after)) assert.equal(view(doc).meta.fonts, null) }) test('slide controls are set_slide, and background keeps its colour', () => { const doc = deck() const s = view(doc).slides[0] assert.deepEqual(slideOp(s, 'layout', 'canvas'), { op: 'set_slide', id: s.id, layout: 'canvas' }) assert.deepEqual(slideOp(s, 'template', 'quote'), { op: 'set_slide', id: s.id, template: 'quote' }) assert.deepEqual(slideOp(s, 'transition', 'fade'), { op: 'set_slide', id: s.id, transition: 'fade' }) assert.deepEqual(slideOp(s, 'phone', 'reflow'), { op: 'set_slide', id: s.id, phone: 'reflow' }) assert.deepEqual(slideOp(s, 'background-color', '#112233'), { op: 'set_slide', id: s.id, background: { kind: 'color', value: '#112233' } }) assert.deepEqual(slideOp(s, 'background', 'theme'), { op: 'set_slide', id: s.id, background: { kind: 'theme', value: '' } }) const coloured = { ...s, background: { kind: 'color', value: '#abcdef' } } assert.deepEqual(slideOp(coloured, 'background', 'color').background, { kind: 'color', value: '#abcdef' }) assert.deepEqual(slideOp(s, 'background', 'color').background, { kind: 'color', value: '#ffffff' }) for (const [f, v] of [['template', 'quote'], ['transition', 'fade'], ['phone', 'reflow'], ['background-color', '#112233'], ['background', 'theme'], ['layout', 'canvas']]) accepts(doc, slideOp(s, f, v)) assert.equal(view(doc).slides[0].layout, 'canvas') assert.throws(() => slideOp(s, 'nope', 1)) }) test('move up and down are move_slide with the right after', () => { const ids = ['a', 'b', 'c'] assert.equal(moveSlideOp(ids, 'a', 'up'), null) assert.deepEqual(moveSlideOp(ids, 'b', 'up'), { op: 'move_slide', id: 'b' }) assert.deepEqual(moveSlideOp(ids, 'c', 'up'), { op: 'move_slide', id: 'c', after: 'a' }) assert.deepEqual(moveSlideOp(ids, 'a', 'down'), { op: 'move_slide', id: 'a', after: 'b' }) assert.equal(moveSlideOp(ids, 'c', 'down'), null) const doc = deck() accepts(doc, moveSlideOp(view(doc).slides.map(s => s.id), 'slideccccccc', 'up')) assert.deepEqual(view(doc).slides.map(s => s.id), ['slideaaaaaaa', 'slideccccccc', 'slidebbbbbbb']) }) test('block props: value kinds, region, a shape label', () => { const doc = deck() const s = view(doc).slides[0] const [heading, text, shape] = s.blocks assert.equal(propValue('2', 'int'), 2) assert.equal(propValue('', 'int'), null) assert.equal(propValue('x', 'int'), null) assert.equal(propValue(true, 'bool'), true) assert.equal(propValue('on', 'bool'), true) assert.equal(propValue('', ''), null) assert.deepEqual(propOp(s.id, heading, 'level', 2), { op: 'set_block', slide: s.id, id: heading.id, props: { level: 2 } }) assert.deepEqual(regionOp(s.id, text, 'aside'), { op: 'set_block', slide: s.id, id: text.id, region: 'aside' }) assert.deepEqual(labelOp(s.id, shape, 'Go'), { op: 'set_text', slide: s.id, id: shape.id, markdown: 'Go' }) accepts(doc, [propOp(s.id, heading, 'level', 2), regionOp(s.id, text, 'aside'), labelOp(s.id, shape, 'Go'), propOp(s.id, text, 'align', null)]) const after = view(doc).slides[0] assert.equal(after.blocks[0].props.level, 2) assert.equal(after.blocks[1].region, 'aside') assert.deepEqual(after.blocks[2].text, [[{ t: 'Go' }]]) }) test('box fields are set_block on the desktop and set_phone in the phone view', () => { const doc = deck() const s = view(doc).slides[1] const b = s.blocks[0] assert.deepEqual(boxFieldOp({ view: 'desktop', slideId: s.id, block: b, key: 'x', percent: '25' }), { op: 'set_block', slide: s.id, id: b.id, box: { x: 0.25, y: 0.1, w: 0.3, h: 0.2 } }) assert.deepEqual(boxFieldOp({ view: 'phone', slideId: s.id, block: b, key: 'w', percent: '50' }), { op: 'set_phone', slide: s.id, id: b.id, phone: { box: { x: 0.1, y: 0.1, w: 0.5, h: 0.2 } } }) assert.equal(boxFieldOp({ view: 'desktop', slideId: s.id, block: b, key: 'x', percent: 'abc' }), null) accepts(doc, [boxFieldOp({ view: 'desktop', slideId: s.id, block: b, key: 'x', percent: '25' }), boxFieldOp({ view: 'phone', slideId: s.id, block: b, key: 'w', percent: '50' })]) const after = view(doc).slides[1].blocks[0] assert.equal(after.box.x, 0.25) assert.deepEqual(after.phone.box, { x: 0.1, y: 0.1, w: 0.5, h: 0.2 }) // Typed values are clamped to the frame. assert.deepEqual(boxFieldOp({ view: 'desktop', slideId: s.id, block: b, key: 'x', percent: '95' }).box, { x: 0.7, y: 0.1, w: 0.3, h: 0.2 }) }) test('move block and the layer buttons', () => { const doc = deck() const s = view(doc).slides[1] const ids = s.blocks.map(b => b.id) assert.equal(moveBlockOp(s.id, ids, ids[0], 'up'), null) assert.deepEqual(moveBlockOp(s.id, ids, ids[1], 'up'), { op: 'move_block', slide: s.id, id: ids[1] }) assert.deepEqual(moveBlockOp(s.id, ids, ids[2], 'up'), { op: 'move_block', slide: s.id, id: ids[2], after: ids[0] }) assert.deepEqual(moveBlockOp(s.id, ids, ids[0], 'down'), { op: 'move_block', slide: s.id, id: ids[0], after: ids[1] }) // z: 0,1,2 — bringing the first forward swaps it with the second. assert.deepEqual(zOps(s.id, s.blocks, ids[0], 'forward'), [ { op: 'set_block', slide: s.id, id: ids[1], z: 0 }, { op: 'set_block', slide: s.id, id: ids[0], z: 1 } ]) assert.deepEqual(zOps(s.id, s.blocks, ids[2], 'forward'), []) assert.deepEqual(zOps(s.id, s.blocks, ids[0], 'back'), []) // Duplicate zs are renumbered on the way through. const dup = s.blocks.map(b => ({ ...b, z: 0 })) assert.deepEqual(zOps(s.id, dup, ids[2], 'back').map(o => [o.id, o.z]).sort(), [[ids[1], 2], [ids[2], 1]].sort()) accepts(doc, zOps(s.id, s.blocks, ids[0], 'forward')) assert.deepEqual(view(doc).slides[1].blocks.map(b => b.z), [1, 0, 2]) accepts(doc, moveBlockOp(s.id, ids, ids[2], 'up')) assert.deepEqual(view(doc).slides[1].blocks.map(b => b.id), [ids[0], ids[2], ids[1]]) }) test('add block: region after the selection on flow, a default box on canvas, a heading level that fits', () => { const doc = deck() const [flow, canvas, blank] = view(doc).slides const a = addBlockOp(flow, 'text', 'newblock0001', flow.blocks[0].id) assert.deepEqual(a, { op: 'add_block', slide: flow.id, id: 'newblock0001', type: 'text', props: {}, after: flow.blocks[0].id, markdown: '', region: 'body' }) assert.equal(addBlockOp(flow, 'heading', 'newblock0002', '').props.level, 2) assert.equal(addBlockOp(blank, 'heading', 'newblock0003', '').props.level, 1) assert.equal(addBlockOp(flow, 'image', 'newblock0004', 'nosuchblock1').region, 'media') assert.equal('after' in addBlockOp(flow, 'image', 'newblock0004', 'nosuchblock1'), false) const c = addBlockOp(canvas, 'shape', 'newblock0005', '') assert.deepEqual(c.box, DEFAULT_BOX) assert.equal(c.z, 3) assert.equal('markdown' in addBlockOp(canvas, 'divider', 'newblock0006', ''), false) assert.deepEqual(addBlockOp(flow, 'table', 'newblock0007', '').props, { rows: 2, cols: 2 }) for (const kind of ['heading', 'text', 'list', 'quote', 'image', 'columns', 'code', 'shape', 'table', 'divider', 'spacer']) { accepts(doc, addBlockOp(view(doc).slides[0], kind, 'added' + kind.padEnd(7, 'x'), '')) accepts(doc, addBlockOp(view(doc).slides[1], kind, 'canvs' + kind.padEnd(7, 'x'), '')) } assert.equal(view(doc).slides[0].blocks.length, 3 + 11) }) test('a block is named by its type and its first words', () => { const doc = deck() const s = view(doc).slides[0] assert.equal(blockLabel(s.blocks[0], 'Heading'), 'Heading: Hello world of slides') assert.equal(blockLabel(s.blocks[2], 'Shape'), 'Shape') assert.equal(blockLabel({ text: [[{ t: 'a'.repeat(60) }]] }, 'Text', 10), 'Text: ' + 'a'.repeat(9) + '…') }) test('blockChange: a control element to its op', () => { const doc = deck() const flow = view(doc).slides[0] const canvas = view(doc).slides[1] const el = (dataset, value, extra = {}) => ({ dataset, value, type: 'text', ...extra }) assert.deepEqual(blockChange({ el: el({ prop: 'level', kind: 'int' }, '3'), slide: flow, block: flow.blocks[0], view: 'desktop' }), [{ op: 'set_block', slide: flow.id, id: flow.blocks[0].id, props: { level: 3 } }]) assert.deepEqual(blockChange({ el: el({ prop: 'header', kind: 'bool' }, 'on', { type: 'checkbox', checked: true }), slide: flow, block: flow.blocks[1], view: 'desktop' }), [{ op: 'set_block', slide: flow.id, id: flow.blocks[1].id, props: { header: true } }]) assert.deepEqual(blockChange({ el: el({ prop: 'size' }, 'l'), slide: flow, block: flow.blocks[1], view: 'desktop' }), [{ op: 'set_block', slide: flow.id, id: flow.blocks[1].id, props: { size: 'l' } }]) assert.deepEqual(blockChange({ el: el({ prop: 'size' }, 'l'), slide: flow, block: flow.blocks[1], view: 'phone' }), [{ op: 'set_phone', slide: flow.id, id: flow.blocks[1].id, phone: { size: 'l' } }]) assert.deepEqual(blockChange({ el: el({ text: 'label' }, ' Go '), slide: flow, block: flow.blocks[2], view: 'desktop' }), [{ op: 'set_text', slide: flow.id, id: flow.blocks[2].id, markdown: 'Go' }]) assert.deepEqual(blockChange({ el: el({ place: 'region' }, 'aside'), slide: flow, block: flow.blocks[1], view: 'desktop' }), [{ op: 'set_block', slide: flow.id, id: flow.blocks[1].id, region: 'aside' }]) assert.deepEqual(blockChange({ el: el({ box: 'y' }, '40'), slide: canvas, block: canvas.blocks[0], view: 'desktop' }), [{ op: 'set_block', slide: canvas.id, id: canvas.blocks[0].id, box: { x: 0.1, y: 0.4, w: 0.3, h: 0.2 } }]) assert.deepEqual(blockChange({ el: el({ phone: 'hidden' }, 'on', { type: 'checkbox', checked: true }), slide: flow, block: flow.blocks[1], view: 'desktop' }), [{ op: 'set_phone', slide: flow.id, id: flow.blocks[1].id, phone: { hidden: true } }]) assert.deepEqual(blockChange({ el: el({ phone: 'order' }, '-1'), slide: flow, block: flow.blocks[1], view: 'desktop' }), [{ op: 'set_phone', slide: flow.id, id: flow.blocks[1].id, phone: { order: -1 } }]) assert.deepEqual(blockChange({ el: el({ phone: 'size' }, ''), slide: flow, block: { ...flow.blocks[1], phone: { size: 's', hidden: true } }, view: 'desktop' }), [{ op: 'set_phone', slide: flow.id, id: flow.blocks[1].id, phone: { hidden: true } }]) assert.deepEqual(blockChange({ el: el({}, 'x'), slide: flow, block: flow.blocks[1], view: 'desktop' }), []) }) // The Blocks list is refilled on EVERY document transaction — a // colleague's keystroke included — and it is the keyboard's way into a // block on a canvas slide. So it is reconciled rather than rebuilt: an // unchanged list has to be the SAME button elements afterwards, or the // focus went out of the document with the old ones. test('the Blocks list reconciles by id and keeps its buttons', () => { const ctx = withDocument() try { const list = stub('div', { 'slides-blocks-buttons': '' }) const none = stub('p', { 'slides-blocks-none': '' }) const blocks = [{ id: 'b1', type: 'heading' }, { id: 'b2', type: 'text' }] const label = b => b.type const ids = () => list.children.map(b => b.dataset.selectBlock) fillBlocks({ list, none, blocks, selected: 'b1', label }) assert.deepEqual(ids(), ['b1', 'b2']) assert.equal(none.hidden, true) const [first, second] = list.children assert.equal(first.getAttribute('aria-pressed'), 'true') fillBlocks({ list, none, blocks, selected: 'b1', label }) assert.equal(list.children[0], first, 'the same element, not a replacement') assert.equal(list.children[1], second) // A selection change writes the attribute and moves nothing. fillBlocks({ list, none, blocks, selected: 'b2', label }) assert.equal(list.children[0], first) assert.equal(first.getAttribute('aria-pressed'), 'false') assert.equal(second.getAttribute('aria-pressed'), 'true') // Reordering moves the elements that are already there. fillBlocks({ list, none, blocks: [blocks[1], blocks[0]], selected: '', label }) assert.deepEqual(ids(), ['b2', 'b1']) assert.equal(list.children[0], second) assert.equal(list.children[1], first) // A removed block takes its button with it; an empty slide says so. fillBlocks({ list, none, blocks: [blocks[0]], selected: '', label }) assert.deepEqual(ids(), ['b1']) assert.equal(list.children[0], first) fillBlocks({ list, none, blocks: [], selected: '', label }) assert.deepEqual(ids(), []) assert.equal(none.hidden, false) } finally { ctx.restore() } })