| 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 | |
| 12 | import { test } from 'node:test' |
| 13 | import assert from 'node:assert/strict' |
| 14 | |
| 15 | import { Schema } from 'prosemirror-model' |
| 16 | import { EditorState } from 'prosemirror-state' |
| 17 | import { schema as basic } from 'prosemirror-schema-basic' |
| 18 | import { addListNodes } from 'prosemirror-schema-list' |
| 19 | import { undoInputRule } from 'prosemirror-inputrules' |
| 20 | |
| 21 | import { autoformatRules, PATTERNS } from './autoformat.mjs' |
| 22 | |
| 23 | const 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. |
| 34 | function 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. |
| 51 | function 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 | |
| 67 | function firstChildType(view) { |
| 68 | return view.state.doc.firstChild.type.name |
| 69 | } |
| 70 | |
| 71 | test('"- " starts a bulleted list', () => { |
| 72 | assert.equal(firstChildType(type('- ')), 'bullet_list') |
| 73 | }) |
| 74 | |
| 75 | test('"* " and "+ " start one too', () => { |
| 76 | assert.equal(firstChildType(type('* ')), 'bullet_list') |
| 77 | assert.equal(firstChildType(type('+ ')), 'bullet_list') |
| 78 | }) |
| 79 | |
| 80 | test('"1. " starts a numbered list', () => { |
| 81 | assert.equal(firstChildType(type('1. ')), 'ordered_list') |
| 82 | }) |
| 83 | |
| 84 | test('"> " starts a quote', () => { |
| 85 | assert.equal(firstChildType(type('> ')), 'blockquote') |
| 86 | }) |
| 87 | |
| 88 | test('"# " 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. |
| 101 | test('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. |
| 115 | test('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. |
| 128 | test('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. |
| 160 | test('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 | |