mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-15 06:33:55 +01:00
Move source editor out of module (#12457)
* Update Copybara options in preparation for open-sourcing the source editor * Move files * Update paths * Remove source-editor module and checks for its existence * Explicitly mention CM6 license in files that contain code adapted from CM6 GitOrigin-RevId: 89b7cc2b409db01ad103198ccbd1b126ab56349b
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
import sinon from 'sinon'
|
||||
import { waitFor } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { EditorSelection, EditorState } from '@codemirror/state'
|
||||
import {
|
||||
cursorPosition,
|
||||
restoreCursorPosition,
|
||||
} from '../../../../../frontend/js/features/source-editor/extensions/cursor-position'
|
||||
|
||||
const doc = `
|
||||
\\documentclass{article}
|
||||
\\title{Your Paper}
|
||||
\\author{You}
|
||||
\\begin{document}
|
||||
\\maketitle
|
||||
\\begin{abstract}
|
||||
Your abstract.
|
||||
\\end{abstract}
|
||||
\\section{Introduction}
|
||||
Your introduction goes here!
|
||||
\\end{document}`
|
||||
|
||||
const mockDoc = () => {
|
||||
return {
|
||||
doc_id: 'test-doc',
|
||||
}
|
||||
}
|
||||
|
||||
describe('CodeMirror cursor position extension', function () {
|
||||
afterEach(function () {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
it('stores cursor position when the view is destroyed', async function () {
|
||||
const currentDoc = mockDoc()
|
||||
|
||||
sinon.stub(window.Storage.prototype, 'getItem').callsFake(key => {
|
||||
switch (key) {
|
||||
case 'doc.position.test-doc':
|
||||
return JSON.stringify({
|
||||
cursorPosition: { row: 1, column: 1 },
|
||||
firstVisibleLine: 5,
|
||||
})
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const setItem = sinon.spy(window.Storage.prototype, 'setItem')
|
||||
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc,
|
||||
extensions: [cursorPosition({ currentDoc })],
|
||||
}),
|
||||
})
|
||||
|
||||
view.dispatch({
|
||||
selection: EditorSelection.cursor(50),
|
||||
})
|
||||
|
||||
view.destroy()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setItem).to.have.been.calledWith(
|
||||
'doc.position.test-doc',
|
||||
JSON.stringify({
|
||||
cursorPosition: {
|
||||
row: 3,
|
||||
column: 6,
|
||||
},
|
||||
firstVisibleLine: 5,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('restores cursor position', async function () {
|
||||
const currentDoc = mockDoc()
|
||||
|
||||
const getItem = sinon
|
||||
.stub(window.Storage.prototype, 'getItem')
|
||||
.callsFake(key => {
|
||||
switch (key) {
|
||||
case 'doc.position.test-doc':
|
||||
return JSON.stringify({
|
||||
cursorPosition: { row: 3, column: 5 },
|
||||
firstVisibleLine: 0,
|
||||
})
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc,
|
||||
extensions: [cursorPosition({ currentDoc })],
|
||||
}),
|
||||
})
|
||||
view.dispatch(restoreCursorPosition(view.state.doc, 'test-doc'))
|
||||
|
||||
expect(getItem).to.have.been.calledWith('doc.position.test-doc')
|
||||
|
||||
await waitFor(() => {
|
||||
const [range] = view.state.selection.ranges
|
||||
expect(range.head).to.eq(49)
|
||||
expect(range.anchor).to.eq(49)
|
||||
expect(range.empty).to.eq(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
import { foldEffect, foldState } from '@codemirror/language'
|
||||
import { EditorSelection, EditorState } from '@codemirror/state'
|
||||
import { DecorationSet, EditorView } from '@codemirror/view'
|
||||
import { expect } from 'chai'
|
||||
import { duplicateSelection } from '../../../../../frontend/js/features/source-editor/commands/ranges'
|
||||
|
||||
type Position = {
|
||||
from: number
|
||||
to: number
|
||||
}
|
||||
|
||||
function folds(foldRanges: DecorationSet) {
|
||||
const ranges: Position[] = []
|
||||
foldRanges.between(Number.MIN_VALUE, Number.MAX_VALUE, (from, to) => {
|
||||
ranges.push({ from, to })
|
||||
})
|
||||
return ranges
|
||||
}
|
||||
|
||||
describe('Line duplication command', function () {
|
||||
describe('For single selections', function () {
|
||||
describe('For cursor selection', function () {
|
||||
it('Cursor selection duplicates line downwards', function () {
|
||||
const view = new EditorView({
|
||||
doc: 'line1\nline2',
|
||||
selection: EditorSelection.cursor(0),
|
||||
})
|
||||
|
||||
duplicateSelection(view)
|
||||
|
||||
expect(view.state.doc.toString()).to.equal('line1\nline1\nline2')
|
||||
|
||||
expect(view.state.selection.ranges.length).to.equal(1)
|
||||
expect(view.state.selection.ranges[0].eq(EditorSelection.cursor(0))).to
|
||||
.be.true
|
||||
})
|
||||
|
||||
it('Preserves folded ranges', function () {
|
||||
const view = new EditorView({
|
||||
doc: '\\begin{itemize}\n\t\\item test\n\\end{itemize}',
|
||||
extensions: foldState,
|
||||
})
|
||||
|
||||
view.dispatch(
|
||||
view.state.update({
|
||||
selection: EditorSelection.cursor(0),
|
||||
// Fold to \begin{itemize}...\end{itemize}
|
||||
effects: [foldEffect.of({ from: 15, to: 28 })],
|
||||
})
|
||||
)
|
||||
|
||||
duplicateSelection(view)
|
||||
|
||||
expect(folds(view.state.field(foldState))).to.deep.equal([
|
||||
{ from: 15, to: 28 },
|
||||
{ from: 57, to: 70 },
|
||||
])
|
||||
|
||||
expect(view.state.selection.ranges.length).to.equal(1)
|
||||
expect(view.state.selection.ranges[0].eq(EditorSelection.cursor(0))).to
|
||||
.be.true
|
||||
})
|
||||
})
|
||||
describe('For range selections', function () {
|
||||
it('Duplicates line with a cursor downwards', function () {
|
||||
const view = new EditorView({
|
||||
doc: 'line1\nline2',
|
||||
selection: EditorSelection.cursor(0),
|
||||
})
|
||||
|
||||
duplicateSelection(view)
|
||||
|
||||
expect(view.state.doc.toString()).to.equal('line1\nline1\nline2')
|
||||
})
|
||||
|
||||
it('Duplicates range forwards', function () {
|
||||
const view = new EditorView({
|
||||
doc: 'line1\nline2',
|
||||
selection: EditorSelection.range(0, 5),
|
||||
})
|
||||
|
||||
duplicateSelection(view)
|
||||
|
||||
expect(view.state.doc.toString()).to.equal('line1line1\nline2')
|
||||
expect(view.state.selection.ranges.length).to.equal(1)
|
||||
expect(view.state.selection.ranges[0].eq(EditorSelection.range(5, 10)))
|
||||
.to.be.true
|
||||
})
|
||||
|
||||
it('Duplicates range backwards', function () {
|
||||
const view = new EditorView({
|
||||
doc: 'line1\nline2',
|
||||
selection: EditorSelection.range(5, 0),
|
||||
})
|
||||
|
||||
duplicateSelection(view)
|
||||
|
||||
expect(view.state.doc.toString()).to.equal('line1line1\nline2')
|
||||
expect(view.state.selection.ranges.length).to.equal(1)
|
||||
expect(view.state.selection.ranges[0].eq(EditorSelection.range(5, 0)))
|
||||
.to.be.true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('For multiple selections', function () {
|
||||
it('Preserves folded ranges', function () {
|
||||
const doc =
|
||||
'\\begin{itemize}\n\t\\item line1\n\\end{itemize}\n\\begin{itemize}\n\t\\item line2\n\\end{itemize}'
|
||||
const view = new EditorView({
|
||||
doc,
|
||||
extensions: [foldState, EditorState.allowMultipleSelections.of(true)],
|
||||
})
|
||||
|
||||
view.dispatch(
|
||||
view.state.update({
|
||||
selection: EditorSelection.create([
|
||||
EditorSelection.cursor(0),
|
||||
EditorSelection.cursor(43),
|
||||
]),
|
||||
effects: [
|
||||
foldEffect.of({ from: 15, to: 29 }),
|
||||
foldEffect.of({ from: 58, to: 72 }),
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
duplicateSelection(view)
|
||||
|
||||
expect(view.state.doc.toString()).to.equal(
|
||||
'\\begin{itemize}\n\t\\item line1\n\\end{itemize}\n\\begin{itemize}\n\t\\item line1\n\\end{itemize}\n\\begin{itemize}\n\t\\item line2\n\\end{itemize}\n\\begin{itemize}\n\t\\item line2\n\\end{itemize}'
|
||||
)
|
||||
|
||||
expect(folds(view.state.field(foldState))).to.deep.equal([
|
||||
{ from: 15, to: 29 },
|
||||
{ from: 58, to: 72 },
|
||||
{ from: 101, to: 115 },
|
||||
{ from: 144, to: 158 },
|
||||
])
|
||||
|
||||
expect(view.state.selection.ranges.length).to.equal(2)
|
||||
expect(view.state.selection.ranges[0].eq(EditorSelection.cursor(0))).to.be
|
||||
.true
|
||||
expect(view.state.selection.ranges[1].eq(EditorSelection.cursor(86))).to
|
||||
.be.true
|
||||
})
|
||||
|
||||
it('Duplicates all selections', function () {
|
||||
const view = new EditorView({
|
||||
doc: 'line1\nline2',
|
||||
extensions: [EditorState.allowMultipleSelections.of(true)],
|
||||
selection: EditorSelection.create([
|
||||
EditorSelection.cursor(1),
|
||||
EditorSelection.range(7, 9),
|
||||
]),
|
||||
})
|
||||
|
||||
duplicateSelection(view)
|
||||
|
||||
expect(view.state.doc.toString()).to.equal('line1\nline1\nlinine2')
|
||||
|
||||
expect(view.state.selection.ranges.length).to.equal(2)
|
||||
expect(view.state.selection.ranges[0].eq(EditorSelection.cursor(1))).to.be
|
||||
.true
|
||||
expect(view.state.selection.ranges[1].eq(EditorSelection.range(15, 17)))
|
||||
.to.be.true
|
||||
})
|
||||
})
|
||||
})
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
import { expect } from 'chai'
|
||||
import { EditorView, DecorationSet } from '@codemirror/view'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { buildDecorations } from '../../../../../frontend/js/features/source-editor/extensions/line-wrapping-indentation'
|
||||
|
||||
const basicDoc = `
|
||||
\\begin{document}
|
||||
Test
|
||||
\\end{document}
|
||||
`
|
||||
|
||||
const docLongLineNoIndentation = `
|
||||
\\begin{document}
|
||||
Test
|
||||
Hello one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four
|
||||
\\end{document}
|
||||
`
|
||||
|
||||
const docLongLineWithIndentation = `
|
||||
\\begin{document}
|
||||
Test
|
||||
Hello one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four
|
||||
\\end{document}
|
||||
`
|
||||
|
||||
const docLongLineWithLotsOfIndentation = `
|
||||
\\begin{document}
|
||||
Test
|
||||
Hello one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four
|
||||
|
||||
Hello one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four
|
||||
|
||||
Hello one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four
|
||||
|
||||
Hello
|
||||
|
||||
Hello one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four
|
||||
|
||||
Hello
|
||||
\\end{document}
|
||||
`
|
||||
|
||||
describe('line-wrapping-indentation', function () {
|
||||
describe('buildDecorations', function () {
|
||||
const _buildView = (doc: string) => {
|
||||
return new EditorView({
|
||||
state: EditorState.create({
|
||||
doc,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
const _toArray = (decorations: DecorationSet) => {
|
||||
const result = []
|
||||
const cursor = decorations.iter()
|
||||
while (cursor.value) {
|
||||
result.push({ from: cursor.from, to: cursor.to, value: cursor.value })
|
||||
cursor.next()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
describe('basic document', function () {
|
||||
it('should have no decorations', function () {
|
||||
const view = _buildView(basicDoc)
|
||||
|
||||
const decorations = buildDecorations(view, 24)
|
||||
expect(decorations).to.exist
|
||||
expect(decorations.size).to.equal(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('document with long lines, no indentation', function () {
|
||||
it('should have no decorations', function () {
|
||||
const view = _buildView(docLongLineNoIndentation)
|
||||
|
||||
const decorations = buildDecorations(view, 24)
|
||||
expect(decorations).to.exist
|
||||
expect(decorations.size).to.equal(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('document with long lines, with indentation', function () {
|
||||
it('should have a decoration', function () {
|
||||
const view = _buildView(docLongLineWithIndentation)
|
||||
|
||||
const decorations = buildDecorations(view, 24)
|
||||
expect(decorations).to.exist
|
||||
expect(decorations.size).to.equal(1)
|
||||
|
||||
const decorationItem = _toArray(decorations)[0]
|
||||
expect(decorationItem.from).to.equal(23)
|
||||
expect(decorationItem.to).to.equal(23)
|
||||
})
|
||||
})
|
||||
|
||||
describe('document with long lines, with lots of indentation', function () {
|
||||
it('should have a decoration', function () {
|
||||
const view = _buildView(docLongLineWithLotsOfIndentation)
|
||||
|
||||
const decorations = buildDecorations(view, 24)
|
||||
expect(decorations).to.exist
|
||||
expect(decorations.size).to.equal(4)
|
||||
|
||||
const decorationsArray = _toArray(decorations)
|
||||
const expectedPositions = [23, 265, 507, 758]
|
||||
|
||||
decorationsArray.forEach((item, index) => {
|
||||
expect(item.from).to.equal(expectedPositions[index])
|
||||
expect(item.to).to.equal(expectedPositions[index])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import sinon from 'sinon'
|
||||
import { expect } from 'chai'
|
||||
import { EditorFacade } from '../../../../../frontend/js/features/source-editor/extensions/realtime'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
|
||||
describe('CodeMirror EditorFacade', function () {
|
||||
let state: EditorState, view: EditorView
|
||||
beforeEach(function () {
|
||||
state = EditorState.create()
|
||||
view = new EditorView({ state })
|
||||
})
|
||||
|
||||
it('should allow us to manipulate the CodeMirror document', function () {
|
||||
const editor = new EditorFacade(view)
|
||||
const text = 'basic test, nothing more'
|
||||
|
||||
editor.cmInsert(0, text)
|
||||
|
||||
expect(editor.getValue()).to.equal(text)
|
||||
|
||||
editor.cmDelete(0, 'b')
|
||||
|
||||
expect(editor.getValue()).to.equal(text.slice(1))
|
||||
})
|
||||
|
||||
it('should allow us to attach change listeners', function () {
|
||||
const editor = new EditorFacade(view)
|
||||
const listenerA = sinon.stub()
|
||||
const listenerB = sinon.stub()
|
||||
|
||||
editor.on('change', listenerA)
|
||||
editor.on('change', listenerB)
|
||||
|
||||
expect(listenerA).to.not.have.been.called
|
||||
expect(listenerB).to.not.have.been.called
|
||||
|
||||
const magicNumber = Math.random()
|
||||
editor.emit('change', magicNumber)
|
||||
|
||||
expect(listenerA).to.have.been.calledWith(magicNumber)
|
||||
expect(listenerB).to.have.been.calledWith(magicNumber)
|
||||
})
|
||||
|
||||
it('should attach to ShareJs document', function () {
|
||||
const editor = new EditorFacade(view)
|
||||
const text = 'something nice'
|
||||
const shareDoc = {
|
||||
on: sinon.stub(),
|
||||
getText: sinon.stub().returns(text),
|
||||
removeListener: sinon.stub(),
|
||||
detach_cm6: undefined,
|
||||
}
|
||||
|
||||
editor.cmInsert(0, text)
|
||||
|
||||
// @ts-ignore
|
||||
editor.attachShareJs(shareDoc)
|
||||
|
||||
expect(shareDoc.on.callCount).to.equal(2)
|
||||
expect(shareDoc.on).to.have.been.calledWith('insert')
|
||||
expect(shareDoc.on).to.have.been.calledWith('delete')
|
||||
|
||||
expect(shareDoc.detach_cm6).to.be.a('function')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import sinon from 'sinon'
|
||||
import { fireEvent, waitFor } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import {
|
||||
restoreScrollPosition,
|
||||
scrollPosition,
|
||||
} from '../../../../../frontend/js/features/source-editor/extensions/scroll-position'
|
||||
|
||||
const doc = `
|
||||
\\documentclass{article}
|
||||
\\title{Your Paper}
|
||||
\\author{You}
|
||||
\\begin{document}
|
||||
\\maketitle
|
||||
\\begin{abstract}
|
||||
Your abstract.
|
||||
\\end{abstract}
|
||||
\\section{Introduction}
|
||||
Your introduction goes here!
|
||||
\\end{document}`
|
||||
|
||||
const mockDoc = () => {
|
||||
return {
|
||||
doc_id: 'test-doc',
|
||||
}
|
||||
}
|
||||
|
||||
describe('CodeMirror scroll position extension', function () {
|
||||
beforeEach(function () {
|
||||
sinon.stub(HTMLElement.prototype, 'scrollHeight').returns(800)
|
||||
sinon.stub(HTMLElement.prototype, 'scrollWidth').returns(500)
|
||||
sinon.stub(HTMLElement.prototype, 'clientHeight').returns(200)
|
||||
sinon.stub(HTMLElement.prototype, 'clientWidth').returns(500)
|
||||
|
||||
sinon
|
||||
.stub(HTMLElement.prototype, 'getBoundingClientRect')
|
||||
.returns({ top: 100, left: 0, right: 500, bottom: 200 } as DOMRect)
|
||||
|
||||
// Range.getClientRects doesn't exist yet in jsdom
|
||||
window.Range.prototype.getClientRects = sinon.stub().returns([])
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
sinon.restore()
|
||||
// @ts-ignore
|
||||
delete window.Range.prototype.getClientRects
|
||||
})
|
||||
|
||||
it('stores scroll position when the view is destroyed', async function () {
|
||||
const currentDoc = mockDoc()
|
||||
|
||||
sinon.stub(window.Storage.prototype, 'getItem').callsFake(key => {
|
||||
switch (key) {
|
||||
case 'doc.position.test-doc':
|
||||
return JSON.stringify({
|
||||
cursorPosition: { row: 2, column: 2 },
|
||||
firstVisibleLine: 5,
|
||||
})
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc,
|
||||
extensions: [scrollPosition({ currentDoc })],
|
||||
}),
|
||||
})
|
||||
|
||||
const setItem = sinon.spy(window.Storage.prototype, 'setItem')
|
||||
fireEvent.scroll(view.scrollDOM, { target: { scrollTop: 10 } })
|
||||
|
||||
view.destroy()
|
||||
|
||||
const expected = JSON.stringify({
|
||||
cursorPosition: { row: 2, column: 2 },
|
||||
firstVisibleLine: 12,
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setItem).to.have.been.calledWith('doc.position.test-doc', expected)
|
||||
})
|
||||
})
|
||||
|
||||
it('restores scroll position', async function () {
|
||||
const currentDoc = mockDoc()
|
||||
|
||||
const getItem = sinon
|
||||
.stub(window.Storage.prototype, 'getItem')
|
||||
.callsFake(key => {
|
||||
switch (key) {
|
||||
case 'editor.position.test-doc':
|
||||
return JSON.stringify({ firstVisibleLine: 12 })
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc,
|
||||
extensions: [scrollPosition({ currentDoc })],
|
||||
}),
|
||||
})
|
||||
view.dispatch(restoreScrollPosition())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getItem).to.have.been.calledWith('doc.position.test-doc')
|
||||
})
|
||||
|
||||
// TODO: scrollTop should be a higher value but requires more mocking
|
||||
// await waitFor(() => {
|
||||
// expect(view.scrollDOM.scrollTop).to.eq(0)
|
||||
// })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { WordCache } from '../../../../../../frontend/js/features/source-editor/extensions/spelling/cache'
|
||||
import { expect } from 'chai'
|
||||
import { Word } from '../../../../../../frontend/js/features/source-editor/extensions/spelling/spellchecker'
|
||||
|
||||
describe('WordCache', function () {
|
||||
describe('basic operations', function () {
|
||||
let cache: WordCache, lang: string
|
||||
beforeEach(function () {
|
||||
cache = new WordCache()
|
||||
lang = 'xx'
|
||||
})
|
||||
|
||||
it('should store values in cache', function () {
|
||||
let word = 'foo'
|
||||
expect(cache.get(lang, word)).to.not.exist
|
||||
cache.set(lang, word, true)
|
||||
expect(cache.get(lang, word)).to.equal(true)
|
||||
|
||||
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'])
|
||||
})
|
||||
|
||||
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)
|
||||
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)
|
||||
})
|
||||
|
||||
it('should check words against cache', function () {
|
||||
cache.set(lang, 'foo', ['a', 'b'])
|
||||
cache.set(lang, 'bar', true)
|
||||
cache.set(lang, 'baz', true)
|
||||
const wordsToCheck = [
|
||||
{ text: 'foo', from: 0 },
|
||||
{ text: 'baz', from: 1 },
|
||||
{ text: 'quux', from: 2 },
|
||||
{ text: 'foo', from: 3 },
|
||||
{ text: 'zaz', from: 4 },
|
||||
] as Word[]
|
||||
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 },
|
||||
])
|
||||
expect(result.unknownWords).to.deep.equal([
|
||||
{ text: 'quux', from: 2 },
|
||||
{ text: 'zaz', from: 4 },
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
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],
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
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'
|
||||
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 { 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
|
||||
}
|
||||
|
||||
describe('SpellChecker', function () {
|
||||
describe('getWordsFromLine', function () {
|
||||
let lang: string, ignoredWords: IgnoredWords
|
||||
beforeEach(function () {
|
||||
/* Note: ignore the word 'test' */
|
||||
lang = 'en'
|
||||
ignoredWords = new Set([]) as unknown as IgnoredWords
|
||||
})
|
||||
|
||||
it('should get words from a line', function () {
|
||||
const view = makeView('Hello test one two')
|
||||
const line = view.state.doc.line(1)
|
||||
const words = getWordsFromLine(view, line, ignoredWords, 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' },
|
||||
{ text: 'one', from: 11, to: 14, lineNumber: 1, lang: 'en' },
|
||||
{ text: 'two', from: 15, to: 18, lineNumber: 1, lang: 'en' },
|
||||
])
|
||||
})
|
||||
|
||||
it('should ignore words in ignoredWords', function () {
|
||||
ignoredWords = new Set(['test']) as unknown as IgnoredWords
|
||||
const view = makeView('Hello test one two')
|
||||
const line = view.state.doc.line(1)
|
||||
const words = 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 = makeView(' ')
|
||||
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 line = view.state.doc.line(1)
|
||||
const words = getWordsFromLine(view, line, ignoredWords, 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' },
|
||||
])
|
||||
})
|
||||
|
||||
it('should ignore command names in the text', function () {
|
||||
const view = makeView('\\foo nine \\bar ten \\baz[]{}')
|
||||
const line = view.state.doc.line(1)
|
||||
const words = getWordsFromLine(view, line, ignoredWords, 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' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
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[] = []
|
||||
const unknownWords: Word[] = []
|
||||
const misspellings: { index: number; suggestions: string[] }[] = []
|
||||
const result = buildSpellCheckResult(
|
||||
knownMisspelledWords,
|
||||
unknownWords,
|
||||
misspellings
|
||||
)
|
||||
expect(result).to.deep.equal({
|
||||
cacheAdditions: [],
|
||||
misspelledWords: [],
|
||||
})
|
||||
})
|
||||
it('should build a realistic result', function () {
|
||||
const _makeWord = (text: string, suggestions?: string[]) => {
|
||||
const word = 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'])]
|
||||
// These words we didn't know
|
||||
const unknownWords = [
|
||||
_makeWord('aaa'),
|
||||
_makeWord('bbb'),
|
||||
_makeWord('ccc'),
|
||||
_makeWord('ddd'),
|
||||
]
|
||||
// These are the suggestions we got back from the backend
|
||||
const misspellings = [
|
||||
{ index: 1, suggestions: ['box', 'bass'] },
|
||||
{ index: 3, suggestions: ['docs', 'dance'] },
|
||||
]
|
||||
// Build the result structure
|
||||
const result = buildSpellCheckResult(
|
||||
knownMisspelledWords,
|
||||
unknownWords,
|
||||
misspellings
|
||||
)
|
||||
expect(result).to.have.keys('cacheAdditions', 'misspelledWords')
|
||||
// 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']],
|
||||
// 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([
|
||||
// Words in the payload that we now know were misspelled
|
||||
['bbb', ['box', 'bass']],
|
||||
['ddd', ['docs', 'dance']],
|
||||
// Word we already knew was misspelled, preserved here
|
||||
['fff', ['food', 'fleece']],
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user