| 1 | // fixtures/wordcount.test.mjs — counting, under plain Node. |
| 2 | // |
| 3 | // countWords is the only part of the word count with any judgement in |
| 4 | // it, and it is the part a person will quote to somebody else, so it is |
| 5 | // the part worth pinning. The dialog around it is markup. |
| 6 | |
| 7 | import { test } from 'node:test' |
| 8 | import assert from 'node:assert/strict' |
| 9 | |
| 10 | import { Schema } from 'prosemirror-model' |
| 11 | import { EditorState } from 'prosemirror-state' |
| 12 | import { schema as basic } from 'prosemirror-schema-basic' |
| 13 | import { addListNodes } from 'prosemirror-schema-list' |
| 14 | |
| 15 | import { countWords, counts } from './wordcount.mjs' |
| 16 | |
| 17 | const schema = new Schema({ |
| 18 | nodes: addListNodes(basic.spec.nodes, 'paragraph block*', 'block'), |
| 19 | marks: basic.spec.marks, |
| 20 | }) |
| 21 | |
| 22 | function docOf(...paragraphs) { |
| 23 | return schema.node( |
| 24 | 'doc', |
| 25 | null, |
| 26 | paragraphs.map((text) => |
| 27 | text ? schema.node('paragraph', null, [schema.text(text)]) : schema.node('paragraph'), |
| 28 | ), |
| 29 | ) |
| 30 | } |
| 31 | |
| 32 | test('nothing is nothing', () => { |
| 33 | assert.equal(countWords(''), 0) |
| 34 | assert.equal(countWords(' '), 0) |
| 35 | assert.equal(countWords(null), 0) |
| 36 | }) |
| 37 | |
| 38 | test('ordinary English', () => { |
| 39 | assert.equal(countWords('one'), 1) |
| 40 | assert.equal(countWords('one two three'), 3) |
| 41 | assert.equal(countWords(' leading and trailing '), 3) |
| 42 | assert.equal(countWords('line\nbreaks\tand\ttabs'), 4) |
| 43 | }) |
| 44 | |
| 45 | // Punctuation is where whitespace-splitting and real segmentation |
| 46 | // diverge, and where a person notices: an em-dashed clause is two |
| 47 | // words, not one. |
| 48 | test('punctuation is not a word', () => { |
| 49 | assert.equal(countWords('yes — no'), 2) |
| 50 | assert.equal(countWords('one, two, three.'), 3) |
| 51 | assert.equal(countWords('"quoted"'), 1) |
| 52 | }) |
| 53 | |
| 54 | // The reason this uses Intl.Segmenter at all. Whitespace-splitting |
| 55 | // finds no boundary in these at all and would report 1. |
| 56 | test('a language without spaces between words still counts', (t) => { |
| 57 | if (typeof Intl === 'undefined' || typeof Intl.Segmenter !== 'function') { |
| 58 | t.skip('this platform has no Intl.Segmenter, so only the whitespace fallback exists') |
| 59 | return |
| 60 | } |
| 61 | assert.ok(countWords('今日は良い天気です') > 1, 'Japanese counted as one word') |
| 62 | assert.ok(countWords('这是一个句子') > 1, 'Chinese counted as one word') |
| 63 | }) |
| 64 | |
| 65 | test('the document count walks every block', () => { |
| 66 | const state = EditorState.create({ doc: docOf('one two', 'three four five'), schema }) |
| 67 | const c = counts(state) |
| 68 | assert.equal(c.words, 5) |
| 69 | assert.equal(c.paragraphs, 2) |
| 70 | }) |
| 71 | |
| 72 | // The blocks are joined with a space, so the last word of one paragraph |
| 73 | // and the first of the next are two words rather than one. Without |
| 74 | // that, "one" and "two" below would be counted as "onetwo". |
| 75 | test('a paragraph boundary is a word boundary', () => { |
| 76 | const state = EditorState.create({ doc: docOf('one', 'two'), schema }) |
| 77 | assert.equal(counts(state).words, 2) |
| 78 | }) |
| 79 | |
| 80 | test('characters are counted with and without spaces', () => { |
| 81 | const state = EditorState.create({ doc: docOf('ab cd'), schema }) |
| 82 | const c = counts(state) |
| 83 | assert.equal(c.characters, 5) |
| 84 | assert.equal(c.charactersNoSpaces, 4) |
| 85 | }) |
| 86 | |
| 87 | test('no selection means no selection count, not a zero', () => { |
| 88 | // A zero would be indistinguishable from "nothing is selected", which |
| 89 | // is why the dialog hides the row rather than showing 0. |
| 90 | const state = EditorState.create({ doc: docOf('one two'), schema }) |
| 91 | assert.equal(counts(state).selection, null) |
| 92 | }) |
| 93 | |