// fixtures/align.test.mjs — the editor half of paragraph alignment. // // Runs under Node's own test runner, no framework and no DOM: the // schema, the command and the active-state reader in align.mjs all work // on ProseMirror documents and state, which are plain data. The one // piece that genuinely needs a DOM (parseDOM matching a real

) is // reached through its own getAttrs with a stand-in element carrying // nothing but a classList, which is all that rule reads. // // What is being defended here is agreement. The class names and the // attribute values in align.mjs are the same vocabulary as // core/src/fragment.rs's ALIGNMENTS and internal/export/pdf.go's // alignOf, and those are separate binaries that cannot import this one. // So this file pins the JS end of it — and pins hardest on the two // cases that fail silently in a browser: an alignment nobody chose // being written into the document anyway, and a command that acts when // it was only being asked a question. import { test } from 'node:test' import assert from 'node:assert/strict' import { Schema } from 'prosemirror-model' import { EditorState, TextSelection } from 'prosemirror-state' import { schema as basic } from 'prosemirror-schema-basic' import { ALIGNMENTS, ALIGN_CLASSES, alignClass, alignFromClasses, alignedParagraph, setAlign, currentAlign, } from './align.mjs' // Imported for its side effect as much as for the table: toolbar.mjs // runs alignmentsAreCovered() at module load, and this import is what // makes Node execute it. Before this line that guard ran only when a // browser mounted the editor, which meant it was dead code in the gate // — a command table that had drifted from ALIGNMENTS would have passed // CI and thrown at startup for every user instead. import { ALIGN_COMMANDS } from './toolbar.mjs' // The same schema editor-entry.js builds, minus the marks and the list // nodes this file has no use for. const schema = new Schema({ nodes: basic.spec.nodes.update('paragraph', alignedParagraph(basic.spec.nodes.get('paragraph'))), marks: basic.spec.marks, }) function docWith(...paragraphs) { return schema.node('doc', null, paragraphs) } function para(align, text) { return schema.node('paragraph', { align }, text ? [schema.text(text)] : []) } // stateOn puts the cursor inside the first paragraph, or selects // everything when `all` is set. function stateOn(doc, all = false) { let state = EditorState.create({ doc, schema }) const selection = all ? TextSelection.create(doc, 1, doc.content.size - 1) : TextSelection.create(doc, 1) return state.apply(state.tr.setSelection(selection)) } test('the class names are the ones the server renders', () => { // Spelled out rather than derived, because this IS the contract: // core/src/fragment.rs's ALIGNMENTS table and views/show.html's // stylesheet carry these same two strings, and internal/docs's // TestEveryClassTheRendererEmitsIsStyled holds those two to each // other. If this assertion is edited, all three have to move. assert.deepEqual(ALIGN_CLASSES, { center: 'docs-align-center', right: 'docs-align-right', }) assert.deepEqual(ALIGNMENTS, ['left', 'center', 'right']) }) test('left, absent and unknown alignments all render as no class', () => { assert.equal(alignClass('center'), 'docs-align-center') assert.equal(alignClass('right'), 'docs-align-right') assert.equal(alignClass('left'), null) assert.equal(alignClass(null), null) assert.equal(alignClass(undefined), null) assert.equal(alignClass('justify'), null) assert.equal(alignClass('CENTER'), null) // A value that would find something on Object.prototype if this were // a bare property lookup. It must not become a class, and it must // certainly not become a function. assert.equal(alignClass('constructor'), null) assert.equal(alignClass('toString'), null) assert.equal(alignClass('left" onmouseover="alert(1)'), null) }) test('toDOM emits a class only for a known alignment', () => { const spec = alignedParagraph(basic.spec.nodes.get('paragraph')) assert.deepEqual(spec.toDOM(para('center', 'x')), ['p', { class: 'docs-align-center' }, 0]) assert.deepEqual(spec.toDOM(para('right', 'x')), ['p', { class: 'docs-align-right' }, 0]) // No attrs object at all for the default — the same decision the // read view makes, and what keeps a copied paragraph plain. assert.deepEqual(spec.toDOM(para(null, 'x')), ['p', 0]) assert.deepEqual(spec.toDOM(para('left', 'x')), ['p', 0]) assert.deepEqual(spec.toDOM(para('justify', 'x')), ['p', 0]) }) test('a pasted paragraph round-trips its alignment and nothing else', () => { const spec = alignedParagraph(basic.spec.nodes.get('paragraph')) const rule = spec.parseDOM[0] assert.equal(rule.tag, 'p', 'the one rule must still be the

