first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -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)
})
})
})

View File

@@ -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
})
})
})

View File

@@ -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])
})
})
})
})
})

View File

@@ -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')
})
})

View File

@@ -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 }, { visual: false })],
}),
})
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 }, { visual: false })],
}),
})
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)
// })
})
})

View File

@@ -0,0 +1,61 @@
import { WordCache } from '@/features/source-editor/extensions/spelling/cache'
import { expect } from 'chai'
import { Word } from '@/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, 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, true)
expect(cache.get(lang, word)).to.equal(true)
expect(cache.get(otherLang, word)).to.not.exist
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', false)
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', from: 0 },
{ text: 'foo', from: 3 },
])
expect(result.unknownWords).to.deep.equal([
{ text: 'quux', from: 2 },
{ text: 'zaz', from: 4 },
])
})
})
})

View File

@@ -0,0 +1,135 @@
import {
getWordsFromLine,
buildSpellCheckResult,
Word,
} from '@/features/source-editor/extensions/spelling/spellchecker'
import { expect } from 'chai'
import { EditorView } from '@codemirror/view'
import { LaTeXLanguage } from '@/features/source-editor/languages/latex/latex-language'
import { LanguageSupport } from '@codemirror/language'
const extensions = [new LanguageSupport(LaTeXLanguage)]
describe('SpellChecker', function () {
describe('getWordsFromLine', function () {
let lang: string
beforeEach(function () {
/* Note: ignore the word 'test' */
lang = 'en'
})
it('should get words from a line', function () {
const view = new EditorView({
doc: 'Hello test one two',
extensions,
})
const line = view.state.doc.line(1)
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' },
{ 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, lang))
expect(words).to.deep.equal([])
})
it('should ignore content of some commands in the text', function () {
const view = new EditorView({
doc: '\\usepackage[foo]{ bar } seven eight',
extensions,
})
const line = view.state.doc.line(1)
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' },
])
})
it('should ignore command names in the text', function () {
const view = new EditorView({
doc: '\\foo nine \\bar ten \\baz[]{}',
extensions,
})
const line = view.state.doc.line(1)
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' },
])
})
})
describe('buildSpellCheckResult', function () {
it('should build an empty result', function () {
const knownMisspelledWords: Word[] = []
const unknownWords: Word[] = []
const misspellings: { index: number }[] = []
const result = buildSpellCheckResult(
knownMisspelledWords,
unknownWords,
misspellings
)
expect(result).to.deep.equal({
cacheAdditions: [],
misspelledWords: [],
})
})
it('should build a realistic result', function () {
const _makeWord = (text: string) => {
return new Word({
text,
from: 0,
to: 0,
lineNumber: 0,
lang: 'xx',
})
}
// We know this word is misspelled
const knownMisspelledWords = [_makeWord('fff')]
// 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 }, { index: 3 }]
// 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', false],
['ddd', false],
// Put these in cache as known-correct
['aaa', true],
['ccc', true],
])
// Check misspellings
expect(result.misspelledWords.map(w => w.text)).to.deep.equal([
// Words in the payload that we now know were misspelled
'bbb',
'ddd',
// Word we already knew was misspelled, preserved here
'fff',
])
})
})
})