| 1 | // Each inspector control produces the one op it stands for |
| 2 | // (inspector-ops.mjs), and every op it produces is one the ops module |
| 3 | // accepts: each is applied to a real deck built through ops.mjs, so a |
| 4 | // field that named a prop the core refuses would fail here. |
| 5 | import test from 'node:test' |
| 6 | import assert from 'node:assert/strict' |
| 7 | import * as Y from 'yjs' |
| 8 | import { applyOps, view } from './ops.mjs' |
| 9 | import { |
| 10 | deckOp, slideOp, moveSlideOp, propValue, propOp, regionOp, labelOp, boxFieldOp, |
| 11 | moveBlockOp, zOps, addBlockOp, blockLabel, DEFAULT_BOX |
| 12 | } from './inspector-ops.mjs' |
| 13 | import { fillBlocks } from './inspector.mjs' |
| 14 | import { el as stub, withDocument } from './dom-stub.mjs' |
| 15 | import { blockChange } from './inspector-block.mjs' |
| 16 | |
| 17 | function deck () { |
| 18 | const doc = new Y.Doc() |
| 19 | applyOps(doc, [ |
| 20 | { op: 'set_meta', title: 'T', theme: 'paper' }, |
| 21 | { op: 'add_slide', id: 'slideaaaaaaa', layout: 'flow', template: 'title-body' }, |
| 22 | { op: 'add_block', slide: 'slideaaaaaaa', id: 'blockheading', type: 'heading', region: 'heading', props: { level: 1 }, markdown: 'Hello world of slides' }, |
| 23 | { op: 'add_block', slide: 'slideaaaaaaa', id: 'blocktexttxt', type: 'text', region: 'body', markdown: 'Body' }, |
| 24 | { op: 'add_block', slide: 'slideaaaaaaa', id: 'blockshapeee', type: 'shape', region: 'body', props: { kind: 'rect' } }, |
| 25 | { op: 'add_slide', id: 'slidebbbbbbb', layout: 'canvas' }, |
| 26 | { 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' }, |
| 27 | { op: 'add_block', slide: 'slidebbbbbbb', id: 'canvasblock2', type: 'shape', box: { x: 0.5, y: 0.5, w: 0.3, h: 0.2 }, z: 1 }, |
| 28 | { op: 'add_block', slide: 'slidebbbbbbb', id: 'canvasblock3', type: 'image', box: { x: 0.2, y: 0.6, w: 0.3, h: 0.2 }, z: 2 }, |
| 29 | { op: 'add_slide', id: 'slideccccccc', layout: 'flow', template: 'blank' } |
| 30 | ], 'test') |
| 31 | return doc |
| 32 | } |
| 33 | const accepts = (doc, ops) => assert.doesNotThrow(() => applyOps(doc, Array.isArray(ops) ? ops : [ops], 'test')) |
| 34 | |
| 35 | test('deck controls are set_meta', () => { |
| 36 | const doc = deck() |
| 37 | const meta = view(doc).meta |
| 38 | assert.deepEqual(deckOp('title', 'New', meta), { op: 'set_meta', title: 'New' }) |
| 39 | assert.deepEqual(deckOp('theme', 'ink', meta), { op: 'set_meta', theme: 'ink' }) |
| 40 | assert.deepEqual(deckOp('accent', '#ff0000', meta), { op: 'set_meta', accent: '#ff0000' }) |
| 41 | assert.deepEqual(deckOp('accent-reset', undefined, meta), { op: 'set_meta', accent: null }) |
| 42 | assert.deepEqual(deckOp('font-heading', 'Georgia, serif', meta), { op: 'set_meta', fonts: { heading: 'Georgia, serif', body: '' } }) |
| 43 | for (const f of ['title', 'theme', 'accent', 'accent-reset']) accepts(doc, deckOp(f, f === 'theme' ? 'ink' : '#123456', meta)) |
| 44 | accepts(doc, deckOp('font-heading', 'Georgia, serif', meta)) |
| 45 | const after = view(doc).meta |
| 46 | assert.deepEqual(deckOp('font-body', 'monospace', after), { op: 'set_meta', fonts: { heading: 'Georgia, serif', body: 'monospace' } }) |
| 47 | // Both back to the theme's clears the map rather than storing two empties. |
| 48 | assert.deepEqual(deckOp('font-heading', '', { fonts: { heading: 'x', body: '' } }), { op: 'set_meta', fonts: null }) |
| 49 | accepts(doc, deckOp('font-heading', '', after)) |
| 50 | assert.equal(view(doc).meta.fonts, null) |
| 51 | }) |
| 52 | |
| 53 | test('slide controls are set_slide, and background keeps its colour', () => { |
| 54 | const doc = deck() |
| 55 | const s = view(doc).slides[0] |
| 56 | assert.deepEqual(slideOp(s, 'layout', 'canvas'), { op: 'set_slide', id: s.id, layout: 'canvas' }) |
| 57 | assert.deepEqual(slideOp(s, 'template', 'quote'), { op: 'set_slide', id: s.id, template: 'quote' }) |
| 58 | assert.deepEqual(slideOp(s, 'transition', 'fade'), { op: 'set_slide', id: s.id, transition: 'fade' }) |
| 59 | assert.deepEqual(slideOp(s, 'phone', 'reflow'), { op: 'set_slide', id: s.id, phone: 'reflow' }) |
| 60 | assert.deepEqual(slideOp(s, 'background-color', '#112233'), { op: 'set_slide', id: s.id, background: { kind: 'color', value: '#112233' } }) |
| 61 | assert.deepEqual(slideOp(s, 'background', 'theme'), { op: 'set_slide', id: s.id, background: { kind: 'theme', value: '' } }) |
| 62 | const coloured = { ...s, background: { kind: 'color', value: '#abcdef' } } |
| 63 | assert.deepEqual(slideOp(coloured, 'background', 'color').background, { kind: 'color', value: '#abcdef' }) |
| 64 | assert.deepEqual(slideOp(s, 'background', 'color').background, { kind: 'color', value: '#ffffff' }) |
| 65 | for (const [f, v] of [['template', 'quote'], ['transition', 'fade'], ['phone', 'reflow'], ['background-color', '#112233'], ['background', 'theme'], ['layout', 'canvas']]) accepts(doc, slideOp(s, f, v)) |
| 66 | assert.equal(view(doc).slides[0].layout, 'canvas') |
| 67 | assert.throws(() => slideOp(s, 'nope', 1)) |
| 68 | }) |
| 69 | |
| 70 | test('move up and down are move_slide with the right after', () => { |
| 71 | const ids = ['a', 'b', 'c'] |
| 72 | assert.equal(moveSlideOp(ids, 'a', 'up'), null) |
| 73 | assert.deepEqual(moveSlideOp(ids, 'b', 'up'), { op: 'move_slide', id: 'b' }) |
| 74 | assert.deepEqual(moveSlideOp(ids, 'c', 'up'), { op: 'move_slide', id: 'c', after: 'a' }) |
| 75 | assert.deepEqual(moveSlideOp(ids, 'a', 'down'), { op: 'move_slide', id: 'a', after: 'b' }) |
| 76 | assert.equal(moveSlideOp(ids, 'c', 'down'), null) |
| 77 | const doc = deck() |
| 78 | accepts(doc, moveSlideOp(view(doc).slides.map(s => s.id), 'slideccccccc', 'up')) |
| 79 | assert.deepEqual(view(doc).slides.map(s => s.id), ['slideaaaaaaa', 'slideccccccc', 'slidebbbbbbb']) |
| 80 | }) |
| 81 | |
| 82 | test('block props: value kinds, region, a shape label', () => { |
| 83 | const doc = deck() |
| 84 | const s = view(doc).slides[0] |
| 85 | const [heading, text, shape] = s.blocks |
| 86 | assert.equal(propValue('2', 'int'), 2) |
| 87 | assert.equal(propValue('', 'int'), null) |
| 88 | assert.equal(propValue('x', 'int'), null) |
| 89 | assert.equal(propValue(true, 'bool'), true) |
| 90 | assert.equal(propValue('on', 'bool'), true) |
| 91 | assert.equal(propValue('', ''), null) |
| 92 | assert.deepEqual(propOp(s.id, heading, 'level', 2), { op: 'set_block', slide: s.id, id: heading.id, props: { level: 2 } }) |
| 93 | assert.deepEqual(regionOp(s.id, text, 'aside'), { op: 'set_block', slide: s.id, id: text.id, region: 'aside' }) |
| 94 | assert.deepEqual(labelOp(s.id, shape, 'Go'), { op: 'set_text', slide: s.id, id: shape.id, markdown: 'Go' }) |
| 95 | accepts(doc, [propOp(s.id, heading, 'level', 2), regionOp(s.id, text, 'aside'), labelOp(s.id, shape, 'Go'), propOp(s.id, text, 'align', null)]) |
| 96 | const after = view(doc).slides[0] |
| 97 | assert.equal(after.blocks[0].props.level, 2) |
| 98 | assert.equal(after.blocks[1].region, 'aside') |
| 99 | assert.deepEqual(after.blocks[2].text, [[{ t: 'Go' }]]) |
| 100 | }) |
| 101 | |
| 102 | test('box fields are set_block on the desktop and set_phone in the phone view', () => { |
| 103 | const doc = deck() |
| 104 | const s = view(doc).slides[1] |
| 105 | const b = s.blocks[0] |
| 106 | assert.deepEqual(boxFieldOp({ view: 'desktop', slideId: s.id, block: b, key: 'x', percent: '25' }), |
| 107 | { op: 'set_block', slide: s.id, id: b.id, box: { x: 0.25, y: 0.1, w: 0.3, h: 0.2 } }) |
| 108 | assert.deepEqual(boxFieldOp({ view: 'phone', slideId: s.id, block: b, key: 'w', percent: '50' }), |
| 109 | { op: 'set_phone', slide: s.id, id: b.id, phone: { box: { x: 0.1, y: 0.1, w: 0.5, h: 0.2 } } }) |
| 110 | assert.equal(boxFieldOp({ view: 'desktop', slideId: s.id, block: b, key: 'x', percent: 'abc' }), null) |
| 111 | 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' })]) |
| 112 | const after = view(doc).slides[1].blocks[0] |
| 113 | assert.equal(after.box.x, 0.25) |
| 114 | assert.deepEqual(after.phone.box, { x: 0.1, y: 0.1, w: 0.5, h: 0.2 }) |
| 115 | // Typed values are clamped to the frame. |
| 116 | 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 }) |
| 117 | }) |
| 118 | |
| 119 | test('move block and the layer buttons', () => { |
| 120 | const doc = deck() |
| 121 | const s = view(doc).slides[1] |
| 122 | const ids = s.blocks.map(b => b.id) |
| 123 | assert.equal(moveBlockOp(s.id, ids, ids[0], 'up'), null) |
| 124 | assert.deepEqual(moveBlockOp(s.id, ids, ids[1], 'up'), { op: 'move_block', slide: s.id, id: ids[1] }) |
| 125 | assert.deepEqual(moveBlockOp(s.id, ids, ids[2], 'up'), { op: 'move_block', slide: s.id, id: ids[2], after: ids[0] }) |
| 126 | assert.deepEqual(moveBlockOp(s.id, ids, ids[0], 'down'), { op: 'move_block', slide: s.id, id: ids[0], after: ids[1] }) |
| 127 | // z: 0,1,2 — bringing the first forward swaps it with the second. |
| 128 | assert.deepEqual(zOps(s.id, s.blocks, ids[0], 'forward'), [ |
| 129 | { op: 'set_block', slide: s.id, id: ids[1], z: 0 }, |
| 130 | { op: 'set_block', slide: s.id, id: ids[0], z: 1 } |
| 131 | ]) |
| 132 | assert.deepEqual(zOps(s.id, s.blocks, ids[2], 'forward'), []) |
| 133 | assert.deepEqual(zOps(s.id, s.blocks, ids[0], 'back'), []) |
| 134 | // Duplicate zs are renumbered on the way through. |
| 135 | const dup = s.blocks.map(b => ({ ...b, z: 0 })) |
| 136 | assert.deepEqual(zOps(s.id, dup, ids[2], 'back').map(o => [o.id, o.z]).sort(), [[ids[1], 2], [ids[2], 1]].sort()) |
| 137 | accepts(doc, zOps(s.id, s.blocks, ids[0], 'forward')) |
| 138 | assert.deepEqual(view(doc).slides[1].blocks.map(b => b.z), [1, 0, 2]) |
| 139 | accepts(doc, moveBlockOp(s.id, ids, ids[2], 'up')) |
| 140 | assert.deepEqual(view(doc).slides[1].blocks.map(b => b.id), [ids[0], ids[2], ids[1]]) |
| 141 | }) |
| 142 | |
| 143 | test('add block: region after the selection on flow, a default box on canvas, a heading level that fits', () => { |
| 144 | const doc = deck() |
| 145 | const [flow, canvas, blank] = view(doc).slides |
| 146 | const a = addBlockOp(flow, 'text', 'newblock0001', flow.blocks[0].id) |
| 147 | assert.deepEqual(a, { op: 'add_block', slide: flow.id, id: 'newblock0001', type: 'text', props: {}, after: flow.blocks[0].id, markdown: '', region: 'body' }) |
| 148 | assert.equal(addBlockOp(flow, 'heading', 'newblock0002', '').props.level, 2) |
| 149 | assert.equal(addBlockOp(blank, 'heading', 'newblock0003', '').props.level, 1) |
| 150 | assert.equal(addBlockOp(flow, 'image', 'newblock0004', 'nosuchblock1').region, 'media') |
| 151 | assert.equal('after' in addBlockOp(flow, 'image', 'newblock0004', 'nosuchblock1'), false) |
| 152 | const c = addBlockOp(canvas, 'shape', 'newblock0005', '') |
| 153 | assert.deepEqual(c.box, DEFAULT_BOX) |
| 154 | assert.equal(c.z, 3) |
| 155 | assert.equal('markdown' in addBlockOp(canvas, 'divider', 'newblock0006', ''), false) |
| 156 | assert.deepEqual(addBlockOp(flow, 'table', 'newblock0007', '').props, { rows: 2, cols: 2 }) |
| 157 | for (const kind of ['heading', 'text', 'list', 'quote', 'image', 'columns', 'code', 'shape', 'table', 'divider', 'spacer']) { |
| 158 | accepts(doc, addBlockOp(view(doc).slides[0], kind, 'added' + kind.padEnd(7, 'x'), '')) |
| 159 | accepts(doc, addBlockOp(view(doc).slides[1], kind, 'canvs' + kind.padEnd(7, 'x'), '')) |
| 160 | } |
| 161 | assert.equal(view(doc).slides[0].blocks.length, 3 + 11) |
| 162 | }) |
| 163 | |
| 164 | test('a block is named by its type and its first words', () => { |
| 165 | const doc = deck() |
| 166 | const s = view(doc).slides[0] |
| 167 | assert.equal(blockLabel(s.blocks[0], 'Heading'), 'Heading: Hello world of slides') |
| 168 | assert.equal(blockLabel(s.blocks[2], 'Shape'), 'Shape') |
| 169 | assert.equal(blockLabel({ text: [[{ t: 'a'.repeat(60) }]] }, 'Text', 10), 'Text: ' + 'a'.repeat(9) + '…') |
| 170 | }) |
| 171 | |
| 172 | test('blockChange: a control element to its op', () => { |
| 173 | const doc = deck() |
| 174 | const flow = view(doc).slides[0] |
| 175 | const canvas = view(doc).slides[1] |
| 176 | const el = (dataset, value, extra = {}) => ({ dataset, value, type: 'text', ...extra }) |
| 177 | assert.deepEqual(blockChange({ el: el({ prop: 'level', kind: 'int' }, '3'), slide: flow, block: flow.blocks[0], view: 'desktop' }), |
| 178 | [{ op: 'set_block', slide: flow.id, id: flow.blocks[0].id, props: { level: 3 } }]) |
| 179 | assert.deepEqual(blockChange({ el: el({ prop: 'header', kind: 'bool' }, 'on', { type: 'checkbox', checked: true }), slide: flow, block: flow.blocks[1], view: 'desktop' }), |
| 180 | [{ op: 'set_block', slide: flow.id, id: flow.blocks[1].id, props: { header: true } }]) |
| 181 | assert.deepEqual(blockChange({ el: el({ prop: 'size' }, 'l'), slide: flow, block: flow.blocks[1], view: 'desktop' }), |
| 182 | [{ op: 'set_block', slide: flow.id, id: flow.blocks[1].id, props: { size: 'l' } }]) |
| 183 | assert.deepEqual(blockChange({ el: el({ prop: 'size' }, 'l'), slide: flow, block: flow.blocks[1], view: 'phone' }), |
| 184 | [{ op: 'set_phone', slide: flow.id, id: flow.blocks[1].id, phone: { size: 'l' } }]) |
| 185 | assert.deepEqual(blockChange({ el: el({ text: 'label' }, ' Go '), slide: flow, block: flow.blocks[2], view: 'desktop' }), |
| 186 | [{ op: 'set_text', slide: flow.id, id: flow.blocks[2].id, markdown: 'Go' }]) |
| 187 | assert.deepEqual(blockChange({ el: el({ place: 'region' }, 'aside'), slide: flow, block: flow.blocks[1], view: 'desktop' }), |
| 188 | [{ op: 'set_block', slide: flow.id, id: flow.blocks[1].id, region: 'aside' }]) |
| 189 | assert.deepEqual(blockChange({ el: el({ box: 'y' }, '40'), slide: canvas, block: canvas.blocks[0], view: 'desktop' }), |
| 190 | [{ op: 'set_block', slide: canvas.id, id: canvas.blocks[0].id, box: { x: 0.1, y: 0.4, w: 0.3, h: 0.2 } }]) |
| 191 | assert.deepEqual(blockChange({ el: el({ phone: 'hidden' }, 'on', { type: 'checkbox', checked: true }), slide: flow, block: flow.blocks[1], view: 'desktop' }), |
| 192 | [{ op: 'set_phone', slide: flow.id, id: flow.blocks[1].id, phone: { hidden: true } }]) |
| 193 | assert.deepEqual(blockChange({ el: el({ phone: 'order' }, '-1'), slide: flow, block: flow.blocks[1], view: 'desktop' }), |
| 194 | [{ op: 'set_phone', slide: flow.id, id: flow.blocks[1].id, phone: { order: -1 } }]) |
| 195 | assert.deepEqual(blockChange({ el: el({ phone: 'size' }, ''), slide: flow, block: { ...flow.blocks[1], phone: { size: 's', hidden: true } }, view: 'desktop' }), |
| 196 | [{ op: 'set_phone', slide: flow.id, id: flow.blocks[1].id, phone: { hidden: true } }]) |
| 197 | assert.deepEqual(blockChange({ el: el({}, 'x'), slide: flow, block: flow.blocks[1], view: 'desktop' }), []) |
| 198 | }) |
| 199 | |
| 200 | // The Blocks list is refilled on EVERY document transaction — a |
| 201 | // colleague's keystroke included — and it is the keyboard's way into a |
| 202 | // block on a canvas slide. So it is reconciled rather than rebuilt: an |
| 203 | // unchanged list has to be the SAME button elements afterwards, or the |
| 204 | // focus went out of the document with the old ones. |
| 205 | test('the Blocks list reconciles by id and keeps its buttons', () => { |
| 206 | const ctx = withDocument() |
| 207 | try { |
| 208 | const list = stub('div', { 'slides-blocks-buttons': '' }) |
| 209 | const none = stub('p', { 'slides-blocks-none': '' }) |
| 210 | const blocks = [{ id: 'b1', type: 'heading' }, { id: 'b2', type: 'text' }] |
| 211 | const label = b => b.type |
| 212 | const ids = () => list.children.map(b => b.dataset.selectBlock) |
| 213 | |
| 214 | fillBlocks({ list, none, blocks, selected: 'b1', label }) |
| 215 | assert.deepEqual(ids(), ['b1', 'b2']) |
| 216 | assert.equal(none.hidden, true) |
| 217 | const [first, second] = list.children |
| 218 | assert.equal(first.getAttribute('aria-pressed'), 'true') |
| 219 | |
| 220 | fillBlocks({ list, none, blocks, selected: 'b1', label }) |
| 221 | assert.equal(list.children[0], first, 'the same element, not a replacement') |
| 222 | assert.equal(list.children[1], second) |
| 223 | |
| 224 | // A selection change writes the attribute and moves nothing. |
| 225 | fillBlocks({ list, none, blocks, selected: 'b2', label }) |
| 226 | assert.equal(list.children[0], first) |
| 227 | assert.equal(first.getAttribute('aria-pressed'), 'false') |
| 228 | assert.equal(second.getAttribute('aria-pressed'), 'true') |
| 229 | |
| 230 | // Reordering moves the elements that are already there. |
| 231 | fillBlocks({ list, none, blocks: [blocks[1], blocks[0]], selected: '', label }) |
| 232 | assert.deepEqual(ids(), ['b2', 'b1']) |
| 233 | assert.equal(list.children[0], second) |
| 234 | assert.equal(list.children[1], first) |
| 235 | |
| 236 | // A removed block takes its button with it; an empty slide says so. |
| 237 | fillBlocks({ list, none, blocks: [blocks[0]], selected: '', label }) |
| 238 | assert.deepEqual(ids(), ['b1']) |
| 239 | assert.equal(list.children[0], first) |
| 240 | fillBlocks({ list, none, blocks: [], selected: '', label }) |
| 241 | assert.deepEqual(ids(), []) |
| 242 | assert.equal(none.hidden, false) |
| 243 | } finally { |
| 244 | ctx.restore() |
| 245 | } |
| 246 | }) |
| 247 | |