rule') const dom = (...classes) => ({ classList: classes }) assert.equal(rule.getAttrs(dom('docs-align-center')).align, 'center') assert.equal(rule.getAttrs(dom('docs-align-right')).align, 'right') assert.equal(rule.getAttrs(dom('docs-align-center', 'something-else')).align, 'center') assert.equal(rule.getAttrs(dom()).align, null) // Someone else's stylesheet, or a class that merely looks like ours. assert.equal(rule.getAttrs(dom('docs-align-justify')).align, null) assert.equal(rule.getAttrs(dom('text-center')).align, null) }) test('alignFromClasses is the inverse of alignClass for every alignment', () => { for (const value of ALIGNMENTS) { const cls = alignClass(value) assert.equal(alignFromClasses(cls ? [cls] : []), cls ? value : null) } }) // The evening this repo lost to y-prosemirror's `undo` is written up in // toolbar.mjs's buildCommands. This is the test that stops it // recurring for a command written here: toolbar.mjs's sync calls every // command with a null dispatch on EVERY state update, so a command that // edits regardless would silently rewrite the document forever. test('with no dispatch the command reports and changes nothing', () => { const doc = docWith(para(null, 'One'), para(null, 'Two')) const state = stateOn(doc) // A snapshot, not `state.doc.eq(doc)`. That comparison cannot fail: // `doc` and `state.doc` are the same object, a transaction never // mutates the state it was built from, and even an attrs object // mutated in place would leave a node equal to itself. Comparing // against JSON captured BEFORE the call is what actually watches the // document, and it catches in-place mutation too. const before = JSON.stringify(doc.toJSON()) let dispatched = 0 assert.equal(setAlign('center')(state, null), true) assert.deepEqual( JSON.parse(JSON.stringify(state.doc.toJSON())), JSON.parse(before), 'the document was modified by a question', ) // And the same call WITH a dispatch does exactly one transaction. setAlign('center')(state, () => { dispatched++ }) assert.equal(dispatched, 1) }) // The command table is the toolbar's own, imported and executed — not a // restatement of it. toolbar.mjs's alignmentsAreCovered() has already // thrown at import time if a name and its value disagree; this asserts // the shape that guard is guarding, so a reader of this file can see // what the contract is without going to look. test('every toolbar alignment command sets the value its name promises', () => { assert.deepEqual(ALIGN_COMMANDS, { align_left: null, align_center: 'center', align_right: 'right', }) for (const [name, value] of Object.entries(ALIGN_COMMANDS)) { assert.equal(name, 'align_' + (value === null ? 'left' : value)) if (value !== null) assert.ok(alignClass(value), `${name} sets an alignment align.mjs does not know`) } }) test('the command aligns the block the cursor is in, and only that one', () => { const state = stateOn(docWith(para(null, 'One'), para(null, 'Two'))) let next = null setAlign('center')(state, (tr) => { next = state.apply(tr) }) assert.equal(next.doc.child(0).attrs.align, 'center') assert.equal(next.doc.child(1).attrs.align, null) }) test('the command aligns every block a selection spans', () => { const state = stateOn(docWith(para(null, 'One'), para(null, 'Two')), true) let next = null setAlign('right')(state, (tr) => { next = state.apply(tr) }) assert.equal(next.doc.child(0).attrs.align, 'right') assert.equal(next.doc.child(1).attrs.align, 'right') }) // Choosing left is choosing the default, and the default is spelled as // the attribute's absence — which is what y-prosemirror needs in order // to REMOVE the Yjs attribute rather than store the string "left". A // document aligned and then un-aligned has to end up back where it // started, or the read view keeps a class the person just turned off. test('choosing left clears the attribute rather than storing a value', () => { const state = stateOn(docWith(para('center', 'One'))) let next = null setAlign(null)(state, (tr) => { next = state.apply(tr) }) assert.equal(next.doc.child(0).attrs.align, null) }) test('one transaction per click, not one per block', () => { const state = stateOn(docWith(para(null, 'One'), para(null, 'Two'), para(null, 'Three')), true) const dispatched = [] setAlign('center')(state, (tr) => dispatched.push(tr)) // internal/core caps a document's retained update log and every // dispatched transaction becomes an entry in it — setExclusive's own // comment in toolbar.mjs makes the same point for the mark controls. assert.equal(dispatched.length, 1) const next = state.apply(dispatched[0]) for (let i = 0; i < 3; i++) { assert.equal(next.doc.child(i).attrs.align, 'center') } }) test('the buttons report the alignment of the block the cursor is in', () => { assert.equal(currentAlign(stateOn(docWith(para(null, 'One')))), 'left') assert.equal(currentAlign(stateOn(docWith(para('center', 'One')))), 'center') assert.equal(currentAlign(stateOn(docWith(para('right', 'One')))), 'right') // An alignment this build does not know reports as left, which is how // it will actually render — a button pressed for something the reader // will never see would be a lie about the document. assert.equal(currentAlign(stateOn(docWith(para('justify', 'One')))), 'left') // A selection across two differently aligned blocks reports the one // it starts in rather than nothing. assert.equal(currentAlign(stateOn(docWith(para('right', 'One'), para(null, 'Two')), true)), 'right') }) // A node with no `align` in its own spec must not acquire one. The // command reports false so the toolbar greys the buttons out, rather // than silently writing an attribute the schema does not declare and // nothing renders. test('a block that has no alignment attribute is left alone', () => { const plain = new Schema({ nodes: basic.spec.nodes, marks: basic.spec.marks }) const doc = plain.node('doc', null, [plain.node('paragraph', null, [plain.text('One')])]) let state = EditorState.create({ doc, schema: plain }) state = state.apply(state.tr.setSelection(TextSelection.create(doc, 1))) assert.equal(setAlign('center')(state, null), false) let dispatched = 0 assert.equal(setAlign('center')(state, () => { dispatched++ }), false) assert.equal(dispatched, 0) })