Improve spell check when dictionary is edited (#22635)

GitOrigin-RevId: 20d36cb987d014809423240a46c7c577781dfde6
This commit is contained in:
Alf Eaton
2025-01-13 10:59:35 +00:00
committed by Copybot
parent 47af13c8a8
commit cffa9c1a28
16 changed files with 218 additions and 375 deletions

View File

@@ -2,7 +2,6 @@ import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import useAsync from '../../../shared/hooks/use-async'
import { postJSON } from '../../../infrastructure/fetch-json'
import ignoredWords from '../ignored-words'
import { debugConsole } from '@/utils/debugging'
import {
OLModalBody,
@@ -15,6 +14,7 @@ import OLNotification from '@/features/ui/components/ol/ol-notification'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLIconButton from '@/features/ui/components/ol/ol-icon-button'
import { bsVersion } from '@/features/utils/bootstrap-5'
import { learnedWords as initialLearnedWords } from '@/features/source-editor/extensions/spelling/learned-words'
type DictionaryModalContentProps = {
handleHide: () => void
@@ -26,22 +26,24 @@ export default function DictionaryModalContent({
handleHide,
}: DictionaryModalContentProps) {
const { t } = useTranslation()
const [learnedWords, setLearnedWords] = useState(ignoredWords.learnedWords)
const [learnedWords, setLearnedWords] = useState<Set<string>>(
initialLearnedWords.global
)
const { isError, runAsync } = useAsync()
const handleRemove = useCallback(
word => {
runAsync(
postJSON('/spelling/unlearn', {
body: {
word,
},
})
)
runAsync(postJSON('/spelling/unlearn', { body: { word } }))
.then(() => {
ignoredWords.remove(word)
setLearnedWords(new Set(ignoredWords.learnedWords))
setLearnedWords(value => {
value.delete(word)
return new Set(value)
})
window.dispatchEvent(
new CustomEvent('editor:remove-learned-word', { detail: word })
)
})
.catch(debugConsole.error)
},
@@ -62,7 +64,7 @@ export default function DictionaryModalContent({
/>
) : null}
{learnedWords?.size > 0 ? (
{learnedWords.size > 0 ? (
<ul className="list-unstyled dictionary-entries-list">
{[...learnedWords].sort(wordsSortFunction).map(learnedWord => (
<li key={learnedWord} className="dictionary-entry">

View File

@@ -1,6 +1,4 @@
import getMeta from '../../utils/meta'
export const globalLearnedWords = new Set([
export const globalIgnoredWords = new Set([
'Overleaf',
'overleaf',
'ShareLaTeX',
@@ -22,41 +20,3 @@ export const globalLearnedWords = new Set([
'Coronavirus',
'coronavirus',
])
export class IgnoredWords {
public learnedWords!: Set<string>
private readonly ignoredMisspellings: Set<string>
constructor() {
this.reset()
this.ignoredMisspellings = globalLearnedWords
window.addEventListener('learnedWords:doreset', () => this.reset()) // for tests
}
reset() {
this.learnedWords = new Set(getMeta('ol-learnedWords'))
window.dispatchEvent(new CustomEvent('learnedWords:reset'))
}
add(wordText: string) {
this.learnedWords.add(wordText)
window.dispatchEvent(
new CustomEvent('learnedWords:add', { detail: wordText })
)
}
remove(wordText: string) {
this.learnedWords.delete(wordText)
window.dispatchEvent(
new CustomEvent('learnedWords:remove', { detail: wordText })
)
}
has(wordText: string) {
return (
this.ignoredMisspellings.has(wordText) || this.learnedWords.has(wordText)
)
}
}
export default new IgnoredWords()

View File

@@ -1,13 +0,0 @@
import { postJSON } from '@/infrastructure/fetch-json'
import { Word } from './spellchecker'
export async function learnWordRequest(word?: Word) {
if (!word || !word.text) {
throw new Error(`Invalid word supplied: ${word}`)
}
return await postJSON('/spelling/learn', {
body: {
word: word.text,
},
})
}

View File

@@ -7,16 +7,14 @@ export const cacheKey = (lang: string, wordText: string) => {
return `${lang}:${wordText}`
}
export type WordCacheValue = string[] | boolean | number
export class WordCache {
private _cache: LRU<string, WordCacheValue>
private _cache: LRU<string, boolean>
constructor() {
this._cache = new LRU({ max: CACHE_MAX })
}
set(lang: string, wordText: string, value: WordCacheValue) {
set(lang: string, wordText: string, value: boolean) {
const key = cacheKey(lang, wordText)
this._cache.set(key, value)
}
@@ -31,6 +29,10 @@ export class WordCache {
this._cache.delete(key)
}
reset() {
this._cache = new LRU({ max: CACHE_MAX })
}
/*
* Given a language and a list of words,
* check the cache and sort the words into two categories:
@@ -44,23 +46,20 @@ export class WordCache {
knownMisspelledWords: Word[]
unknownWords: Word[]
} {
const knownMisspelledWords = []
const unknownWords = []
const seen: Record<string, WordCacheValue | undefined> = {}
const knownMisspelledWords: Word[] = []
const unknownWords: Word[] = []
const seen: Record<string, boolean | undefined> = {}
for (const word of wordsToCheck) {
const wordText = word.text
if (seen[wordText] == null) {
if (seen[wordText] === undefined) {
seen[wordText] = this.get(lang, wordText)
}
const cached = seen[wordText]
if (cached == null) {
if (cached === undefined) {
// Word is not known
unknownWords.push(word)
} else if (cached === true) {
// Word is known to be correct
} else {
} else if (!cached) {
// Word is known to be misspelled
word.suggestions = cached
knownMisspelledWords.push(word)
}
}
@@ -74,7 +73,7 @@ export class WordCache {
export const addWordToCache = StateEffect.define<{
lang: string
wordText: string
value: string[] | boolean
value: boolean
}>()
export const removeWordFromCache = StateEffect.define<{

View File

@@ -5,8 +5,6 @@ import {
Prec,
} from '@codemirror/state'
import { EditorView, showTooltip, Tooltip, keymap } from '@codemirror/view'
import { addIgnoredWord } from './ignored-words'
import { learnWordRequest } from './backend'
import { Word, Mark, getMarkAtPosition } from './spellchecker'
import { debugConsole } from '@/utils/debugging'
import {
@@ -17,6 +15,8 @@ import { sendMB } from '@/infrastructure/event-tracking'
import ReactDOM from 'react-dom'
import { SpellingSuggestions } from '@/features/source-editor/extensions/spelling/spelling-suggestions'
import { SplitTestProvider } from '@/shared/context/split-test-context'
import { addLearnedWord } from '@/features/source-editor/extensions/spelling/learned-words'
import { postJSON } from '@/infrastructure/fetch-json'
/*
* The time until which a click event will be ignored, so it doesn't immediately close the spelling menu.
@@ -176,10 +176,14 @@ const createSpellingSuggestionList = (word: Word) => (view: EditorView) => {
}
}}
handleLearnWord={() => {
learnWordRequest(word)
postJSON('/spelling/learn', {
body: {
word: word.text,
},
})
.then(() => {
view.dispatch({
effects: [addIgnoredWord.of(word), hideSpellingMenu.of(null)],
view.dispatch(addLearnedWord(word.text), {
effects: hideSpellingMenu.of(null),
})
sendMB('spelling-word-added', {
language: getSpellCheckLanguage(view.state),
@@ -203,9 +207,11 @@ const createSpellingSuggestionList = (word: Word) => (view: EditorView) => {
return
}
view.dispatch({
changes: [{ from: tooltip.pos, to: tooltip.end, insert: text }],
effects: [hideSpellingMenu.of(null)],
window.setTimeout(() => {
view.dispatch({
changes: [{ from: tooltip.pos, to: tooltip.end, insert: text }],
effects: [hideSpellingMenu.of(null)],
})
})
view.focus()

View File

@@ -1,31 +0,0 @@
import { StateField, StateEffect } from '@codemirror/state'
import ignoredWords, { IgnoredWords } from '../../../dictionary/ignored-words'
export const ignoredWordsField = StateField.define<IgnoredWords>({
create() {
return ignoredWords
},
update(ignoredWords, transaction) {
for (const effect of transaction.effects) {
if (effect.is(addIgnoredWord)) {
const newWord = effect.value
ignoredWords.add(newWord.text)
}
}
return ignoredWords
},
})
export const addIgnoredWord = StateEffect.define<{
text: string
}>()
export const removeIgnoredWord = StateEffect.define<{
text: string
}>()
export const updateAfterAddingIgnoredWord = StateEffect.define<string>()
export const updateAfterRemovingIgnoredWord = StateEffect.define<string>()
export const resetSpellChecker = StateEffect.define()

View File

@@ -6,20 +6,12 @@ import {
TransactionSpec,
} from '@codemirror/state'
import { misspelledWordsField } from './misspelled-words'
import {
addIgnoredWord,
ignoredWordsField,
removeIgnoredWord,
resetSpellChecker,
updateAfterAddingIgnoredWord,
} from './ignored-words'
import { addWordToCache, cacheField, removeWordFromCache } from './cache'
import { removeLearnedWord } from './learned-words'
import { cacheField } from './cache'
import { hideSpellingMenu, spellingMenuField } from './context-menu'
import { SpellChecker } from './spellchecker'
import { parserWatcher } from '../wait-for-parser'
import type { HunspellManager } from '@/features/source-editor/hunspell/HunspellManager'
import { debugConsole } from '@/utils/debugging'
import { captureException } from '@/infrastructure/error-reporter'
type Options = {
spellCheckLanguage?: string
@@ -44,12 +36,26 @@ export const spelling = ({ spellCheckLanguage, hunspellManager }: Options) => {
: null
),
misspelledWordsField,
ignoredWordsField,
cacheField,
spellingMenuField,
dictionary,
]
}
const dictionary = ViewPlugin.define(view => {
const listener = (event: Event) => {
view.dispatch(removeLearnedWord((event as CustomEvent<string>).detail))
}
window.addEventListener('editor:remove-learned-word', listener)
return {
destroy() {
window.removeEventListener('editor:remove-learned-word', listener)
},
}
})
const spellingTheme = EditorView.baseTheme({
'.ol-cm-spelling-error': {
textDecorationColor: 'red',
@@ -82,16 +88,6 @@ const spellCheckerField = StateField.define<SpellChecker | null>({
effect.value.hunspellManager
)
: null
} else if (effect.is(addIgnoredWord)) {
value?.addWord(effect.value.text).catch(error => {
captureException(error)
debugConsole.error(error)
})
} else if (effect.is(removeIgnoredWord)) {
value?.removeWord(effect.value.text).catch(error => {
captureException(error)
debugConsole.error(error)
})
}
}
return value
@@ -157,40 +153,3 @@ export const setSpellCheckLanguage = ({
],
}
}
export const addLearnedWord = (
spellCheckLanguage: string,
word: string
): TransactionSpec => {
return {
effects: [
addWordToCache.of({
lang: spellCheckLanguage,
wordText: word,
value: true,
}),
updateAfterAddingIgnoredWord.of(word),
],
}
}
export const removeLearnedWord = (
spellCheckLanguage: string,
word: string
): TransactionSpec => {
return {
effects: [
removeWordFromCache.of({
lang: spellCheckLanguage,
wordText: word,
}),
removeIgnoredWord.of({ text: word }),
],
}
}
export const resetLearnedWords = (): TransactionSpec => {
return {
effects: [resetSpellChecker.of(null)],
}
}

View File

@@ -0,0 +1,24 @@
import { StateEffect } from '@codemirror/state'
import getMeta from '@/utils/meta'
export const addLearnedWordEffect = StateEffect.define<string>()
export const removeLearnedWordEffect = StateEffect.define<string>()
export const learnedWords = {
global: new Set(getMeta('ol-learnedWords')),
}
export const addLearnedWord = (text: string) => {
learnedWords.global.add(text)
return {
effects: addLearnedWordEffect.of(text),
}
}
export const removeLearnedWord = (text: string) => {
learnedWords.global.delete(text)
return {
effects: removeLearnedWordEffect.of(text),
}
}

View File

@@ -1,6 +1,6 @@
import { StateField, StateEffect, Line } from '@codemirror/state'
import { EditorView, Decoration, DecorationSet } from '@codemirror/view'
import { updateAfterAddingIgnoredWord } from './ignored-words'
import { addLearnedWordEffect } from './learned-words'
import { Word } from './spellchecker'
import { setSpellCheckLanguageEffect } from '@/features/source-editor/extensions/spelling/index'
@@ -59,11 +59,15 @@ export const misspelledWordsField = StateField.define<DecorationSet>({
add: effect.value.map(word => createMark(word)),
sort: true,
})
} else if (effect.is(updateAfterAddingIgnoredWord)) {
} else if (effect.is(addLearnedWordEffect)) {
const word = effect.value
// Remove existing marks matching the text of a supplied word
marks = marks.update({
filter(_from, _to, mark) {
return mark.spec.word.text !== effect.value
return (
mark.spec.word.text !== word &&
mark.spec.word.text !== capitaliseWord(word)
)
},
})
} else if (effect.is(setSpellCheckLanguageEffect)) {
@@ -76,3 +80,6 @@ export const misspelledWordsField = StateField.define<DecorationSet>({
return EditorView.decorations.from(field)
},
})
const capitaliseWord = (word: string) =>
word.charAt(0).toUpperCase() + word.substring(1)

View File

@@ -1,11 +1,10 @@
import { addMisspelledWords, misspelledWordsField } from './misspelled-words'
import { ignoredWordsField, resetSpellChecker } from './ignored-words'
import { cacheField, addWordToCache, WordCacheValue } from './cache'
import { addLearnedWordEffect, removeLearnedWordEffect } from './learned-words'
import { cacheField, addWordToCache } from './cache'
import { WORD_REGEX } from './helpers'
import OError from '@overleaf/o-error'
import { EditorView, ViewUpdate } from '@codemirror/view'
import { ChangeSet, Line, Range, RangeValue } from '@codemirror/state'
import { IgnoredWords } from '../../../dictionary/ignored-words'
import { getNormalTextSpansFromLine } from '../../utils/tree-query'
import { waitForParser } from '../wait-for-parser'
import { debugConsole } from '@/utils/debugging'
@@ -77,14 +76,6 @@ export class SpellChecker {
} 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))
})
) {
// for tests
this.trackedChanges = ChangeSet.empty(0)
this.spellCheckAsap(update.view)
}
// At the point that the spellchecker is initialized, the editor may not
// yet be editable, and the parser may not be ready. Therefore, to do the
@@ -100,6 +91,34 @@ export class SpellChecker {
) {
this.firstCheckPending = true
this.spellCheckAsap(update.view)
} else {
for (const tr of update.transactions) {
for (const effect of tr.effects) {
if (effect.is(addLearnedWordEffect)) {
this.addWord(effect.value)
.then(() => {
update.view.state.field(cacheField, false)?.reset()
this.trackedChanges = ChangeSet.empty(0)
this.spellCheckAsap(update.view)
})
.catch(error => {
captureException(error)
debugConsole.error(error)
})
} else if (effect.is(removeLearnedWordEffect)) {
this.removeWord(effect.value)
.then(() => {
update.view.state.field(cacheField, false)?.reset()
this.trackedChanges = ChangeSet.empty(0)
this.spellCheckAsap(update.view)
})
.catch(error => {
captureException(error)
debugConsole.error(error)
})
}
}
}
}
}
@@ -114,22 +133,29 @@ export class SpellChecker {
this.language,
wordsToCheck
)
const processResult = (
misspellings: { index: number; suggestions?: string[] }[]
) => {
const processResult = (misspellings: { index: number }[]) => {
this.trackedChanges = ChangeSet.empty(0)
if (this.firstCheck) {
this.firstCheck = false
this.firstCheckPending = false
}
const result = buildSpellCheckResult(
const { misspelledWords, cacheAdditions } = buildSpellCheckResult(
knownMisspelledWords,
unknownWords,
misspellings
)
view.dispatch({
effects: compileEffects(result),
effects: [
addMisspelledWords.of(misspelledWords),
...cacheAdditions.map(([word, value]) => {
return addWordToCache.of({
lang: word.lang,
wordText: word.text,
value,
})
}),
],
})
}
if (unknownWords.length === 0) {
@@ -264,19 +290,10 @@ export class SpellChecker {
}
}
const ignoredWords = this.hunspellManager
? null
: view.state.field(ignoredWordsField)
for (const i of changedLineNumbers) {
const line = view.state.doc.line(i)
wordsToCheck.push(
...getWordsFromLine(
view,
line,
ignoredWords,
this.language,
this.segmenter
)
...getWordsFromLine(view, line, this.language, this.segmenter)
)
}
@@ -290,7 +307,6 @@ export class Word {
public to: number
public lineNumber: number
public lang: string
public suggestions?: WordCacheValue
constructor(options: {
text: string
@@ -320,25 +336,26 @@ export class Word {
export const buildSpellCheckResult = (
knownMisspelledWords: Word[],
unknownWords: Word[],
misspellings: { index: number; suggestions?: string[] }[]
misspellings: { index: number }[]
) => {
const cacheAdditions: [Word, string[] | boolean][] = []
const cacheAdditions: [Word, boolean][] = []
// Put known misspellings into cache
const misspelledWords = misspellings.map(item => {
const word = {
...unknownWords[item.index],
}
word.suggestions = item.suggestions
if (word.suggestions) {
cacheAdditions.push([word, word.suggestions])
}
cacheAdditions.push([word, false])
return word
})
const misspelledWordsSet = new Set<string>(
misspelledWords.map(word => word.text)
)
// if word was not misspelled, put it in the cache
for (const word of unknownWords) {
if (!misspelledWords.find(mw => mw.text === word.text)) {
if (!misspelledWordsSet.has(word.text)) {
cacheAdditions.push([word, true])
}
}
@@ -349,34 +366,16 @@ export const buildSpellCheckResult = (
}
}
export const compileEffects = (results: {
cacheAdditions: [Word, string[] | boolean][]
misspelledWords: Word[]
}) => {
const { cacheAdditions, misspelledWords } = results
return [
addMisspelledWords.of(misspelledWords),
...cacheAdditions.map(([word, value]) => {
return addWordToCache.of({
lang: word.lang,
wordText: word.text,
value,
})
}),
]
}
export function* getWordsFromLine(
view: EditorView,
line: Line,
ignoredWords: IgnoredWords | null,
lang: string,
segmenter?: Intl.Segmenter
) {
for (const span of getNormalTextSpansFromLine(view, line)) {
if (segmenter) {
for (const value of segmenter.segment(span.text)) {
if (value.isWordLike && !ignoredWords?.has(value.segment)) {
if (value.isWordLike) {
const word = value.segment
const from = span.from + value.index
yield new Word({
@@ -396,15 +395,13 @@ export function* getWordsFromLine(
word = word.slice(1)
from++
}
if (!ignoredWords?.has(word)) {
yield new Word({
text: word,
from,
to: from + word.length,
lineNumber: line.number,
lang,
})
}
yield new Word({
text: word,
from,
to: from + word.length,
lineNumber: line.number,
lang,
})
}
}
}

View File

@@ -41,34 +41,28 @@ export const SpellingSuggestions: FC<SpellingSuggestionsProps> = ({
handleLearnWord,
handleCorrectWord,
}) => {
const [suggestions, setSuggestions] = useState(() =>
Array.isArray(word.suggestions)
? word.suggestions.slice(0, ITEMS_TO_SHOW)
: []
)
const [suggestions, setSuggestions] = useState<string[]>([])
const [waiting, setWaiting] = useState(!word.suggestions)
const [waiting, setWaiting] = useState(true)
useEffect(() => {
if (!word.suggestions) {
spellChecker
?.suggest(word.text)
.then(result => {
setSuggestions(result.suggestions.slice(0, ITEMS_TO_SHOW))
setWaiting(false)
sendMB('spelling-suggestion-shown', {
language: spellCheckLanguage,
count: result.suggestions.length,
// word: transaction.state.sliceDoc(mark.from, mark.to),
})
spellChecker
?.suggest(word.text)
.then(result => {
setSuggestions(result.suggestions.slice(0, ITEMS_TO_SHOW))
setWaiting(false)
sendMB('spelling-suggestion-shown', {
language: spellCheckLanguage,
count: result.suggestions.length,
// word: transaction.state.sliceDoc(mark.from, mark.to),
})
.catch(error => {
captureException(error, {
tags: { ol_spell_check_language: spellCheckLanguage },
})
debugConsole.error(error)
})
.catch(error => {
captureException(error, {
tags: { ol_spell_check_language: spellCheckLanguage },
})
}
debugConsole.error(error)
})
}, [word, spellChecker, spellCheckLanguage])
const language = useMemo(() => {

View File

@@ -33,12 +33,7 @@ import { setAutoPair } from '../extensions/auto-pair'
import { setAutoComplete } from '../extensions/auto-complete'
import { usePhrases } from './use-phrases'
import { setPhrases } from '../extensions/phrases'
import {
addLearnedWord,
removeLearnedWord,
resetLearnedWords,
setSpellCheckLanguage,
} from '../extensions/spelling'
import { setSpellCheckLanguage } from '../extensions/spelling'
import {
createChangeManager,
dispatchEditorEvent,
@@ -566,39 +561,6 @@ function useCodeMirrorScope(view: EditorView) {
}
}, [view, cursorHighlights, currentDoc])
const handleAddLearnedWords = useCallback(
(event: CustomEvent<string>) => {
// If the word addition is from adding the word to the dictionary via the
// editor, there will be a transaction running now so wait for that to
// finish before starting a new one
window.setTimeout(() => {
view.dispatch(addLearnedWord(spellCheckLanguage, event.detail))
}, 0)
},
[spellCheckLanguage, view]
)
useEventListener('learnedWords:add', handleAddLearnedWords)
const handleRemoveLearnedWords = useCallback(
(event: CustomEvent<string>) => {
window.setTimeout(() => {
view.dispatch(removeLearnedWord(spellCheckLanguage, event.detail))
})
},
[spellCheckLanguage, view]
)
useEventListener('learnedWords:remove', handleRemoveLearnedWords)
const handleResetLearnedWords = useCallback(() => {
window.setTimeout(() => {
view.dispatch(resetLearnedWords())
})
}, [view])
useEventListener('learnedWords:reset', handleResetLearnedWords)
useEventListener(
'editor:focus',
useCallback(() => {

View File

@@ -1,8 +1,9 @@
import { useEffect, useState } from 'react'
import getMeta from '@/utils/meta'
import { globalLearnedWords } from '@/features/dictionary/ignored-words'
import { globalIgnoredWords } from '@/features/dictionary/ignored-words'
import { HunspellManager } from '@/features/source-editor/hunspell/HunspellManager'
import { debugConsole } from '@/utils/debugging'
import { learnedWords } from '@/features/source-editor/extensions/spelling/learned-words'
export const useHunspell = (spellCheckLanguage: string | null) => {
const [hunspellManager, setHunspellManager] = useState<HunspellManager>()
@@ -14,8 +15,8 @@ export const useHunspell = (spellCheckLanguage: string | null) => {
)
if (lang?.dic) {
const hunspellManager = new HunspellManager(lang.dic, [
...globalLearnedWords,
...getMeta('ol-learnedWords'),
...globalIgnoredWords,
...learnedWords.global,
])
setHunspellManager(hunspellManager)
debugConsole.log(spellCheckLanguage, hunspellManager)

View File

@@ -1,21 +1,26 @@
import DictionaryModal from '@/features/dictionary/components/dictionary-modal'
import { EditorProviders } from '../../../helpers/editor-providers'
import { learnedWords } from '@/features/source-editor/extensions/spelling/learned-words'
describe('<DictionaryModalContent />', function () {
let originalLearnedWords
beforeEach(function () {
cy.then(() => {
originalLearnedWords = learnedWords.global
})
cy.interceptCompile()
})
afterEach(function () {
cy.window().then(win => {
win.dispatchEvent(new CustomEvent('learnedWords:doreset'))
cy.then(() => {
learnedWords.global = originalLearnedWords
})
})
it('list words', function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-learnedWords', ['foo', 'bar'])
win.dispatchEvent(new CustomEvent('learnedWords:doreset'))
cy.then(win => {
learnedWords.global = new Set(['foo', 'bar'])
})
cy.mount(
@@ -29,9 +34,8 @@ describe('<DictionaryModalContent />', function () {
})
it('shows message when empty', function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-learnedWords', [])
win.dispatchEvent(new CustomEvent('learnedWords:doreset'))
cy.then(win => {
learnedWords.global = new Set([])
})
cy.mount(
@@ -46,9 +50,8 @@ describe('<DictionaryModalContent />', function () {
it('removes words', function () {
cy.intercept('/spelling/unlearn', { statusCode: 200 })
cy.window().then(win => {
win.metaAttributesCache.set('ol-learnedWords', ['Foo', 'bar'])
win.dispatchEvent(new CustomEvent('learnedWords:doreset'))
cy.then(win => {
learnedWords.global = new Set(['Foo', 'bar'])
})
cy.mount(
@@ -73,9 +76,8 @@ describe('<DictionaryModalContent />', function () {
it('handles errors', function () {
cy.intercept('/spelling/unlearn', { statusCode: 500 }).as('unlearn')
cy.window().then(win => {
win.metaAttributesCache.set('ol-learnedWords', ['foo'])
win.dispatchEvent(new CustomEvent('learnedWords:doreset'))
cy.then(win => {
learnedWords.global = new Set(['foo'])
})
cy.mount(

View File

@@ -1,6 +1,6 @@
import { WordCache } from '../../../../../../frontend/js/features/source-editor/extensions/spelling/cache'
import { WordCache } from '@/features/source-editor/extensions/spelling/cache'
import { expect } from 'chai'
import { Word } from '../../../../../../frontend/js/features/source-editor/extensions/spelling/spellchecker'
import { Word } from '@/features/source-editor/extensions/spelling/spellchecker'
describe('WordCache', function () {
describe('basic operations', function () {
@@ -18,25 +18,25 @@ describe('WordCache', function () {
word = 'bar'
expect(cache.get(lang, word)).to.not.exist
cache.set(lang, word, ['a', 'b'])
expect(cache.get(lang, word)).to.deep.equal(['a', 'b'])
cache.set(lang, word, false)
expect(cache.get(lang, word)).to.equal(false)
})
it('should store words in separate languages', function () {
const word = 'foo'
const otherLang = 'zz'
cache.set(lang, word, 101)
expect(cache.get(lang, word)).to.equal(101)
cache.set(lang, word, true)
expect(cache.get(lang, word)).to.equal(true)
expect(cache.get(otherLang, word)).to.not.exist
cache.set(otherLang, word, 202)
expect(cache.get(lang, word)).to.equal(101)
expect(cache.get(otherLang, word)).to.equal(202)
cache.set(otherLang, word, false)
expect(cache.get(lang, word)).to.equal(true)
expect(cache.get(otherLang, word)).to.equal(false)
})
it('should check words against cache', function () {
cache.set(lang, 'foo', ['a', 'b'])
cache.set(lang, 'foo', false)
cache.set(lang, 'bar', true)
cache.set(lang, 'baz', true)
const wordsToCheck = [
@@ -49,8 +49,8 @@ describe('WordCache', function () {
const result = cache.checkWords(lang, wordsToCheck)
expect(result).to.have.keys('knownMisspelledWords', 'unknownWords')
expect(result.knownMisspelledWords).to.deep.equal([
{ text: 'foo', suggestions: ['a', 'b'], from: 0 },
{ text: 'foo', suggestions: ['a', 'b'], from: 3 },
{ text: 'foo', from: 0 },
{ text: 'foo', from: 3 },
])
expect(result.unknownWords).to.deep.equal([
{ text: 'quux', from: 2 },

View File

@@ -5,7 +5,6 @@ import {
} from '@/features/source-editor/extensions/spelling/spellchecker'
import { expect } from 'chai'
import { EditorView } from '@codemirror/view'
import { IgnoredWords } from '@/features/dictionary/ignored-words'
import { LaTeXLanguage } from '@/features/source-editor/languages/latex/latex-language'
import { LanguageSupport } from '@codemirror/language'
@@ -13,11 +12,10 @@ const extensions = [new LanguageSupport(LaTeXLanguage)]
describe('SpellChecker', function () {
describe('getWordsFromLine', function () {
let lang: string, ignoredWords: IgnoredWords
let lang: string
beforeEach(function () {
/* Note: ignore the word 'test' */
lang = 'en'
ignoredWords = new Set([]) as unknown as IgnoredWords
})
it('should get words from a line', function () {
@@ -26,7 +24,7 @@ describe('SpellChecker', function () {
extensions,
})
const line = view.state.doc.line(1)
const words = Array.from(getWordsFromLine(view, line, ignoredWords, lang))
const words = Array.from(getWordsFromLine(view, line, lang))
expect(words).to.deep.equal([
{ text: 'Hello', from: 0, to: 5, lineNumber: 1, lang: 'en' },
{ text: 'test', from: 6, to: 10, lineNumber: 1, lang: 'en' },
@@ -35,28 +33,13 @@ describe('SpellChecker', function () {
])
})
it('should ignore words in ignoredWords', function () {
ignoredWords = new Set(['test']) as unknown as IgnoredWords
const view = new EditorView({
doc: 'Hello test one two',
extensions,
})
const line = view.state.doc.line(1)
const words = Array.from(getWordsFromLine(view, line, ignoredWords, lang))
expect(words).to.deep.equal([
{ text: 'Hello', from: 0, to: 5, lineNumber: 1, lang: 'en' },
{ text: 'one', from: 11, to: 14, lineNumber: 1, lang: 'en' },
{ text: 'two', from: 15, to: 18, lineNumber: 1, lang: 'en' },
])
})
it('should get no words from an empty line', function () {
const view = new EditorView({
doc: ' ',
extensions,
})
const line = view.state.doc.line(1)
const words = Array.from(getWordsFromLine(view, line, ignoredWords, lang))
const words = Array.from(getWordsFromLine(view, line, lang))
expect(words).to.deep.equal([])
})
@@ -66,7 +49,7 @@ describe('SpellChecker', function () {
extensions,
})
const line = view.state.doc.line(1)
const words = Array.from(getWordsFromLine(view, line, ignoredWords, lang))
const words = Array.from(getWordsFromLine(view, line, lang))
expect(words).to.deep.equal([
{ text: 'seven', from: 24, to: 29, lineNumber: 1, lang: 'en' },
{ text: 'eight', from: 30, to: 35, lineNumber: 1, lang: 'en' },
@@ -79,7 +62,7 @@ describe('SpellChecker', function () {
extensions,
})
const line = view.state.doc.line(1)
const words = Array.from(getWordsFromLine(view, line, ignoredWords, lang))
const words = Array.from(getWordsFromLine(view, line, lang))
expect(words).to.deep.equal([
{ text: 'nine', from: 5, to: 9, lineNumber: 1, lang: 'en' },
{ text: 'ten', from: 15, to: 18, lineNumber: 1, lang: 'en' },
@@ -91,7 +74,7 @@ describe('SpellChecker', function () {
it('should build an empty result', function () {
const knownMisspelledWords: Word[] = []
const unknownWords: Word[] = []
const misspellings: { index: number; suggestions: string[] }[] = []
const misspellings: { index: number }[] = []
const result = buildSpellCheckResult(
knownMisspelledWords,
unknownWords,
@@ -103,21 +86,17 @@ describe('SpellChecker', function () {
})
})
it('should build a realistic result', function () {
const _makeWord = (text: string, suggestions?: string[]) => {
const word = new Word({
const _makeWord = (text: string) => {
return new Word({
text,
from: 0,
to: 0,
lineNumber: 0,
lang: 'xx',
})
if (suggestions != null) {
word.suggestions = suggestions
}
return word
}
// We know this word is misspelled
const knownMisspelledWords = [_makeWord('fff', ['food', 'fleece'])]
const knownMisspelledWords = [_makeWord('fff')]
// These words we didn't know
const unknownWords = [
_makeWord('aaa'),
@@ -126,10 +105,7 @@ describe('SpellChecker', function () {
_makeWord('ddd'),
]
// These are the suggestions we got back from the backend
const misspellings = [
{ index: 1, suggestions: ['box', 'bass'] },
{ index: 3, suggestions: ['docs', 'dance'] },
]
const misspellings = [{ index: 1 }, { index: 3 }]
// Build the result structure
const result = buildSpellCheckResult(
knownMisspelledWords,
@@ -140,21 +116,19 @@ describe('SpellChecker', function () {
// Check cache additions
expect(result.cacheAdditions.map(([k, v]) => [k.text, v])).to.deep.equal([
// Put these in cache as known misspellings
['bbb', ['box', 'bass']],
['ddd', ['docs', 'dance']],
['bbb', false],
['ddd', false],
// Put these in cache as known-correct
['aaa', true],
['ccc', true],
])
// Check misspellings
expect(
result.misspelledWords.map(w => [w.text, w.suggestions])
).to.deep.equal([
expect(result.misspelledWords.map(w => w.text)).to.deep.equal([
// Words in the payload that we now know were misspelled
['bbb', ['box', 'bass']],
['ddd', ['docs', 'dance']],
'bbb',
'ddd',
// Word we already knew was misspelled, preserved here
['fff', ['food', 'fleece']],
'fff',
])
})
})