oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

1// fixtures/autoformat.test.mjs — the input rules, under plain Node.
2//
3// No DOM and no browser: ProseMirror's state, model and inputrules
4// packages are all headless, so a rule can be fired against a real
5// EditorState and the resulting document inspected. Only
6// prosemirror-view needs a browser, and nothing here touches it.
7//
8// That matters because Chromium will not start in this project's
9// sandbox at all, so a rule tested only through the browser drive is a
10// rule tested nowhere most of the time. These run in the gate.
11
12import { test } from 'node:test'
13import assert from 'node:assert/strict'
14
15import { Schema } from 'prosemirror-model'
16import { EditorState } from 'prosemirror-state'
17import { schema as basic } from 'prosemirror-schema-basic'
18import { addListNodes } from 'prosemirror-schema-list'
19import { undoInputRule } from 'prosemirror-inputrules'
20
21import { autoformatRules, PATTERNS } from './autoformat.mjs'
22
23const schema = new Schema({
24 nodes: addListNodes(basic.spec.nodes, 'paragraph block*', 'block'),
25 marks: basic.spec.marks,
26})
27
28// A minimal stand-in for an EditorView. prosemirror-inputrules' own
29// handleTextInput reads state, calls dispatch, and asks for other
30// plugins' props through someProp — none of which needs a DOM. Written
31// out rather than mocked with a library because what it has to provide
32// is exactly these three things, and a reader should be able to see
33// that.
34function fakeView(state) {
35 return {
36 state,
37 dispatch(tr) {
38 this.state = this.state.apply(tr)
39 },
40 someProp() {
41 return undefined
42 },
43 }
44}
45
46// type runs one text input through the plugin, the way a keystroke
47// would: the document already contains everything before the final
48// character, and that character is what is "typed".
49//
50// Returns the view, so a test can go on to press Backspace on it.
51function type(text) {
52 const before = text.slice(0, -1)
53 const last = text.slice(-1)
54 const plugin = autoformatRules(schema)
55 const doc = schema.node('doc', null, [
56 before ? schema.node('paragraph', null, [schema.text(before)]) : schema.node('paragraph'),
57 ])
58 let state = EditorState.create({ doc, schema, plugins: [plugin] })
59 // Put the caret at the end of the paragraph.
60 state = state.apply(state.tr.setSelection(state.selection.constructor.atEnd(doc)))
61 const view = fakeView(state)
62 const end = view.state.selection.from
63 plugin.props.handleTextInput(view, end, end, last)
64 return view
65}
66
67function firstChildType(view) {
68 return view.state.doc.firstChild.type.name
69}
70
71test('"- " starts a bulleted list', () => {
72 assert.equal(firstChildType(type('- ')), 'bullet_list')
73})
74
75test('"* " and "+ " start one too', () => {
76 assert.equal(firstChildType(type('* ')), 'bullet_list')
77 assert.equal(firstChildType(type('+ ')), 'bullet_list')
78})
79
80test('"1. " starts a numbered list', () => {
81 assert.equal(firstChildType(type('1. ')), 'ordered_list')
82})
83
84test('"> " starts a quote', () => {
85 assert.equal(firstChildType(type('> ')), 'blockquote')
86})
87
88test('"# " makes a heading, and "### " makes a level-3 one', () => {
89 const one = type('# ')
90 assert.equal(firstChildType(one), 'heading')
91 assert.equal(one.state.doc.firstChild.attrs.level, 1)
92
93 const three = type('### ')
94 assert.equal(firstChildType(three), 'heading')
95 assert.equal(three.state.doc.firstChild.attrs.level, 3)
96})
97
98// Backspace immediately afterwards is the whole reason autoformat is
99// tolerable rather than infuriating: it must give back the characters
100// that were typed, not merely undo into an empty paragraph.
101test('Backspace right after a rule gives back the typed characters', () => {
102 const view = type('- ')
103 assert.equal(firstChildType(view), 'bullet_list')
104
105 const ran = undoInputRule(view.state, (tr) => view.dispatch(tr))
106 assert.equal(ran, true, 'undoInputRule did not fire after a rule')
107
108 assert.equal(firstChildType(view), 'paragraph')
109 assert.equal(view.state.doc.firstChild.textContent, '- ')
110})
111
112// And away from a rule it must decline, so baseKeymap's own Backspace
113// still deletes a character. A version of this that always returned
114// true would eat every Backspace in the document.
115test('Backspace away from a rule declines, leaving Backspace alone', () => {
116 const doc = schema.node('doc', null, [
117 schema.node('paragraph', null, [schema.text('ordinary text')]),
118 ])
119 const plugin = autoformatRules(schema)
120 let state = EditorState.create({ doc, schema, plugins: [plugin] })
121 state = state.apply(state.tr.setSelection(state.selection.constructor.atEnd(doc)))
122 assert.equal(undoInputRule(state, () => {}), false)
123})
124
125// The negative cases are the ones people actually notice. A rule that
126// fires mid-sentence, or on a decimal, is worse than one that never
127// fires at all.
128test('the patterns do not fire where they must not', () => {
129 // A hyphen inside a word, and a bullet opener that is not at a block
130 // start, are both handled by ProseMirror matching only the text
131 // before the caret WITHIN the current textblock — but the patterns
132 // themselves must also refuse the shapes below.
133 assert.equal(PATTERNS.BULLET.test('a- '), false)
134 assert.equal(PATTERNS.BULLET.test('-x '), false)
135 assert.equal(PATTERNS.BULLET.test('--- '), false)
136
137 // "3.14" must stay a number. The pattern requires the space.
138 assert.equal(PATTERNS.ORDERED.test('3.14'), false)
139 assert.equal(PATTERNS.ORDERED.test('3. '), true)
140
141 // Four hashes is not a heading this build offers — the Style control
142 // shows three levels, and a rule reaching a level the control cannot
143 // report would be a way to make the toolbar lie.
144 assert.equal(PATTERNS.HEADING.test('#### '), false)
145 assert.equal(PATTERNS.HEADING.test('### '), true)
146
147 // A quote marker needs its space too.
148 assert.equal(PATTERNS.QUOTE.test('>x '), false)
149 assert.equal(PATTERNS.QUOTE.test('> '), true)
150})
151
152// Typing "7. " starts the list at 1, not at 7 — deliberately, because
153// core/src/fragment.rs does not read the `order` attribute, so setting
154// it would make the editor disagree with the read view and the PDF with
155// nothing reporting it. See autoformat.mjs's ORDERED comment.
156//
157// Asserted rather than left as a comment: the day `order` becomes real
158// in fragment.rs, this test fails and points at the decision to revisit
159// instead of letting the two quietly diverge.
160test('a numbered list always starts at 1 while the renderer ignores order', () => {
161 const view = type('7. ')
162 assert.equal(firstChildType(view), 'ordered_list')
163 assert.equal(view.state.doc.firstChild.attrs.order, 1)
164})
165