oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

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.
18import { test } from 'node:test'
19import assert from 'node:assert/strict'
20import { Schema } from 'prosemirror-model'
21import { EditorState, TextSelection } from 'prosemirror-state'
22import { schema as basic } from 'prosemirror-schema-basic'
23import {
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.
38import { 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.
42const schema = new Schema({
43 nodes: basic.spec.nodes.update('paragraph', alignedParagraph(basic.spec.nodes.get('paragraph'))),
44 marks: basic.spec.marks,
45})
46
47function docWith(...paragraphs) {
48 return schema.node('doc', null, paragraphs)
49}
50
51function 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.
57function 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
65test('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
78test('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
94test('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
105test('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
119test('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.
131test('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.
158test('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
170test('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
178test('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.
191test('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
198test('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
212test('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.
229test('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