| 1 | // fixtures/align.test.mjs — the editor half of paragraph alignment. |
| 2 | // |
| 3 | // Runs under Node's own test runner, no framework and no DOM: the |
| 4 | // schema, the command and the active-state reader in align.mjs all work |
| 5 | // on ProseMirror documents and state, which are plain data. The one |
| 6 | // piece that genuinely needs a DOM (parseDOM matching a real <p>) is |
| 7 | // reached through its own getAttrs with a stand-in element carrying |
| 8 | // nothing but a classList, which is all that rule reads. |
| 9 | // |
| 10 | // What is being defended here is agreement. The class names and the |
| 11 | // attribute values in align.mjs are the same vocabulary as |
| 12 | // core/src/fragment.rs's ALIGNMENTS and internal/export/pdf.go's |
| 13 | // alignOf, and those are separate binaries that cannot import this one. |
| 14 | // So this file pins the JS end of it — and pins hardest on the two |
| 15 | // cases that fail silently in a browser: an alignment nobody chose |
| 16 | // being written into the document anyway, and a command that acts when |
| 17 | // it was only being asked a question. |
| 18 | import { test } from 'node:test' |
| 19 | import assert from 'node:assert/strict' |
| 20 | import { Schema } from 'prosemirror-model' |
| 21 | import { EditorState, TextSelection } from 'prosemirror-state' |
| 22 | import { schema as basic } from 'prosemirror-schema-basic' |
| 23 | import { |
| 24 | ALIGNMENTS, |
| 25 | ALIGN_CLASSES, |
| 26 | alignClass, |
| 27 | alignFromClasses, |
| 28 | alignedParagraph, |
| 29 | setAlign, |
| 30 | currentAlign, |
| 31 | } from './align.mjs' |
| 32 | // Imported for its side effect as much as for the table: toolbar.mjs |
| 33 | // runs alignmentsAreCovered() at module load, and this import is what |
| 34 | // makes Node execute it. Before this line that guard ran only when a |
| 35 | // browser mounted the editor, which meant it was dead code in the gate |
| 36 | // — a command table that had drifted from ALIGNMENTS would have passed |
| 37 | // CI and thrown at startup for every user instead. |
| 38 | import { ALIGN_COMMANDS } from './toolbar.mjs' |
| 39 | |
| 40 | // The same schema editor-entry.js builds, minus the marks and the list |
| 41 | // nodes this file has no use for. |
| 42 | const schema = new Schema({ |
| 43 | nodes: basic.spec.nodes.update('paragraph', alignedParagraph(basic.spec.nodes.get('paragraph'))), |
| 44 | marks: basic.spec.marks, |
| 45 | }) |
| 46 | |
| 47 | function docWith(...paragraphs) { |
| 48 | return schema.node('doc', null, paragraphs) |
| 49 | } |
| 50 | |
| 51 | function para(align, text) { |
| 52 | return schema.node('paragraph', { align }, text ? [schema.text(text)] : []) |
| 53 | } |
| 54 | |
| 55 | // stateOn puts the cursor inside the first paragraph, or selects |
| 56 | // everything when `all` is set. |
| 57 | function stateOn(doc, all = false) { |
| 58 | let state = EditorState.create({ doc, schema }) |
| 59 | const selection = all |
| 60 | ? TextSelection.create(doc, 1, doc.content.size - 1) |
| 61 | : TextSelection.create(doc, 1) |
| 62 | return state.apply(state.tr.setSelection(selection)) |
| 63 | } |
| 64 | |
| 65 | test('the class names are the ones the server renders', () => { |
| 66 | // Spelled out rather than derived, because this IS the contract: |
| 67 | // core/src/fragment.rs's ALIGNMENTS table and views/show.html's |
| 68 | // stylesheet carry these same two strings, and internal/docs's |
| 69 | // TestEveryClassTheRendererEmitsIsStyled holds those two to each |
| 70 | // other. If this assertion is edited, all three have to move. |
| 71 | assert.deepEqual(ALIGN_CLASSES, { |
| 72 | center: 'docs-align-center', |
| 73 | right: 'docs-align-right', |
| 74 | }) |
| 75 | assert.deepEqual(ALIGNMENTS, ['left', 'center', 'right']) |
| 76 | }) |
| 77 | |
| 78 | test('left, absent and unknown alignments all render as no class', () => { |
| 79 | assert.equal(alignClass('center'), 'docs-align-center') |
| 80 | assert.equal(alignClass('right'), 'docs-align-right') |
| 81 | assert.equal(alignClass('left'), null) |
| 82 | assert.equal(alignClass(null), null) |
| 83 | assert.equal(alignClass(undefined), null) |
| 84 | assert.equal(alignClass('justify'), null) |
| 85 | assert.equal(alignClass('CENTER'), null) |
| 86 | // A value that would find something on Object.prototype if this were |
| 87 | // a bare property lookup. It must not become a class, and it must |
| 88 | // certainly not become a function. |
| 89 | assert.equal(alignClass('constructor'), null) |
| 90 | assert.equal(alignClass('toString'), null) |
| 91 | assert.equal(alignClass('left" onmouseover="alert(1)'), null) |
| 92 | }) |
| 93 | |
| 94 | test('toDOM emits a class only for a known alignment', () => { |
| 95 | const spec = alignedParagraph(basic.spec.nodes.get('paragraph')) |
| 96 | assert.deepEqual(spec.toDOM(para('center', 'x')), ['p', { class: 'docs-align-center' }, 0]) |
| 97 | assert.deepEqual(spec.toDOM(para('right', 'x')), ['p', { class: 'docs-align-right' }, 0]) |
| 98 | // No attrs object at all for the default — the same decision the |
| 99 | // read view makes, and what keeps a copied paragraph plain. |
| 100 | assert.deepEqual(spec.toDOM(para(null, 'x')), ['p', 0]) |
| 101 | assert.deepEqual(spec.toDOM(para('left', 'x')), ['p', 0]) |
| 102 | assert.deepEqual(spec.toDOM(para('justify', 'x')), ['p', 0]) |
| 103 | }) |
| 104 | |
| 105 | test('a pasted paragraph round-trips its alignment and nothing else', () => { |
| 106 | const spec = alignedParagraph(basic.spec.nodes.get('paragraph')) |
| 107 | const rule = spec.parseDOM[0] |
| 108 | assert.equal(rule.tag, 'p', 'the one rule must still be the <p> rule') |
| 109 | const dom = (...classes) => ({ classList: classes }) |
| 110 | assert.equal(rule.getAttrs(dom('docs-align-center')).align, 'center') |
| 111 | assert.equal(rule.getAttrs(dom('docs-align-right')).align, 'right') |
| 112 | assert.equal(rule.getAttrs(dom('docs-align-center', 'something-else')).align, 'center') |
| 113 | assert.equal(rule.getAttrs(dom()).align, null) |
| 114 | // Someone else's stylesheet, or a class that merely looks like ours. |
| 115 | assert.equal(rule.getAttrs(dom('docs-align-justify')).align, null) |
| 116 | assert.equal(rule.getAttrs(dom('text-center')).align, null) |
| 117 | }) |
| 118 | |
| 119 | test('alignFromClasses is the inverse of alignClass for every alignment', () => { |
| 120 | for (const value of ALIGNMENTS) { |
| 121 | const cls = alignClass(value) |
| 122 | assert.equal(alignFromClasses(cls ? [cls] : []), cls ? value : null) |
| 123 | } |
| 124 | }) |
| 125 | |
| 126 | // The evening this repo lost to y-prosemirror's `undo` is written up in |
| 127 | // toolbar.mjs's buildCommands. This is the test that stops it |
| 128 | // recurring for a command written here: toolbar.mjs's sync calls every |
| 129 | // command with a null dispatch on EVERY state update, so a command that |
| 130 | // edits regardless would silently rewrite the document forever. |
| 131 | test('with no dispatch the command reports and changes nothing', () => { |
| 132 | const doc = docWith(para(null, 'One'), para(null, 'Two')) |
| 133 | const state = stateOn(doc) |
| 134 | // A snapshot, not `state.doc.eq(doc)`. That comparison cannot fail: |
| 135 | // `doc` and `state.doc` are the same object, a transaction never |
| 136 | // mutates the state it was built from, and even an attrs object |
| 137 | // mutated in place would leave a node equal to itself. Comparing |
| 138 | // against JSON captured BEFORE the call is what actually watches the |
| 139 | // document, and it catches in-place mutation too. |
| 140 | const before = JSON.stringify(doc.toJSON()) |
| 141 | let dispatched = 0 |
| 142 | assert.equal(setAlign('center')(state, null), true) |
| 143 | assert.deepEqual( |
| 144 | JSON.parse(JSON.stringify(state.doc.toJSON())), |
| 145 | JSON.parse(before), |
| 146 | 'the document was modified by a question', |
| 147 | ) |
| 148 | // And the same call WITH a dispatch does exactly one transaction. |
| 149 | setAlign('center')(state, () => { dispatched++ }) |
| 150 | assert.equal(dispatched, 1) |
| 151 | }) |
| 152 | |
| 153 | // The command table is the toolbar's own, imported and executed — not a |
| 154 | // restatement of it. toolbar.mjs's alignmentsAreCovered() has already |
| 155 | // thrown at import time if a name and its value disagree; this asserts |
| 156 | // the shape that guard is guarding, so a reader of this file can see |
| 157 | // what the contract is without going to look. |
| 158 | test('every toolbar alignment command sets the value its name promises', () => { |
| 159 | assert.deepEqual(ALIGN_COMMANDS, { |
| 160 | align_left: null, |
| 161 | align_center: 'center', |
| 162 | align_right: 'right', |
| 163 | }) |
| 164 | for (const [name, value] of Object.entries(ALIGN_COMMANDS)) { |
| 165 | assert.equal(name, 'align_' + (value === null ? 'left' : value)) |
| 166 | if (value !== null) assert.ok(alignClass(value), `${name} sets an alignment align.mjs does not know`) |
| 167 | } |
| 168 | }) |
| 169 | |
| 170 | test('the command aligns the block the cursor is in, and only that one', () => { |
| 171 | const state = stateOn(docWith(para(null, 'One'), para(null, 'Two'))) |
| 172 | let next = null |
| 173 | setAlign('center')(state, (tr) => { next = state.apply(tr) }) |
| 174 | assert.equal(next.doc.child(0).attrs.align, 'center') |
| 175 | assert.equal(next.doc.child(1).attrs.align, null) |
| 176 | }) |
| 177 | |
| 178 | test('the command aligns every block a selection spans', () => { |
| 179 | const state = stateOn(docWith(para(null, 'One'), para(null, 'Two')), true) |
| 180 | let next = null |
| 181 | setAlign('right')(state, (tr) => { next = state.apply(tr) }) |
| 182 | assert.equal(next.doc.child(0).attrs.align, 'right') |
| 183 | assert.equal(next.doc.child(1).attrs.align, 'right') |
| 184 | }) |
| 185 | |
| 186 | // Choosing left is choosing the default, and the default is spelled as |
| 187 | // the attribute's absence — which is what y-prosemirror needs in order |
| 188 | // to REMOVE the Yjs attribute rather than store the string "left". A |
| 189 | // document aligned and then un-aligned has to end up back where it |
| 190 | // started, or the read view keeps a class the person just turned off. |
| 191 | test('choosing left clears the attribute rather than storing a value', () => { |
| 192 | const state = stateOn(docWith(para('center', 'One'))) |
| 193 | let next = null |
| 194 | setAlign(null)(state, (tr) => { next = state.apply(tr) }) |
| 195 | assert.equal(next.doc.child(0).attrs.align, null) |
| 196 | }) |
| 197 | |
| 198 | test('one transaction per click, not one per block', () => { |
| 199 | const state = stateOn(docWith(para(null, 'One'), para(null, 'Two'), para(null, 'Three')), true) |
| 200 | const dispatched = [] |
| 201 | setAlign('center')(state, (tr) => dispatched.push(tr)) |
| 202 | // internal/core caps a document's retained update log and every |
| 203 | // dispatched transaction becomes an entry in it — setExclusive's own |
| 204 | // comment in toolbar.mjs makes the same point for the mark controls. |
| 205 | assert.equal(dispatched.length, 1) |
| 206 | const next = state.apply(dispatched[0]) |
| 207 | for (let i = 0; i < 3; i++) { |
| 208 | assert.equal(next.doc.child(i).attrs.align, 'center') |
| 209 | } |
| 210 | }) |
| 211 | |
| 212 | test('the buttons report the alignment of the block the cursor is in', () => { |
| 213 | assert.equal(currentAlign(stateOn(docWith(para(null, 'One')))), 'left') |
| 214 | assert.equal(currentAlign(stateOn(docWith(para('center', 'One')))), 'center') |
| 215 | assert.equal(currentAlign(stateOn(docWith(para('right', 'One')))), 'right') |
| 216 | // An alignment this build does not know reports as left, which is how |
| 217 | // it will actually render — a button pressed for something the reader |
| 218 | // will never see would be a lie about the document. |
| 219 | assert.equal(currentAlign(stateOn(docWith(para('justify', 'One')))), 'left') |
| 220 | // A selection across two differently aligned blocks reports the one |
| 221 | // it starts in rather than nothing. |
| 222 | assert.equal(currentAlign(stateOn(docWith(para('right', 'One'), para(null, 'Two')), true)), 'right') |
| 223 | }) |
| 224 | |
| 225 | // A node with no `align` in its own spec must not acquire one. The |
| 226 | // command reports false so the toolbar greys the buttons out, rather |
| 227 | // than silently writing an attribute the schema does not declare and |
| 228 | // nothing renders. |
| 229 | test('a block that has no alignment attribute is left alone', () => { |
| 230 | const plain = new Schema({ nodes: basic.spec.nodes, marks: basic.spec.marks }) |
| 231 | const doc = plain.node('doc', null, [plain.node('paragraph', null, [plain.text('One')])]) |
| 232 | let state = EditorState.create({ doc, schema: plain }) |
| 233 | state = state.apply(state.tr.setSelection(TextSelection.create(doc, 1))) |
| 234 | assert.equal(setAlign('center')(state, null), false) |
| 235 | let dispatched = 0 |
| 236 | assert.equal(setAlign('center')(state, () => { dispatched++ }), false) |
| 237 | assert.equal(dispatched, 0) |
| 238 | }) |
| 239 | |