Ensure that the correct lines are spell-checked (#19967)

GitOrigin-RevId: f63601288821d37bddcddc776270263e291d3985
This commit is contained in:
Alf Eaton 2024-09-03 11:24:15 +01:00 committed by Copybot
parent 607b3e3494
commit abfa29e629
5 changed files with 95 additions and 654 deletions

View file

@ -1,142 +0,0 @@
import OError from '@overleaf/o-error'
import { Text } from '@codemirror/state'
import { Word } from './spellchecker'
import { ViewUpdate } from '@codemirror/view'
export class LineTracker {
private _lines: boolean[]
constructor(doc: Text) {
/*
* Maintain an array of booleans, one for each line of the document.
* `true` means a line has changed
*/
this._lines = new Array(doc.lines).fill(true)
}
dump() {
return [...this._lines]
}
count() {
return this._lines.length
}
lineHasChanged(lineNumber: number) {
return this._lines[lineNumber - 1] === true
}
/*
* Given a list of words, clear the 'changed' mark
* on the lines the words are on
*/
clearChangedLinesForWords(words: Word[]) {
words.forEach(word => {
this.clearLine(word.lineNumber)
})
}
clearLine(lineNumber: number) {
this._lines[lineNumber - 1] = false
}
clearAllLines() {
this._lines = this._lines.map(() => false)
}
resetAllLines() {
this._lines = this._lines.map(() => true)
}
markLineAsUpdated(lineNumber: number) {
this._lines[lineNumber - 1] = true
}
/*
* On update, for all changes, mark the affected lines
* as changed
*/
applyUpdate(update: ViewUpdate) {
for (const transaction of update.transactions) {
if (transaction.docChanged) {
let lineShift = 0
transaction.changes.iterChanges(
(fromA, toA, fromB, toB, insertedText) => {
const insertedLength = insertedText.length
const removedLength = toA - fromA
const hasInserted = insertedLength > 0
const hasRemoved = removedLength > 0
const oldDoc = transaction.startState.doc
if (hasRemoved) {
const startLine = oldDoc.lineAt(fromA).number
const endLine = oldDoc.lineAt(toA).number
/* Mark start line as changed, and remove deleted lines
* Example:
* with this text:
* |1|aaaa|
* |2|bbbb| => [false, false, false, false]
* |3|cccc|
* |4|dddd|
*
* with a selection covering 'bbcccc' across lines 2 and 3,
* press backspace,
* resulting in:
* |1|aaaa|
* |2|bb| => [false, true, false]
* |3|dddd|
*/
this._lines.splice(
startLine - 1 + lineShift,
endLine - startLine + 1,
true
)
lineShift -= endLine - startLine
}
if (hasInserted) {
const startLine = oldDoc.lineAt(fromA).number
/* Mark start line as changed, and insert new (changed) lines after.
* Example:
* with this text:
* |1|aaaa|
* |2|bbbb| => [false, false, false]
* |3|cccc|
*
* with the cursor at the end of line 2,
* insert the following text:
* |1|xx|
* |2|yy|
*
* results in:
* |1|aaaa|
* |2|bbbbxx| => [false, true, true, false]
* |3|yy|
* |4|cccc|
*/
const changes = new Array(insertedText.lines).fill(true)
try {
this._lines.splice(startLine - 1 + lineShift, 1, ...changes)
lineShift += changes.length - 1
} catch (error) {
if (error instanceof RangeError) {
throw new OError(error.message).withInfo({
changesSize: changes.length,
})
}
throw error
}
}
}
)
}
if (update.state.doc.lines !== this._lines.length) {
throw new OError(
'LineTracker length does not match document line count'
).withInfo({
documentLines: update.state.doc.lines,
trackerLines: this._lines.length,
})
}
}
}
}

View file

@ -1,7 +1,6 @@
import { StateField, StateEffect, Transaction } from '@codemirror/state'
import { StateField, StateEffect } from '@codemirror/state'
import { EditorView, Decoration, DecorationSet } from '@codemirror/view'
import { updateAfterAddingIgnoredWord } from './ignored-words'
import _ from 'lodash'
import { Word } from './spellchecker'
export const addMisspelledWords = StateEffect.define<Word[]>()
@ -9,11 +8,10 @@ export const addMisspelledWords = StateEffect.define<Word[]>()
export const resetMisspelledWords = StateEffect.define()
const createMark = (word: Word) => {
const mark = Decoration.mark({
return Decoration.mark({
class: 'ol-cm-spelling-error',
word,
})
return mark.range(word.from, word.to)
}).range(word.from, word.to)
}
/*
@ -26,13 +24,24 @@ export const misspelledWordsField = StateField.define<DecorationSet>({
return Decoration.none
},
update(marks, transaction) {
if (transaction.docChanged) {
// Remove any marks whose text has just been edited
marks = marks.update({
filter(from, to) {
return !transaction.changes.touchesRange(from, to)
},
})
}
marks = marks.map(transaction.changes)
marks = removeMarksUnderEdit(marks, transaction)
for (const effect of transaction.effects) {
if (effect.is(addMisspelledWords)) {
// We're setting a new list of misspelled words
const misspelledWords = effect.value
marks = mergeMarks(marks, misspelledWords)
// Merge the new misspelled words into the existing set of marks
marks = marks.update({
add: effect.value.map(word => createMark(word)),
sort: true,
})
} else if (effect.is(updateAfterAddingIgnoredWord)) {
// Remove a misspelled word, all instances that match text
const word = effect.value
@ -48,46 +57,6 @@ export const misspelledWordsField = StateField.define<DecorationSet>({
},
})
/*
* Remove any marks whos text has just been edited
*/
const removeMarksUnderEdit = (
marks: DecorationSet,
transaction: Transaction
) => {
transaction.changes.iterChanges((fromA, toA, fromB, toB) => {
marks = marks.update({
// Filter out marks that overlap the change span
filter: (from, to, mark) => {
const changeStartWithinMark = from <= fromB && to >= fromB
const changeEndWithinMark = from <= toB && to >= toB
const markHasBeenEdited = changeStartWithinMark || changeEndWithinMark
return !markHasBeenEdited
},
})
})
return marks
}
/*
* Given the set of marks, and a list of new misspelled-words,
* merge these together into a new set of marks
*/
const mergeMarks = (marks: DecorationSet, words: Word[]) => {
const affectedLines = new Set(words.map(w => w.lineNumber))
marks = marks
.update({
filter: (from, to, mark) => {
return !affectedLines.has(mark.spec.word.lineNumber)
},
})
.update({
add: _.sortBy(words, ['from']).map(w => createMark(w)),
sort: true,
})
return marks
}
/*
* Remove existing marks matching the text of a supplied word
*/

View file

@ -1,12 +1,11 @@
import { addMisspelledWords, misspelledWordsField } from './misspelled-words'
import { ignoredWordsField, resetSpellChecker } from './ignored-words'
import { LineTracker } from './line-tracker'
import { cacheField, addWordToCache, WordCacheValue } from './cache'
import { WORD_REGEX } from './helpers'
import OError from '@overleaf/o-error'
import { spellCheckRequest } from './backend'
import { EditorView, ViewUpdate } from '@codemirror/view'
import { Line, Range, RangeValue } from '@codemirror/state'
import { ChangeSet, Line, Range, RangeValue } from '@codemirror/state'
import { IgnoredWords } from '../../../dictionary/ignored-words'
import {
getNormalTextSpansFromLine,
@ -15,10 +14,6 @@ import {
import { waitForParser } from '../wait-for-parser'
import { debugConsole } from '@/utils/debugging'
const _log = (...args: any) => {
debugConsole.debug('[SpellChecker]: ', ...args)
}
/*
* Spellchecker, handles updates, schedules spelling checks
*/
@ -26,43 +21,41 @@ export class SpellChecker {
private abortController?: AbortController | null = null
private timeout: number | null = null
private firstCheck = true
private lineTracker: LineTracker | null = null
private waitingForParser = false
private firstCheckPending = false
private trackedChanges: ChangeSet
// eslint-disable-next-line no-useless-constructor
constructor(private readonly language: string) {
this.language = language
this.trackedChanges = ChangeSet.empty(0)
}
destroy() {
_log('destroy')
this._clearPendingSpellCheck()
}
_abortRequest() {
if (this.abortController) {
_log('abort request')
this.abortController.abort()
this.abortController = null
}
}
handleUpdate(update: ViewUpdate) {
if (!this.lineTracker) {
this.lineTracker = new LineTracker(update.state.doc)
}
if (update.docChanged) {
this.lineTracker.applyUpdate(update)
this.trackedChanges = this.trackedChanges.compose(update.changes)
this.scheduleSpellCheck(update.view)
} else if (update.viewportChanged) {
this.trackedChanges = ChangeSet.empty(0)
this.scheduleSpellCheck(update.view)
} else if (
update.transactions.some(tr => {
return tr.effects.some(effect => effect.is(resetSpellChecker))
})
) {
this.lineTracker.resetAllLines()
// for tests
this.trackedChanges = ChangeSet.empty(0)
this.spellCheckAsap(update.view)
}
// At the point that the spellchecker is initialized, the editor may not
@ -78,21 +71,15 @@ export class SpellChecker {
update.state.facet(EditorView.editable)
) {
this.firstCheckPending = true
_log('Scheduling initial spellcheck')
this.spellCheckAsap(update.view)
}
}
_performSpellCheck(view: EditorView) {
_log('Begin ---------------->')
const wordsToCheck = this.getWordsToCheck(view)
if (wordsToCheck.length === 0) {
return
}
_log(
'- words to check',
wordsToCheck.map(w => w.text)
)
const cache = view.state.field(cacheField)
const { knownMisspelledWords, unknownWords } = cache.checkWords(
this.language,
@ -101,7 +88,8 @@ export class SpellChecker {
const processResult = (
misspellings: { index: number; suggestions: string[] }[]
) => {
this.lineTracker?.clearChangedLinesForWords(wordsToCheck)
this.trackedChanges = ChangeSet.empty(0)
if (this.firstCheck) {
this.firstCheck = false
this.firstCheckPending = false
@ -111,25 +99,13 @@ export class SpellChecker {
unknownWords,
misspellings
)
_log('- result', result)
window.setTimeout(() => {
view.dispatch({
effects: compileEffects(result),
})
}, 0)
_log('<---------------- End')
}
_log('- before spellcheck request')
_log(
' - unknownWords',
unknownWords.map(w => w.text)
)
_log(
' - knownMisspelledWords',
knownMisspelledWords.map(w => w.text)
)
if (unknownWords.length === 0) {
_log('- skip request')
processResult([])
} else {
this._abortRequest()
@ -141,7 +117,7 @@ export class SpellChecker {
})
.catch(error => {
this.abortController = null
_log('>> error in spellcheck request', error)
debugConsole.error(error)
})
}
}
@ -152,10 +128,7 @@ export class SpellChecker {
}
this.waitingForParser = true
waitForParser(
view,
view => viewportRangeToCheck(this.firstCheck, view).to
).then(() => {
waitForParser(view, view => view.viewport.to).then(() => {
this.waitingForParser = false
this._performSpellCheck(view)
})
@ -187,22 +160,36 @@ export class SpellChecker {
}
getWordsToCheck(view: EditorView) {
const lang = this.language
const ignoredWords = view.state.field(ignoredWordsField)
_log('- ignored words', ignoredWords)
if (!this.lineTracker) {
this.lineTracker = new LineTracker(view.state.doc)
const wordsToCheck: Word[] = []
const { from, to } = view.viewport
const changedLineNumbers = new Set<number>()
if (this.trackedChanges.length > 0) {
this.trackedChanges.iterChangedRanges((fromA, toA, fromB, toB) => {
if (fromB <= to && toB >= from) {
const fromLine = view.state.doc.lineAt(fromB).number
const toLine = view.state.doc.lineAt(toB).number
for (let i = fromLine; i <= toLine; i++) {
changedLineNumbers.add(i)
}
let wordsToCheck: Word[] = []
for (const line of viewportLinesToCheck(
this.lineTracker,
this.firstCheck,
view
)) {
wordsToCheck = wordsToCheck.concat(
getWordsFromLine(view, line, ignoredWords, lang)
}
})
} else {
const fromLine = view.state.doc.lineAt(from).number
const toLine = view.state.doc.lineAt(to).number
for (let i = fromLine; i <= toLine; i++) {
changedLineNumbers.add(i)
}
}
const ignoredWords = view.state.field(ignoredWordsField)
for (const i of changedLineNumbers) {
const line = view.state.doc.line(i)
wordsToCheck.push(
...getWordsFromLine(view, line, ignoredWords, this.language)
)
}
return wordsToCheck
}
}
@ -246,34 +233,29 @@ export const buildSpellCheckResult = (
misspellings: { index: number; suggestions: string[] }[]
) => {
const cacheAdditions: [Word, string[] | boolean][] = []
// Put known misspellings into cache
const misspelledWords = misspellings.map(item => {
const word = { ...unknownWords[item.index] }
const word = {
...unknownWords[item.index],
}
word.suggestions = item.suggestions
if (word.suggestions) {
cacheAdditions.push([word, word.suggestions])
}
return word
})
// if word was not misspelled, put it in the cache
for (const word of unknownWords) {
if (!misspelledWords.find(mw => mw.text === word.text)) {
cacheAdditions.push([word, true])
}
}
const finalMisspellings = misspelledWords.concat(knownMisspelledWords)
_log('- result')
_log(
' - finalMisspellings',
finalMisspellings.map(w => w.text)
)
_log(
' - cacheAdditions',
cacheAdditions.map(([w, v]) => `'${w.text}'=>${v}`)
)
return {
cacheAdditions,
misspelledWords: finalMisspellings,
misspelledWords: misspelledWords.concat(knownMisspelledWords),
}
}
@ -294,73 +276,20 @@ export const compileEffects = (results: {
]
}
const viewportRangeToCheck = (firstCheck: boolean, view: EditorView) => {
const doc = view.state.doc
let { from, to } = view.viewport
let firstLineNumber = doc.lineAt(from).number
let lastLineNumber = doc.lineAt(to).number
/*
* On first check, we scan the viewport plus some padding on either side.
* Then on subsequent checks we just scan the viewport
*/
if (firstCheck) {
const visibleLineCount = lastLineNumber - firstLineNumber + 1
const padding = Math.floor(visibleLineCount * 2)
firstLineNumber = Math.max(firstLineNumber - padding, 1)
lastLineNumber = Math.min(lastLineNumber + padding, doc.lines)
from = doc.line(firstLineNumber).from
to = doc.line(lastLineNumber).to
}
return { from, to, firstLineNumber, lastLineNumber }
}
export const viewportLinesToCheck = (
lineTracker: LineTracker,
firstCheck: boolean,
view: EditorView
) => {
const { firstLineNumber, lastLineNumber } = viewportRangeToCheck(
firstCheck,
view
)
_log('- viewport lines', firstLineNumber, lastLineNumber)
const lines = []
for (
let lineNumber = firstLineNumber;
lineNumber <= lastLineNumber;
lineNumber++
) {
if (!lineTracker.lineHasChanged(lineNumber)) {
continue
}
lines.push(view.state.doc.line(lineNumber))
}
_log(
'- lines to check',
lines.map(l => l.number)
)
return lines
}
export const getWordsFromLine = (
view: EditorView,
line: Line,
ignoredWords: IgnoredWords,
lang: string
): Word[] => {
const lineNumber = line.number
const normalTextSpans: Array<NormalTextSpan> = getNormalTextSpansFromLine(
view,
line
)
const words: Word[] = []
let regexResult
normalTextSpans.forEach(span => {
WORD_REGEX.lastIndex = 0 // reset global stateful regexp for this usage
while ((regexResult = WORD_REGEX.exec(span.text))) {
let word = regexResult[0]
for (const span of normalTextSpans) {
for (const match of span.text.matchAll(WORD_REGEX)) {
let word = match[0]
if (word.startsWith("'")) {
word = word.slice(1)
}
@ -368,18 +297,19 @@ export const getWordsFromLine = (
word = word.slice(0, -1)
}
if (!ignoredWords.has(word)) {
const from = span.from + match.index
words.push(
new Word({
text: word,
from: span.from + regexResult.index,
to: span.from + regexResult.index + word.length,
lineNumber,
from,
to: from + word.length,
lineNumber: line.number,
lang,
})
)
}
}
})
}
return words
}

View file

@ -1,206 +0,0 @@
import { LineTracker } from '../../../../../../frontend/js/features/source-editor/extensions/spelling/line-tracker'
// import sinon from 'sinon'
import { expect } from 'chai'
import { EditorView } from '@codemirror/view'
import { EditorState } from '@codemirror/state'
import { Word } from '../../../../../../frontend/js/features/source-editor/extensions/spelling/spellchecker'
const doc = [
'Hello test one two',
'three four five six',
'seven eight nine.',
].join('\n')
describe('LineTracker', function () {
describe('basic operations', function () {
let state: EditorState,
view: EditorView,
lineTracker: LineTracker,
check: (spec: [number, any][]) => void
beforeEach(function () {
state = EditorState.create({
doc,
extensions: [
EditorView.updateListener.of(update => {
lineTracker.applyUpdate(update)
}),
],
})
view = new EditorView({ state })
lineTracker = new LineTracker(view.state.doc)
check = spec => {
spec.forEach(([n, expectedValue]) => {
expect(lineTracker.lineHasChanged(n)).to.equal(expectedValue)
})
}
})
it('start with the correct number of lines', function () {
expect(state.doc.lines).to.equal(3)
expect(lineTracker.count()).to.equal(state.doc.lines)
})
it('starts with all lines marked as changed', function () {
expect(state.doc.lines).to.equal(3)
check([
[1, true],
[2, true],
[3, true],
])
})
it('clears a line', function () {
check([[1, true]])
lineTracker.clearLine(1)
check([[1, false]])
})
it('clears lines based on a list of words', function () {
lineTracker.clearChangedLinesForWords([
{ lineNumber: 1 },
{ lineNumber: 3 },
] as Word[])
check([
[1, false],
[2, true],
[3, false],
])
})
it('should update lines in response to text insertion', function () {
lineTracker.clearChangedLinesForWords([
{ lineNumber: 1 },
{ lineNumber: 2 },
{ lineNumber: 3 },
] as Word[])
check([
[1, false],
[2, false],
[3, false],
])
let transaction = view.state.update({
changes: [{ from: 0, insert: 'x' }],
})
view.dispatch(transaction)
check([
[1, true],
[2, false],
[3, false],
])
transaction = view.state.update({
changes: [{ from: view.state.doc.length - 2, insert: 'x' }],
})
view.dispatch(transaction)
check([
[1, true],
[2, false],
[3, true],
])
})
it('should update lines in response to large text insertion', function () {
lineTracker.clearChangedLinesForWords([
{ lineNumber: 1 },
{ lineNumber: 2 },
{ lineNumber: 3 },
] as Word[])
check([
[1, false],
[2, false],
[3, false],
])
const text = new Array(1000).fill('x').join('\n')
const transaction = view.state.update({
changes: [{ from: 0, insert: text }],
})
view.dispatch(transaction)
expect(lineTracker.count()).to.equal(1002)
const expectations: [number, boolean][] = []
for (let i = 1; i <= 1000; i++) {
expectations.push([i, true])
}
expectations.push([1001, false])
expectations.push([1002, false])
check(expectations)
})
it('should update lines in response to removal of a line', function () {
lineTracker.clearChangedLinesForWords([
{ lineNumber: 1 },
{ lineNumber: 2 },
{ lineNumber: 3 },
] as Word[])
check([
[1, false],
[2, false],
[3, false],
])
// Overwrite the line plus some part of the second line
const transaction = view.state.update({
changes: [{ from: 0, to: doc[0].length + 3, insert: 'x' }],
})
view.dispatch(transaction)
check([
[1, true],
[2, false],
])
})
it('should handle multiple changes', function () {
lineTracker.clearChangedLinesForWords([
{ lineNumber: 1 },
{ lineNumber: 2 },
{ lineNumber: 3 },
] as Word[])
check([
[1, false],
[2, false],
[3, false],
])
const transaction = view.state.update({
changes: [
{ from: 0, insert: 'x' },
{ from: doc[0].length + 2, insert: 'xxxxx\nxxxxx\nxxxx' },
],
})
view.dispatch(transaction)
check([
[1, true],
[2, true],
[3, true],
[4, false],
[5, false],
])
})
it('should handle multiple deletions', function () {
const transaction = view.state.update({
changes: [
{ from: 0, to: 24, insert: '' },
{ from: 39, to: 44, insert: '' },
],
})
view.dispatch(transaction)
check([
[1, true],
[2, true],
])
})
it('should handle multiple insertions', function () {
const transactionUndo = view.state.update({
changes: [
{ from: 0, to: 0, insert: 'big change\n'.repeat(20) },
{ from: 50, to: 50, insert: 'xxxx' },
],
})
view.dispatch(transactionUndo)
})
})
})

View file

@ -1,29 +1,15 @@
import {
getWordsFromLine,
viewportLinesToCheck,
buildSpellCheckResult,
Word,
} from '../../../../../../frontend/js/features/source-editor/extensions/spelling/spellchecker'
import { LineTracker } from '../../../../../../frontend/js/features/source-editor/extensions/spelling/line-tracker'
} from '@/features/source-editor/extensions/spelling/spellchecker'
import { expect } from 'chai'
import { EditorView } from '@codemirror/view'
import { EditorState, Line } from '@codemirror/state'
import _ from 'lodash'
import { IgnoredWords } from '../../../../../../frontend/js/features/dictionary/ignored-words'
import { LaTeXLanguage } from '../../../../../../frontend/js/features/source-editor/languages/latex/latex-language'
import { IgnoredWords } from '@/features/dictionary/ignored-words'
import { LaTeXLanguage } from '@/features/source-editor/languages/latex/latex-language'
import { LanguageSupport } from '@codemirror/language'
const latex = new LanguageSupport(LaTeXLanguage)
const makeView = (text: string) => {
const view = new EditorView({
state: EditorState.create({
doc: text,
extensions: [latex],
}),
})
return view
}
const extensions = [new LanguageSupport(LaTeXLanguage)]
describe('SpellChecker', function () {
describe('getWordsFromLine', function () {
@ -35,7 +21,10 @@ describe('SpellChecker', function () {
})
it('should get words from a line', function () {
const view = makeView('Hello test one two')
const view = new EditorView({
doc: 'Hello test one two',
extensions,
})
const line = view.state.doc.line(1)
const words = getWordsFromLine(view, line, ignoredWords, lang)
expect(words).to.deep.equal([
@ -48,7 +37,10 @@ describe('SpellChecker', function () {
it('should ignore words in ignoredWords', function () {
ignoredWords = new Set(['test']) as unknown as IgnoredWords
const view = makeView('Hello test one two')
const view = new EditorView({
doc: 'Hello test one two',
extensions,
})
const line = view.state.doc.line(1)
const words = getWordsFromLine(view, line, ignoredWords, lang)
expect(words).to.deep.equal([
@ -59,14 +51,20 @@ describe('SpellChecker', function () {
})
it('should get no words from an empty line', function () {
const view = makeView(' ')
const view = new EditorView({
doc: ' ',
extensions,
})
const line = view.state.doc.line(1)
const words = getWordsFromLine(view, line, ignoredWords, lang)
expect(words).to.deep.equal([])
})
it('should ignore content of some commands in the text', function () {
const view = makeView('\\usepackage[foo]{ bar } seven eight')
const view = new EditorView({
doc: '\\usepackage[foo]{ bar } seven eight',
extensions,
})
const line = view.state.doc.line(1)
const words = getWordsFromLine(view, line, ignoredWords, lang)
expect(words).to.deep.equal([
@ -76,7 +74,10 @@ describe('SpellChecker', function () {
})
it('should ignore command names in the text', function () {
const view = makeView('\\foo nine \\bar ten \\baz[]{}')
const view = new EditorView({
doc: '\\foo nine \\bar ten \\baz[]{}',
extensions,
})
const line = view.state.doc.line(1)
const words = getWordsFromLine(view, line, ignoredWords, lang)
expect(words).to.deep.equal([
@ -86,117 +87,6 @@ describe('SpellChecker', function () {
})
})
describe('viewportLinesToCheck', function () {
const expectLines = (lines: Line[], expectations: any) => {
expect(lines.map(l => l.number)).to.deep.equal(expectations)
}
const expectLineRange = (lines: Line[], from: number, to: number) => {
expect(lines.map(l => l.number)).to.deep.equal(_.range(from, to + 1))
}
let view: EditorView
beforeEach(function () {
view = makeView(new Array(1000).fill('aa bb cc dd').join('\n'))
// Test preconditions on these structures
const viewport = view.viewport
expect(view.state.doc.lines).to.equal(1000)
const firstVisibleLine = view.state.doc.lineAt(viewport.from).number
const lastVisibleLine = view.state.doc.lineAt(viewport.to).number
expect(firstVisibleLine).to.equal(1)
expect(lastVisibleLine).to.equal(36)
})
describe('when all lines are changed', function () {
let lineTracker: LineTracker
beforeEach(function () {
lineTracker = new LineTracker(view.state.doc)
})
it('should check all lines in the viewport when not first check', function () {
const firstCheck = false
const linesToCheck = viewportLinesToCheck(lineTracker, firstCheck, view)
expectLineRange(linesToCheck, 1, 36)
})
it('should check more lines than the viewport on first check', function () {
const firstCheck = true
const lineTracker = new LineTracker(view.state.doc)
const linesToCheck = viewportLinesToCheck(lineTracker, firstCheck, view)
expectLineRange(linesToCheck, 1, 108)
})
})
describe('when no lines have changed', function () {
let lineTracker: LineTracker
beforeEach(function () {
lineTracker = new LineTracker(view.state.doc)
lineTracker.clearAllLines()
})
it('on first check, should not check any lines', function () {
const firstCheck = true
const linesToCheck = viewportLinesToCheck(lineTracker, firstCheck, view)
expect(linesToCheck).to.deep.equal([])
})
it('on not first check, should not check any lines', function () {
const firstCheck = false
const linesToCheck = viewportLinesToCheck(lineTracker, firstCheck, view)
expectLines(linesToCheck, [])
})
})
describe('when some lines have changed in viewport', function () {
let lineTracker: LineTracker
beforeEach(function () {
lineTracker = new LineTracker(view.state.doc)
lineTracker.clearAllLines()
lineTracker.markLineAsUpdated(3)
lineTracker.markLineAsUpdated(7)
})
it('should check correct lines', function () {
const firstCheck = false
const linesToCheck = viewportLinesToCheck(lineTracker, firstCheck, view)
expectLines(linesToCheck, [3, 7])
})
})
describe('when some lines have changed outside viewport', function () {
let lineTracker: LineTracker
beforeEach(function () {
lineTracker = new LineTracker(view.state.doc)
lineTracker.clearAllLines()
lineTracker.markLineAsUpdated(300)
lineTracker.markLineAsUpdated(307)
})
it('should not check lines', function () {
const firstCheck = false
const linesToCheck = viewportLinesToCheck(lineTracker, firstCheck, view)
expectLines(linesToCheck, [])
})
})
describe('when some lines have changed inside and outside viewport', function () {
let lineTracker: LineTracker
beforeEach(function () {
lineTracker = new LineTracker(view.state.doc)
lineTracker.clearAllLines()
lineTracker.markLineAsUpdated(10)
lineTracker.markLineAsUpdated(12)
lineTracker.markLineAsUpdated(300)
lineTracker.markLineAsUpdated(307)
})
it('should check only lines in viewport', function () {
const firstCheck = false
const linesToCheck = viewportLinesToCheck(lineTracker, firstCheck, view)
expectLines(linesToCheck, [10, 12])
})
})
})
describe('buildSpellCheckResult', function () {
it('should build an empty result', function () {
const knownMisspelledWords: Word[] = []