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,223 @@
import { expect, use } from 'chai'
import { toggleRanges } from '../../../../../frontend/js/features/source-editor/commands/ranges'
import { CodemirrorTestSession, viewHelpers } from '../helpers/codemirror'
use(viewHelpers)
const BOLD_COMMAND = toggleRanges('\\textbf')
describe('toggleRanges', function () {
describe('when text outside of a command is selected', function () {
it('wraps the selection in a command', function () {
const cm = new CodemirrorTestSession(['this <is my> range'])
cm.applyCommand(BOLD_COMMAND)
expect(cm).line(1).to.equal('this \\textbf{<is my>} range')
})
describe('when it is an empty selection', function () {
it('inserts a wrapping command and keep cursor inside the argument', function () {
const cm = new CodemirrorTestSession(['this is | my range'])
cm.applyCommand(BOLD_COMMAND)
expect(cm).line(1).to.equal('this is \\textbf{|} my range')
})
})
describe('when it is an empty selection before a command', function () {
it('inserts a wrapping command and keep cursor inside the argument', function () {
const cm = new CodemirrorTestSession(['this is |\\textbf{my range}'])
cm.applyCommand(BOLD_COMMAND)
expect(cm).line(1).to.equal('this is \\textbf{|}\\textbf{my range}')
})
})
})
describe('when text inside a command is selected', function () {
describe('if the whole command is selected', function () {
it('removes the wrapping command', function () {
const cm = new CodemirrorTestSession(['this \\textbf{<is my>} range'])
cm.applyCommand(BOLD_COMMAND)
expect(cm).line(1).to.equal('this <is my> range')
})
})
describe('if the command is empty', function () {
it('removes the command', function () {
const cm = new CodemirrorTestSession(['\\textbf{|}'])
cm.applyCommand(BOLD_COMMAND)
expect(cm).line(1).to.equal('|')
})
})
describe('if the selection is at the beginning of a wrapping command', function () {
it('shifts the start of the command', function () {
const cm = new CodemirrorTestSession(['\\textbf{<this is> my} range'])
cm.applyCommand(BOLD_COMMAND)
expect(cm).line(1).to.equal('this is\\textbf{ my} range')
})
})
describe('if the selection is at the end of a wrapping command', function () {
it('shifts the end of the command', function () {
const cm = new CodemirrorTestSession(['\\textbf{this <is my>} range'])
cm.applyCommand(BOLD_COMMAND)
expect(cm).line(1).to.equal('\\textbf{this }<is my> range')
})
})
describe('if the selection is in the middle of a wrapping command', function () {
it('splits command in two with non-empty selection', function () {
const cm = new CodemirrorTestSession(['\\textbf{this <is my> range}'])
cm.applyCommand(BOLD_COMMAND)
expect(cm).line(1).to.equal('\\textbf{this }<is my>\\textbf{ range}')
})
it('splits command in two with empty selection', function () {
const cm = new CodemirrorTestSession(['\\textbf{this is | my range}'])
cm.applyCommand(BOLD_COMMAND)
expect(cm).line(1).to.equal('\\textbf{this is }|\\textbf{ my range}')
})
})
})
describe('when selection spans between two wrapping commands', function () {
it('joins the two commands into one', function () {
const cm = new CodemirrorTestSession([
'\\textbf{this <is} my \\textbf{ran>ge}',
])
cm.applyCommand(BOLD_COMMAND)
expect(cm).line(1).to.equal('\\textbf{this <is my ran>ge}')
})
})
describe('when selection spans across a wrapping command', function () {
it('extends to the left', function () {
const cm = new CodemirrorTestSession(['<this \\textbf{is my> range}'])
cm.applyCommand(BOLD_COMMAND)
expect(cm).line(1).to.equal('\\textbf{<this is my> range}')
})
it('extends to the right', function () {
const cm = new CodemirrorTestSession(['\\textbf{this is <my} range>'])
cm.applyCommand(BOLD_COMMAND)
expect(cm).line(1).to.equal('\\textbf{this is <my range>}')
})
})
describe('when selection includes more than content', function () {
describe('when selection contains command', function () {
it('still unbolds', function () {
const cm = new CodemirrorTestSession(['<\\textbf{this is my range>}'])
cm.applyCommand(BOLD_COMMAND)
expect(cm).line(1).to.equal('<this is my range>')
})
})
describe('when selection contains opening bracket', function () {
it('still unbolds', function () {
const cm = new CodemirrorTestSession(['\\textbf<{this is my range>}'])
cm.applyCommand(BOLD_COMMAND)
expect(cm).line(1).to.equal('<this is my range>')
})
})
describe('when selection contains closing bracket', function () {
it('still unbolds', function () {
const cm = new CodemirrorTestSession(['\\textbf{<this is my range}>'])
cm.applyCommand(BOLD_COMMAND)
expect(cm).line(1).to.equal('<this is my range>')
})
})
describe('when selection contains both brackets', function () {
it('still unbolds', function () {
const cm = new CodemirrorTestSession(['\\textbf<{this is my range}>'])
cm.applyCommand(BOLD_COMMAND)
expect(cm).line(1).to.equal('<this is my range>')
})
})
describe('when selection contains entire command', function () {
it('still unbolds', function () {
const cm = new CodemirrorTestSession(['<\\textbf{this is my range}>'])
cm.applyCommand(BOLD_COMMAND)
expect(cm).line(1).to.equal('<this is my range>')
})
})
describe('when toggling outer command', function () {
it('it functions on the outer command', function () {
const cm = new CodemirrorTestSession([
'\\textbf{\\textit{<this is my range>}}',
])
cm.applyCommand(BOLD_COMMAND)
expect(cm).line(1).to.equal('<\\textit{this is my range}>')
})
it('prevents breaking commands', function () {
const cm = new CodemirrorTestSession([
'\\textbf{\\textit{this <is} my} range>',
])
cm.applyCommand(BOLD_COMMAND)
expect(cm).line(1).to.equal('\\textbf{\\textit{this <is} my} range>')
})
})
describe('when range is after a command', function () {
it('still formats list items', function () {
const cm = new CodemirrorTestSession([
'\\begin{itemize}',
' \\item <My item>',
'\\end{itemize}',
])
cm.applyCommand(BOLD_COMMAND)
expect(cm).line(2).to.equal(' \\item \\textbf{<My item>}')
})
it('still formats after command', function () {
const cm = new CodemirrorTestSession(['\\noindent <My paragraph>'])
cm.applyCommand(BOLD_COMMAND)
expect(cm).line(1).to.equal('\\noindent \\textbf{<My paragraph>}')
})
it('still formats after unknown command with arguments', function () {
const cm = new CodemirrorTestSession(['\\foo{test}<My paragraph>'])
cm.applyCommand(BOLD_COMMAND)
expect(cm).line(1).to.equal('\\foo{test}\\textbf{<My paragraph>}')
})
it('still formats after known command with arguments', function () {
const cm1 = new CodemirrorTestSession(['\\cite{foo}<text>'])
cm1.applyCommand(BOLD_COMMAND)
expect(cm1).line(1).to.equal('\\cite{foo}\\textbf{<text>}')
const cm2 = new CodemirrorTestSession(['\\href{url}{title}<text>'])
cm2.applyCommand(BOLD_COMMAND)
expect(cm2).line(1).to.equal('\\href{url}{title}\\textbf{<text>}')
})
})
})
it('still formats text next to a command', function () {
const cm = new CodemirrorTestSession(['<item>\\foo'])
cm.applyCommand(BOLD_COMMAND)
expect(cm).line(1).to.equal('\\textbf{item}\\foo')
})
it('still formats part of a text next to command', function () {
const cm = new CodemirrorTestSession(['hello <world>\\foo'])
cm.applyCommand(BOLD_COMMAND)
expect(cm).line(1).to.equal('hello \\textbf{world}\\foo')
})
it('still formats command without arguments', function () {
const cm = new CodemirrorTestSession(['\\item<\\foo>'])
cm.applyCommand(BOLD_COMMAND)
expect(cm).line(1).to.equal('\\item\\textbf{<\\foo>}')
})
it('skips formatting if in the middle of two commands', function () {
const cm = new CodemirrorTestSession(['\\f<oo\\b>ar'])
cm.applyCommand(BOLD_COMMAND)
expect(cm).line(1).to.equal('\\foo\\bar')
})
})

View File

@@ -0,0 +1,990 @@
import { Folder } from '../../../../../types/folder'
import { docId, mockDocContent } from '../helpers/mock-doc'
import { mockScope } from '../helpers/mock-scope'
import { EditorProviders } from '../../../helpers/editor-providers'
import CodeMirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
import { activeEditorLine } from '../helpers/active-editor-line'
import { TestContainer } from '../helpers/test-container'
import { FC } from 'react'
import { MetadataContext } from '@/features/ide-react/context/metadata-context'
import { ReferencesContext } from '@/features/ide-react/context/references-context'
describe('autocomplete', { scrollBehavior: false }, function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
window.metaAttributesCache.set('ol-showSymbolPalette', true)
cy.interceptEvents()
cy.interceptMetadata()
})
it('opens autocomplete on matched text', function () {
const rootFolder: Folder[] = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [
{
_id: docId,
name: 'main.tex',
},
],
folders: [
{
_id: 'test-folder-id',
name: 'test-folder',
docs: [
{
_id: 'test-doc-in-folder',
name: 'example.tex',
},
],
fileRefs: [
{
_id: 'test-file-in-folder',
name: 'example.png',
hash: '42',
},
],
folders: [],
},
],
fileRefs: [
{
_id: 'test-image-file',
name: 'frog.jpg',
hash: '21',
},
{
_id: 'uppercase-extension-image-file',
name: 'frog.JPG',
hash: '22',
},
],
},
]
const scope = mockScope()
scope.project.rootFolder = rootFolder
cy.mount(
<TestContainer>
<EditorProviders scope={scope} rootFolder={rootFolder as any}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.get('.cm-editor').as('editor')
cy.contains('\\section{Results}')
// no autocomplete
cy.findAllByRole('listbox').should('have.length', 0)
// put the cursor on a blank line to type in
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
// single backslash
cy.get('@line').type('\\')
// autocomplete
cy.findAllByRole('listbox').should('have.length', 1)
// another backslash
cy.get('@line').type('\\')
// no autocomplete open
cy.findAllByRole('listbox').should('have.length', 0)
// a space then another backslash
cy.get('@line').type(' \\')
// autocomplete open
cy.findAllByRole('listbox').should('have.length', 1)
// start a command
cy.get('@line').type('includegr')
// select option from autocomplete
// disabled as selector not working (Cypress bug?)
// cy.findByRole('listbox')
// .findByRole('option', {
// name: '\\includegraphics[]{}',
// selected: true,
// })
// .click()
cy.contains('\\includegraphics[]{}').click()
// start a command in the optional argument
cy.get('@line').type('width=0.3\\text')
// select option from autocomplete
// disabled as selector not working (Cypress bug?)
// cy.findByRole('listbox')
// .findByRole('option', {
// name: '\\textwidth',
// selected: false,
// })
// .click()
cy.contains('\\textwidth').click()
// move to required argument and start a label
cy.get('@line')
// .type('{tab}') // Tab not supported in Cypress
.type('{rightArrow}{rightArrow}')
cy.get('@line').type('fr')
// select option from autocomplete
// disabled as selector not working (Cypress bug?)
// cy.findByRole('listbox')
// .findByRole('option', {
// name: 'frog.jpg',
// selected: true,
// })
// .click()
cy.contains('frog.jpg').click()
cy.contains('\\includegraphics[width=0.3\\textwidth]{frog.jpg}')
// start a new line and select an "includegraphics" command completion
cy.get('@line').type('{Enter}')
activeEditorLine().type('\\includegr')
cy.contains('\\includegraphics[]{}').click()
// select a completion for a file in a folder, without typing the folder name
activeEditorLine()
.type('{rightArrow}{rightArrow}')
.type('examp')
.type('{Backspace}')
.type('ple')
cy.contains('test-folder/example.png').click()
cy.contains('\\includegraphics[]{test-folder/example.png}')
activeEditorLine()
.type(`${'{leftArrow}'.repeat('test-folder/example.png}'.length)}fr`)
.type('{ctrl+ }')
cy.findAllByRole('listbox').should('have.length', 1)
cy.findByRole('listbox').contains('frog.JPG').click()
activeEditorLine().should('have.text', '\\includegraphics[]{frog.JPG}')
})
it('opens autocomplete on begin environment', function () {
const rootFolder: Folder[] = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [
{
_id: docId,
name: 'main.tex',
},
],
folders: [
{
_id: 'test-folder-id',
name: 'test-folder',
docs: [
{
_id: 'test-doc-in-folder',
name: 'example.tex',
},
],
fileRefs: [
{
_id: 'test-file-in-folder',
name: 'example.png',
hash: '42',
},
],
folders: [],
},
],
fileRefs: [
{
_id: 'test-image-file',
name: 'frog.jpg',
hash: '43',
},
],
},
]
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders scope={scope} rootFolder={rootFolder as any}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.get('.cm-editor').as('editor')
cy.contains('\\section{Results}')
// put the cursor on a blank line to type in
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
// ---- Basic autocomplete of environments
cy.get('@line').type('\\begin{itemi')
cy.findAllByRole('option').contains('\\begin{itemize}').click()
cy.get('.cm-line').eq(16).contains('\\begin{itemize}')
cy.get('.cm-line').eq(17).contains('\\item')
cy.get('.cm-line').eq(18).contains('\\end{itemize}')
// ---- Autocomplete on a malformed `\begin{`
// first, ensure that the "abcdef" environment is present in the doc
cy.get('.cm-line')
.eq(20)
.type('\\begin{{}abcdef}{Enter}{Enter}\\end{{}abcdef}{Enter}{Enter}')
cy.get('.cm-line').eq(24).as('line')
cy.get('@line').type('\\begin{abcdef')
cy.findAllByRole('option').contains('\\begin{abcdef}').click()
cy.get('.cm-line').eq(24).contains('\\begin{abcdef}')
cy.get('.cm-line').eq(26).contains('\\end{abcdef}')
// ---- Autocomplete starting from end of `\begin`
cy.get('.cm-line').eq(22).type('{Enter}{Enter}{Enter}')
cy.get('.cm-line').eq(24).as('line')
cy.get('@line').type('\\begin {leftArrow}{leftArrow}')
cy.get('@line').type('{ctrl} ')
cy.findAllByRole('option').contains('\\begin{align}').click()
cy.get('.cm-line').eq(24).contains('\\begin{align}')
cy.get('.cm-line').eq(26).contains('\\end{align}')
// ---- Start typing a begin command
cy.get('.cm-line').eq(28).as('line')
cy.get('@line').click()
cy.get('@line').type('\\begin{{}ab')
cy.findAllByRole('option').as('options')
cy.get('@options').should('have.length', 5)
// ---- The environment being typed should appear in the list
cy.get('@options').contains('\\begin{ab}')
// ---- A new environment used elsewhere in the doc should appear next
cy.get('@options')
.eq(0)
.invoke('text')
.should('match', /^\\begin\{abcdef}/)
// ---- The built-in environments should appear at the top of the list
cy.get('@options')
.eq(1)
.invoke('text')
.should('match', /^\\begin\{abstract}/)
})
it('opens autocomplete using metadata for usepackage parameter', function () {
const rootFolder: Folder[] = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [
{
_id: docId,
name: 'main.tex',
},
],
folders: [],
fileRefs: [],
},
]
const metadata = {
commands: [],
labels: new Set<string>(),
packageNames: new Set(['foo']),
}
const MetadataProvider: FC = ({ children }) => {
return (
<MetadataContext.Provider value={metadata}>
{children}
</MetadataContext.Provider>
)
}
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders
scope={scope}
rootFolder={rootFolder as any}
providers={{ MetadataProvider }}
>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.get('.cm-editor').as('editor')
// no autocomplete
cy.findAllByRole('listbox').should('have.length', 0)
// put the cursor on a blank line to type in
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
// a usepackage command
cy.get('@line').type('\\usepackage')
// autocomplete open
cy.findAllByRole('listbox').should('have.length', 1)
cy.findAllByRole('option').eq(0).should('contain.text', '\\usepackage{}')
cy.findAllByRole('option').eq(1).should('contain.text', '\\usepackage[]{}')
// the start of a package name from the metadata
cy.get('@line').type('{fo')
// autocomplete open
cy.findAllByRole('listbox')
.should('have.length', 1)
.type('{downArrow}{downArrow}{Enter}')
cy.contains('\\usepackage{foo}')
})
it('opens autocomplete using metadata for cite parameter', function () {
const rootFolder: Folder[] = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [
{
_id: docId,
name: 'main.tex',
},
],
folders: [],
fileRefs: [],
},
]
const scope = mockScope()
const ReferencesProvider: FC = ({ children }) => {
return (
<ReferencesContext.Provider
value={{
referenceKeys: new Set(['ref-1', 'ref-2', 'ref-3']),
indexAllReferences: cy.stub(),
}}
>
{children}
</ReferencesContext.Provider>
)
}
cy.mount(
<TestContainer>
<EditorProviders
scope={scope}
providers={{ ReferencesProvider }}
rootFolder={rootFolder as any}
>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.get('.cm-editor').as('editor')
// no autocomplete
cy.findAllByRole('listbox').should('have.length', 0)
// put the cursor on a blank line to type in
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
// a cite command with no opening brace
cy.get('@line').type('\\cite')
// select completion
cy.findAllByRole('listbox').contains('\\cite{}').click()
cy.get('@line').contains('\\cite{}')
// autocomplete open again
cy.findAllByRole('listbox').contains('ref-2').click()
cy.get('@line').contains('\\cite{ref-2}')
// start typing another reference
cy.get('@line').type('{leftArrow}, re')
// autocomplete open again
cy.findAllByRole('listbox').contains('ref-3').click()
cy.get('@line').contains('\\cite{ref-2, ref-3}')
})
it('autocomplete stops after space after command', function () {
const rootFolder: Folder[] = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [
{
_id: docId,
name: 'main.tex',
},
],
folders: [],
fileRefs: [],
},
]
const scope = mockScope()
scope.project.rootFolder = rootFolder
cy.mount(
<TestContainer>
<EditorProviders scope={scope} rootFolder={rootFolder as any}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.get('.cm-editor').as('editor')
cy.contains('\\section{Results}')
// no autocomplete
cy.findAllByRole('listbox').should('have.length', 0)
// put the cursor on a blank line to type in
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
// single backslash
cy.get('@line').type('\\')
// autocomplete
cy.findAllByRole('listbox').should('have.length', 1)
// start a command
cy.get('@line').type('ite')
// offers completion for item
cy.contains(/\\item.*cmd/).click()
cy.get('@line').type('{Enter}{Enter}\\item ')
cy.contains('\\begin{itemize').should('not.exist')
})
it('autocomplete does not remove closing brackets in commands with multiple braces {}{}', function () {
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
// put the cursor on a blank line to type in
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
cy.get('@line').type('\\frac')
// select completion
cy.findAllByRole('listbox').contains('\\frac{}{}').click()
cy.get('@line').type('\\textbf')
// select completion
cy.findAllByRole('listbox').contains('\\textbf{}').click()
cy.get('@line').should('contain.text', '\\frac{\\textbf{}}{}')
// go to new line
cy.get('@line').click()
cy.get('@line').type('{enter}')
cy.get('.cm-line').eq(17).as('line')
cy.get('@line').click()
cy.get('@line').type('\\frac')
// select completion
cy.findAllByRole('listbox').contains('\\frac{}{}').click()
cy.get('@line').type('\\partial')
// select completion
cy.findAllByRole('listbox').contains('\\partial').click()
cy.get('@line').should('contain.text', '\\frac{\\partial}{}')
})
it('autocomplete does not remove paired closing brackets in nested commands', function () {
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
// put the cursor on a blank line to type in
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
// type some commands
// note: '{{}' is a single opening brace
cy.get('@line').type('\\sqrt{{}2} some more text \\sqrt{{}\\sqrt{{}}}')
// put the cursor inside the middle pair of braces
cy.get('@line').type('{leftArrow}{leftArrow}')
// start a command
cy.get('@line').type('\\sqrt')
// select completion
cy.findAllByRole('listbox').contains('\\sqrt{}').click()
// assert that the existing closing brace hasn't been removed
cy.get('@line').should(
'have.text',
'\\sqrt{2} some more text \\sqrt{\\sqrt{\\sqrt{}}}'
)
})
it('autocomplete does remove unpaired closing brackets in nested commands', function () {
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
// put the cursor on a blank line to type in
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
// type some commands
// note: '{{}' is a single opening brace
cy.get('@line').type('\\sqrt{{}2} some more text \\sqrt{{}\\sqrt{{}}}}')
// put the cursor inside the middle pair of braces
cy.get('@line').type('{leftArrow}{leftArrow}{leftArrow}')
// start a command
cy.get('@line').type('\\sqrt')
// select completion
cy.findAllByRole('listbox').contains('\\sqrt{}').click()
// assert that the existing closing brace hasn't been removed
cy.get('@line').should(
'have.text',
'\\sqrt{2} some more text \\sqrt{\\sqrt{\\sqrt{}}}'
)
})
it('displays completions for existing commands with multiple parameters', function () {
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.get('.cm-editor').as('editor')
// put the cursor on a blank line to type in
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
// a new command, then the start of the command on a new blank line
cy.get('@line').type('\\foo[bar]{{}baz}{{}zap}')
// enter, to create a new line
cy.get('@editor').trigger('keydown', { key: 'Enter' })
// put the cursor on the new line to type in
cy.get('.cm-line').eq(17).as('line')
cy.get('@line').click()
// the start of the command
cy.get('@line').type('\\foo')
// select the new completion
cy.findAllByRole('listbox').contains('\\foo[]{}{}').click()
// fill in the optional parameter
cy.get('@line').type('bar')
cy.get('@editor').contains('\\foo[bar]{}{}')
})
it('displays completions for existing commands in math mode', function () {
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.get('.cm-editor').as('editor')
// put the cursor on a blank line to type in
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
// a new command, then the start of the command on a new blank line
cy.get('@line').type('$\\somemathcommand$')
// enter, to create a new line
cy.get('@editor').trigger('keydown', { key: 'Enter' })
// put the cursor on the new line to type in
cy.get('.cm-line').eq(17).as('line')
cy.get('@line').click()
// the start of the command
cy.get('@line').type('hello \\somema')
// select the new completion
cy.findAllByRole('listbox').contains('\\somemathcommand').click()
cy.get('@editor').contains('hello \\somemathcommand')
})
it('displays completions for nested existing commands', function () {
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.get('.cm-editor').as('editor')
// put the cursor on a blank line to type in
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
// a new command, then the start of the command on a new blank line
cy.get('@line').type('\\newcommand{{}\\foo}[1]{{}#1}')
// enter, to create a new line
cy.get('@editor').trigger('keydown', { key: 'Enter' })
// put the cursor on the new line to type in
cy.get('.cm-line').eq(17).as('line')
cy.get('@line').click()
// the start of the command
cy.get('@line').type('\\fo')
// select the new completion
cy.findAllByRole('listbox')
.contains(/\\foo{}\s*cmd/)
.click()
cy.get('@line').contains('\\foo')
})
it('displays unique completions for commands', function () {
const scope = mockScope()
const metadata = {
commands: [
{
caption: '\\label{}', // label{} is also included in top-hundred-snippets
meta: 'amsmath-cmd',
score: 1,
snippet: '\\label{$1}',
},
],
labels: new Set<string>(),
packageNames: new Set<string>('amsmath'),
}
const MetadataProvider: FC = ({ children }) => {
return (
<MetadataContext.Provider value={metadata}>
{children}
</MetadataContext.Provider>
)
}
cy.mount(
<TestContainer>
<EditorProviders scope={scope} providers={{ MetadataProvider }}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.get('.cm-editor').as('editor')
// put the cursor on a blank line to type in
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
// start typing a command
cy.get('@line').type('\\label')
cy.findAllByRole('option').contains('\\label{}').should('have.length', 1)
})
it('displays symbol completions in autocomplete when the feature is enabled', function () {
const scope = mockScope()
window.metaAttributesCache.set('ol-showSymbolPalette', true)
const user = {
id: '123abd',
email: 'testuser@example.com',
}
cy.mount(
<TestContainer>
<EditorProviders user={user} scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
// put the cursor on a blank line to type in
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
// type the name of a symbol
cy.get('@line').type(' \\alpha')
cy.findAllByRole('listbox').should('have.length', 1)
// the symbol completion should exist
cy.findAllByRole('option', {
name: /^\\alpha\s+𝛼\s+Greek$/,
}).should('have.length', 1)
})
it('does not display symbol completions in autocomplete when the feature is disabled', function () {
const scope = mockScope()
window.metaAttributesCache.set('ol-showSymbolPalette', false)
const user = {
id: '123abd',
email: 'testuser@example.com',
}
cy.mount(
<TestContainer>
<EditorProviders user={user} scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
// put the cursor on a blank line to type in
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
// type the name of a symbol
cy.get('@line').type(' \\alpha')
cy.findAllByRole('listbox').should('have.length', 1)
cy.findAllByRole('option', {
name: /^\\alpha\s+𝛼\s+Greek$/,
}).should('have.length', 0)
})
it('displays environment completion when typing up to closing brace', function () {
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
// Put the cursor on a blank line to type in
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
// Type \begin{itemize}.
// Note: '{{}' is a single opening brace
cy.get('@line').type('\\begin{{}itemize}', {
delay: 100,
})
cy.findAllByRole('listbox').should('have.length', 1)
})
it('displays environment completion when typing inside \\begin{}', function () {
const scope = mockScope(mockDocContent('\\begin{}'))
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
// Put the cursor on a blank line above target line
cy.get('.cm-line').eq(20).as('line')
cy.get('@line').click()
// Move to the position between the braces then type 'itemize'
cy.get('@line').type(`{downArrow}${'{rightArrow}'.repeat(7)}itemize`, {
delay: 100,
})
cy.findAllByRole('listbox').should('have.length', 1)
})
it('displays environment completion when typing after \\begin{', function () {
const scope = mockScope(mockDocContent('\\begin{'))
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
// Put the cursor on a blank line above target line
cy.get('.cm-line').eq(20).as('line')
cy.get('@line').click()
// Move to the position after the opening brace then type 'itemize}'
cy.get('@line').type(`{downArrow}${'{rightArrow}'.repeat(7)}itemize}`, {
delay: 100,
})
cy.findAllByRole('listbox').should('have.length', 1)
})
it('removes .tex but not .txt file extension from \\include and \\input', function () {
const rootFolder: Folder[] = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [
{
_id: docId,
name: 'main.tex',
},
{
_id: 'test-include-tex-doc',
name: 'example.tex',
},
{
_id: 'test-include-txt',
name: 'sometext.txt',
},
],
folders: [],
fileRefs: [],
},
]
const scope = mockScope()
scope.project.rootFolder = rootFolder
cy.mount(
<TestContainer>
<EditorProviders scope={scope} rootFolder={rootFolder as any}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
// Put the cursor on a blank line and type
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
cy.get('@line').type('\\include{e', { delay: 100 })
cy.findAllByRole('option').contains('example.tex').click()
activeEditorLine().contains('\\include{example')
activeEditorLine().type('}{Enter}')
activeEditorLine().type('\\include{s', { delay: 100 })
cy.findAllByRole('option').contains('sometext.txt').click()
activeEditorLine().should('have.text', '\\include{sometext.txt}')
activeEditorLine().type('{Enter}')
activeEditorLine().type('\\inclu', { delay: 100 })
cy.contains('\\include{}').click()
cy.contains('example.tex').click()
activeEditorLine().should('have.text', '\\include{example}')
activeEditorLine().type('{Enter}')
activeEditorLine().type('\\inclu', { delay: 100 })
cy.findAllByRole('option').contains('\\include{}').click()
cy.findAllByRole('option').contains('sometext.txt').click()
activeEditorLine().should('have.text', '\\include{sometext.txt}')
activeEditorLine().type('{Enter}')
activeEditorLine().click().as('line')
activeEditorLine().type('\\input{e', { delay: 100 })
cy.findAllByRole('option').contains('example.tex').click()
activeEditorLine().should('have.text', '\\input{example}')
activeEditorLine().type('{Enter}')
activeEditorLine().click().as('line')
activeEditorLine().type('\\input{s', { delay: 100 })
cy.findAllByRole('option').contains('sometext.txt').click()
activeEditorLine().should('have.text', '\\input{sometext.txt}')
activeEditorLine().type('{Enter}')
activeEditorLine().type('\\inpu', { delay: 100 })
cy.findAllByRole('option').contains('\\input{}').click()
cy.findAllByRole('option').contains('example.tex').click()
activeEditorLine().should('have.text', '\\input{example}')
activeEditorLine().type('{Enter}')
activeEditorLine().type('\\inpu', { delay: 100 })
cy.findAllByRole('option').contains('\\input{}').click()
cy.findAllByRole('option').contains('sometext.txt').click()
activeEditorLine().should('have.text', '\\input{sometext.txt}')
})
it('excludes the current command from completions', function () {
const scope = mockScope(mockDocContent(''))
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.get('.cm-line').eq(21).type('\\fff \\ff')
cy.findAllByRole('listbox').should('have.length', 1)
cy.findAllByRole('option').contains('\\fff').click()
cy.get('.cm-line').eq(21).should('have.text', '\\fff \\fff')
})
})

View File

@@ -0,0 +1,206 @@
import { mockScope } from '../helpers/mock-scope'
import { EditorProviders } from '../../../helpers/editor-providers'
import CodeMirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
import { TestContainer } from '../helpers/test-container'
describe('close brackets', { scrollBehavior: false }, function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
cy.interceptEvents()
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.get('.cm-line').eq(20).as('active-line')
cy.get('@active-line').click()
})
describe('unprefixed characters', function () {
it('auto-closes a curly bracket', function () {
cy.get('@active-line').type('{{}')
cy.get('@active-line').should('have.text', '{}')
cy.get('@active-line').type('{backspace}')
cy.get('@active-line').should('have.text', '')
})
it('auto-closes a square bracket', function () {
cy.get('@active-line').type('[')
cy.get('@active-line').should('have.text', '[]')
cy.get('@active-line').type('{backspace}')
cy.get('@active-line').should('have.text', '')
})
it('does not auto-close a round bracket', function () {
cy.get('@active-line').type('(')
cy.get('@active-line').should('have.text', '(')
})
it('auto-closes a dollar sign', function () {
cy.get('@active-line').type('$')
cy.get('@active-line').should('have.text', '$$')
cy.get('@active-line').type('{backspace}')
cy.get('@active-line').should('have.text', '')
})
it('auto-closes another dollar sign', function () {
cy.get('@active-line').type('$$')
cy.get('@active-line').should('have.text', '$$$$')
cy.get('@active-line').type('{backspace}{backspace}')
cy.get('@active-line').should('have.text', '')
})
it('avoids creating an odd number of adjacent dollar signs', function () {
cy.get('@active-line').type('$2')
cy.get('@active-line').should('have.text', '$2$')
cy.get('@active-line').type('{leftArrow}$')
cy.get('@active-line').should('have.text', '$$2$')
})
})
describe('prefixed characters', function () {
it('auto-closes a backslash-prefixed round bracket', function () {
cy.get('@active-line').type('\\(')
cy.get('@active-line').should('have.text', '\\(\\)')
})
it('auto-closes a backslash-prefixed square bracket', function () {
cy.get('@active-line').type('\\[')
cy.get('@active-line').should('have.text', '\\[\\]')
})
it('does not auto-close a backslash-prefixed curly bracket', function () {
cy.get('@active-line').type('\\{{}')
cy.get('@active-line').should('have.text', '\\{')
})
it('does not auto-close a backslash-prefixed dollar sign', function () {
cy.get('@active-line').type('\\$')
cy.get('@active-line').should('have.text', '\\$')
})
})
describe('double-prefixed characters', function () {
it('auto-closes a double-backslash-prefixed square bracket with a square bracket', function () {
cy.get('@active-line').type('\\\\[')
cy.get('@active-line').should('have.text', '\\\\[]')
})
it('auto-closes a double-backslash-prefixed curly bracket with a curly bracket', function () {
cy.get('@active-line').type('\\\\{')
cy.get('@active-line').should('have.text', '\\\\{}')
})
it('auto-closes a double-backslash-prefixed dollar sign with a dollar sign', function () {
cy.get('@active-line').type('\\\\$')
cy.get('@active-line').should('have.text', '\\\\$$')
})
it('does not auto-close a double-backslash-prefixed round bracket', function () {
cy.get('@active-line').type('\\\\(')
cy.get('@active-line').should('have.text', '\\\\(')
})
})
describe('adjacent characters', function () {
it('does auto-close a dollar sign before punctuation', function () {
cy.get('@active-line').type(':2')
cy.get('@active-line').type('{leftArrow}{leftArrow}$')
cy.get('@active-line').should('have.text', '$$:2')
})
it('does auto-close a dollar sign after punctuation', function () {
cy.get('@active-line').type('2:')
cy.get('@active-line').type('$')
cy.get('@active-line').should('have.text', '2:$$')
})
it('does not auto-close a dollar sign before text', function () {
cy.get('@active-line').type('2')
cy.get('@active-line').type('{leftArrow}$')
cy.get('@active-line').should('have.text', '$2')
})
it('does not auto-close a dollar sign after text', function () {
cy.get('@active-line').type('2')
cy.get('@active-line').type('$')
cy.get('@active-line').should('have.text', '2$')
})
it('does not auto-close a dollar sign before a command', function () {
cy.get('@active-line').type('\\nu')
cy.get('@active-line').type('{leftArrow}{leftArrow}{leftArrow}$')
cy.get('@active-line').should('have.text', '$\\nu')
})
it('does auto-close a dollar sign before a newline', function () {
cy.get('@active-line').type('\\\\')
cy.get('@active-line').type('{leftArrow}{leftArrow}$')
cy.get('@active-line').should('have.text', '$$\\\\')
})
it('does auto-close a curly bracket before punctuation', function () {
cy.get('@active-line').type(':2')
cy.get('@active-line').type('{leftArrow}{leftArrow}{{}')
cy.get('@active-line').should('have.text', '{}:2')
})
it('does auto-close a curly bracket after punctuation', function () {
cy.get('@active-line').type('2:')
cy.get('@active-line').type('{{}')
cy.get('@active-line').should('have.text', '2:{}')
})
it('does not auto-close a curly bracket before text', function () {
cy.get('@active-line').type('2')
cy.get('@active-line').type('{leftArrow}{{}')
cy.get('@active-line').should('have.text', '{2')
})
it('does auto-close a curly bracket after text', function () {
cy.get('@active-line').type('2')
cy.get('@active-line').type('{{}')
cy.get('@active-line').should('have.text', '2{}')
})
it('does auto-close $$ before punctuation', function () {
cy.get('@active-line').type(':2')
cy.get('@active-line').type('{leftArrow}{leftArrow}$$')
cy.get('@active-line').should('have.text', '$$$$:2')
})
it('does not auto-close $$ before text', function () {
cy.get('@active-line').type('2')
cy.get('@active-line').type('{leftArrow}$$')
cy.get('@active-line').should('have.text', '$$2')
})
})
describe('closed brackets', function () {
it('does type over a closing dollar sign', function () {
cy.get('@active-line').type('$2$')
cy.get('@active-line').should('have.text', '$2$')
})
it('does type over two closing dollar signs', function () {
cy.get('@active-line').type('$$2$$')
cy.get('@active-line').should('have.text', '$$2$$')
})
it('does type over a closing curly bracket', function () {
cy.get('@active-line').type('{{}2}')
cy.get('@active-line').should('have.text', '{2}')
})
it('does type over a closing square bracket', function () {
cy.get('@active-line').type('[2]')
cy.get('@active-line').should('have.text', '[2]')
})
})
})

View File

@@ -0,0 +1,118 @@
import { EditorProviders } from '../../../helpers/editor-providers'
import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
import { mockScope } from '../helpers/mock-scope'
import { TestContainer } from '../helpers/test-container'
import { isMac } from '@/shared/utils/os'
describe('Cursor and active line highlight', function () {
const content = `line 1
${'long line '.repeat(200)}`
function assertIsFullLineHeight($item: JQuery<HTMLElement>) {
cy.get('@line').then($line => {
expect(Math.round($item.outerHeight()!)).to.equal(
Math.round($line.outerHeight()!)
)
})
}
beforeEach(function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
cy.interceptEvents()
const scope = mockScope(content)
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodemirrorEditor />
</EditorProviders>
</TestContainer>
)
})
it('has cursor', function () {
// put the cursor on a blank line to type in
cy.get('.cm-line').eq(1).as('line')
cy.get('@line').click()
cy.get('.cm-cursor').as('cursor')
cy.get('.cm-cursor').then(assertIsFullLineHeight)
})
it('has cursor on empty line whose height is the same as the line', function () {
// Put the cursor on a blank line
cy.get('.cm-line').eq(1).as('line')
cy.get('@line').click()
cy.get('.cm-cursor').as('cursor')
cy.get('@cursor').then(assertIsFullLineHeight)
})
it('has cursor on non-empty line whose height is the same as the line', function () {
// Put the cursor on a blank line
cy.get('.cm-line').eq(1).as('line')
cy.get('@line').click()
cy.get('@line').type('wombat')
cy.get('.cm-cursor').as('cursor')
cy.get('@cursor').then(assertIsFullLineHeight)
})
it('puts cursor in the correct place inside brackets', function () {
// Put the cursor on a blank line
cy.get('.cm-line').eq(1).as('line')
cy.get('@line').click()
cy.get('@line').type('[{Enter}')
// Get the line inside the bracket
cy.get('.cm-line').eq(2).as('line')
// Check that the middle of the cursor is within the line boundaries
cy.get('.cm-cursor').then($cursor => {
cy.get('@line').then($line => {
const cursorCentreY = $cursor.offset()!.top + $cursor.outerHeight()! / 2
const lineTop = $line.offset()!.top
const lineBottom = lineTop + $line.outerHeight()!
expect(cursorCentreY).to.be.within(lineTop, lineBottom)
})
})
})
it('has active line highlight line decoration of same height as line when there is no selection and line does not wrap', function () {
// Put the cursor on a blank line
cy.get('.cm-line').eq(1).as('line')
cy.get('@line').click()
cy.get('.cm-content .cm-activeLine').as('highlight')
cy.get('.ol-cm-activeLineLayer .cm-activeLine').should('not.exist')
cy.get('@highlight').then(assertIsFullLineHeight)
})
it('has active line highlight layer decoration of same height as non-wrapped line when there is no selection and line wraps', function () {
// Put the cursor on a blank line
cy.get('.cm-line').eq(2).as('line')
cy.get('@line').click()
cy.get('.ol-cm-activeLineLayer .cm-activeLine').as('highlight')
cy.get('.cm-content .cm-activeLine').should('not.exist')
cy.get('.cm-line').eq(1).as('line')
cy.get('@highlight').then(assertIsFullLineHeight)
})
it('has no active line highlight when there is a selection', function () {
// Put the cursor on a blank line
cy.get('.cm-line').eq(1).as('line')
cy.get('@line').click()
cy.get('@line').type(isMac ? '{cmd}A' : '{ctrl}A')
cy.get('.cm-activeLine').should('not.exist')
})
})

View File

@@ -0,0 +1,599 @@
import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
import { EditorProviders } from '../../../helpers/editor-providers'
import { mockScope, rootFolderId } from '../helpers/mock-scope'
import { FC } from 'react'
import { FileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
import { TestContainer } from '../helpers/test-container'
import getMeta from '@/utils/meta'
const clickToolbarButton = (text: string) => {
cy.findByLabelText(text).click()
cy.findByLabelText(text).trigger('mouseout')
}
const chooseFileFromComputer = () => {
cy.get('@file-input').selectFile(
{
fileName: 'frog.jpg',
contents: Cypress.Buffer.from('image-data'),
mimeType: 'image/jpg',
},
{
force: true,
}
)
}
const matchUrl = (urlToMatch: RegExp | string) =>
Cypress.sinon.match(req => {
if (!req || typeof req.url !== 'string') {
return false
}
if (typeof urlToMatch === 'string') {
return req.url.endsWith(urlToMatch)
}
return Boolean(req.url.match(urlToMatch))
})
describe('<FigureModal />', function () {
// TODO: rewrite these tests to be in source mode when toolbar is added there
// TODO: Write tests for width toggle, when we can match on source code
function mount() {
const content = ''
const scope = mockScope(content)
scope.editor.showVisual = true
const FileTreePathProvider: FC = ({ children }) => (
<FileTreePathContext.Provider
value={{
dirname: cy.stub(),
findEntityByPath: cy.stub(),
pathInFolder: cy.stub(),
previewByPath: cy
.stub()
.as('previewByPath')
.returns({ url: 'frog.jpg', extension: 'jpg' }),
}}
>
{children}
</FileTreePathContext.Provider>
)
cy.mount(
<TestContainer>
<EditorProviders scope={scope} providers={{ FileTreePathProvider }}>
<CodemirrorEditor />
</EditorProviders>
</TestContainer>
)
}
beforeEach(function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
cy.interceptMathJax()
cy.interceptEvents()
cy.interceptMetadata()
mount()
})
describe('Upload from computer source', function () {
beforeEach(function () {
cy.interceptFileUpload()
clickToolbarButton('Insert Figure')
cy.findByRole('menu').within(() => {
cy.findByText('Upload from computer').click()
})
cy.findByLabelText('Uppy Dashboard')
.get('.uppy-Dashboard-input:first')
.as('file-input')
})
it('Shows file name and size when selecting file', function () {
chooseFileFromComputer()
cy.findByLabelText('File name').should('have.text', 'frog.jpg')
cy.findByLabelText('File size').should('have.text', '10 B')
})
it('Uploads file when clicking insert', function () {
chooseFileFromComputer()
cy.get('@uploadRequest').should('not.have.been.called')
cy.findByText('Insert figure').click()
cy.get('@uploadRequest').should(
'have.been.calledWith',
matchUrl(`/project/test-project/upload?folder_id=${rootFolderId}`)
)
cy.get('.cm-content').should(
'have.text',
'\\begin{figure} \\centering \\caption{Enter Caption} 🏷fig:enter-label\\end{figure}'
)
})
it('Enables insert button when choosing file', function () {
cy.findByRole('button', { name: 'Insert figure' }).should('be.disabled')
chooseFileFromComputer()
cy.findByRole('button', { name: 'Insert figure' }).should('be.enabled')
})
})
describe('Upload from project files source', function () {
beforeEach(function () {
clickToolbarButton('Insert Figure')
cy.findByRole('menu').within(() => {
cy.findByText('From project files').click()
})
})
it('Lists files from project', function () {
cy.findByRole('textbox', { name: 'Image file' }).click()
cy.findByRole('listbox')
.children()
.should('have.length', 2)
.should('contain.text', 'frog.jpg')
.should('contain.text', 'unicorn.png')
.should('contain.text', 'figures/')
})
it('Enables insert button when choosing file', function () {
cy.findByRole('button', { name: 'Insert figure' }).should('be.disabled')
cy.findByRole('textbox', { name: 'Image file' }).click()
cy.findByRole('listbox').within(() => {
cy.findByText('frog.jpg').click()
})
cy.findByRole('button', { name: 'Insert figure' }).should('be.enabled')
})
it('Inserts file when pressing insert button', function () {
cy.findByRole('textbox', { name: 'Image file' }).click()
cy.findByRole('listbox').within(() => {
cy.findByText('frog.jpg').click()
})
cy.findByRole('button', { name: 'Insert figure' }).click()
cy.get('.cm-content').should(
'have.text',
'\\begin{figure} \\centering \\caption{Enter Caption} 🏷fig:enter-label\\end{figure}'
)
})
})
describe('From another project source', function () {
beforeEach(function () {
cy.interceptProjectListing()
cy.interceptCompile()
cy.interceptLinkedFile()
clickToolbarButton('Insert Figure')
cy.findByRole('menu').within(() => {
cy.findByRole('button', { name: 'From another project' }).click()
})
cy.findByRole('textbox', { name: 'Project' }).as('project-dropdown')
cy.findByRole('textbox', { name: 'Image file' }).as('file-dropdown')
})
it('List projects and files in projects', function () {
cy.findByRole('button', { name: 'Insert figure' }).should('be.disabled')
cy.get('@file-dropdown').should('be.disabled')
cy.get('@project-dropdown').click()
cy.findByRole('listbox').within(() => {
cy.findAllByRole('option').should('have.length', 2)
cy.findByRole('option', { name: 'My first project' }).click()
})
cy.get('@file-dropdown').should('be.enabled')
cy.get('@file-dropdown').click()
cy.findByRole('listbox').as('file-select')
cy.get('@file-select').children().should('have.length', 2)
cy.get('@file-select').should('contain.text', 'frog.jpg')
cy.get('@file-select').should('contain.text', 'figures/unicorn.png')
cy.get('@file-select').within(() => {
cy.findByText('frog.jpg').click()
})
cy.findByRole('button', { name: 'Insert figure' }).should('be.enabled')
})
it('Enables insert button when choosing file', function () {
cy.findByRole('button', { name: 'Insert figure' }).should('be.disabled')
cy.get('@project-dropdown').click()
cy.findByRole('listbox').within(() => {
cy.findByRole('option', { name: 'My first project' }).click()
})
cy.get('@file-dropdown').click()
cy.findByRole('listbox').within(() => {
cy.findByRole('option', { name: 'frog.jpg' }).click()
})
cy.findByRole('button', { name: 'Insert figure' }).should('be.enabled')
})
it('Creates linked file when pressing insert', function () {
cy.get('@project-dropdown').click()
cy.findByRole('listbox').within(() => {
cy.findByRole('option', { name: 'My first project' }).click()
})
cy.get('@file-dropdown').click()
cy.findByRole('listbox').within(() => {
cy.findByText('frog.jpg').click()
})
cy.findByText('Insert figure').click()
cy.get('@linked-file-request').should('have.been.calledWithMatch', {
body: {
provider: 'project_file',
data: {
source_entity_path: '/frog.jpg',
source_project_id: 'fake-project-1',
},
},
})
cy.get('.cm-content').should(
'have.text',
'\\begin{figure} \\centering \\caption{Enter Caption} 🏷fig:enter-label\\end{figure}'
)
})
it('Creates linked output file when pressing insert', function () {
cy.get('@project-dropdown').click()
cy.findByRole('listbox').within(() => {
cy.findByRole('option', { name: 'My first project' }).click()
})
cy.findByRole('button', { name: 'select from output files' }).click()
cy.findByRole('textbox', { name: 'Output file' }).click()
cy.findByRole('listbox').within(() => {
cy.findByRole('option', { name: 'output.pdf' }).click()
})
cy.findByText('Insert figure').click()
cy.get('@linked-file-request').should('have.been.calledWithMatch', {
body: {
provider: 'project_output_file',
data: {
source_output_file_path: 'output.pdf',
source_project_id: 'fake-project-1',
},
},
})
cy.get('.cm-content').should(
'have.text',
'\\begin{figure} \\centering \\caption{Enter Caption} 🏷fig:enter-label\\end{figure}'
)
})
})
describe('Feature flags', function () {
describe('with hasLinkUrlFeature=false', function () {
beforeEach(function () {
Object.assign(getMeta('ol-ExposedSettings'), {
hasLinkedProjectFileFeature: true,
hasLinkedProjectOutputFileFeature: true,
hasLinkUrlFeature: false,
})
mount()
clickToolbarButton('Insert Figure')
})
it('should not have import from url option', function () {
cy.findByRole('menu').within(() => {
cy.findByText('From URL').should('not.exist')
})
})
})
describe('with hasLinkedProjectFileFeature=false and hasLinkedProjectOutputFileFeature=false', function () {
beforeEach(function () {
Object.assign(getMeta('ol-ExposedSettings'), {
hasLinkedProjectFileFeature: false,
hasLinkedProjectOutputFileFeature: false,
hasLinkUrlFeature: true,
})
mount()
clickToolbarButton('Insert Figure')
})
it('should not have import from project file option', function () {
cy.findByRole('menu').within(() => {
cy.findByText('From another project').should('not.exist')
})
})
})
function setupFromAnotherProject() {
mount()
cy.interceptProjectListing()
clickToolbarButton('Insert Figure')
cy.findByRole('menu').within(() => {
cy.findByText('From another project').click()
})
cy.findByRole('textbox', { name: 'Project' }).click()
cy.findByRole('listbox').within(() => {
cy.findByRole('option', { name: 'My first project' }).click()
})
}
function expectNoOutputSwitch() {
it('should hide output switch', function () {
cy.findByText('select from output files').should('not.exist')
cy.findByText('select from source files').should('not.exist')
})
}
describe('with hasLinkedProjectFileFeature=false', function () {
beforeEach(function () {
Object.assign(getMeta('ol-ExposedSettings'), {
hasLinkedProjectFileFeature: false,
hasLinkedProjectOutputFileFeature: true,
hasLinkUrlFeature: true,
})
cy.interceptCompile()
setupFromAnotherProject()
})
expectNoOutputSwitch()
it('should show output file selector', function () {
cy.findByRole('textbox', { name: 'Output file' }).click()
cy.findByRole('listbox').within(() => {
cy.findByRole('option', { name: 'output.pdf' }).click()
})
})
})
describe('with hasLinkedProjectOutputFileFeature=false', function () {
beforeEach(function () {
Object.assign(getMeta('ol-ExposedSettings'), {
hasLinkedProjectFileFeature: true,
hasLinkedProjectOutputFileFeature: false,
hasLinkUrlFeature: true,
})
setupFromAnotherProject()
})
expectNoOutputSwitch()
it('should show source file selector', function () {
cy.findByRole('textbox', { name: 'Image file' }).click()
cy.findByRole('listbox').within(() => {
cy.findByRole('option', { name: 'frog.jpg' }).click()
})
})
})
})
describe('From URL source', function () {
beforeEach(function () {
cy.interceptLinkedFile()
clickToolbarButton('Insert Figure')
cy.findByRole('menu').within(() => {
cy.findByText('From URL').click()
})
cy.findByLabelText('File name in this project').as(
'relocated-file-name-input'
)
cy.findByLabelText('Image URL').as('image-url-input')
cy.findByRole('checkbox', { name: 'Include label' }).as(
'include-label-checkbox'
)
cy.findByRole('checkbox', { name: 'Include caption' }).as(
'include-caption-checkbox'
)
})
it('Auto fills name based on url', function () {
cy.get('@image-url-input').type('https://my-fake-website.com/frog.jpg')
cy.get('@relocated-file-name-input').should('have.value', 'frog.jpg')
cy.get('@relocated-file-name-input').type('pig')
cy.get('@relocated-file-name-input').should('have.value', 'pig.jpg')
})
it('Enables insert button when name and url is available', function () {
cy.findByRole('button', { name: 'Insert figure' }).should('be.disabled')
cy.get('@image-url-input').type('https://my-fake-website.com/frog.jpg')
cy.findByRole('button', { name: 'Insert figure' }).should('be.enabled')
})
it('Adds linked file when pressing insert', function () {
cy.get('@image-url-input').type('https://my-fake-website.com/frog.jpg')
cy.findByRole('button', { name: 'Insert figure' }).click()
cy.get('@linked-file-request').should('have.been.calledWithMatch', {
body: {
provider: 'url',
data: {
url: 'https://my-fake-website.com/frog.jpg',
},
},
})
cy.get('.cm-content').should(
'have.text',
'\\begin{figure} \\centering \\caption{Enter Caption} 🏷fig:enter-label\\end{figure}'
)
})
it('Selects the caption when the figure is inserted with a caption', function () {
cy.get('@image-url-input').type('https://my-fake-website.com/frog.jpg')
cy.findByRole('button', { name: 'Insert figure' }).click()
cy.get('@linked-file-request').should('have.been.calledWithMatch', {
body: {
provider: 'url',
data: {
url: 'https://my-fake-website.com/frog.jpg',
},
},
})
cy.get('.cm-selectionLayer .cm-selectionBackground').should(
'have.length',
1
)
// If caption is selected then typing will replace the whole caption
cy.focused().type('My caption')
cy.get('.cm-content').should(
'have.text',
'\\begin{figure} \\centering \\caption{My caption} 🏷fig:enter-label\\end{figure}'
)
})
it('Selects the label when the figure is inserted without a caption', function () {
cy.get('@image-url-input').type('https://my-fake-website.com/frog.jpg')
cy.get('@include-caption-checkbox').uncheck()
cy.findByRole('button', { name: 'Insert figure' }).click()
cy.get('@linked-file-request').should('have.been.calledWithMatch', {
body: {
provider: 'url',
data: {
url: 'https://my-fake-website.com/frog.jpg',
},
},
})
cy.get('.cm-selectionLayer .cm-selectionBackground').should(
'have.length',
1
)
// If label is selected then typing will replace the whole label
cy.focused().type('fig:my-label')
cy.get('.cm-content').should(
'have.text',
'\\begin{figure} \\centering \\label{fig:my-label}\\end{figure}'
)
})
it('Places the cursor after the figure if it is inserted without a caption or a label', function () {
cy.get('@image-url-input').type('https://my-fake-website.com/frog.jpg')
cy.get('@include-caption-checkbox').uncheck()
cy.get('@include-label-checkbox').uncheck()
cy.findByRole('button', { name: 'Insert figure' }).click()
cy.get('@linked-file-request').should('have.been.calledWithMatch', {
body: {
provider: 'url',
data: {
url: 'https://my-fake-website.com/frog.jpg',
},
},
})
cy.get('.cm-content').should(
'have.text',
'\\begin{figure} \\centering\\end{figure}'
)
cy.focused().type('Some more text')
cy.get('.cm-content').should(
'have.text',
'\\begin{figure} \\centering\\end{figure}Some more text'
)
})
})
describe('Editing existing figure', function () {
it('Parses existing label and caption', function () {
cy.get('.cm-content').type(
`\\begin{{}figure}
\\centering
\\includegraphics[width=0.5\\linewidth]{{}frog.jpg}
\\caption{{}My caption}
\\label{{}fig:my-label}
\\end{{}figure}`,
{ delay: 0 }
)
cy.get('[aria-label="Edit figure"]').click()
cy.findByRole('checkbox', { name: 'Include caption' }).should(
'be.checked'
)
cy.findByRole('checkbox', { name: 'Include label' }).should('be.checked')
})
it('Parses existing width', function () {
cy.get('.cm-content').type(
`\\begin{{}figure}
\\centering
\\includegraphics[width=0.75\\linewidth]{{}frog.jpg}
\\caption{{}My caption}
\\label{{}fig:my-label}
\\end{{}figure}`,
{ delay: 0 }
)
cy.get('[aria-label="Edit figure"]').click()
cy.get('[value="0.75"]').should('be.checked')
})
it('Removes existing label when unchecked', function () {
cy.get('.cm-content').type(
`\\begin{{}figure}
\\centering
\\includegraphics[width=0.75\\linewidth]{{}frog.jpg}
\\label{{}fig:my-label}
\\end{{}figure}`,
{ delay: 0 }
)
cy.get('[aria-label="Edit figure"]').click()
cy.findByRole('checkbox', { name: 'Include label' }).click()
cy.findByRole('checkbox', { name: 'Include label' }).should(
'not.be.checked'
)
cy.findByText('Done').click()
cy.get('.cm-content').should(
'have.text',
'\\begin{figure}\\centering\\end{figure}'
)
})
it('Removes existing caption when unchecked', function () {
cy.get('.cm-content').type(
`\\begin{{}figure}
\\centering
\\includegraphics[width=0.75\\linewidth]{{}frog.jpg}
\\caption{{}My caption}
\\label{{}fig:my-label}
\\end{{}figure}`,
{ delay: 0 }
)
cy.get('[aria-label="Edit figure"]').click()
cy.findByRole('checkbox', { name: 'Include caption' }).click()
cy.findByRole('checkbox', { name: 'Include caption' }).should(
'not.be.checked'
)
cy.findByText('Done').click()
cy.get('.cm-content').should(
'have.text',
'\\begin{figure}\\centering🏷fig:my-label\\end{figure}'
)
})
it('Preserves other content when removing figure', function () {
cy.get('.cm-content').type(
`text above
\\begin{{}figure}
\\centering
\\includegraphics[width=0.75\\linewidth]{{}frog.jpg}
\\caption{{}My caption}
\\label{{}fig:my-label}
\\end{{}figure}
text below`,
{ delay: 0 }
)
cy.get('[aria-label="Edit figure"]').click()
cy.findByRole('button', { name: 'Remove or replace figure' }).click()
cy.findByText('Delete figure').click()
cy.get('.cm-content').should('have.text', 'text abovetext below')
})
it('Opens figure modal on pasting image', function () {
cy.fixture<Uint8Array>('images/gradient.png').then(gradientBuffer => {
const gradientFile = new File([gradientBuffer], 'gradient.png', {
type: 'image/png',
})
const clipboardData = new DataTransfer()
clipboardData.items.add(gradientFile)
cy.wrap(clipboardData.files).should('have.length', 1)
cy.get('.cm-content').trigger('paste', { clipboardData })
cy.findByText('Upload from computer').should('be.visible')
cy.findByLabelText('File name in this project').should(
'have.value',
'gradient.png'
)
})
})
// TODO: Add tests for replacing image when we can match on image path
// TODO: Add tests for changing image size when we can match on figure width
})
})

View File

@@ -0,0 +1,73 @@
import { EditorProviders } from '../../../helpers/editor-providers'
import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
import { mockScope } from '../helpers/mock-scope'
import { TestContainer } from '../helpers/test-container'
describe('<CodeMirrorEditor/> fundamentals', function () {
const content = `
test
`
beforeEach(function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
cy.interceptEvents()
const scope = mockScope(content)
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodemirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.get('.cm-line').eq(0).as('first-line')
cy.get('.cm-line').eq(1).as('line')
cy.get('.cm-line').eq(2).as('empty-line')
cy.get('@line').click()
})
it('deletes with backspace', function () {
cy.get('@line').type('{backspace}')
cy.get('@line').should('have.text', 'tes')
})
it('moves with arrow keys', function () {
cy.get('@line').type('{leftArrow}1')
cy.get('@line').should('have.text', 'tes1t')
cy.get('@line').type('{rightArrow}2')
cy.get('@line').should('have.text', 'tes1t2')
cy.get('@line').type('{downArrow}3')
cy.get('@line').type('{upArrow}{upArrow}4')
cy.get('@empty-line').should('have.text', '3')
cy.get('@first-line').should('have.text', '4')
})
it('deletes with delete', function () {
cy.get('@line').type('{leftArrow}{del}')
cy.get('@line').should('have.text', 'tes')
})
it('types characters', function () {
cy.get('@empty-line').type('hello codemirror!')
cy.get('@empty-line').should('have.text', 'hello codemirror!')
})
it('replaces selections', function () {
cy.get('@line').type('{shift}{leftArrow}{leftArrow}{leftArrow}')
cy.get('@line').type('abby cat')
cy.get('@line').should('have.text', 'tabby cat')
})
it('inserts LaTeX commands', function () {
cy.get('@empty-line').type('\\cmd[opt]{{}arg}')
cy.get('@empty-line').should('have.text', '\\cmd[opt]{arg}')
})
it('allows line-breaks', function () {
cy.get('.cm-content').find('.cm-line').should('have.length', 3)
cy.get('@empty-line').type('{enter}{enter}')
cy.get('.cm-content').find('.cm-line').should('have.length', 5)
})
})

View File

@@ -0,0 +1,280 @@
import { mockScope } from '../helpers/mock-scope'
import { EditorProviders } from '../../../helpers/editor-providers'
import CodeMirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
import { metaKey } from '../helpers/meta-key'
import { activeEditorLine } from '../helpers/active-editor-line'
import { TestContainer } from '../helpers/test-container'
const CHARACTERS =
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ\\0123456789'
describe('keyboard shortcuts', { scrollBehavior: false }, function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
cy.interceptEvents()
cy.interceptMetadata()
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
cy.get('.cm-editor').as('editor')
})
it('comment line with {meta+/}', function () {
cy.get('@line').type('text')
cy.get('@line').type(`{${metaKey}+/}`)
cy.get('@line').should('have.text', '% text')
cy.get('@line').type(`{${metaKey}+/}`)
cy.get('@line').should('have.text', 'text')
})
it('comment line with {ctrl+#}', function () {
cy.get('@line').type('text')
cy.get('@editor').trigger('keydown', { key: '#', ctrlKey: true })
cy.get('@line').should('have.text', '% text')
cy.get('@editor').trigger('keydown', { key: '#', ctrlKey: true })
cy.get('@line').should('have.text', 'text')
})
it('undo line with {meta+z}', function () {
cy.get('@line').type('text')
cy.get('@line').type(`{${metaKey}+z}`)
cy.get('@line').should('have.text', '')
})
it('redo line with {meta+shift+z}', function () {
cy.get('@line').type('text')
cy.get('@line').type(`{${metaKey}+z}`) // undo
cy.get('@line').type(`{${metaKey}+shift+z}`) // redo
cy.get('@line').should('have.text', 'text')
})
it('redo line with {meta+y}', function () {
cy.get('@line').type('text')
cy.get('@line').type(`{${metaKey}+z}`) // undo
cy.get('@line').type(`{${metaKey}+y}`) // redo
cy.get('@line').should('have.text', 'text')
})
it('delete line with {meta+d}', function () {
cy.get('.cm-line').then($lines => {
const linesCount = $lines.length
cy.get('@line').type(`{${metaKey}+d}`)
cy.get('.cm-line').should('have.length', linesCount - 1)
})
})
it('indent line with {tab}', function () {
cy.get('@line').trigger('keydown', { key: 'Tab' })
cy.get('@line').should('have.text', ' ')
})
it('unindent line with {shift+tab}', function () {
cy.get('@line').trigger('keydown', { key: 'Tab' }) // indent
cy.get('@line').trigger('keydown', { key: 'Tab', shiftKey: true }) // unindent
cy.get('@line').should('have.text', '')
})
it('uppercase selection with {ctrl+u}', function () {
cy.get('@line').type('a')
cy.get('@line').type('{shift+leftArrow}') // select text
cy.get('@line').type('{ctrl+u}')
cy.get('@line').should('have.text', 'A')
})
it('lowercase selection with {ctrl+shift+u}', function () {
if (navigator.platform.startsWith('Linux')) {
// Skip test as {ctrl+shift+u} is bound elsewhere in some Linux systems
// eslint-disable-next-line mocha/no-skipped-tests
this.skip()
}
cy.get('@line').type('A')
cy.get('@line').type('{shift+leftArrow}') // select text
cy.get('@line').type('{ctrl+shift+u}') // TODO: ctrl+shift+u is a system shortcut so this fails in CI
cy.get('@line').should('have.text', 'a')
})
it('wrap selection with "\\textbf{}" by using {meta+b}', function () {
cy.get('@line').type('a')
cy.get('@line').type('{shift+leftArrow}') // select text
cy.get('@line').type(`{${metaKey}+b}`)
cy.get('@line').should('have.text', '\\textbf{a}')
})
it('wrap selection with "\\textit{}" by using {meta+i}', function () {
cy.get('@line').type('a')
cy.get('@line').type('{shift+leftArrow}') // select text
cy.get('@line').type(`{${metaKey}+i}`)
cy.get('@line').should('have.text', '\\textit{a}')
})
})
describe('emacs keybindings', { scrollBehavior: false }, function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
cy.interceptEvents()
cy.interceptMetadata()
const shortDoc = `
\\documentclass{article}
\\begin{document}
contentLine1
contentLine2
contentLine3
\\end{document}`
const scope = mockScope(shortDoc)
const userSettings = { mode: 'emacs' }
cy.mount(
<TestContainer>
<EditorProviders scope={scope} userSettings={userSettings}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.get('.cm-line').eq(1).as('line')
cy.get('@line').scrollIntoView()
cy.get('@line').click()
cy.get('.cm-editor').as('editor')
})
it('emulates search behaviour', function () {
activeEditorLine().should('have.text', '\\documentclass{article}')
// Search should be closed
cy.findByRole('search').should('have.length', 0)
// Invoke C-s
cy.get('@line').type('{ctrl}s')
// Search should now be open
cy.findByRole('search').should('have.length', 1)
cy.findByRole('textbox', { name: 'Find' }).as('search-input')
// Write a search query
cy.get('@search-input').should('have.focus').type('contentLine')
cy.contains(`1 of 3`)
// Should assert that activeEditorLine.index() === 21, but activeEditorLine
// only works if editor is focused, not the search box.
// Repeated C-s should go to next match
cy.get('@search-input').type('{ctrl}s')
cy.contains(`2 of 3`)
// Should assert that activeEditorLine.index() === 22, but activeEditorLine
// only works if editor is focused, not the search box.
// C-g should close the search
cy.get('@search-input').type('{ctrl}g')
cy.findByRole('search').should('have.length', 0)
// Cursor should be back to where the search originated from
activeEditorLine().should('have.text', '\\documentclass{article}')
// Invoke C-r
cy.get('@line').type('{ctrl}r')
// Search should now be open at first match
cy.findByRole('search').should('have.length', 1)
cy.contains(`0 of 3`)
// Repeated C-r should go to previous match
cy.get('@search-input').type('{ctrl}r')
cy.contains(`3 of 3`)
// Close search panel to clear global variable
cy.get('@search-input').type('{ctrl}g')
cy.findByRole('search').should('have.length', 0)
})
it('toggle comments with M-;', function () {
cy.get('@line').should('have.text', '\\documentclass{article}')
cy.get('@line').type('{alt};')
cy.get('@line').should('have.text', '% \\documentclass{article}')
})
it('should jump between start and end with M-S-, and M-S-.', function () {
activeEditorLine().should('have.text', '\\documentclass{article}')
activeEditorLine().type('{alt}{shift},')
activeEditorLine().should('have.text', '')
activeEditorLine().type('{alt}{shift}.')
activeEditorLine().should('have.text', '\\end{document}')
})
it('can enter characters', function () {
cy.get('.cm-line').eq(0).as('line')
cy.get('@line').scrollIntoView()
cy.get('@line').click()
cy.get('@line').type(CHARACTERS)
cy.get('@line').should('have.text', CHARACTERS)
})
})
describe('vim keybindings', { scrollBehavior: false }, function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
cy.interceptEvents()
cy.interceptMetadata()
// Make a short doc that will fit entirely into the dom tree, so that
// index() corresponds to line number - 1
const shortDoc = `
\\documentclass{article}
\\begin{document}
contentLine1
contentLine2
contentLine3
\\end{document}
`
const scope = mockScope(shortDoc)
const userSettings = { mode: 'vim' }
cy.mount(
<TestContainer>
<EditorProviders scope={scope} userSettings={userSettings}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.get('.cm-line').eq(1).as('line')
cy.get('@line').scrollIntoView()
cy.get('@line').click()
cy.get('.cm-editor').as('editor')
})
it('can enter characters', function () {
cy.get('.cm-line').eq(0).as('line')
cy.get('@line').scrollIntoView()
cy.get('@line').click()
cy.get('@line').type(`i${CHARACTERS}{esc}`)
cy.get('@line').should('have.text', CHARACTERS)
})
it('can move around in normal mode', function () {
// Move cursor up
cy.get('@line').type('k')
activeEditorLine().should('have.text', '')
// Move cursor down
cy.get('@line').type('j')
activeEditorLine().should('have.text', '\\begin{document}')
// Move the cursor left, insert 1, move it right, insert a 2
cy.get('@line').type('hi1{esc}la2{esc}')
cy.get('@line').should('have.text', '\\documentclass{article1}2')
})
})

View File

@@ -0,0 +1,157 @@
import { mockScope } from '../helpers/mock-scope'
import { EditorProviders } from '../../../helpers/editor-providers'
import CodeMirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
import { TestContainer } from '../helpers/test-container'
import forEach from 'mocha-each'
import PackageVersions from '../../../../../app/src/infrastructure/PackageVersions'
const languages = [
{ code: 'af', dic: 'af_ZA', name: 'Afrikaans' },
{ code: 'an', dic: 'an_ES', name: 'Aragonese' },
{ code: 'ar', dic: 'ar', name: 'Arabic' },
{ code: 'be_BY', dic: 'be_BY', name: 'Belarusian' },
{ code: 'bg', dic: 'bg_BG', name: 'Bulgarian' },
{ code: 'bn_BD', dic: 'bn_BD', name: 'Bengali' },
{ code: 'bo', dic: 'bo', name: 'Tibetan' },
{ code: 'br', dic: 'br_FR', name: 'Breton' },
{ code: 'bs_BA', dic: 'bs_BA', name: 'Bosnian' },
{ code: 'ca', dic: 'ca', name: 'Catalan' },
{ code: 'cs', dic: 'cs_CZ', name: 'Czech' },
{ code: 'de', dic: 'de_DE', name: 'German' },
{ code: 'de_AT', dic: 'de_AT', name: 'German (Austria)' },
{ code: 'de_CH', dic: 'de_CH', name: 'German (Switzerland)' },
{ code: 'dz', dic: 'dz', name: 'Dzongkha' },
{ code: 'el', dic: 'el_GR', name: 'Greek' },
{ code: 'en_AU', dic: 'en_AU', name: 'English (Australian)' },
{ code: 'en_CA', dic: 'en_CA', name: 'English (Canadian)' },
{ code: 'en_GB', dic: 'en_GB', name: 'English (British)' },
{ code: 'en_US', dic: 'en_US', name: 'English (American)' },
{ code: 'en_ZA', dic: 'en_ZA', name: 'English (South African)' },
{ code: 'eo', dic: 'eo', name: 'Esperanto' },
{ code: 'es', dic: 'es_ES', name: 'Spanish' },
{ code: 'et', dic: 'et_EE', name: 'Estonian' },
{ code: 'eu', dic: 'eu', name: 'Basque' },
{ code: 'fa', dic: 'fa_IR', name: 'Persian' },
{ code: 'fo', dic: 'fo', name: 'Faroese' },
{ code: 'fr', dic: 'fr', name: 'French' },
{ code: 'ga', dic: 'ga_IE', name: 'Irish' },
{ code: 'gd_GB', dic: 'gd_GB', name: 'Scottish Gaelic' },
{ code: 'gl', dic: 'gl_ES', name: 'Galician' },
{ code: 'gu_IN', dic: 'gu_IN', name: 'Gujarati' },
{ code: 'gug_PY', dic: 'gug_PY', name: 'Guarani' },
{ code: 'he_IL', dic: 'he_IL', name: 'Hebrew' },
{ code: 'hi_IN', dic: 'hi_IN', name: 'Hindi' },
{ code: 'hr', dic: 'hr_HR', name: 'Croatian' },
{ code: 'hu_HU', dic: 'hu_HU', name: 'Hungarian' },
{ code: 'id', dic: 'id_ID', name: 'Indonesian' },
{ code: 'is_IS', dic: 'is_IS', name: 'Icelandic' },
{ code: 'it', dic: 'it_IT', name: 'Italian' },
{ code: 'kk', dic: 'kk_KZ', name: 'Kazakh' },
{ code: 'kmr', dic: 'kmr_Latn', name: 'Kurmanji' },
{ code: 'ko', dic: 'ko', name: 'Korean' },
{ code: 'lo_LA', dic: 'lo_LA', name: 'Laotian' },
{ code: 'lt', dic: 'lt_LT', name: 'Lithuanian' },
{ code: 'lv', dic: 'lv_LV', name: 'Latvian' },
{ code: 'ml_IN', dic: 'ml_IN', name: 'Malayalam' },
{ code: 'mn_MN', dic: 'mn_MN', name: 'Mongolian' },
{ code: 'nb_NO', dic: 'nb_NO', name: 'Norwegian (Bokmål)' },
{ code: 'ne_NP', dic: 'ne_NP', name: 'Nepali' },
{ code: 'nl', dic: 'nl', name: 'Dutch' },
{ code: 'nn_NO', dic: 'nn_NO', name: 'Norwegian (Nynorsk)' },
{ code: 'oc_FR', dic: 'oc_FR', name: 'Occitan' },
{ code: 'pl', dic: 'pl_PL', name: 'Polish' },
{ code: 'pt_BR', dic: 'pt_BR', name: 'Portuguese (Brazilian)' },
{ code: 'pt_PT', dic: 'pt_PT', name: 'Portuguese (European)' },
{ code: 'ro', dic: 'ro_RO', name: 'Romanian' },
{ code: 'ru', dic: 'ru_RU', name: 'Russian' },
{ code: 'si_LK', dic: 'si_LK', name: 'Sinhala' },
{ code: 'sk', dic: 'sk_SK', name: 'Slovak' },
{ code: 'sl', dic: 'sl_SI', name: 'Slovenian' },
{ code: 'sr_RS', dic: 'sr_RS', name: 'Serbian' },
{ code: 'sv', dic: 'sv_SE', name: 'Swedish' },
{ code: 'sw_TZ', dic: 'sw_TZ', name: 'Swahili' },
{ code: 'te_IN', dic: 'te_IN', name: 'Telugu' },
{ code: 'th_TH', dic: 'th_TH', name: 'Thai' },
{ code: 'tl', dic: 'tl', name: 'Tagalog' },
{ code: 'tr_TR', dic: 'tr_TR', name: 'Turkish' },
{ code: 'uz_UZ', dic: 'uz_UZ', name: 'Uzbek' },
{ code: 'vi_VN', dic: 'vi_VN', name: 'Vietnamese' },
]
const suggestions = {
af: ['medicyne', 'medisyne'],
be_BY: [екi', 'лекі'],
bg: [екарствo', 'лекарство'],
de: ['Medicin', 'Medizin'],
en_CA: ['theatr', 'theatre'],
en_GB: ['medecine', 'medicine'],
en_US: ['theatr', 'theater'],
es: ['medicaminto', 'medicamento'],
fr: ['medecin', 'médecin'],
sv: ['medecin', 'medicin'],
}
forEach(Object.keys(suggestions)).describe(
'Spell check in client (%s)',
(spellCheckLanguage: keyof typeof suggestions) => {
const content = `
\\documentclass{}
\\title{}
\\author{}
\\begin{document}
\\maketitle
\\begin{abstract}
\\end{abstract}
\\section{}
\\end{document}`
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-preventCompileOnLoad', true)
win.metaAttributesCache.set('ol-learnedWords', ['baz'])
win.metaAttributesCache.set(
'ol-dictionariesRoot',
`js/dictionaries/${PackageVersions.version.dictionaries}/`
)
win.metaAttributesCache.set('ol-baseAssetPath', '/__cypress/src/')
win.metaAttributesCache.set('ol-languages', languages)
})
cy.interceptEvents()
const scope = mockScope(content)
scope.project.spellCheckLanguage = spellCheckLanguage
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.get('.cm-line').eq(13).as('line')
cy.get('@line').click()
})
it('shows suggestions for misspelled word', function () {
const [from, to] = suggestions[spellCheckLanguage]
cy.get('@line').type(`${from} ${to}`)
cy.get('@line')
.get('.ol-cm-spelling-error', { timeout: 10000 })
.should('have.length', 1)
cy.get('@line').get('.ol-cm-spelling-error').should('have.text', from)
cy.get('@line').get('.ol-cm-spelling-error').rightclick()
cy.findByRole('menuitem', { name: to }).click()
cy.get('@line').contains(`${to} ${to}`)
cy.get('@line').find('.ol-cm-spelling-error').should('not.exist')
})
}
)

View File

@@ -0,0 +1,787 @@
// Needed since eslint gets confused by mocha-each
/* eslint-disable mocha/prefer-arrow-callback */
import { EditorProviders } from '../../../helpers/editor-providers'
import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
import { mockScope } from '../helpers/mock-scope'
import forEach from 'mocha-each'
import { TestContainer } from '../helpers/test-container'
const mountEditor = (content: string | string[]) => {
if (Array.isArray(content)) {
content = content.join('\n')
}
if (!content.startsWith('\n')) {
content = '\n' + content
}
const scope = mockScope(content)
scope.editor.showVisual = true
cy.viewport(1000, 800)
cy.mount(
<TestContainer style={{ width: 1000, height: 800 }}>
<EditorProviders scope={scope}>
<CodemirrorEditor />
</EditorProviders>
</TestContainer>
)
// wait for the content to be parsed and revealed
cy.get('.cm-content').should('have.css', 'opacity', '1')
cy.get('.cm-line').first().click()
}
function checkTable(
expected: (string | { text: string; colspan: number })[][]
) {
cy.get('.table-generator').as('table').should('exist')
cy.get('@table')
.find('tbody')
.as('body')
.find('tr')
.should('have.length', expected.length)
cy.get('@body')
.find('tr')
.each((row, rowIndex) => {
// Add one to the expected length to account for the row selector
cy.wrap(row)
.find('.table-generator-cell')
.as('cells')
.should('have.length', expected[rowIndex].length)
cy.get('@cells').each((cell, cellIndex) => {
const expectation = expected[rowIndex][cellIndex]
const cellText =
typeof expectation === 'string' ? expectation : expectation.text
const colspan =
typeof expectation === 'string' ? undefined : expectation.colspan
cy.wrap(cell).should('contain.text', cellText)
if (colspan) {
cy.wrap(cell).should('have.attr', 'colspan', colspan.toString())
}
})
})
}
function checkBordersWithNoMultiColumn(
verticalBorderIndices: boolean[],
horizontalBorderIndices: boolean[]
) {
cy.get('.table-generator').as('table').should('have.length', 1)
cy.get('@table')
.find('tbody')
.as('body')
.find('tr')
.should('have.length', verticalBorderIndices.length - 1)
.each((row, rowIndex) => {
cy.wrap(row)
.find('.table-generator-cell')
.should('have.length', horizontalBorderIndices.length - 1)
.each((cell, cellIndex) => {
if (cellIndex === 0) {
cy.wrap(cell).should(
horizontalBorderIndices[0] ? 'have.class' : 'not.have.class',
'table-generator-cell-border-left'
)
}
cy.wrap(cell).should(
horizontalBorderIndices[cellIndex + 1]
? 'have.class'
: 'not.have.class',
'table-generator-cell-border-right'
)
cy.wrap(cell).should(
verticalBorderIndices[rowIndex] ? 'have.class' : 'not.have.class',
'table-generator-row-border-top'
)
if (rowIndex === verticalBorderIndices.length - 2) {
cy.wrap(cell).should(
verticalBorderIndices[rowIndex + 1]
? 'have.class'
: 'not.have.class',
'table-generator-row-border-bottom'
)
}
})
})
}
describe('<CodeMirrorEditor/> Table editor', function () {
beforeEach(function () {
cy.interceptEvents()
cy.interceptMathJax()
cy.interceptCompile({ prefix: 'compile', times: Number.MAX_SAFE_INTEGER })
cy.intercept('/project/*/doc/*/metadata', { body: {} })
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
})
describe('Table rendering', function () {
it('Renders a simple table', function () {
mountEditor(`
\\begin{tabular}{ccc}
cell 1 & cell 2 & cell 3 \\\\
cell 4 & cell 5 & cell 6 \\\\
\\end{tabular}`)
// Find cell in table
checkTable([
['cell 1', 'cell 2', 'cell 3'],
['cell 4', 'cell 5', 'cell 6'],
])
})
it('Renders a table with \\multicolumn', function () {
mountEditor(`
\\begin{tabular}{ccc}
\\multicolumn{2}{c}{cell 1 and cell 2} & cell 3 \\\\
cell 4 & cell 5 & cell 6 \\\\
\\end{tabular}`)
// Find cell in table
checkTable([
[{ text: 'cell 1 and cell 2', colspan: 2 }, 'cell 3'],
['cell 4', 'cell 5', 'cell 6'],
])
})
it('Renders borders', function () {
mountEditor(`
\\begin{tabular}{c|c}
cell 1 & cell 2 \\\\
\\hline
cell 3 & cell 4 \\\\
\\end{tabular}`)
checkBordersWithNoMultiColumn([false, true, false], [false, true, false])
})
it('Renders math in cells', function () {
mountEditor(`
\\begin{tabular}{c}
$\\pi$
\\end{tabular}`)
cy.get('.MathJax').should('have.text', '$\\pi$')
})
})
describe('The toolbar', function () {
it('Renders the toolbar when the table is selected', function () {
mountEditor(`
\\begin{tabular}{c}
cell
\\end{tabular}
`)
cy.get('.table-generator-floating-toolbar').should('not.exist')
cy.get('.table-generator-cell').click()
cy.get('.table-generator-floating-toolbar').should('exist')
// The element is partially covered, but we can still click it
cy.get('.cm-line').first().click({ force: true })
cy.get('.table-generator-floating-toolbar').should('not.exist')
})
it('Adds and removes borders when theme is changed', function () {
mountEditor(`
\\begin{tabular}{c|c}
cell 1 & cell 2 \\\\
cell 3 & cell 4 \\\\
\\end{tabular}
`)
checkBordersWithNoMultiColumn([false, false, false], [false, true, false])
cy.get('.table-generator-floating-toolbar').should('not.exist')
cy.get('.table-generator-cell').first().click()
cy.get('.table-generator-floating-toolbar').as('toolbar').should('exist')
cy.get('@toolbar').findByText('Custom borders').click({ force: true })
cy.get('.table-generator').findByText('All borders').click()
// The element is partially covered, but we can still click it
cy.get('.cm-line').first().click({ force: true })
// Table should be unchanged
checkTable([
['cell 1', 'cell 2'],
['cell 3', 'cell 4'],
])
checkBordersWithNoMultiColumn([true, true, true], [true, true, true])
cy.get('.table-generator-cell').first().click()
cy.get('@toolbar').findByText('All borders').click()
cy.get('.table-generator').findByText('No borders').click()
// The element is partially covered, but we can still click it
cy.get('.cm-line').first().click({ force: true })
// Table should be unchanged
checkTable([
['cell 1', 'cell 2'],
['cell 3', 'cell 4'],
])
checkBordersWithNoMultiColumn(
[false, false, false],
[false, false, false]
)
})
it('Changes the column alignment with dropdown buttons', function () {
mountEditor(`
\\begin{tabular}{cc}
cell 1 & cell 2 \\\\
cell 3 & cell 4 \\\\
\\end{tabular}
`)
cy.get('.table-generator-cell')
.should('have.class', 'alignment-center')
.first()
.click()
cy.get('.table-generator-floating-toolbar').as('toolbar').should('exist')
cy.get('@toolbar')
.findByLabelText('Alignment')
.should('be.disabled')
.should('contain.text', 'format_align_center')
cy.get('.column-selector').first().click()
cy.get('@toolbar')
.findByLabelText('Alignment')
.should('not.be.disabled')
.click()
cy.get('.table-generator').findByLabelText('Left').click()
// The element is partially covered, but we can still click it
cy.get('.cm-line').first().click({ force: true })
// Table contents shouldn't have changed
checkTable([
['cell 1', 'cell 2'],
['cell 3', 'cell 4'],
])
// Check that alignment button updated to reflect the left alignment
cy.get('.table-generator-cell').first().click()
cy.get('@toolbar')
.findByLabelText('Alignment')
.should('be.disabled')
.should('contain.text', 'format_align_left')
cy.get('.table-generator-cell')
.eq(0)
.should('have.class', 'alignment-left')
cy.get('.table-generator-cell')
.eq(1)
.should('have.class', 'alignment-center')
cy.get('.table-generator-cell')
.eq(2)
.should('have.class', 'alignment-left')
cy.get('.table-generator-cell')
.eq(3)
.should('have.class', 'alignment-center')
})
it('Removes rows and columns', function () {
mountEditor(`
\\begin{tabular}{|c|c|c|}
\\hline
cell 1 & cell 2 & cell 3 \\\\ \\hline
cell 4 & cell 5 & cell 6 \\\\ \\hline
cell 7 & cell 8 & cell 9 \\\\ \\hline
\\end{tabular}
`)
checkTable([
['cell 1', 'cell 2', 'cell 3'],
['cell 4', 'cell 5', 'cell 6'],
['cell 7', 'cell 8', 'cell 9'],
])
cy.get('.table-generator-cell').first().click()
cy.get('.table-generator-floating-toolbar').as('toolbar').should('exist')
cy.get('@toolbar')
.findByLabelText('Delete row or column')
.should('be.disabled')
cy.get('.column-selector').eq(1).click()
cy.get('@toolbar').findByLabelText('Delete row or column').click()
checkTable([
['cell 1', 'cell 3'],
['cell 4', 'cell 6'],
['cell 7', 'cell 9'],
])
cy.get('.row-selector').eq(2).click()
cy.get('@toolbar').findByLabelText('Delete row or column').click()
checkTable([
['cell 1', 'cell 3'],
['cell 4', 'cell 6'],
])
checkBordersWithNoMultiColumn([true, true, true], [true, true, true])
})
it('Removes rows correctly when removing from the left', function () {
mountEditor(`
\\begin{tabular}{|c|c|c|}\\hline
cell 1&cell 2&cell 3 \\\\\\hline
\\end{tabular}
`)
checkTable([['cell 1', 'cell 2', 'cell 3']])
cy.get('.table-generator').findByText('cell 1').click()
cy.get('.table-generator')
.findByText('cell 1')
.type('{shift}{rightarrow}')
cy.get('.table-generator-floating-toolbar').as('toolbar').should('exist')
cy.get('@toolbar')
.findByLabelText('Delete row or column')
.should('be.enabled')
cy.get('@toolbar').findByLabelText('Delete row or column').click()
checkTable([['cell 3']])
checkBordersWithNoMultiColumn([true, true], [true, true])
})
it('Merges and unmerged cells', function () {
mountEditor(`
\\begin{tabular}{ccc}
cell 1 & cell 2 & cell 3 \\\\
cell 4 & cell 5 & cell 6 \\\\
\\end{tabular}
`)
cy.get('.table-generator-cell').first().click()
cy.get('.table-generator-cell').first().type('{shift}{rightarrow}')
cy.get('.table-generator-floating-toolbar').as('toolbar').should('exist')
cy.get('@toolbar').findByLabelText('Merge cells').click()
checkTable([
[{ text: 'cell 1 cell 2', colspan: 2 }, 'cell 3'],
['cell 4', 'cell 5', 'cell 6'],
])
cy.get('@toolbar').findByLabelText('Unmerge cells').click()
checkTable([
['cell 1 cell 2', '', 'cell 3'],
['cell 4', 'cell 5', 'cell 6'],
])
})
it('Adds rows and columns', function () {
mountEditor(`
\\begin{tabular}{c}
cell 1
\\end{tabular}
`)
cy.get('.table-generator').findByText('cell 1').click()
cy.get('.table-generator-floating-toolbar').as('toolbar').should('exist')
// Set border theme to "All borders" so that we can check that theme is
// preserved when adding new rows and columns
cy.get('@toolbar').findByText('No borders').click()
cy.get('.table-generator').findByText('All borders').click()
cy.get('.table-generator').findByText('cell 1').click()
cy.get('@toolbar').findByLabelText('Insert').click()
cy.get('.table-generator').findByText('Insert column left').click()
checkTable([['', 'cell 1']])
checkBordersWithNoMultiColumn([true, true], [true, true, true])
cy.get('.table-generator').findByText('cell 1').click()
cy.get('@toolbar').findByLabelText('Insert').click()
cy.get('.table-generator').findByText('Insert column right').click()
checkTable([['', 'cell 1', '']])
checkBordersWithNoMultiColumn([true, true], [true, true, true, true])
cy.get('.table-generator').findByText('cell 1').click()
cy.get('@toolbar').findByLabelText('Insert').click()
cy.get('.table-generator').findByText('Insert row above').click()
checkTable([
['', '', ''],
['', 'cell 1', ''],
])
checkBordersWithNoMultiColumn(
[true, true, true],
[true, true, true, true]
)
cy.get('.table-generator').findByText('cell 1').click()
cy.get('@toolbar').findByLabelText('Insert').click()
cy.get('.table-generator').findByText('Insert row below').click()
checkTable([
['', '', ''],
['', 'cell 1', ''],
['', '', ''],
])
checkBordersWithNoMultiColumn(
[true, true, true, true],
[true, true, true, true]
)
})
it('Removes the table on toolbar button click', function () {
mountEditor(`
\\begin{tabular}{c}
cell 1
\\end{tabular}`)
cy.get('.table-generator').findByText('cell 1').click()
cy.get('.table-generator-floating-toolbar').as('toolbar').should('exist')
cy.get('@toolbar').findByLabelText('Delete table').click()
cy.get('.table-generator').should('not.exist')
})
it('Moves the caption when using dropdown', function () {
mountEditor(`
\\begin{table}
\\caption{Table caption}
\\label{tab:table}
\\begin{tabular}{c}
cell 1
\\end{tabular}
\\end{table}`)
cy.get('.table-generator').findByText('cell 1').click()
cy.get('.table-generator-floating-toolbar').as('toolbar').should('exist')
cy.get('@toolbar').findByText('Caption above').click()
cy.get('.table-generator-toolbar-dropdown-menu')
.findByText('Caption below')
.click()
// Check that caption is below table
cy.get('.ol-cm-command-caption').then(([caption]) => {
const { top: captionYPosition } = caption.getBoundingClientRect()
cy.get('.table-generator').then(([table]) => {
const { top: tableYPosition } = table.getBoundingClientRect()
cy.wrap(captionYPosition).should('be.greaterThan', tableYPosition)
})
})
cy.get('@toolbar').findByText('Caption below').click()
cy.get('.table-generator-toolbar-dropdown-menu')
.findByText('Caption above')
.click()
// Check that caption is above table
cy.get('.ol-cm-command-caption').then(([caption]) => {
const { top: captionYPosition } = caption.getBoundingClientRect()
cy.get('.table-generator').then(([table]) => {
const { top: tableYPosition } = table.getBoundingClientRect()
cy.wrap(captionYPosition).should('be.lessThan', tableYPosition)
})
})
// Removes caption when clicking "No caption"
cy.get('@toolbar').findByText('Caption above').click()
cy.get('.table-generator-toolbar-dropdown-menu')
.findByText('No caption')
.click()
cy.get('@toolbar').findByText('No caption').should('exist')
cy.get('.ol-cm-command-caption').should('not.exist')
cy.get('.ol-cm-command-label').should('not.exist')
})
it('Renders a table with custom column spacing', function () {
mountEditor(`
\\begin{tabular}{@{}c@{}l!{}}
cell 1 & cell 2 \\\\
cell 3 & cell 4 \\\\
\\end{tabular}`)
checkTable([
['cell 1', 'cell 2'],
['cell 3', 'cell 4'],
])
cy.get('.table-generator-cell').first().click()
cy.get('.table-generator-floating-toolbar').as('toolbar').should('exist')
cy.get('@toolbar').findByText('No borders').click()
cy.get('.table-generator').findByText('All borders').click()
// The element is partially covered, but we can still click it
cy.get('.cm-line').first().click({ force: true })
checkTable([
['cell 1', 'cell 2'],
['cell 3', 'cell 4'],
])
checkBordersWithNoMultiColumn([true, true, true], [true, true, true])
})
it('Disables caption dropdown when not directly inside table environment', function () {
mountEditor(`
\\begin{table}
\\caption{Table caption}
\\label{tab:table}
\\begin{adjustbox}{max width=\\textwidth}
\\begin{tabular}{c}
cell 1
\\end{tabular}
\\end{adjustbox}
\\end{table}`)
cy.get('.table-generator').findByText('cell 1').click()
cy.get('.table-generator-floating-toolbar').as('toolbar').should('exist')
cy.get('@toolbar')
.contains('button', 'Caption above')
.should('be.disabled')
})
describe('Fixed width columns', function () {
it('Can add fixed width columns', function () {
// Check that no column indicators exist
mountEditor(`
\\begin{tabular}{cc}
cell 1 & cell 2\\\\
cell 3 & cell 4 \\\\
\\end{tabular}`)
cy.get('.table-generator-column-indicator-label').should('not.exist')
cy.get('.table-generator-cell').eq(0).as('cell')
// Activate the table
cy.get('@cell').click()
cy.get('.table-generator-floating-toolbar')
.as('toolbar')
.should('exist')
// Select the second column
cy.get('.column-selector').eq(1).click()
cy.get('@toolbar').findByLabelText('Adjust column width').click()
cy.get('.table-generator-toolbar-dropdown-menu')
.findByText('Fixed width, wrap text')
.click()
// The modal should be open
cy.get('.table-generator-width-modal').as('modal').should('be.visible')
// The width input should be focused
cy.get('@modal')
.get('#column-width-modal-width')
.should('be.focused')
.type('20')
// Change the unit to inches
cy.get('@modal')
.findAllByLabelText(/Length unit/)
.first()
.click()
cy.get('@modal')
.findByRole('listbox')
.as('dropdown')
.should('be.visible')
cy.get('@dropdown').findByText('in').click()
// Confirm the change
cy.get('@modal').findByText('OK').click()
// Modal should close
cy.get('@modal').should('not.exist')
// Check that the width is applied to the right column
cy.get('.table-generator-column-widths-row td')
// 3rd element (buffer, column 1, column 2)
.eq(2)
.should('contain.text', '20in')
})
forEach([
['20in', 'in'],
['20', 'Custom'],
['\\foobar', 'Custom'],
]).it(
`Understands '%s' width descriptor`,
function (width, expectedUnit) {
// Check that no column indicators exist
mountEditor(`
\\begin{tabular}{cp{${width}}}
cell 1 & cell 2\\\\
cell 3 & cell 4 \\\\
\\end{tabular}`)
// Activate the table
cy.get('.table-generator-cell').eq(0).click()
// Click the column width indicator
cy.get('.table-generator-column-indicator-label').click()
// The modal should be open
cy.get('.table-generator-width-modal')
.as('modal')
.should('be.visible')
cy.get('@modal')
.findAllByLabelText(/Length unit/)
.first()
.should('have.value', expectedUnit)
cy.get('@modal').findByText('Cancel').click()
}
)
it(`It can justify fixed width cells`, function () {
// Check that no column indicators exist
mountEditor(`
\\begin{tabular}{>{\\raggedright\\arraybackslash}p{2cm}c}
cell 1 & cell 2\\\\
cell 3 & cell 4 \\\\
\\end{tabular}`)
// Activate the table
cy.get('.table-generator-cell').eq(0).click()
cy.get('.table-generator-floating-toolbar')
.as('toolbar')
.should('exist')
// Select the first column
cy.get('.column-selector').first().click()
// Verify current alignment is left, and open the menu
cy.get('@toolbar')
.findByLabelText('Alignment')
.should('not.be.disabled')
.should('contain.text', 'format_align_left')
.click()
// Change to justified alignment
cy.get('.table-generator').findByLabelText('Justify').click()
// Verify that alignment icon and class alignments were updated
cy.get('@toolbar')
.findByLabelText('Alignment')
.should('contain.text', 'format_align_justify')
cy.get('.table-generator-cell')
.eq(0)
.should('have.class', 'alignment-paragraph')
cy.get('.table-generator-cell')
.eq(1)
.should('have.class', 'alignment-center')
cy.get('.table-generator-cell')
.eq(2)
.should('have.class', 'alignment-paragraph')
cy.get('.table-generator-cell')
.eq(3)
.should('have.class', 'alignment-center')
})
})
})
describe('Tabular interactions', function () {
it('Can type into cells', function () {
mountEditor(`
\\begin{tabular}{cccc}
cell 1 & cell 2 & cell 3 & cell 4\\\\
cell 5 & \\multicolumn{2}{c}cell 6} & cell 7 \\\\
\\end{tabular}`)
cy.get('.table-generator-cell').eq(0).as('cell-1')
cy.get('.table-generator-cell').eq(5).as('cell-6')
// Escape should cancel editing
cy.get('@cell-1').type('foo{Esc}')
cy.get('@cell-1').should('have.text', 'cell 1')
// Enter should commit change. Direct typing should override the current contents
cy.get('@cell-1').type('foo{Enter}')
cy.get('@cell-1').should('have.text', 'foo')
// Enter should start editing at the end of current text
cy.get('@cell-1').type('{Enter}')
cy.get('@cell-1').find('textarea').should('exist')
cy.get('@cell-1').type('bar{Enter}')
cy.get('@cell-1').should('have.text', 'foobar')
// Double clicking should start editing at the end of current text
cy.get('@cell-1').dblclick()
cy.get('@cell-1').find('textarea').should('exist')
cy.get('@cell-1').type('baz{Enter}')
cy.get('@cell-1').should('have.text', 'foobarbaz')
cy.get('@cell-1').type('{Backspace}')
cy.get('@cell-1').should('have.text', '')
// Typing also works for multicolumn cells
cy.get('@cell-6').type('foo{Enter}')
checkTable([
['', 'cell 2', 'cell 3', 'cell 4'],
['cell 5', { text: 'foo', colspan: 2 }, 'cell 7'],
])
})
it('Can paste tabular data into cells', function () {
mountEditor(`
\\begin{tabular}{cc }
cell 1 & cell 2\\\\
cell 3 & cell 4 \\\\
\\end{tabular}`)
cy.get('.table-generator-cell').eq(0).as('cell-1')
// TODO: Seems as cypress can't access clipboard, so we can't test copying
cy.get('@cell-1').click()
const clipboardData = new DataTransfer()
clipboardData.setData('text/plain', 'foo\tbar\nbaz\tqux')
cy.get('@cell-1').trigger('paste', { clipboardData })
checkTable([
['foo', 'bar'],
['baz', 'qux'],
])
})
it('Can navigate cells with keyboard', function () {
mountEditor(`
\\begin{tabular}{cc }
cell 1 & cell 2\\\\
cell 3 & cell 4 \\\\
\\end{tabular}`)
cy.get('.table-generator-cell').eq(0).as('cell-1')
cy.get('.table-generator-cell').eq(1).as('cell-2')
cy.get('.table-generator-cell').eq(2).as('cell-3')
cy.get('.table-generator-cell').eq(3).as('cell-4')
// Arrow key navigation
cy.get('@cell-1').click()
cy.get('@cell-1').type('{rightarrow}')
cy.get('@cell-2').should('have.focus').should('have.class', 'selected')
cy.get('@cell-2').type('{leftarrow}')
cy.get('@cell-1').should('have.focus').should('have.class', 'selected')
cy.get('@cell-1').type('{downarrow}')
cy.get('@cell-3').should('have.focus').should('have.class', 'selected')
cy.get('@cell-3').type('{rightarrow}')
cy.get('@cell-4').should('have.focus').should('have.class', 'selected')
cy.get('@cell-4').type('{uparrow}')
cy.get('@cell-2').should('have.focus').should('have.class', 'selected')
cy.get('@cell-2').type('{leftarrow}')
cy.get('@cell-1').should('have.focus').should('have.class', 'selected')
// Tab navigation
cy.get('@cell-1').tab()
cy.get('@cell-2').should('have.focus').should('have.class', 'selected')
cy.get('@cell-2').tab()
cy.get('@cell-3').should('have.focus').should('have.class', 'selected')
cy.get('@cell-3').tab()
cy.get('@cell-4').should('have.focus').should('have.class', 'selected')
cy.get('@cell-4').tab({ shift: true })
cy.get('@cell-3').should('have.focus').should('have.class', 'selected')
cy.get('@cell-3').tab({ shift: true })
cy.get('@cell-2').should('have.focus').should('have.class', 'selected')
cy.get('@cell-2').tab({ shift: true })
cy.get('@cell-1').should('have.focus').should('have.class', 'selected')
// Tabbing when editing a cell should commit change and move to next cell
cy.get('@cell-1').type('foo')
cy.get('@cell-1').tab()
cy.get('@cell-2').should('have.focus').should('have.class', 'selected')
cy.get('@cell-1').should('have.text', 'foo')
})
it('Can select rows and columns with selectors', function () {
mountEditor(`
\\begin{tabular}{cc }
cell 1 & cell 2\\\\
cell 3 & cell 4 \\\\
\\end{tabular}`)
cy.get('.table-generator-cell').eq(0).as('cell-1')
cy.get('.table-generator-cell').eq(1).as('cell-2')
cy.get('.table-generator-cell').eq(2).as('cell-3')
cy.get('.table-generator-cell').eq(3).as('cell-4')
cy.get('.column-selector').eq(0).click()
cy.get('@cell-1').should('have.class', 'selected')
cy.get('@cell-2').should('not.have.class', 'selected')
cy.get('@cell-3').should('have.class', 'selected')
cy.get('@cell-4').should('not.have.class', 'selected')
cy.get('.column-selector').eq(1).click()
cy.get('@cell-1').should('not.have.class', 'selected')
cy.get('@cell-2').should('have.class', 'selected')
cy.get('@cell-3').should('not.have.class', 'selected')
cy.get('@cell-4').should('have.class', 'selected')
cy.get('.column-selector').eq(0).click({ shiftKey: true })
cy.get('@cell-1').should('have.class', 'selected')
cy.get('@cell-2').should('have.class', 'selected')
cy.get('@cell-3').should('have.class', 'selected')
cy.get('@cell-4').should('have.class', 'selected')
cy.get('.row-selector').eq(0).click()
cy.get('@cell-1').should('have.class', 'selected')
cy.get('@cell-2').should('have.class', 'selected')
cy.get('@cell-3').should('not.have.class', 'selected')
cy.get('@cell-4').should('not.have.class', 'selected')
cy.get('.row-selector').eq(1).click()
cy.get('@cell-1').should('not.have.class', 'selected')
cy.get('@cell-2').should('not.have.class', 'selected')
cy.get('@cell-3').should('have.class', 'selected')
cy.get('@cell-4').should('have.class', 'selected')
cy.get('.row-selector').eq(0).click({ shiftKey: true })
cy.get('@cell-1').should('have.class', 'selected')
cy.get('@cell-2').should('have.class', 'selected')
cy.get('@cell-3').should('have.class', 'selected')
cy.get('@cell-4').should('have.class', 'selected')
})
it('Allow compilation shortcuts to work', function () {
mountEditor(`
\\begin{tabular}{cc }
cell 1 & cell 2\\\\
cell 3 & cell 4 \\\\
\\end{tabular}`)
cy.get('.table-generator-cell').eq(0).as('cell-1').click()
cy.get('@cell-1').type('{ctrl}{enter}')
cy.wait('@compile')
cy.get('@cell-1').type('foo{ctrl}{enter}')
cy.wait('@compile')
cy.get('@cell-1').type('{esc}')
cy.get('@cell-1').type('{ctrl}{s}')
cy.wait('@compile')
cy.get('@cell-1').type('foo{ctrl}{s}')
cy.wait('@compile')
})
})
})

View File

@@ -0,0 +1,191 @@
import { EditorProviders } from '../../../helpers/editor-providers'
import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
import { mockScope } from '../helpers/mock-scope'
import { TestContainer } from '../helpers/test-container'
const mountEditor = (content: string) => {
const scope = mockScope(content)
scope.editor.showVisual = true
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodemirrorEditor />
</EditorProviders>
</TestContainer>
)
// wait for the content to be parsed and revealed
cy.get('.cm-content').should('have.css', 'opacity', '1')
}
describe('<CodeMirrorEditor/> command tooltip in Visual mode', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
cy.interceptEvents()
cy.interceptMetadata()
})
it('shows a tooltip for \\href', function () {
const content = [
'\\documentclass{article}',
'\\usepackage{hyperref}',
'\\begin{document}',
'',
'\\end{document}',
].join('\n')
mountEditor(content)
cy.window().then(win => {
cy.stub(win, 'open').as('window-open')
})
// wait for preamble to be escaped
cy.get('.cm-line').eq(0).should('have.text', '')
// enter the command
cy.get('.cm-line').eq(0).as('content-line')
cy.get('@content-line').type('\\href{{}}{{}foo')
cy.get('@content-line').should('have.text', '{foo}')
// enter the URL in the tooltip form
cy.findByLabelText('URL').type('https://example.com')
// open the link
cy.findByRole('button', { name: 'Go to page' }).click()
cy.get('@window-open').should(
'have.been.calledWithMatch',
Cypress.sinon.match.has('href', 'https://example.com/'),
'_blank'
)
// remove the link
cy.findByRole('menu').should('have.length', 1)
cy.findByRole('button', { name: 'Remove link' }).click()
cy.findByRole('menu').should('have.length', 0)
cy.get('@content-line').should('have.text', 'foo')
})
it('can navigate the \\href tooltip using the keyboard', function () {
const content = [
'\\documentclass{article}',
'\\usepackage{hyperref}',
'\\begin{document}',
'',
'\\end{document}',
].join('\n')
mountEditor(content)
// wait for preamble to be escaped
cy.get('.cm-line').eq(0).should('have.text', '')
// enter the command
cy.get('.cm-line').eq(0).as('content-line')
cy.get('@content-line').type('\\href{{}}{{}foo')
// into tooltip URL form input
cy.tab()
// down to first button
cy.tab()
// back into tooltip URL form input
cy.tab({ shift: true })
// back into document
cy.tab({ shift: true })
// close the tooltip
cy.get('@content-line').trigger('keydown', { key: 'Escape' })
cy.findByRole('menu').should('have.length', 0)
})
it('shows a tooltip for \\url', function () {
mountEditor('')
cy.window().then(win => {
cy.stub(win, 'open').as('window-open')
})
// enter the command and URL
cy.get('.cm-line').eq(0).as('content-line')
cy.get('@content-line').type('\\url{{}https://example.com')
cy.get('@content-line').should('have.text', '{https://example.com}')
// open the link
cy.findByRole('button', { name: 'Go to page' }).click()
cy.get('@window-open').should(
'have.been.calledWithMatch',
Cypress.sinon.match.has('href', 'https://example.com/'),
'_blank'
)
})
it('shows a tooltip for \\include', function () {
mountEditor('')
// enter the command and file name
cy.get('.cm-line').eq(0).as('content-line')
cy.get('@content-line').type('\\include{{}foo')
// assert the focused command is undecorated
cy.get('@content-line').should('have.text', '\\include{foo}')
cy.contains('figures/foo.tex').click()
cy.get('@content-line').should('have.text', '\\include{figures/foo}')
cy.get('@content-line').type('{leftArrow}')
// assert the unfocused command has a menu
cy.findByRole('menu').should('have.length', 1)
cy.findByText('Edit file')
// assert the unfocused command is decorated
cy.get('@content-line').type('{downArrow}')
cy.findByRole('menu').should('have.length', 0)
cy.get('@content-line').should('have.text', '\\include{figures/foo}')
})
it('shows a tooltip for \\input', function () {
mountEditor('')
// enter the command and file name
cy.get('.cm-line').eq(0).as('content-line')
cy.get('@content-line').type('\\input{{}foo')
// assert the focused command is undecorated
cy.get('@content-line').should('have.text', '\\input{foo}')
cy.contains('figures/foo.tex').click()
cy.get('@content-line').should('have.text', '\\input{figures/foo}')
cy.get('@content-line').type('{leftArrow}')
// open the target
cy.findByRole('menu').should('have.length', 1)
cy.findByRole('button', { name: 'Edit file' })
// assert the unfocused command is decorated
cy.get('@content-line').type('{downArrow}')
cy.findByRole('menu').should('have.length', 0)
cy.get('@content-line').should('have.text', '\\input{figures/foo}')
})
it('shows a tooltip for \\ref', function () {
const content = ['\\section{Foo} \\label{sec:foo}', ''].join('\n')
mountEditor(content)
// assert the unfocused label is decorated
cy.get('.cm-line').eq(0).as('heading-line')
cy.get('@heading-line').should('have.text', '{Foo} 🏷sec:foo')
// enter the command and cross-reference label
cy.get('.cm-line').eq(1).as('content-line')
cy.get('@content-line').type('\\ref{{}sec:foo')
cy.get('@content-line').should('have.text', '🏷{sec:foo}')
// open the target
cy.findByRole('menu').should('have.length', 1)
cy.findByRole('button', { name: 'Go to target' }).click()
cy.findByRole('menu').should('have.length', 0)
// assert the focused label is undecorated
cy.get('@heading-line').should('have.text', 'Foo \\label{sec:foo}')
})
})

View File

@@ -0,0 +1,63 @@
import { EditorProviders } from '../../../helpers/editor-providers'
import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
import { mockScope } from '../helpers/mock-scope'
import { TestContainer } from '../helpers/test-container'
const mountEditor = (content: string) => {
const scope = mockScope(content)
scope.editor.showVisual = true
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodemirrorEditor />
</EditorProviders>
</TestContainer>
)
// wait for the content to be parsed and revealed
cy.get('.cm-content').should('have.css', 'opacity', '1')
}
describe('<CodeMirrorEditor/> floats', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
cy.interceptEvents()
})
it('decorates a caption', function () {
mountEditor('\n\\caption{Foo}\n')
cy.get('.cm-line').eq(2).click()
cy.get('.cm-content').should('have.text', 'Foo')
})
it('decorates a caption in a figure', function () {
mountEditor('\\begin{figure}\n\\caption{Foo}\n\\end{figure}\n')
cy.get('.cm-line').eq(3).click()
cy.get('.cm-content').should('have.text', 'Foo')
})
it('decorates a caption in a table', function () {
mountEditor('\\begin{table}\n\\caption{Foo}\n\\end{table}\n')
cy.get('.cm-line').eq(3).click()
cy.get('.cm-content').should('have.text', 'Foo')
})
it('decorates a starred caption', function () {
mountEditor('\n\\caption*{Foo}\n')
cy.get('.cm-line').eq(2).click()
cy.get('.cm-content').should('have.text', 'Foo')
})
it('decorates a starred figure', function () {
mountEditor('\\begin{figure*}\n\\caption{Foo}\n\\end{figure*}\n')
cy.get('.cm-line').eq(3).click()
cy.get('.cm-content').should('have.text', 'Foo')
})
it('decorates a starred table', function () {
mountEditor('\\begin{table*}\n\\caption{Foo}\n\\end{table*}\n')
cy.get('.cm-line').eq(3).click()
cy.get('.cm-content').should('have.text', 'Foo')
})
})

View File

@@ -0,0 +1,314 @@
import { EditorProviders } from '../../../helpers/editor-providers'
import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
import { mockScope } from '../helpers/mock-scope'
import { TestContainer } from '../helpers/test-container'
import { isMac } from '@/shared/utils/os'
const mountEditor = (content: string) => {
const scope = mockScope(content)
scope.editor.showVisual = true
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodemirrorEditor />
</EditorProviders>
</TestContainer>
)
// wait for the content to be parsed and revealed
cy.get('.cm-content').should('have.css', 'opacity', '1')
}
describe('<CodeMirrorEditor/> lists in Rich Text mode', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
cy.interceptEvents()
})
it('creates a nested list inside an unindented list', function () {
const content = [
'\\begin{itemize}',
'\\item Test',
'\\item Test',
'\\end{itemize}',
].join('\n')
mountEditor(content)
// create a nested list
cy.get('.cm-line')
.eq(1)
.type(isMac ? '{cmd}]' : '{ctrl}]')
cy.get('.cm-content').should('have.text', [' Test', ' Test'].join(''))
})
it('creates a nested list inside an indented list', function () {
const content = [
'\\begin{itemize}',
' \\item Test',
' \\item Test',
'\\end{itemize}',
].join('\n')
mountEditor(content)
// create a nested list
cy.get('.cm-line')
.eq(1)
.type(isMac ? '{cmd}]' : '{ctrl}]')
cy.get('.cm-content').should('have.text', [' Test', ' Test'].join(''))
})
it('creates a nested list on Tab at the start of an item', function () {
const content = [
'\\begin{itemize}',
'\\item Test',
'\\item Test',
'\\end{itemize}',
].join('\n')
mountEditor(content)
// move to the start of the item and press Tab
cy.get('.cm-line').eq(1).as('line')
cy.get('@line').click()
cy.get('@line').type('{leftArrow}'.repeat(4))
cy.get('@line').trigger('keydown', {
key: 'Tab',
})
cy.get('.cm-content').should('have.text', [' Test', ' Test'].join(''))
})
it('does not creates a nested list on Tab when not at the start of an item', function () {
const content = [
'\\begin{itemize}',
'\\item Test',
'\\item Test',
'\\end{itemize}',
].join('\n')
mountEditor(content)
// focus a line (at the end of a list item) and press Tab
cy.get('.cm-line').eq(2).click()
cy.get('.cm-line').eq(1).trigger('keydown', {
key: 'Tab',
})
cy.get('.cm-content').should('have.text', [' Test', ' Test '].join(''))
})
it('removes a nested list on Shift-Tab', function () {
const content = [
'\\begin{itemize}',
'\\item Test',
'\\item Test',
'\\end{itemize}',
].join('\n')
mountEditor(content)
// move to the start of the list item and press Tab
cy.get('.cm-line').eq(1).as('line')
cy.get('@line').click()
cy.get('@line').type('{leftArrow}'.repeat(4))
cy.get('@line').trigger('keydown', {
key: 'Tab',
})
cy.get('.cm-content').should('have.text', [' Test', ' Test'].join(''))
// focus the indented line and press Shift-Tab
cy.get('.cm-line').eq(1).trigger('keydown', {
key: 'Tab',
shiftKey: true,
})
cy.get('.cm-content').should('have.text', [' Test', ' Test'].join(''))
})
it('does not remove a top-level nested list on Shift-Tab', function () {
const content = [
'\\begin{itemize}',
'\\item Test',
'\\item Test',
'\\end{itemize}',
].join('\n')
mountEditor(content)
// focus a list item and press Shift-Tab
cy.get('.cm-line').eq(2).click()
cy.get('.cm-line').eq(1).trigger('keydown', {
key: 'Tab',
shiftKey: true,
})
cy.get('.cm-content').should('have.text', [' Test', ' Test'].join(''))
})
it('handles up arrow at the start of a list item', function () {
const content = [
'\\begin{itemize}',
'\\item One',
'\\item Two',
'\\end{itemize}',
].join('\n')
mountEditor(content)
cy.get('.cm-line').eq(1).as('line')
cy.get('@line').click()
cy.get('@line').type('{leftArrow}'.repeat(3)) // to the start of the item
cy.get('@line').type('{upArrow}{Shift}{rightArrow}{rightArrow}{rightArrow}') // up and extend to the end of the item
cy.window().should(win => {
expect(win.getSelection()?.toString()).to.equal('One')
})
})
it('handles up arrow at the start of an indented list item', function () {
const content = [
'\\begin{itemize}',
' \\item One',
' \\item Two',
'\\end{itemize}',
].join('\n')
mountEditor(content)
cy.get('.cm-line').eq(1).as('line')
cy.get('@line').click()
cy.get('@line').type('{leftArrow}'.repeat(3)) // to the start of the item
cy.get('@line').type('{upArrow}{Shift}{rightArrow}{rightArrow}{rightArrow}') // up and extend to the end of the item
cy.window().should(win => {
expect(win.getSelection()?.toString()).to.equal('One')
})
})
it('handles keyboard navigation around a list', function () {
const content = [
'',
'\\begin{itemize}',
'\\item One',
'\\item Two',
'\\end{itemize}',
'',
].join('\n')
mountEditor(content)
cy.get('.cm-line').eq(0).as('line')
cy.get('@line').click('left')
cy.get('@line').type(
'{downArrow}'.repeat(2) + // down to the second list item
'{rightArrow}'.repeat(2) + // along a few characters
'{upArrow}'.repeat(1) + // up to the first list item
'{rightArrow}'.repeat(2) + // along to the start of the second list item
'{shift}' + // start extending the selection
'{rightArrow}'.repeat(3) // cover the word
)
cy.window().should(win => {
expect(win.getSelection()?.toString()).to.equal('Two')
})
})
it('positions the cursor after creating a new line with leading whitespace', function () {
const content = [
'\\begin{itemize}',
'\\item foo bar',
'\\end{itemize}',
].join('\n')
mountEditor(content)
cy.get('.cm-line').eq(1).click()
cy.get('.cm-line').eq(0).type('{leftArrow}'.repeat(4))
cy.get('.cm-line').eq(0).type('{enter}baz')
cy.get('.cm-content').should('have.text', [' foo', ' bazbar'].join(''))
})
it('handles Enter in an empty list item at the end of a top-level list', function () {
const content = [
'\\begin{itemize}',
'\\item foo',
'\\item ',
'\\end{itemize}',
'',
].join('\n')
mountEditor(content)
cy.get('.cm-line').eq(2).click()
cy.focused().type('{enter}')
cy.get('.cm-content').should('have.text', [' foo'].join(''))
})
it('handles Enter in an empty list item at the end of a nested list', function () {
const content = [
'\\begin{itemize}',
'\\item foo bar',
'\\begin{itemize}',
'\\item baz',
'\\item ',
'\\end{itemize}',
'\\end{itemize}',
].join('\n')
mountEditor(content)
cy.get('.cm-line').eq(3).click()
cy.focused().type('{enter}')
cy.get('.cm-content').should(
'have.text',
[' foo', ' bar', ' baz', ' '].join('')
)
})
it('handles Enter in an empty list item at the end of a nested list with subsequent items', function () {
const content = [
'\\begin{itemize}',
'\\item foo bar',
'\\begin{itemize}',
'\\item baz',
'\\item ',
'\\end{itemize}',
'\\item test',
'\\end{itemize}',
].join('\n')
mountEditor(content)
cy.get('.cm-line').eq(3).click()
cy.focused().type('{enter}')
cy.get('.cm-content').should(
'have.text',
[' foo', ' bar', ' baz', ' ', ' test'].join('')
)
})
it('decorates a description list', function () {
const content = [
'\\begin{description}',
'\\item[foo] Bar',
'\\item Test',
'\\end{description}',
].join('\n')
mountEditor(content)
cy.get('.cm-line').eq(1).click()
cy.get('.cm-content').should('have.text', ['foo Bar', 'Test'].join(''))
cy.get('.cm-line').eq(1).type('{Enter}baz')
cy.get('.cm-content').should(
'have.text',
['foo Bar', 'Test', '[baz] '].join('')
)
cy.get('.cm-line').eq(2).type('{rightArrow}{rightArrow}Test')
cy.get('.cm-content').should(
'have.text',
['foo Bar', 'Test', 'baz Test'].join('')
)
})
})

View File

@@ -0,0 +1,802 @@
import { EditorProviders } from '../../../helpers/editor-providers'
import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
import { mockScope } from '../helpers/mock-scope'
import { TestContainer } from '../helpers/test-container'
const menuIconsText = 'content_copyexpand_more'
const mountEditor = (content = '') => {
const scope = mockScope(content)
scope.editor.showVisual = true
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodemirrorEditor />
</EditorProviders>
</TestContainer>
)
// wait for the content to be parsed and revealed
cy.get('.cm-content').as('content')
cy.get('@content').should('have.css', 'opacity', '1')
}
describe('<CodeMirrorEditor/> paste HTML in Visual mode', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
cy.interceptEvents()
})
it('handles paste', function () {
mountEditor()
const data = 'foo'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.spy(clipboardData, 'getData').as('get-data')
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').type('{esc}')
cy.get('@content').should('have.text', 'foo' + menuIconsText)
cy.get('@get-data').should('have.been.calledTwice')
cy.get('@get-data').should('have.been.calledWithExactly', 'text/html')
cy.get('@get-data').should('have.been.calledWithExactly', 'text/plain')
})
it('handles a pasted bullet list', function () {
mountEditor()
const data = '<ul><li>foo</li><li>bar</li></ul>'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', ' foo bar' + menuIconsText)
cy.get('.ol-cm-item').should('have.length', 2)
})
it('handles a pasted numbered list', function () {
mountEditor()
const data = '<ol><li>foo</li><li>bar</li></ol>'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', ' foo bar' + menuIconsText)
cy.get('.ol-cm-item').should('have.length', 2)
})
it('handles a pasted nested bullet list', function () {
mountEditor()
const data =
'<ul><li>foo</li><li><ul><li>bar</li><li>baz</li></ul></li></ul>'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', ' foo bar baz' + menuIconsText)
cy.get('.ol-cm-item').should('have.length', 4)
cy.get('.cm-line').should('have.length', 6)
})
it('handles a pasted nested numbered list', function () {
mountEditor()
const data =
'<ol><li>foo</li><li><ol><li>bar</li><li>baz</li></ol></li></ol>'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', ' foo bar baz' + menuIconsText)
cy.get('.ol-cm-item').should('have.length', 4)
cy.get('.cm-line').should('have.length', 6)
})
it('removes a solitary item from a list', function () {
mountEditor()
const data = '<ul><li>foo</li></ul>'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'foo' + menuIconsText)
cy.get('.ol-cm-item').should('have.length', 0)
})
it('handles a pasted table', function () {
mountEditor()
const data =
'<table><tbody><tr><td>foo</td><td>bar</td></tr></tbody></table>'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'foobar' + menuIconsText)
cy.get('.table-generator-cell').should('have.length', 2)
})
it('handles a pasted table with cell borders', function () {
mountEditor()
const data =
'<table><tbody><tr><td style="border-left:1px solid black;border-right:1px solid black;border-top:1px solid black;border-bottom:1px solid black">foo</td><td style="border-left:1px solid black;border-right:1px solid black;border-top:1px solid black;border-bottom:1px solid black">bar</td></tr></tbody></table>'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'foobar' + menuIconsText)
cy.get('.table-generator-cell').should('have.length', 2)
cy.get('.table-generator-cell-border-left').should('have.length', 1)
cy.get('.table-generator-cell-border-right').should('have.length', 2)
cy.get('.table-generator-row-border-top').should('have.length', 2)
cy.get('.table-generator-row-border-bottom').should('have.length', 2)
})
it('handles a pasted table with row borders', function () {
mountEditor()
const data =
'<table><tbody><tr style="border-top:1px solid black;border-bottom:1px solid black"><td>foo</td><td>bar</td></tr></tbody></table>'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'foobar' + menuIconsText)
cy.get('.table-generator-cell').should('have.length', 2)
cy.get('.table-generator-cell-border-left').should('have.length', 0)
cy.get('.table-generator-cell-border-right').should('have.length', 0)
cy.get('.table-generator-row-border-top').should('have.length', 2)
cy.get('.table-generator-row-border-bottom').should('have.length', 2)
})
it('handles a pasted table with adjacent borders', function () {
mountEditor()
const data = [
'<table><tbody>',
'<tr><td style="border-left:1px solid black;border-right:1px solid black;border-top:1px solid black;border-bottom:1px solid black">foo</td><td style="border-left:1px solid black;border-right:1px solid black;border-top:1px solid black;border-bottom:1px solid black">bar</td></tr>',
'<tr><td style="border-left:1px solid black;border-right:1px solid black;border-top:1px solid black;border-bottom:1px solid black">foo</td><td style="border-left:1px solid black;border-right:1px solid black;border-top:1px solid black;border-bottom:1px solid black">bar</td></tr>',
'<tr><td style="border-left:1px solid black;border-right:1px solid black;border-top:1px solid black;border-bottom:1px solid black">foo</td><td style="border-left:1px solid black;border-right:1px solid black;border-top:1px solid black;border-bottom:1px solid black">bar</td></tr>',
'</tbody></table>',
].join('\n')
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'foobarfoobarfoobar' + menuIconsText)
cy.get('.table-generator-cell').should('have.length', 6)
cy.get('.table-generator-cell-border-left').should('have.length', 3)
cy.get('.table-generator-cell-border-right').should('have.length', 6)
cy.get('.table-generator-row-border-top').should('have.length', 6)
cy.get('.table-generator-row-border-bottom').should('have.length', 2)
})
it('handles a pasted table with alignment', function () {
mountEditor()
const data =
'<table><tbody><tr><td>foo</td><td style="text-align:left">foo</td><td style="text-align:center">foo</td><td style="text-align:right">foo</td><td style="text-align:justify">foo</td></tr></tbody></table>'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'foofoofoofoofoo' + menuIconsText)
cy.get('.table-generator-cell').should('have.length', 5)
cy.get('.table-generator-cell.alignment-left').should('have.length', 3)
cy.get('.table-generator-cell.alignment-center').should('have.length', 1)
cy.get('.table-generator-cell.alignment-right').should('have.length', 1)
})
it('handles a pasted table with merged columns', function () {
mountEditor()
const data = [
`<table><tbody>`,
`<tr><td>test</td><td>test</td><td>test</td></tr>`,
`<tr><td colspan="2">test</td><td>test</td></tr>`,
`<tr><td>test</td><td colspan="2" style="text-align:right">test</td></tr>`,
`</tbody></table>`,
].join('')
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should(
'have.text',
'testtesttesttesttesttesttest' + menuIconsText
)
cy.get('.table-generator-cell').should('have.length', 7)
cy.get('.table-generator-cell[colspan="2"]').should('have.length', 2)
})
it('handles a pasted table with merged rows', function () {
mountEditor()
const data = [
`<table><tbody>`,
`<tr><td>test</td><td>test</td><td>test</td></tr>`,
`<tr><td rowspan="2">test</td><td>test</td><td>test</td></tr>`,
`<tr><td>test</td><td>test</td></tr>`,
`</tbody></table>`,
].join('')
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should(
'have.text',
'testtesttest\\multirow{2}{*}{test}testtesttesttest' + menuIconsText
)
cy.get('.table-generator-cell').should('have.length', 9)
})
it('handles a pasted table with merged rows and columns', function () {
mountEditor()
const data = [
`<table><tbody>`,
`<tr><td colspan="2" rowspan="2">test</td><td>test</td></tr>`,
`<tr><td>test</td></tr>`,
`<tr><td>test</td><td>test</td><td>test</td></tr>`,
`</tbody></table>`,
].join('')
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should(
'have.text',
'\\multirow{2}{*}{test}testtesttesttesttest' + menuIconsText
)
cy.get('.table-generator-cell').should('have.length', 8)
cy.get('.table-generator-cell[colspan="2"]').should('have.length', 1)
})
it('ignores rowspan="1" and colspan="1"', function () {
mountEditor()
const data = [
`<table><tbody>`,
`<tr><td colspan="1" rowspan="1">test</td><td>test</td><td>test</td></tr>`,
`<tr><td>test</td><td>test</td><td>test</td></tr>`,
`</tbody></table>`,
].join('')
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should(
'have.text',
'testtesttesttesttesttest' + menuIconsText
)
cy.get('.table-generator-cell').should('have.length', 6)
cy.get('.table-generator-cell[colspan]').should('have.length', 0)
})
it('handles a pasted table with adjacent borders and merged cells', function () {
mountEditor()
const data = [
'<table><tbody>',
'<tr><td style="border-left:1px solid black;border-right:1px solid black;border-top:1px solid black;border-bottom:1px solid black" colspan="2">foo</td></tr>',
'<tr><td style="border-left:1px solid black;border-right:1px solid black;border-top:1px solid black;border-bottom:1px solid black">foo</td><td style="border-left:1px solid black;border-right:1px solid black;border-top:1px solid black;border-bottom:1px solid black">bar</td></tr>',
'<tr><td style="border-left:1px solid black;border-right:1px solid black;border-top:1px solid black;border-bottom:1px solid black">foo</td><td style="border-left:1px solid black;border-right:1px solid black;border-top:1px solid black;border-bottom:1px solid black">bar</td></tr>',
'</tbody></table>',
].join('\n')
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'foofoobarfoobar' + menuIconsText)
cy.get('.table-generator-cell').should('have.length', 5)
cy.get('.table-generator-cell[colspan="2"]').should('have.length', 1)
cy.get('.table-generator-cell-border-left').should('have.length', 2)
cy.get('.table-generator-cell-border-right').should('have.length', 4)
cy.get('.table-generator-row-border-top').should('have.length', 5)
cy.get('.table-generator-row-border-bottom').should('have.length', 2)
})
it('handles a pasted table with cell styles', function () {
mountEditor()
const data =
'<table><tbody><tr><td style="font-weight:bold">foo</td><td style="font-style:italic">bar</td><td style="font-style:italic;font-weight:bold">baz</td></tr></tbody></table>'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'foobarbaz' + menuIconsText)
cy.findByText(/Sorry/).should('not.exist')
cy.get('td b').should('have.length', 2)
cy.get('td i').should('have.length', 2)
})
it('handles a pasted table with formatting markup', function () {
mountEditor()
const data =
'<table><tbody><tr>' +
'<td><b>foo</b></td>' +
'<td><i>bar</i></td>' +
'<td><b><i>baz</i></b></td>' +
'<td><i><b>buzz</b></i></td>' +
'<td><sup>up</sup></td>' +
'<td><sub>down</sub></td>' +
'</tr></tbody></table>'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should(
'have.text',
'foobarbazbuzzupdown' + menuIconsText
)
cy.findByText(/Sorry/).should('not.exist')
cy.get('td b').should('have.length', 3)
cy.get('td i').should('have.length', 3)
cy.get('td sup').should('have.length', 1)
cy.get('td sub').should('have.length', 1)
})
it('handles a pasted table with a caption', function () {
mountEditor()
const data =
'<table><caption>A table</caption><tbody><tr><td>foo</td><td>bar</td></tr></tbody></table>'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'A tablefoobar' + menuIconsText)
cy.get('.table-generator-cell').should('have.length', 2)
cy.get('.ol-cm-command-caption').should('have.length', 1)
})
it('handles a pasted link', function () {
mountEditor()
const data =
'<a href="https://example.com/?q=$foo_~bar&x=\\bar#fragment{y}%2">foo</a>'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', '{foo}' + menuIconsText)
cy.get('.ol-cm-command-href').should('have.length', 1)
cy.get('.cm-line').eq(0).type('{leftArrow}')
cy.findByLabelText('URL').should(
'have.value',
'https://example.com/?q=$foo_~bar&x=\\\\bar\\#fragment%7By%7D\\%2'
)
// TODO: assert that the "Go to page" link has been unescaped
})
it('handles pasted code in pre blocks', function () {
mountEditor()
const data = `test <pre><code>\\textbf{foo}</code></pre> <pre style="font-family: 'Lucida Console', monospace">\\textbf{foo}</pre> test`
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should(
'have.text',
'test \\textbf{foo}\\textbf{foo}test' + menuIconsText
)
cy.get('.ol-cm-environment-verbatim').should('have.length', 10)
})
it('handles a pasted blockquote', function () {
mountEditor()
const data = 'test <blockquote>foo</blockquote> test'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'test footest' + menuIconsText)
cy.get('.ol-cm-environment-quote').should('have.length', 5)
cy.get('.cm-line').eq(2).click()
cy.get('@content').should(
'have.text',
'test \\begin{quote}foo\\end{quote}test' + menuIconsText
)
})
it('handles pasted paragraphs', function () {
mountEditor()
const data = [
'test',
'<p>foo</p>',
'<p>bar</p>',
'<p>baz</p>',
'test',
].join('\n')
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'test foobarbaztest' + menuIconsText)
cy.get('.cm-line').should('have.length', 7)
})
it('handles pasted paragraphs in list items and table cells', function () {
mountEditor()
const data = [
'test',
'<p>foo</p><p>bar</p><p>baz</p>',
'<ul><li><p>foo</p></li><li><p>foo</p></li></ul>',
'<ol><li><p>foo</p></li><li><p>foo</p></li></ol>',
'<table><tbody><tr><td><p>foo</p></td></tr></tbody></table>',
'test',
].join('\n')
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should(
'have.text',
'test foobarbaz foo foo foo foofootest' + menuIconsText
)
cy.get('.cm-line').should('have.length', 14)
})
it('handles pasted inline code', function () {
mountEditor()
const data = 'test <code>foo</code> test'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'test foo test' + menuIconsText)
cy.get('.ol-cm-command-verb')
.should('have.length', 1)
.should('have.text', 'foo')
})
it('use text/plain for a wrapper code element', function () {
mountEditor()
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', '<code>foo</code>')
clipboardData.setData('text/plain', 'foo')
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'foo')
cy.get('.ol-cm-command-verb').should('have.length', 0)
})
it('use text/plain for a code element in a pre element', function () {
mountEditor()
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', '<pre><code>foo</code></pre>')
clipboardData.setData('text/plain', 'foo')
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'foo')
cy.get('.ol-cm-command-verb').should('have.length', 0)
cy.get('.ol-cm-environment-verbatim').should('have.length', 0)
})
it('use text/plain for a pre element with monospace font', function () {
mountEditor()
const clipboardData = new DataTransfer()
clipboardData.setData(
'text/html',
'<pre style="font-family:Courier,monospace">foo</pre>'
)
clipboardData.setData('text/plain', 'foo')
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'foo')
cy.get('.ol-cm-command-verb').should('have.length', 0)
cy.get('.ol-cm-environment-verbatim').should('have.length', 0)
})
it('handles pasted text with formatting', function () {
mountEditor()
const data =
'<b>foo</b><sup>th</sup> <i>bar</i><sub>2</sub> baz <em>woo</em> <strong>woo</strong> woo'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should(
'have.text',
'footh bar2 baz woo woo woo' + menuIconsText
)
cy.get('.ol-cm-command-textbf').should('have.length', 2)
cy.get('.ol-cm-command-textit').should('have.length', 2)
cy.get('.ol-cm-command-textsuperscript').should('have.length', 1)
cy.get('.ol-cm-command-textsubscript').should('have.length', 1)
})
it('handles pasted text with bold CSS formatting', function () {
mountEditor()
const data =
'<span style="font-weight:bold">foo</span> <span style="font-weight:800">foo</span> foo'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'foo foo foo' + menuIconsText)
cy.get('.ol-cm-command-textbf').should('have.length', 2)
})
it('handles pasted text with italic CSS formatting', function () {
mountEditor()
const data = '<span style="font-style:italic">foo</span> foo'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'foo foo' + menuIconsText)
cy.get('.ol-cm-command-textit').should('have.length', 1)
})
it('handles pasted text with non-bold CSS', function () {
mountEditor()
const data =
'<strong style="font-weight:normal">foo</strong> <strong style="font-weight:200">foo</strong> foo'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'foo foo foo' + menuIconsText)
cy.get('.ol-cm-command-textbf').should('have.length', 0)
})
it('handles pasted text with non-italic CSS', function () {
mountEditor()
const data =
'<em style="font-style:normal">foo</em> <i style="font-style:normal">foo</i> foo'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'foo foo foo' + menuIconsText)
cy.get('.ol-cm-command-textit').should('have.length', 0)
})
it('handles pasted elements with duplicate CSS formatting', function () {
mountEditor()
const data = [
'<strong style="font-weight:bold">foo</strong>',
'<b style="font-weight:bold">foo</b>',
'<em style="font-style:italic">foo</em>',
'<i style="font-style:italic">foo</i>',
'foo',
].join(' ')
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('.ol-cm-command-textbf').should('have.length', 2)
cy.get('.ol-cm-command-textit').should('have.length', 2)
})
it('removes a non-breaking space when a text node contains no other content', function () {
mountEditor()
const data = 'foo<span>\xa0</span>bar'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'foo bar' + menuIconsText)
})
it('does not remove a non-breaking space when a text node contains other content', function () {
mountEditor()
const data = 'foo\xa0bar'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'foo bar' + menuIconsText)
})
it('removes all zero-width spaces', function () {
mountEditor()
const data = 'foo\u200bbar'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'foobar' + menuIconsText)
})
it('ignores HTML pasted from VS Code', function () {
mountEditor()
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', '<b>foo</b>')
clipboardData.setData('text/plain', 'foo')
clipboardData.setData('application/vnd.code.copymetadata', 'test')
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'foo')
cy.get('.ol-cm-command-textbf').should('have.length', 0)
})
it('protects special characters', function () {
mountEditor()
const data = 'foo & bar~baz'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'foo & bar~baz' + menuIconsText)
cy.get('.ol-cm-character').should('have.length', 2)
})
it('does not protect special characters in code blocks', function () {
mountEditor()
const data = 'foo & bar~baz <code>\\textbf{foo}</code>'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should(
'have.text',
'foo & bar~baz \\verb|\\textbf{foo}|' + menuIconsText
)
cy.get('.cm-line').eq(0).type('{Enter}')
cy.get('@content').should('have.text', 'foo & bar~baz \\textbf{foo}')
cy.get('.ol-cm-character').should('have.length', 2)
cy.get('.ol-cm-command-verb').should('have.length', 1)
})
// FIXME: need to assert on source code
// eslint-disable-next-line mocha/no-skipped-tests
it.skip('tidies whitespace in pasted tables', function () {
mountEditor()
const data = `<table>
<tr>
<td>
<p><b>test</b></p>
</td>
</tr>
</table>`
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('.cm-line').should('have.length', 8)
})
it('tidies whitespace in pasted lists', function () {
mountEditor()
const data = `<ul>
<li> foo </li>
<li>
<p>
<b>test</b></p>
<p>test test test
test test
test test test</p>
</li>
</ul>`
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('.cm-line').should('have.length', 6)
cy.get('@content').should(
'have.text',
' foo testtest test test test test test test test' + menuIconsText
)
})
it('collapses whitespace in adjacent inline elements', function () {
mountEditor()
const data = `<p><b> foo </b><span> test </span><i> bar </i> baz</p>`
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'foo test bar baz' + menuIconsText)
})
it('treats a pasted image as a figure even if there is HTML', function () {
mountEditor()
cy.fixture<Uint8Array>('images/gradient.png').then(image => {
const file = new File([image], 'gradient.png', { type: 'image/png' })
const html = `<meta charset="utf-8"><img src="https://example.com/gradient.png" alt="gradient">`
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', html)
clipboardData.items.add(file)
cy.get('.cm-content').trigger('paste', { clipboardData })
// figure modal paste handler should appear
cy.findByText('Upload from computer').should('be.visible')
})
})
it('does not treat a pasted image as a figure if there is Office HTML', function () {
mountEditor()
cy.fixture<Uint8Array>('images/gradient.png').then(image => {
const file = new File([image], 'gradient.png', { type: 'image/png' })
const html = `<meta charset="utf-8"><meta name="ProgId" content="MS.Word"><img src="https://example.com/gradient.png" alt="gradient">`
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', html)
clipboardData.items.add(file)
cy.get('.cm-content').trigger('paste', { clipboardData })
// paste options button should appear
cy.findByLabelText('Paste options').should('be.visible')
})
})
})

View File

@@ -0,0 +1,148 @@
import { mockScope } from '../helpers/mock-scope'
import { EditorProviders } from '../../../helpers/editor-providers'
import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
import { FC } from 'react'
import { FileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
import { TestContainer } from '../helpers/test-container'
import { PermissionsContext } from '@/features/ide-react/context/permissions-context'
const FileTreePathProvider: FC = ({ children }) => (
<FileTreePathContext.Provider
value={{
dirname: cy.stub(),
findEntityByPath: cy.stub(),
pathInFolder: cy.stub(),
previewByPath: cy
.stub()
.as('previewByPath')
.returns({ url: '/images/frog.jpg', extension: 'jpg' }),
}}
>
{children}
</FileTreePathContext.Provider>
)
const PermissionsProvider: FC = ({ children }) => (
<PermissionsContext.Provider
value={{
read: true,
comment: true,
resolveOwnComments: false,
resolveAllComments: false,
trackedWrite: false,
write: false,
admin: false,
labelVersion: false,
}}
>
{children}
</PermissionsContext.Provider>
)
const mountEditor = (content: string) => {
const scope = mockScope(content)
scope.permissions.write = false
scope.permissions.trackedWrite = false
scope.editor.showVisual = true
cy.mount(
<TestContainer>
<EditorProviders
scope={scope}
providers={{ FileTreePathProvider, PermissionsProvider }}
>
<CodemirrorEditor />
</EditorProviders>
</TestContainer>
)
// wait for the content to be parsed and revealed
cy.get('.cm-content').should('have.css', 'opacity', '1')
}
describe('<CodeMirrorEditor/> in Visual mode with read-only permission', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
cy.interceptMathJax()
cy.interceptEvents()
cy.interceptMetadata()
})
it('decorates footnote content', function () {
mountEditor('Foo \\footnote{Bar.} ')
// select the footnote, so it expands
cy.get('.ol-cm-footnote').click()
cy.get('.cm-line').eq(0).as('first-line')
cy.get('@first-line').should('contain', 'Foo')
cy.get('@first-line').should('contain', 'Bar')
})
it('does not display the table toolbar', function () {
mountEditor('\\begin{tabular}{c}\n cell\n\\end{tabular}')
cy.get('.table-generator-floating-toolbar').should('not.exist')
cy.get('.table-generator-cell').click()
cy.get('.table-generator-floating-toolbar').should('not.exist')
})
it('does not enter a table cell on double-click', function () {
mountEditor('\\begin{tabular}{c}\n cell\n\\end{tabular}\n\n')
cy.get('.table-generator-cell').dblclick()
cy.get('.table-generator-cell').get('textarea').should('not.exist')
})
it('does not enter a table cell on Enter', function () {
mountEditor('\\begin{tabular}{c}\n cell\n\\end{tabular}')
cy.get('.table-generator-cell').trigger('keydown', { key: 'Enter' })
cy.get('.table-generator-cell').get('textarea').should('not.exist')
})
it('does not paste into a table cell', function () {
mountEditor('\\begin{tabular}{c}\n cell\n\\end{tabular}\n\n')
cy.get('.cm-line').last().click()
cy.get('.table-generator-cell-render').eq(0).click()
const clipboardData = new DataTransfer()
clipboardData.setData('text/plain', 'bar')
cy.get('.table-generator-cell-render')
.eq(0)
.trigger('paste', { clipboardData })
cy.get('.cm-content').should('have.text', 'cell')
})
it('does not display the figure edit button', function () {
cy.intercept('/images/frog.jpg', { fixture: 'images/gradient.png' })
mountEditor(
`\\begin{figure}
\\centering
\\includegraphics[width=0.5\\linewidth]{frog.jpg}
\\caption{My caption}
\\label{fig:my-label}
\\end{figure}`
)
cy.get('img.ol-cm-graphics').should('have.length', 1)
cy.findByRole('button', { name: 'Edit figure' }).should('not.exist')
})
it('does not display editing features in the href tooltip', function () {
mountEditor('\\href{https://example.com/}{foo}\n\n')
// move the selection outside the link
cy.get('.cm-line').eq(2).click()
// put the selection inside the href command
cy.findByText('foo').click()
cy.findByRole('button', { name: 'Go to page' })
cy.findByLabelText('URL').should('be.disabled')
cy.findByRole('button', { name: 'Remove link' }).should('not.exist')
})
})

View File

@@ -0,0 +1,332 @@
import { EditorProviders } from '../../../helpers/editor-providers'
import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
import { mockScope } from '../helpers/mock-scope'
import { TestContainer } from '../helpers/test-container'
import { isMac } from '@/shared/utils/os'
const selectAll = () => {
cy.get('.cm-content').trigger(
'keydown',
isMac ? { key: 'a', metaKey: true } : { key: 'a', ctrlKey: true }
)
}
const clickToolbarButton = (name: string) => {
cy.findByRole('button', { name }).click()
cy.findByRole('button', { name }).trigger('mouseout')
}
const mountEditor = (content: string) => {
const scope = mockScope(content)
scope.editor.showVisual = true
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodemirrorEditor />
</EditorProviders>
</TestContainer>
)
// wait for the content to be parsed and revealed
cy.get('.cm-content').should('have.css', 'opacity', '1')
}
describe('<CodeMirrorEditor/> toolbar in Rich Text mode', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
cy.interceptEvents()
cy.interceptMetadata()
})
it('should handle Undo and Redo', function () {
mountEditor('')
cy.get('.cm-line').eq(0).type('hi')
cy.get('.cm-content').should('have.text', 'hi')
clickToolbarButton('Undo')
cy.get('.cm-content').should('have.text', '')
clickToolbarButton('Redo')
cy.get('.cm-content').should('have.text', 'hi')
})
it('should handle section level changes', function () {
mountEditor('hi')
cy.get('.cm-content').should('have.text', 'hi')
clickToolbarButton('Choose section heading level')
cy.findByRole('menu').within(() => {
cy.findByText('Subsection').click()
})
cy.get('.cm-content').should('have.text', 'hi')
cy.get('.ol-cm-command-subsection').should('have.length', 1)
clickToolbarButton('Choose section heading level')
cy.findByRole('menu').within(() => {
cy.findByText('Normal text').click()
})
cy.get('.cm-content').should('have.text', 'hi')
cy.get('.ol-cm-command-subsection').should('have.length', 0)
})
it('should toggle Bold and Italic', function () {
mountEditor('hi')
cy.get('.cm-content').should('have.text', 'hi')
selectAll()
// bold
clickToolbarButton('Format Bold')
cy.get('.cm-content').should('have.text', '{hi}')
cy.get('.ol-cm-command-textbf').should('have.length', 1)
clickToolbarButton('Format Bold')
cy.get('.cm-content').should('have.text', 'hi')
cy.get('.ol-cm-command-textbf').should('have.length', 0)
// italic
clickToolbarButton('Format Italic')
cy.get('.cm-content').should('have.text', '{hi}')
cy.get('.ol-cm-command-textit').should('have.length', 1)
clickToolbarButton('Format Italic')
cy.get('.cm-content').should('have.text', 'hi')
cy.get('.ol-cm-command-textit').should('have.length', 0)
})
it('should wrap content with inline math', function () {
mountEditor('2+3=5')
selectAll()
clickToolbarButton('Insert Math')
cy.findByRole('button', { name: 'Insert Inline Math' }).click()
cy.get('.cm-content').should('have.text', '\\(2+3=5\\)')
})
it('should wrap content with display math', function () {
mountEditor('2+3=5')
selectAll()
clickToolbarButton('Insert Math')
cy.findByRole('button', { name: 'Insert Display Math' }).click()
cy.get('.cm-content').should('have.text', '\\[2+3=5\\]')
})
it('should wrap content with a link', function () {
mountEditor('test')
selectAll()
clickToolbarButton('Insert Link')
cy.get('.cm-content').should('have.text', '{test}')
cy.findByLabelText('URL') // tooltip form
})
it('should insert a bullet list', function () {
mountEditor('test')
selectAll()
clickToolbarButton('More')
clickToolbarButton('Bullet List')
cy.get('.cm-content').should('have.text', ' test')
cy.get('.cm-line').eq(0).type('ing')
cy.get('.cm-line').eq(0).should('have.text', ' testing')
})
it('should insert a numbered list', function () {
mountEditor('test')
selectAll()
clickToolbarButton('More')
clickToolbarButton('Numbered List')
cy.get('.cm-content').should('have.text', ' test')
cy.get('.cm-line').eq(0).type('ing')
cy.get('.cm-line').eq(0).should('have.text', ' testing')
})
it('should toggle between list types', function () {
mountEditor('test')
selectAll()
clickToolbarButton('More')
clickToolbarButton('Numbered List')
// expose the markup
cy.get('.cm-line').eq(0).type('{rightArrow}')
cy.get('.cm-content').should(
'have.text',
[
//
'\\begin{enumerate}',
' test',
'\\end{enumerate}',
].join('')
)
clickToolbarButton('Bullet List')
cy.get('.cm-content').should(
'have.text',
[
//
'\\begin{itemize}',
' test',
'\\end{itemize}',
].join('')
)
})
it('should remove a list', function () {
mountEditor('test')
selectAll()
clickToolbarButton('More')
clickToolbarButton('Numbered List')
// expose the markup
cy.get('.cm-line').eq(0).type('{rightArrow}')
cy.get('.cm-content').should(
'have.text',
[
//
'\\begin{enumerate}',
' test',
'\\end{enumerate}',
].join('')
)
clickToolbarButton('Numbered List')
cy.get('.cm-content').should('have.text', 'test')
})
it('should not remove a parent list', function () {
mountEditor('test\ntest')
selectAll()
clickToolbarButton('More')
clickToolbarButton('Numbered List')
// expose the markup
cy.get('.cm-line').eq(1).type('{rightArrow}')
cy.get('.cm-content').should(
'have.text',
[
//
'\\begin{enumerate}',
' test',
' test',
'\\end{enumerate}',
].join('')
)
cy.get('.cm-line').eq(2).click()
cy.findByRole('button', { name: 'Increase Indent' }).click()
// expose the markup
cy.get('.cm-line').eq(1).type('{rightArrow}')
cy.get('.cm-content').should(
'have.text',
[
//
' test',
' \\begin{enumerate}',
' test',
' \\end{enumerate}',
].join('')
)
cy.get('.cm-line').eq(1).click()
clickToolbarButton('More')
clickToolbarButton('Numbered List')
cy.get('.cm-line').eq(0).type('{upArrow}')
cy.get('.cm-content').should(
'have.text',
[
//
'\\begin{enumerate}',
' test',
' test',
'\\end{enumerate}',
].join('')
)
})
it('should not remove a nested list', function () {
mountEditor('test\ntest')
selectAll()
clickToolbarButton('More')
clickToolbarButton('Numbered List')
// expose the markup
cy.get('.cm-line').eq(1).type('{rightArrow}')
cy.get('.cm-content').should(
'have.text',
[
//
'\\begin{enumerate}',
' test',
' test',
'\\end{enumerate}',
].join('')
)
cy.get('.cm-line').eq(2).click()
cy.findByRole('button', { name: 'Increase Indent' }).click()
// expose the markup
cy.get('.cm-line').eq(1).type('{rightArrow}')
cy.get('.cm-content').should(
'have.text',
[
//
' test',
' \\begin{enumerate}',
' test',
' \\end{enumerate}',
].join('')
)
cy.get('.cm-line').eq(0).click()
clickToolbarButton('More')
clickToolbarButton('Numbered List')
// expose the markup
cy.get('.cm-line').eq(1).type('{rightArrow}')
cy.get('.cm-content').should(
'have.text',
[
//
'test',
' \\begin{enumerate}',
' test',
' \\end{enumerate}',
].join('')
)
})
it('should display the Toggle Symbol Palette button when available', function () {
window.metaAttributesCache.set('ol-symbolPaletteAvailable', true)
mountEditor('')
clickToolbarButton('Toggle Symbol Palette')
})
it('should not display the Toggle Symbol Palette button when not available', function () {
window.metaAttributesCache.set('ol-symbolPaletteAvailable', false)
mountEditor('')
cy.findByLabelText('Toggle Symbol Palette').should('not.exist')
})
})

View File

@@ -0,0 +1,114 @@
import { mockScope } from '../helpers/mock-scope'
import { EditorProviders } from '../../../helpers/editor-providers'
import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
import { TestContainer } from '../helpers/test-container'
describe('<CodeMirrorEditor/> tooltips in Visual mode', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
cy.interceptMathJax()
cy.interceptMetadata()
cy.interceptEvents()
const scope = mockScope('\n\n\n')
scope.editor.showVisual = true
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodemirrorEditor />
</EditorProviders>
</TestContainer>
)
// wait for the content to be parsed and revealed
cy.get('.cm-content').should('have.css', 'opacity', '1')
cy.get('.cm-line').eq(0).as('first-line')
})
it('displays a tooltip for \\href commands', function () {
cy.get('@first-line').type(
'\\href{{}https://example.com}{{}foo}{leftArrow}'
)
cy.get('.cm-content').should('have.text', '{foo}')
cy.get('.cm-tooltip').should('have.length', 1)
cy.get('.cm-tooltip').within(() => {
cy.findByLabelText('URL').should('have.value', 'https://example.com')
cy.findByLabelText('URL').type('/foo')
cy.findByLabelText('URL').should('have.value', 'https://example.com/foo')
cy.window().then(win => {
cy.stub(win, 'open').as('open-window')
})
cy.findByRole('button', { name: 'Go to page' }).click()
cy.get('@open-window').should(
'have.been.calledWithMatch',
Cypress.sinon.match.has('href', 'https://example.com/foo'),
'_blank'
)
cy.findByRole('button', { name: 'Remove link' }).click()
})
cy.get('.cm-content').should('have.text', 'foo')
cy.get('.cm-tooltip').should('have.length', 0)
})
it('displays a tooltip for \\url commands', function () {
cy.get('@first-line').type('\\url{{}https://example.com}{leftArrow}')
cy.get('.cm-content').should('have.text', '{https://example.com}')
cy.get('.cm-tooltip').should('have.length', 1)
cy.get('.cm-tooltip').within(() => {
cy.window().then(win => {
cy.stub(win, 'open').as('open-window')
})
cy.findByRole('button', { name: 'Go to page' }).click()
cy.get('@open-window').should(
'have.been.calledWithMatch',
Cypress.sinon.match.has('href', 'https://example.com/'),
'_blank'
)
})
cy.get('@first-line').type('{rightArrow}{rightArrow}')
cy.get('.cm-content').should('have.text', 'https://example.com')
cy.get('.cm-tooltip').should('have.length', 0)
})
it('displays a tooltip for \\ref commands', function () {
cy.get('@first-line').type(
'\\label{{}fig:frog}{Enter}\\ref{{}fig:frog}{leftArrow}'
)
cy.get('.cm-content').should('have.text', '🏷fig:frog🏷{fig:frog}')
cy.get('.cm-tooltip').should('have.length', 1)
cy.get('.cm-tooltip').within(() => {
cy.findByRole('button', { name: 'Go to target' }).click()
})
cy.window().then(win => {
expect(win.getSelection()?.toString()).to.equal('fig:frog')
})
})
it('displays a tooltip for \\include commands', function () {
cy.get('@first-line').type('\\include{{}main}{leftArrow}')
cy.get('.cm-content').should('have.text', '\\include{main}')
cy.get('.cm-tooltip').should('have.length', 1)
cy.get('.cm-tooltip').within(() => {
cy.findByRole('button', { name: 'Edit file' }).click()
// TODO: assert event fired with "main.tex" as the name?
})
cy.get('@first-line').type('{rightArrow}{rightArrow}')
cy.get('.cm-content').should('have.text', '🔗main')
cy.get('.cm-tooltip').should('have.length', 0)
})
it('displays a tooltip for \\input commands', function () {
cy.get('@first-line').type('\\input{{}main}{leftArrow}')
cy.get('.cm-content').should('have.text', '\\input{main}')
cy.get('.cm-tooltip').should('have.length', 1)
cy.get('.cm-tooltip').within(() => {
cy.findByRole('button', { name: 'Edit file' }).click()
// TODO: assert event fired with "main.tex" as the name?
})
cy.get('@first-line').type('{rightArrow}{rightArrow}')
cy.get('.cm-content').should('have.text', '🔗main')
cy.get('.cm-tooltip').should('have.length', 0)
})
})

View File

@@ -0,0 +1,735 @@
// Needed since eslint gets confused by mocha-each
/* eslint-disable mocha/prefer-arrow-callback */
import { FC } from 'react'
import { EditorProviders } from '../../../helpers/editor-providers'
import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
import { mockScope } from '../helpers/mock-scope'
import forEach from 'mocha-each'
import { FileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
import { TestContainer } from '../helpers/test-container'
describe('<CodeMirrorEditor/> in Visual mode', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
cy.interceptEvents()
cy.interceptMetadata()
cy.interceptMathJax()
// 3 blank lines
const content = '\n'.repeat(3)
const scope = mockScope(content)
scope.editor.showVisual = true
const FileTreePathProvider: FC = ({ children }) => (
<FileTreePathContext.Provider
value={{
dirname: cy.stub(),
findEntityByPath: cy.stub(),
pathInFolder: cy.stub(),
previewByPath: cy
.stub()
.as('previewByPath')
.callsFake(path => ({ url: path, extension: 'png' })),
}}
>
{children}
</FileTreePathContext.Provider>
)
cy.mount(
<TestContainer>
<EditorProviders scope={scope} providers={{ FileTreePathProvider }}>
<CodemirrorEditor />
</EditorProviders>
</TestContainer>
)
// wait for the content to be parsed and revealed
cy.get('.cm-content').should('have.css', 'opacity', '1')
cy.get('.cm-line').eq(0).as('first-line')
cy.get('.cm-line').eq(1).as('second-line')
cy.get('.cm-line').eq(2).as('third-line')
cy.get('.cm-line').eq(3).as('fourth-line')
cy.get('.ol-cm-toolbar [aria-label="Format Bold"]').as('toolbar-bold')
cy.get('@first-line').click()
})
afterEach(function () {
window.metaAttributesCache.clear()
})
forEach(['LaTeX', 'TeX']).it('renders the %s logo', function (logo) {
cy.get('@first-line').type(`\\${logo}{{}}{Enter}`)
cy.get('@first-line').should('have.text', logo)
})
it('renders \\dots', function () {
cy.get('@first-line').type('\\dots{Esc}')
cy.get('@first-line').should('have.text', '\\dots')
cy.get('@first-line').type('{Enter}')
cy.get('@first-line').should('have.text', '…')
})
it('creates a new list item on Enter', function () {
cy.get('@first-line').type('\\begin{{}itemize')
// select the first autocomplete item
cy.findByRole('option').eq(0).click()
cy.get('@first-line')
.should('have.text', ' ')
.find('.ol-cm-item')
.should('have.length', 1)
cy.get('@first-line').type('test{Enter}test')
cy.get('@first-line')
.should('have.text', ' test')
.find('.ol-cm-item')
.should('have.length', 1)
cy.get('@second-line')
.should('have.text', ' test')
.find('.ol-cm-item')
.should('have.length', 1)
})
it('finishes a list on Enter in the last item if empty', function () {
cy.get('@first-line').type('\\begin{{}itemize')
// select the first autocomplete item
cy.findByRole('option').eq(0).click()
cy.get('@first-line').type('test{Enter}{Enter}')
cy.get('@first-line')
.should('have.text', ' test')
.find('.ol-cm-item')
.should('have.length', 1)
cy.get('.cm-line').eq(1).should('have.text', '')
})
it('does not finish a list on Enter in an earlier item if empty', function () {
cy.get('@first-line').type('\\begin{{}itemize')
// select the first autocomplete item
cy.findByRole('option').eq(0).click()
cy.get('@second-line').type('test{Enter}test{Enter}{upArrow}{Enter}{Enter}')
cy.get('.cm-content').should('have.text', ' testtest')
})
forEach(['textbf', 'textit']).it('handles \\%s text', function (command) {
cy.get('@first-line').type(`\\${command}{`)
cy.get('@first-line').should('have.text', `{}`)
cy.get('@first-line').type('{rightArrow} ')
cy.get('@first-line').should('have.text', '{} ')
cy.get('@first-line').type('{Backspace}{leftArrow}test text')
cy.get('@first-line').should('have.text', '{test text}')
cy.get('@first-line').type('{rightArrow} foo')
cy.get('@first-line').should('have.text', 'test text foo') // no braces
cy.get('@first-line').find(`.ol-cm-command-${command}`)
})
forEach([
'part',
'chapter',
'section',
'subsection',
'subsubsection',
'paragraph',
'subparagraph',
]).it('handles \\%s sectioning command', function (command) {
cy.get('@first-line').type(`\\${command}{`)
cy.get('@first-line').should('have.text', `\\${command}{}`)
cy.get('@first-line').type('{rightArrow} ')
cy.get('@first-line').should('have.text', `\\${command}{} `)
// Type a section heading
cy.get('@first-line').type('{Backspace}{leftArrow}title')
cy.get('@first-line').should('have.text', '{title}') // braces are visible as cursor is adjacent
cy.get('@first-line').type('{leftArrow}')
cy.get('@first-line').should('have.text', 'title') // braces are hidden as cursor is not adjacent
cy.get('@first-line').type('{Enter}')
cy.get('@first-line').should('have.text', 'title') // braces are hidden as cursor is on the next line
cy.get('@first-line').find(`.ol-cm-heading.ol-cm-command-${command}`)
})
forEach([
'textsc',
'texttt',
'textmd',
'textsf',
'textsubscript',
'textsuperscript',
'sout',
'emph',
'underline',
'url',
'caption',
]).it('handles \\%s text', function (command) {
cy.get('@first-line').type(`\\${command}{`)
cy.get('@first-line').should('have.text', `\\${command}{}`)
cy.get('@first-line').type('{rightArrow} ')
cy.get('@first-line').should('have.text', `\\${command}{} `)
cy.get('@first-line').type('{Backspace}{leftArrow}test text{rightArrow} ')
cy.get('@first-line').should('have.text', 'test text ')
cy.get('@first-line').find(`.ol-cm-command-${command}`)
})
it('handles \\verb text', function () {
cy.get('@first-line').type(`\\verb|`)
cy.get('@first-line').should('have.text', `\\verb|`)
cy.get('@first-line').type('| ')
cy.get('@first-line').should('have.text', `\\verb|| `)
cy.get('@first-line').type('{Backspace}{leftArrow}test text{rightArrow} ')
cy.get('@first-line').should('have.text', 'test text ')
cy.get('@first-line').find(`.ol-cm-command-verb`)
})
forEach([
['label', '🏷'],
['cite', '📚'],
['include', '🔗'],
]).it('handles \\%s commands', function (command, icon) {
cy.get('@first-line').type(`\\${command}{} `)
cy.get('@first-line').should('have.text', `\\${command}{} `)
cy.get('@first-line').type('{Backspace}{leftArrow}key')
cy.get('@first-line').should('have.text', `\\${command}{key}`)
cy.get('@first-line').type('{rightArrow}')
cy.get('@first-line').should('have.text', `\\${command}{key}`)
cy.get('@first-line').type(' ')
cy.get('@first-line').should('have.text', `${icon}key `)
})
forEach([['ref', '🏷']]).it(
'handles \\%s commands',
function (command, icon) {
cy.get('@first-line').type(`\\${command}{} `)
cy.get('@first-line').should('have.text', `${icon} `)
cy.get('@first-line').type('{Backspace}{leftArrow}key')
cy.get('@first-line').should('have.text', `${icon}{key}`)
cy.get('@first-line').type('{rightArrow}')
cy.get('@first-line').should('have.text', `${icon}{key}`)
cy.get('@first-line').type(' ')
cy.get('@first-line').should('have.text', `${icon}key `)
}
)
it('handles \\href command', function () {
cy.get('@first-line').type('\\href{{}https://overleaf.com} ')
cy.get('@first-line').should('have.text', '\\href{https://overleaf.com} ')
cy.get('@first-line').type('{Backspace}{{}{Del}Overleaf ')
cy.get('@first-line').should(
'have.text',
'\\href{https://overleaf.com}{Overleaf '
)
cy.get('@first-line').type('{Backspace}} ')
cy.get('@first-line').should('have.text', 'Overleaf ')
cy.get('@first-line').find('.ol-cm-link-text')
})
it('displays unknown commands unchanged', function () {
cy.get('@first-line').type('\\foo[bar]{{}baz} ')
cy.get('@first-line').should('have.text', '\\foo[bar]{baz} ')
})
describe('Figure environments', function () {
beforeEach(function () {
cy.get('@first-line').type('\\begin{{}figure')
cy.get('@first-line').type('{Enter}') // end with cursor in file path
})
it('loads figures', function () {
cy.get('@third-line').type('path/to/image')
cy.get('@third-line').should(
'contain.text',
' \\includegraphics[width=0.5\\linewidth]{path/to/image}'
)
// move the cursor out of the figure
cy.get('@third-line').type('{DownArrow}{DownArrow}{DownArrow}{DownArrow}')
// Should be removed from dom when line is hidden
cy.get('.cm-content').should(
'not.contain.text',
'\\includegraphics[width=0.5\\linewidth]{path/to/image}'
)
cy.get('img.ol-cm-graphics').should('have.attr', 'src', 'path/to/image')
})
it('marks lines as figure environments', function () {
// inside the figure
cy.get('@second-line').should('have.class', 'ol-cm-environment-figure')
// outside the figure
cy.get('.cm-line')
.eq(6)
.should('not.have.class', 'ol-cm-environment-figure')
})
it('marks environment has centered when it has \\centering command', function () {
// inside the figure
cy.get('@third-line').should('have.class', 'ol-cm-environment-centered')
// outside the figure
cy.get('.cm-line')
.eq(6)
.should('not.have.class', 'ol-cm-environment-centered')
// the line containing \centering
cy.get('@second-line')
.should('have.text', ' \\centering')
.should('have.class', 'ol-cm-environment-centered')
cy.get('@second-line').type('{Backspace}')
cy.get('@second-line')
.should('have.text', ' \\centerin')
.should('not.have.class', 'ol-cm-environment-centered')
})
})
describe('verbatim environments', function () {
beforeEach(function () {
cy.get('@first-line').type('\\begin{{}verbatim')
cy.get('@first-line').type('{Enter}test') // end with cursor in content
})
it('marks lines as verbatim environments', function () {
// inside the environment
cy.get('@second-line').should('have.class', 'ol-cm-environment-verbatim')
// outside the environment
cy.get('.cm-line')
.eq(4)
.should('not.have.class', 'ol-cm-environment-verbatim')
// move the cursor out of the environment
cy.get('.cm-line').eq(4).click()
cy.get('.cm-content').should('have.text', ' test')
})
})
describe('lstlisting environments', function () {
beforeEach(function () {
cy.get('@first-line').type('\\begin{{}lstlisting')
cy.get('@first-line').type('{Enter}test') // end with cursor in content
})
it('marks lines as lstlisting environments', function () {
// inside the environment
cy.get('@second-line').should(
'have.class',
'ol-cm-environment-lstlisting'
)
// outside the environment
cy.get('.cm-line')
.eq(4)
.should('not.have.class', 'ol-cm-environment-lstlisting')
// move the cursor out of the environment
cy.get('.cm-line').eq(4).click()
cy.get('.cm-content').should('have.text', ' test')
})
})
describe('Toolbar', function () {
describe('Formatting buttons highlighting', function () {
it('handles empty selections inside of bold', function () {
cy.get('@first-line').type('\\textbf{{}test}{LeftArrow}') // \textbf{test|}
cy.get('@toolbar-bold').should('have.class', 'active')
cy.get('@first-line').type('{LeftArrow}') // \textbf{tes|t}
cy.get('@toolbar-bold').should('have.class', 'active')
cy.get('@first-line').type('{LeftArrow}'.repeat(3)) // \textbf{|test}
cy.get('@toolbar-bold').should('have.class', 'active')
})
it('handles empty selections outside bold', function () {
cy.get('@first-line').type('\\textbf{{}test}')
cy.get('@toolbar-bold').should('not.have.class', 'active')
cy.get('@first-line').type('{LeftArrow}'.repeat(6))
cy.get('@toolbar-bold').should('not.have.class', 'active')
})
it('handles range selections inside bold', function () {
cy.get('@first-line').type('\\textbf{{}test}')
cy.get('@first-line').type('{LeftArrow}'.repeat(4))
cy.get('@first-line').type('{Shift}{RightArrow}{RightArrow}')
cy.get('@toolbar-bold').should('have.class', 'active')
})
it('handles range selections spanning bold', function () {
cy.get('@first-line').type('\\textbf{{}test} outside')
cy.get('@first-line').type('{LeftArrow}'.repeat(10))
cy.get('@first-line').type('{Shift}' + '{RightArrow}'.repeat(5))
cy.get('@toolbar-bold').should('not.have.class', 'active')
})
it('does not highlight bold when commands at selection ends are different', function () {
cy.get('@first-line').type('\\textbf{{}first} \\textbf{{}second}')
cy.get('@first-line').type('{LeftArrow}'.repeat(12))
cy.get('@first-line').type('{Shift}' + '{RightArrow}'.repeat(7))
cy.get('@toolbar-bold').should('not.have.class', 'active')
})
it('highlight when ends share common formatting ancestor', function () {
cy.get('@first-line').type(
'\\textbf{{}\\textit{{}first} \\textit{{}second}}'
)
cy.get('@first-line').type('{LeftArrow}'.repeat(13))
cy.get('@first-line').type('{Shift}' + '{RightArrow}'.repeat(7))
cy.get('@toolbar-bold').should('have.class', 'active')
})
})
})
describe('Beamer frames', function () {
it('hides markup', function () {
cy.get('@first-line').type(
'\\begin{{}frame}{{}Slide\\\\title}{Enter}\\end{{}frame}{Enter}'
)
cy.get('.ol-cm-divider')
cy.get('.ol-cm-frame-title')
})
it('typesets title', function () {
cy.get('@first-line').type(
'\\begin{{}frame}{{}Slide\\\\title}{Enter}\\end{{}frame}{Enter}'
)
cy.get('.ol-cm-frame-title').should('contain.html', 'Slide<br>title')
})
it('typesets math in title', function () {
cy.get('@first-line').type(
'\\begin{{}frame}{{}Slide $\\pi$}{Enter}\\end{{}frame}{Enter}'
)
cy.get('.MathJax').should('contain.text', '$\\pi$')
})
it('typesets subtitle', function () {
cy.get('@first-line').type(
'\\begin{{}frame}{{}Slide title}{{}Slide subtitle}{Enter}\\end{{}frame}{Enter}'
)
cy.get('.ol-cm-frame-subtitle').should('have.html', 'Slide subtitle')
})
})
it('typesets \\maketitle', function () {
cy.get('@first-line').type(
[
'\\author{{}Author}',
'\\title{{}Document title\\\\with $\\pi$}',
'\\begin{{}document}',
'\\maketitle',
'\\end{{}document}',
'',
].join('{Enter}')
)
cy.get('.ol-cm-maketitle').should('have.class', 'MathJax')
cy.get('.ol-cm-title').should('contain.html', 'Document title<br>with')
cy.get('.ol-cm-author').should('have.text', 'Author')
cy.get('.ol-cm-preamble-widget').click()
const deleteLine =
'{command}{leftArrow}{shift}{command}{rightArrow}{backspace}'
// italic, bold and emph
cy.get('@second-line').type(deleteLine)
cy.get('@second-line').type(
'\\title{{}formatted with \\textit{{}italic} \\textbf{{}bold} \\emph{{}emph}}'
)
cy.get('.ol-cm-title').should(
'contain.html',
'formatted with <i>italic</i> <b>bold</b> <em>emph</em>'
)
cy.get('@second-line').type(deleteLine)
cy.get('@second-line').type(
'\\title{{}title\\\\ \\textbf{{}\\textit{{}\\emph{{}formated}}} \\textit{{}only italic}}'
)
cy.get('.ol-cm-title').should(
'contain.html',
'title<br> <b><i><em>formated</em></i></b> <i>only italic</i>'
)
// texttt command
cy.get('@second-line').type(deleteLine)
cy.get('@second-line').type('\\title{{}title with \\texttt{{}command}}')
cy.get('.ol-cm-title').should(
'contain.html',
'title with <span class="ol-cm-command-texttt">command</span>'
)
cy.get('@second-line').type(deleteLine)
cy.get('@second-line').type(
'\\title{{}title with \\texttt{{}\\textbf{{}command}}}'
)
cy.get('.ol-cm-title').should(
'contain.html',
'title with <span class="ol-cm-command-texttt"><b>command</b></span>'
)
cy.get('@second-line').type(deleteLine)
cy.get('@second-line').type('\\title{{}Title with \\& ampersands}')
cy.get('.ol-cm-title').should('contain.html', 'Title with &amp; ampersands')
// unsupported command
cy.get('@second-line').type(deleteLine)
cy.get('@second-line').type('\\title{{}My \\LaTeX{{}} document}')
cy.get('.ol-cm-title').should('contain.html', 'My \\LaTeX{} document')
})
it('decorates footnotes', function () {
cy.get('@first-line').type('Foo \\footnote{{}Bar.} ')
cy.get('@first-line').should('contain', 'Foo')
cy.get('@first-line').should('not.contain', 'Bar')
cy.get('@first-line').type('{leftArrow}')
cy.get('@first-line').should('have.text', 'Foo \\footnote{Bar.} ')
})
it('should show document preamble', function () {
cy.get('@first-line').type(
[
'\\author{{}Author}',
'\\title{{}Document title}',
'\\begin{{}document}',
'\\maketitle',
'\\end{{}document}',
'',
].join('{Enter}')
)
cy.get('.ol-cm-preamble-widget').should('have.length', 1)
cy.get('.ol-cm-preamble-widget').click()
cy.get('.ol-cm-preamble-line').eq(0).should('contain', '\\author{Author}')
cy.get('.ol-cm-preamble-line')
.eq(1)
.should('contain', '\\title{Document title}')
cy.get('.ol-cm-preamble-line').eq(2).should('contain', '\\begin{document}')
cy.get('.ol-cm-preamble-line').eq(3).should('not.exist')
})
it('should exclude maketitle from preamble extents if nested in another environment', function () {
cy.get('@first-line').type(
[
'\\author{{}Author}',
'\\title{{}Document title}',
'\\begin{{}document}',
'\\begin{{}frame}{{}Foo}',
'\\maketitle',
'\\end{{}frame}',
'\\end{{}document}',
'',
].join('{Enter}')
)
cy.get('.ol-cm-preamble-widget').should('have.length', 1)
cy.get('.ol-cm-preamble-widget').click()
cy.get('.ol-cm-preamble-line').should('have.length', 3)
})
it('should show multiple authors', function () {
cy.get('@first-line').type(
[
'\\author{{}Author \\and Author2}',
'\\author{{}Author3}',
'\\title{{}Document title}',
'\\begin{{}document}',
'\\maketitle',
'\\end{{}document}',
'',
].join('{Enter}')
)
cy.get('.ol-cm-preamble-widget').should('have.length', 1)
cy.get('.ol-cm-preamble-widget').click()
cy.get('.ol-cm-authors').should('have.length', 1)
cy.get('.ol-cm-authors .ol-cm-author').should('have.length', 3)
})
it('should update authors', function () {
cy.get('@first-line').type(
[
'\\author{{}Author \\and Author2}',
'\\author{{}Author3}',
'\\title{{}Document title}',
'\\begin{{}document}',
'\\maketitle',
'\\end{{}document}',
'',
].join('{Enter}')
)
cy.get('.ol-cm-preamble-widget').should('have.length', 1)
cy.get('.ol-cm-preamble-widget').click()
cy.get('.ol-cm-authors').should('have.length', 1)
cy.get('.ol-cm-author').eq(0).should('contain', 'Author')
cy.get('.ol-cm-author').eq(1).should('contain', 'Author2')
cy.get('.ol-cm-author').eq(2).should('contain', 'Author3')
cy.get('.ol-cm-author').eq(0).click()
cy.get('.ol-cm-preamble-line').eq(0).type('{leftarrow}{backspace}New')
cy.get('.ol-cm-author').eq(1).should('contain', 'AuthorNew')
// update author without changing node from/to coordinates
cy.get('.ol-cm-author').eq(0).click()
cy.get('.ol-cm-preamble-line').eq(0).type('{leftarrow}{shift}{leftarrow}X')
cy.get('.ol-cm-author').eq(1).should('contain', 'AuthorNeX')
})
it('should ignore some commands in author', function () {
cy.get('@first-line').type(
[
'\\author{{}Author with \\corref{{}cor1} and \\fnref{{}label2} in the name}',
'\\title{{}Document title}',
'\\begin{{}document}',
'\\maketitle',
'\\end{{}document}',
'',
].join('{Enter}')
)
cy.get('.ol-cm-authors').should('have.length', 1)
cy.get('.ol-cm-author').should(
'contain.html',
'Author with and in the name'
)
})
describe('decorates color commands', function () {
it('decorates textcolor', function () {
cy.get('@first-line').type('\\textcolor{{}red}{{}foo}')
cy.get('.ol-cm-textcolor')
.should('have.length', 1)
.should('have.text', 'foo')
.should('have.attr', 'style', 'color: rgb(255,0,0)')
})
it('decorates colorbox', function () {
cy.get('@first-line').type('\\colorbox{{}yellow}{{}foo}')
cy.get('.ol-cm-colorbox')
.should('have.length', 1)
.should('have.text', 'foo')
.should('have.attr', 'style', 'background-color: rgb(255,255,0)')
})
})
describe('handling of special characters', function () {
it('decorates a tilde with a non-breaking space', function () {
cy.get('@first-line').type('Test~test')
cy.get('@first-line').should('have.text', 'Test\xa0test')
})
it('decorates a backslash-prefixed tilde with a tilde', function () {
cy.get('@first-line').type('Test\\~test')
cy.get('@first-line').should('have.text', 'Test~test')
})
it('decorates a backslash-prefixed dollar sign with a dollar sign', function () {
cy.get('@first-line').type('\\$5.00')
cy.get('@first-line').should('have.text', '$5.00')
cy.get('.ol-cm-character').should('have.length', 1)
})
it('decorates line breaks', function () {
cy.get('@first-line').type('Test \\\\ test')
cy.get('@second-line').click()
cy.get('@first-line').should('have.text', 'Test ↩ test')
})
it('decorates spacing commands', function () {
cy.get('@first-line').type('\\thinspace')
cy.get('@second-line').click()
cy.get('@first-line')
.find('.ol-cm-space')
.should('have.attr', 'style', 'width: calc(0.166667em);')
})
it('decorates spacing symbols', function () {
cy.get('@first-line').type('\\,')
cy.get('@second-line').click()
cy.get('@first-line')
.find('.ol-cm-space')
.should('have.attr', 'style', 'width: calc(0.166667em);')
})
})
describe('decorates theorems', function () {
it('decorates a proof environment', function () {
cy.get('@first-line').type(
['\\begin{{}proof}{Enter}', 'foo{Enter}', '\\end{{}proof}{Enter}'].join(
''
)
)
cy.get('.cm-content').should('have.text', 'Prooffoo')
})
it('decorates a theorem environment', function () {
cy.get('@first-line').type(
[
'\\begin{{}theorem}{Enter}',
'foo{Enter}',
'\\end{{}theorem}{Enter}',
].join('')
)
cy.get('.cm-content').should('have.text', 'Theoremfoo')
})
it('decorates a theorem environment with a label', function () {
cy.get('@first-line').type(
[
'\\begin{{}theorem}[Bar]{Enter}',
'foo{Enter}',
'\\end{{}theorem}{Enter}',
].join('')
)
cy.get('.cm-content').should('have.text', 'Theorem (Bar)foo')
})
it('decorates a custom theorem environment with a label', function () {
cy.get('@first-line').type(
[
'\\newtheorem{{}thm}{{}Foo}{Enter}',
'\\begin{{}thm}[Bar]{Enter}',
'foo{Enter}',
'\\end{{}thm}{Enter}',
].join('')
)
cy.get('.cm-content').should(
'have.text',
['\\newtheorem{thm}{Foo}', 'Foo (Bar)foo'].join('')
)
})
})
forEach(['quote', 'quotation', 'quoting', 'displayquote']).it(
'renders a %s environment',
function (environment) {
cy.get('@first-line').type(`\\begin{{}${environment}`)
cy.findAllByRole('listbox').should('have.length', 1)
cy.findByRole('listbox').contains(`\\begin{${environment}}`).click()
cy.get('@second-line').type('foo')
cy.get('.cm-content').should(
'have.text',
[`\\begin{${environment}}`, ' foo', `\\end{${environment}}`].join('')
)
cy.get('.cm-line').eq(4).click()
cy.get('.cm-content').should('have.text', ' foo')
}
)
it('invokes MathJax when math is written', function () {
cy.get('@first-line').type('foo $\\pi$ bar')
cy.get('@second-line').type(
'foo \n\\[\\epsilon{rightArrow}{rightArrow}\nbar'
)
cy.get('.MathJax').first().should('have.text', '\\pi')
cy.get('.MathJax').eq(1).should('have.text', '\\epsilon')
})
// TODO: \input
// TODO: Abstract
})

View File

@@ -0,0 +1,597 @@
import CodeMirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
import { EditorProviders } from '../../../helpers/editor-providers'
import { mockScope } from '../helpers/mock-scope'
import { metaKey } from '../helpers/meta-key'
import { docId } from '../helpers/mock-doc'
import { activeEditorLine } from '../helpers/active-editor-line'
import { TestContainer } from '../helpers/test-container'
import customLocalStorage from '@/infrastructure/local-storage'
import { OnlineUsersContext } from '@/features/ide-react/context/online-users-context'
import { FC } from 'react'
describe('<CodeMirrorEditor/>', { scrollBehavior: false }, function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
cy.interceptEvents()
})
it('deletes selected text on Backspace', function () {
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
// put the cursor on a blank line to type in
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
cy.get('@line').type('this is some text')
cy.get('@line').should('have.text', 'this is some text')
cy.get('@line').type('{shift}{leftArrow}{leftArrow}{leftArrow}{leftArrow}')
cy.get('@line').type('{backspace}')
cy.get('@line').should('have.text', 'this is some ')
})
it('renders client-side lint annotations in the gutter', function () {
const scope = mockScope()
const userSettings = { syntaxValidation: true }
cy.clock()
cy.mount(
<TestContainer>
<EditorProviders scope={scope} userSettings={userSettings}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.tick(1000)
cy.clock().invoke('restore')
// TODO: aria role/label for gutter markers?
cy.get('.cm-lint-marker-error').should('have.length', 2)
cy.get('.cm-lint-marker-warning').should('have.length', 0)
})
it('renders annotations in the gutter', function () {
const scope = mockScope()
scope.pdf.logEntryAnnotations = {
[docId]: [
{
row: 20,
type: 'error',
text: 'Another error',
},
{
row: 19,
type: 'error',
text: 'An error',
},
{
row: 20,
type: 'warning',
text: 'A warning on the same line',
},
{
row: 25,
type: 'warning',
text: 'Another warning',
},
],
}
const userSettings = { syntaxValidation: false }
cy.clock()
cy.mount(
<TestContainer>
<EditorProviders scope={scope} userSettings={userSettings}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.tick(1000)
cy.clock().invoke('restore')
// TODO: aria role/label for gutter markers?
cy.get('.cm-lint-marker-error').should('have.length', 2)
cy.get('.cm-lint-marker-warning').should('have.length', 1)
})
it('renders code in an editor', function () {
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.contains('Your introduction goes here!')
})
it('does not indent when entering new line off non-empty line', function () {
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
// put the cursor on a blank line to type in
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
cy.get('@line').type('foo{enter}')
activeEditorLine().should('have.text', '')
})
it('indents automatically when using snippet', function () {
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
// put the cursor on a blank line to type in
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
cy.get('@line').type('\\begin{{}itemiz')
cy.findAllByRole('listbox').contains('\\begin{itemize}').click()
activeEditorLine().invoke('text').should('match', /^ {4}/)
})
it('keeps indentation when going to a new line', function () {
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
// put the cursor on a blank line to type in
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
// Single indentation
cy.get('@line').trigger('keydown', { key: 'Tab' })
cy.get('@line').type('{enter}')
activeEditorLine().should('have.text', ' ')
// Double indentation
activeEditorLine().trigger('keydown', { key: 'Tab' }).type('{enter}')
activeEditorLine().should('have.text', ' ')
})
it('renders cursor highlights', function () {
const scope = mockScope()
const value = {
onlineUsers: {},
onlineUserCursorHighlights: {
[docId]: [
{
label: 'Test User',
cursor: { row: 10, column: 5 },
hue: 150,
},
{
label: 'Another User',
cursor: { row: 7, column: 2 },
hue: 50,
},
{
label: 'Starter User',
cursor: { row: 0, column: 0 },
hue: 0,
},
],
},
onlineUsersArray: [],
onlineUsersCount: 3,
}
const OnlineUsersProvider: FC = ({ children }) => {
return (
<OnlineUsersContext.Provider value={value}>
{children}
</OnlineUsersContext.Provider>
)
}
cy.mount(
<TestContainer>
<EditorProviders scope={scope} providers={{ OnlineUsersProvider }}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.get('.ol-cm-cursorHighlight').should('have.length', 3)
})
it('does not allow typing to the document in read-only mode', function () {
const scope = mockScope()
scope.permissionsLevel = 'readOnly'
scope.permissions.write = false
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
// Handling the thrown error on failing to type text
cy.on('fail', error => {
if (error.message.includes('it requires a valid typeable element')) {
return
}
throw error
})
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
cy.get('@line').type('text')
cy.get('@line').should('not.contain.text', 'text')
})
it('highlights matching brackets', function () {
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
// put the cursor on a blank line to type in
cy.get('.cm-line').eq(16).click()
const pairs = ['()', '[]', '{}']
pairs.forEach(pair => {
activeEditorLine().type(pair).as('line')
cy.get('@line').find('.cm-matchingBracket')
cy.get('@line').type('{enter}')
})
})
it('folds code', function () {
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
// select foldable line
cy.get('.cm-line').eq(9).as('line')
cy.get('@line').click()
const testUnfoldedState = () => {
cy.get('.cm-gutterElement').eq(11).should('have.text', '11')
cy.get('.cm-gutterElement').eq(12).should('have.text', '12')
}
const testFoldedState = () => {
cy.get('.cm-gutterElement').eq(11).should('have.text', '13')
cy.get('.cm-gutterElement').eq(12).should('have.text', '14')
}
testUnfoldedState()
// Fold
cy.get('span[title="Fold line"]').eq(1).click()
testFoldedState()
// Unfold
cy.get('span[title="Unfold line"]').eq(1).click()
testUnfoldedState()
})
it('save file with `:w` command in vim mode', function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', false)
cy.interceptCompile()
const scope = mockScope()
const userSettings = { mode: 'vim' }
cy.mount(
<TestContainer>
<EditorProviders scope={scope} userSettings={userSettings}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
// Compile on initial load
cy.waitForCompile()
cy.interceptCompile()
// put the cursor on a blank line to type in
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
cy.get('.cm-vim-panel').should('have.length', 0)
cy.get('@line').type(':')
cy.get('.cm-vim-panel').should('have.length', 1)
cy.get('.cm-vim-panel input').type('w')
cy.get('.cm-vim-panel input').type('{enter}')
// Compile after save
cy.waitForCompile()
})
it('search and replace text', function () {
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
cy.get('@line').type(
'{enter}text_to_find{enter}abcde 1{enter}abcde 2{enter}abcde 3{enter}ABCDE 4{enter}'
)
// select text `text_to_find`
cy.get('.cm-line').eq(17).as('lineToFind')
cy.get('@lineToFind').dblclick()
// search panel is not displayed
cy.findByRole('search').should('have.length', 0)
cy.get('@lineToFind').type(`{${metaKey}+f}`)
// search panel is displayed
cy.findByRole('search').should('have.length', 1)
cy.findByRole('textbox', { name: 'Find' }).as('search-input')
cy.findByRole('textbox', { name: 'Replace' }).as('replace-input')
cy.get('@search-input')
// search input should be focused
.should('be.focused')
// search input's value should be set to the selected text
.should('have.value', 'text_to_find')
cy.get('@search-input').clear()
cy.get('@search-input').type('abcde')
cy.findByRole('button', { name: 'next' }).as('next-btn')
cy.findByRole('button', { name: 'previous' }).as('previous-btn')
// shows the number of matches
cy.contains(`1 of 4`)
for (let i = 4; i; i--) {
// go to previous occurrence
cy.get('@previous-btn').click()
// shows the number of matches
cy.contains(`${i} of 4`)
}
for (let i = 1; i <= 4; i++) {
// shows the number of matches
cy.contains(`${i} of 4`)
// go to next occurrence
cy.get('@next-btn').click()
}
// roll round to 1
cy.contains(`1 of 4`)
// matches case
cy.contains('Aa').click()
cy.get('@search-input').clear()
cy.get('@search-input').type('ABCDE')
cy.get('.cm-searchMatch-selected').should('contain.text', 'ABCDE')
cy.get('@search-input').clear()
cy.contains('Aa').click()
// matches regex
cy.contains('[.*]').click()
cy.get('@search-input').type('\\\\author\\{{}\\w+\\}')
cy.get('.cm-searchMatch-selected').should('contain.text', '\\author{You}')
cy.contains('[.*]').click()
cy.get('@search-input').clear()
cy.get('.cm-searchMatch-selected').should('not.exist')
// replace
cy.get('@search-input').type('abcde 1')
cy.get('@replace-input').type('test 1')
cy.findByRole('button', { name: 'Replace' }).click()
cy.get('.cm-line')
.eq(18)
.should('contain.text', 'test 1')
.should('not.contain.text', 'abcde')
// replace all
cy.get('@search-input').clear()
cy.get('@search-input').type('abcde')
cy.get('@replace-input').clear()
cy.get('@replace-input').type('test')
cy.findByRole('button', { name: /replace all/i }).click()
cy.get('@search-input').clear()
cy.get('@replace-input').clear()
cy.should('not.contain.text', 'abcde')
// replace all within selection
cy.get('@search-input').clear()
cy.get('@search-input').type('contentLine')
cy.get('.ol-cm-search-form-position').should('have.text', '1 of 100')
cy.get('.cm-line').eq(27).as('contentLine')
cy.get('@contentLine').should('contain.text', 'contentLine 0')
cy.get('@contentLine').click()
cy.get('@contentLine').type('{shift}{downArrow}{downArrow}{downArrow}')
cy.findByLabelText('Within selection').click()
cy.get('.ol-cm-search-form-position').should('have.text', '1 of 3')
cy.get('@replace-input').clear()
cy.get('@replace-input').type('contentedLine')
cy.findByRole('button', { name: /replace all/i }).click()
cy.get('.cm-line:contains("contentedLine")').should('have.length', 3)
cy.findByLabelText('Within selection').click()
cy.get('.ol-cm-search-form-position').should('have.text', '2 of 97')
cy.get('@search-input').clear()
cy.get('@replace-input').clear()
// close the search form, to clear the stored query
cy.findByRole('button', { name: 'Close' }).click()
})
it('navigates in the search panel', function () {
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
// Open the search panel
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
cy.get('@line').type(`{${metaKey}+f}`)
cy.findByRole('search').within(() => {
cy.findByLabelText('Find').as('find-input')
cy.findByLabelText('Replace').as('replace-input')
cy.get('[type="checkbox"][name="caseSensitive"]').as('case-sensitive')
cy.get('[type="checkbox"][name="regexp"]').as('regexp')
cy.get('[type="checkbox"][name="wholeWord"]').as('whole-word')
cy.get('[type="checkbox"][name="withinSelection"]').as('within-selection')
cy.get('label').contains('Aa').as('case-sensitive-label')
cy.get('label').contains('[.*]').as('regexp-label')
cy.get('label').contains('W').as('whole-word-label')
cy.findByLabelText('Within selection').as('within-selection-label')
cy.findByRole('button', { name: 'Replace' }).as('replace')
cy.findByRole('button', { name: 'Replace All' }).as('replace-all')
cy.findByRole('button', { name: 'previous' }).as('find-previous')
cy.findByRole('button', { name: 'next' }).as('find-next')
cy.findByRole('button', { name: 'Close' }).as('close')
// Tab forwards...
cy.get('@find-input').should('be.focused').tab()
cy.get('@replace-input').should('be.focused').tab()
cy.get('@case-sensitive').should('be.focused').tab()
cy.get('@regexp').should('be.focused').tab()
cy.get('@whole-word').should('be.focused').tab()
cy.get('@within-selection').should('be.focused').tab()
cy.get('@find-previous').should('be.focused').tab()
cy.get('@find-next').should('be.focused').tab()
cy.get('@replace').should('be.focused').tab()
cy.get('@replace-all').should('be.focused').tab()
// ... then backwards
cy.get('@close').should('be.focused').tab({ shift: true })
cy.get('@replace-all').should('be.focused').tab({ shift: true })
cy.get('@replace').should('be.focused').tab({ shift: true })
cy.get('@find-next').should('be.focused').tab({ shift: true })
cy.get('@find-previous').should('be.focused').tab({ shift: true })
cy.get('@within-selection').should('be.focused').tab({ shift: true })
cy.get('@whole-word').should('be.focused').tab({ shift: true })
cy.get('@regexp').should('be.focused').tab({ shift: true })
cy.get('@case-sensitive').should('be.focused').tab({ shift: true })
cy.get('@replace-input').should('be.focused').tab({ shift: true })
cy.get('@find-input').should('be.focused')
for (const option of [
'@case-sensitive-label',
'@regexp-label',
'@whole-word-label',
'@within-selection-label',
]) {
// Toggle when clicked, then focus the search input
cy.get(option).click()
cy.get(option).should('have.class', 'checked')
cy.get('@find-input').should('be.focused')
// Toggle when clicked again, then focus the search input
cy.get(option).click()
cy.get(option).should('not.have.class', 'checked')
cy.get('@find-input').should('be.focused')
}
})
})
it('restores stored cursor and scroll position', function () {
const scope = mockScope()
customLocalStorage.setItem(`doc.position.${docId}`, {
cursorPosition: { row: 50, column: 5 },
firstVisibleLine: 45,
})
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
activeEditorLine()
.should('have.text', 'contentLine 29')
.should(() => {
const selection = window.getSelection() as Selection
expect(selection.isCollapsed).to.be.true
const rect = selection.getRangeAt(0).getBoundingClientRect()
expect(Math.round(rect.top)).to.be.gte(100)
expect(Math.round(rect.left)).to.be.gte(90)
})
})
})

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',
])
})
})
})

View File

@@ -0,0 +1,26 @@
export const activeEditorLine = () => {
// wait for the selection to be in the editor content DOM
cy.window().then(win => {
cy.get('.cm-content').should($el => {
const contentNode = $el.get(0)
const range = win.getSelection()?.getRangeAt(0)
expect(range?.intersectsNode(contentNode)).to.be.true
})
})
// find the closest line block ancestor of the selection
return cy.window().then(win => {
const activeNode = win.getSelection()?.focusNode
if (!activeNode) {
return cy.wrap(null)
}
// use the parent element if this is a node, e.g. text
const activeElement = (
'closest' in activeNode ? activeNode : activeNode.parentElement
) as HTMLElement | undefined
return cy.wrap(activeElement?.closest('.cm-line'))
})
}

View File

@@ -0,0 +1,249 @@
/* eslint-disable no-dupe-class-members */
import { LanguageSupport } from '@codemirror/language'
import { EditorSelection, Line, SelectionRange } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { Assertion } from 'chai'
import { LaTeXLanguage } from '../../../../../frontend/js/features/source-editor/languages/latex/latex-language'
export class CodemirrorTestSession {
public view: EditorView
constructor(content: string[] | string) {
this.view = createView(content)
}
insert(content: string): void {
this.view.dispatch(
this.view.state.changeByRange(range => {
const changeDescription = [
{
from: range.from,
to: range.to,
insert: content,
},
]
const changes = this.view.state.changes(changeDescription)
return {
range: EditorSelection.cursor(range.head).map(changes),
changes,
}
})
)
}
insertAt(position: number, content: string) {
const changes = [{ from: position, insert: content }]
this.view.dispatch({
changes,
selection: this.view.state.selection.map(
this.view.state.changes(changes),
1
),
})
}
insertAtLine(line: number, offset: number, content: string): void
insertAtLine(line: number, content: string): void
insertAtLine(
lineNumber: number,
offsetOrContent: string | number,
content?: string
) {
const line = this.view.state.doc.line(lineNumber)
if (typeof offsetOrContent === 'string' && typeof content === 'string') {
throw new Error(
'If a third argument is provided, the second must be an integer'
)
}
// Insert at end of line
if (typeof offsetOrContent === 'string') {
content = offsetOrContent
offsetOrContent = line.to
}
if (typeof content !== 'string') {
throw new Error('content must be provided to insertAtLine')
}
if (offsetOrContent < line.from || offsetOrContent > line.to) {
throw new Error('Offset is outside the range of the line')
}
this.insertAt(line.from + offsetOrContent, content)
}
delete(position: number, length: number) {
this.view.dispatch({
changes: [{ from: position - length, to: position }],
})
}
applyCommand(command: (view: EditorView) => any) {
return command(this.view)
}
setCursor(position: number): void
setCursor(line: number, offset: number): void
setCursor(positionOrLine: number, offset?: number) {
if (offset !== undefined) {
const line = this.view.state.doc.line(positionOrLine)
positionOrLine = line.from + offset
}
this.view.dispatch({
selection: EditorSelection.cursor(positionOrLine),
})
}
setSelection(selection: EditorSelection) {
this.view.dispatch({
selection,
})
}
}
const latex = new LanguageSupport(LaTeXLanguage)
function createView(content: string[] | string): EditorView {
if (Array.isArray(content)) {
content = content.join('\n')
}
return new EditorView({
doc: stripSelectionMarkers(content),
selection: createSelections(content) ?? EditorSelection.cursor(0),
extensions: [latex],
})
}
function stripSelectionMarkers(content: string) {
return content.replaceAll(/[<|>]/g, '')
}
function hasSelectionMarkers(content: string) {
return !!content.match(/[<|>]/g)
}
function createSelections(content: string, offset = 0) {
const selections = []
let index = 0
for (let i = 0; i < content.length; i++) {
if (content[i] === '|') {
selections.push(EditorSelection.cursor(index + offset))
}
if (content[i] === '<') {
// find end
const startOfRange = index
let foundEnd = false
for (++i; i < content.length; ++i) {
if (content[i] === '|') {
throw new Error(
"Invalid cursor indicator '|' within a range started with '<'"
)
}
if (content[i] === '<') {
throw new Error(
"Invalid start range indicator '<' inside another range"
)
}
if (content[i] === '>') {
foundEnd = true
selections.push(
EditorSelection.range(startOfRange + offset, index + offset)
)
break
}
index++
}
if (!foundEnd) {
throw new Error("Missing end range indicator '>'")
}
}
index++
}
if (selections.length) {
return EditorSelection.create(selections)
}
return null
}
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace Chai {
interface Assertion {
line(lineNumber: number): Assertion
}
}
}
export function viewHelpers(chai: Chai.ChaiStatic, utils: Chai.ChaiUtils) {
utils.addMethod(
chai.Assertion.prototype,
'line',
function getLine(this: Chai.Assertion, line: number) {
const object = utils.flag(this, 'object')
new Assertion(object).to.be.instanceOf(CodemirrorTestSession)
const testSession = object as CodemirrorTestSession
const lineInEditor = testSession.view.state.doc.line(line)
utils.flag(this, 'object', lineInEditor.text)
utils.flag(this, 'cmSession', testSession)
utils.flag(this, 'line', lineInEditor)
}
)
utils.overwriteMethod(chai.Assertion.prototype, 'equal', (_super: any) => {
return function newEqual(
this: Chai.Assertion,
value: string,
requireSelections?: boolean
) {
const session = utils.flag(this, 'cmSession') as
| CodemirrorTestSession
| undefined
utils.flag(this, 'cmSession', null)
const line = utils.flag(this, 'line') as Line | undefined
utils.flag(this, 'line', null)
if (!session || !line) {
// eslint-disable-next-line prefer-rest-params
return _super.apply(this, arguments)
}
const lineContent = stripSelectionMarkers(value)
if (requireSelections === undefined) {
requireSelections = hasSelectionMarkers(value)
}
// We can now check selections as well
const selections = createSelections(value, line.from)
const contentAssertion = new Assertion(line.text)
utils.transferFlags(this, contentAssertion)
contentAssertion.to.equal(lineContent)
if (selections) {
const selectionAssertion = new Assertion(
session.view.state.selection.ranges
)
utils.transferFlags(this, selectionAssertion, false)
for (const rangeToMatch of selections.ranges) {
selectionAssertion.satisfies(
(ranges: SelectionRange[]) =>
ranges.some(
possibleMatch =>
possibleMatch.eq(rangeToMatch) ||
// Allow reverse selections as well, as we don't syntactically
// distinguish them
EditorSelection.range(
possibleMatch.to,
possibleMatch.from
).eq(rangeToMatch)
),
`Selections [${session.view.state.selection.ranges
.map(range => `{ from: ${range.from}, to: ${range.to}}`)
.join(', ')}] did not include selection {from: ${
rangeToMatch.from
}, to: ${rangeToMatch.to}}`
)
}
}
}
})
}

View File

@@ -0,0 +1,3 @@
import { isMac } from '@/shared/utils/os'
export const metaKey = isMac ? 'meta' : 'ctrl'

View File

@@ -0,0 +1,104 @@
import { ShareDoc } from '../../../../../types/share-doc'
import { EventEmitter } from 'events'
export const docId = 'test-doc'
export function mockDocContent(content: string) {
return `
\\documentclass{article}
\\title{Your Paper}
\\author{You}
\\begin{document}
\\maketitle
\\begin{abstract}
Your abstract.
\\end{abstracts}
\\section{Introduction}
Your introduction goes here!
\\section{Results}
Your results go here! \\cite{foo}
${content}
\\end{document}`
}
const contentLines = Array.from(Array(100), (e, i) => `contentLine ${i}`)
const defaultContent = mockDocContent(contentLines.join('\n'))
const MAX_DOC_LENGTH = 2 * 1024 * 1024 // ol-maxDocLength
class MockShareDoc extends EventEmitter {
constructor(public text: string) {
super()
}
getText() {
return this.text
}
insert() {
// do nothing
}
del() {
// do nothing
}
}
export const mockDoc = (content = defaultContent) => {
const mockShareJSDoc: ShareDoc = new MockShareDoc(content)
return {
doc_id: docId,
getSnapshot: () => {
return content
},
attachToCM6: (cm6: any) => {
cm6.attachShareJs(mockShareJSDoc, MAX_DOC_LENGTH)
},
detachFromCM6: () => {
// Do nothing
},
on: () => {
// Do nothing
},
off: () => {
// Do nothing
},
ranges: {
changes: [],
comments: [],
getIdSeed: () => '123',
setIdSeed: () => {},
getTrackedDeletesLength: () => 0,
getDirtyState: () => ({
comment: {
moved: {},
removed: {},
added: {},
},
change: {
moved: {},
removed: {},
added: {},
},
}),
resetDirtyState: () => {},
},
setTrackChangesIdSeeds: () => {},
getTrackingChanges: () => true,
setTrackingChanges: () => {},
getInflightOp: () => null,
getPendingOp: () => null,
hasBufferedOps: () => false,
leaveAndCleanUpPromise: () => false,
}
}

View File

@@ -0,0 +1,85 @@
import { docId, mockDoc } from './mock-doc'
import { sleep } from '../../../helpers/sleep'
import { Folder } from '../../../../../types/folder'
export const rootFolderId = '012345678901234567890123'
export const figuresFolderId = '123456789012345678901234'
export const figureId = '234567890123456789012345'
export const mockScope = (content?: string) => {
return {
editor: {
sharejs_doc: mockDoc(content),
open_doc_name: 'test.tex',
open_doc_id: docId,
showVisual: false,
wantTrackChanges: false,
},
pdf: {
logEntryAnnotations: {},
},
project: {
_id: 'test-project',
name: 'Test Project',
spellCheckLanguage: 'en',
rootFolder: [
{
_id: rootFolderId,
name: 'rootFolder',
docs: [
{
_id: docId,
name: 'test.tex',
},
],
folders: [
{
_id: figuresFolderId,
name: 'figures',
docs: [
{
_id: 'fake-nested-doc-id',
name: 'foo.tex',
},
],
folders: [],
fileRefs: [
{
_id: figureId,
name: 'frog.jpg',
hash: '42',
},
{
_id: 'fake-figure-id',
name: 'unicorn.png',
hash: '43',
},
],
},
],
fileRefs: [],
},
] as Folder[],
features: {
trackChanges: true,
},
trackChangesState: {},
members: [],
},
permissions: {
comment: true,
trackedWrite: true,
write: true,
},
ui: {
reviewPanelOpen: false,
},
toggleReviewPanel: cy.stub(),
toggleTrackChangesForEveryone: cy.stub(),
refreshResolvedCommentsDropdown: cy.stub(() => sleep(1000)),
onlineUserCursorHighlights: {},
permissionsLevel: 'owner',
$on: cy.stub().log(false),
$broadcast: cy.stub().log(false),
$emit: cy.stub().log(false),
}
}

View File

@@ -0,0 +1,12 @@
import { FC, ComponentProps, Suspense } from 'react'
const style = { width: 785, height: 785 }
export const TestContainer: FC<ComponentProps<'div'>> = ({
children,
...rest
}) => (
<div style={style} {...rest}>
<Suspense fallback={null}>{children}</Suspense>
</div>
)

View File

@@ -0,0 +1,329 @@
import { expect } from 'chai'
import { EditorState, Text } from '@codemirror/state'
import { LaTeXLanguage } from '../../../../../../frontend/js/features/source-editor/languages/latex/latex-language'
import {
ensureSyntaxTree,
foldNodeProp,
LanguageSupport,
} from '@codemirror/language'
import { EditorView } from '@codemirror/view'
const latex = new LanguageSupport(LaTeXLanguage)
const makeView = (lines: string[]): EditorView => {
const text = Text.of(lines)
const view = new EditorView({
state: EditorState.create({
doc: text,
extensions: [latex],
}),
})
return view
}
type Fold = { from: number; to: number }
const _getFolds = (view: EditorView) => {
const ranges: Fold[] = []
const tree = ensureSyntaxTree(view.state, view.state.doc.length)
if (!tree) {
throw new Error("Couldn't get Syntax Tree")
}
tree.iterate({
enter: nodeRef => {
const prop = nodeRef.type.prop(foldNodeProp)
if (prop) {
const hasFold = prop(nodeRef.node, view.state)
if (hasFold) {
ranges.push({ from: hasFold.from, to: hasFold.to })
}
}
},
})
return ranges
}
describe('CodeMirror LaTeX-folding', function () {
describe('With empty document', function () {
let view: EditorView, content: string[]
beforeEach(function () {
content = ['']
view = makeView(content)
})
it('should not produce any folds', function () {
const folds = _getFolds(view)
expect(folds).to.be.empty
})
})
describe('Sectioning command folding', function () {
describe('with no foldable sections', function () {
let view: EditorView, content: string[]
beforeEach(function () {
content = ['hello', 'test']
view = makeView(content)
})
it('should not produce any folds', function () {
const folds = _getFolds(view)
expect(folds).to.be.empty
})
})
describe('with one foldable section', function () {
let view: EditorView, content: string[]
beforeEach(function () {
content = ['hello', '\\section{one}', 'a', 'b', 'c']
view = makeView(content)
})
it('should produce one fold', function () {
const folds = _getFolds(view)
expect(folds.length).to.equal(1)
})
it('should fold from the section line to last line', function () {
const folds = _getFolds(view)
const fold = folds[0]
expect(view.state.doc.lineAt(fold.from).number).to.equal(2)
expect(view.state.doc.lineAt(fold.to).number).to.equal(
view.state.doc.lines
)
})
})
describe('with two foldable sections', function () {
let view: EditorView, content: string[]
beforeEach(function () {
content = [
'hello',
'\\section{one}',
'a',
'b',
'\\section{two}',
'c',
'd',
]
view = makeView(content)
})
it('should produce two folds', function () {
const folds = _getFolds(view)
expect(folds.length).to.equal(2)
expect(view.state.doc.lineAt(folds[0].from).number).to.equal(2)
expect(view.state.doc.lineAt(folds[0].to).number).to.equal(4)
expect(view.state.doc.lineAt(folds[1].from).number).to.equal(5)
expect(view.state.doc.lineAt(folds[1].to).number).to.equal(
view.state.doc.lines
)
})
})
describe('with realistic nesting', function () {
let view: EditorView, content: string[]
beforeEach(function () {
content = [
'hello',
'\\chapter{1}',
' a',
' \\section{1.1}',
' a',
' \\subsection{1.1.1}',
' a',
' \\section{1.2}',
' a',
' \\subsection{1.2.1}',
' a',
'\\chapter{2}',
' a',
' \\section{2.1}',
' a',
' \\section{2.2}',
' a',
]
view = makeView(content)
})
it('should produce many folds', function () {
const folds = _getFolds(view)
expect(folds.length).to.equal(8)
const foldDescriptions = folds.map(fold => {
const fromLine = view.state.doc.lineAt(fold.from).number
const toLine = view.state.doc.lineAt(fold.to).number
return { fromLine, toLine }
})
expect(foldDescriptions).to.deep.equal([
{ fromLine: 2, toLine: 11 },
{ fromLine: 4, toLine: 7 },
{ fromLine: 6, toLine: 7 },
{ fromLine: 8, toLine: 11 },
{ fromLine: 10, toLine: 11 },
{ fromLine: 12, toLine: 17 },
{ fromLine: 14, toLine: 15 },
{ fromLine: 16, toLine: 17 },
])
})
})
})
describe('Environment folding', function () {
describe('with single environment', function () {
let view: EditorView, content: string[]
beforeEach(function () {
content = ['\\begin{foo}', 'content', '\\end{foo}']
view = makeView(content)
})
it('should fold the environment', function () {
const folds = _getFolds(view)
expect(folds.length).to.equal(1)
expect(folds).to.deep.equal([{ from: 11, to: 20 }])
})
})
describe('with nested environment', function () {
let view: EditorView, content: string[]
beforeEach(function () {
content = [
'\\begin{foo}',
'\\begin{bar}',
'content',
'\\end{bar}',
'\\end{foo}',
]
view = makeView(content)
})
it('should fold the environment', function () {
const folds = _getFolds(view)
expect(folds.length).to.equal(2)
expect(folds).to.deep.equal([
{ from: 11, to: 42 },
{ from: 23, to: 32 },
])
})
})
})
describe('Comment folding', function () {
describe('with a single set of comments', function () {
let view: EditorView, content: string[]
beforeEach(function () {
content = ['Hello', '% {', 'this is folded', '% }', 'End']
view = makeView(content)
})
it('should fold the region marked by comments', function () {
const folds = _getFolds(view)
expect(folds.length).to.equal(1)
expect(folds).to.deep.equal([{ from: 9, to: 27 }])
})
})
describe('with several sets of comments', function () {
let view: EditorView, content: string[]
beforeEach(function () {
content = [
'Hello',
'% {',
'this is folded',
'% }',
'',
'% {',
'and this also',
'% }',
'End',
]
view = makeView(content)
})
it('should fold both regions marked by comments', function () {
const folds = _getFolds(view)
expect(folds.length).to.equal(2)
expect(folds).to.deep.equal([
{ from: 9, to: 27 },
{ from: 33, to: 50 },
])
})
})
describe('with nested sets of comments', function () {
let view: EditorView, content: string[]
beforeEach(function () {
content = [
'Hello',
'% {',
'one',
'% {',
'two',
'% {',
'three',
'% }',
'two',
'% }',
'one',
'% }',
'End',
]
view = makeView(content)
})
it('should fold all the regions marked by comments, with nesting', function () {
const folds = _getFolds(view)
expect(folds.length).to.equal(3)
expect(folds).to.deep.equal([
{ from: 9, to: 50 },
{ from: 17, to: 42 },
{ from: 25, to: 34 },
])
})
})
describe('with fold comment spanning entire document', function () {
let view: EditorView, content: string[]
beforeEach(function () {
content = ['% {', 'Hello', '% }']
view = makeView(content)
})
it('should fold', function () {
const folds = _getFolds(view)
expect(folds.length).to.equal(1)
expect(folds).to.deep.equal([{ from: 3, to: 12 }])
})
})
describe('with fold comment at start of document', function () {
let view: EditorView, content: string[]
beforeEach(function () {
content = ['% {', 'Hello', '% }', 'Test']
view = makeView(content)
})
it('should fold', function () {
const folds = _getFolds(view)
expect(folds.length).to.equal(1)
expect(folds).to.deep.equal([{ from: 3, to: 12 }])
})
})
describe('with fold comment at end of document', function () {
let view: EditorView, content: string[]
beforeEach(function () {
content = ['Test', '% {', 'Hello', '% }']
view = makeView(content)
})
it('should fold', function () {
const folds = _getFolds(view)
expect(folds.length).to.equal(1)
expect(folds).to.deep.equal([{ from: 8, to: 17 }])
})
})
})
})

View File

@@ -0,0 +1,786 @@
import { assert } from 'chai'
import LintWorker from '../../../../../../frontend/js/features/source-editor/languages/latex/linter/latex-linter.worker.js'
import { errorsToDiagnostics } from '../../../../../../frontend/js/features/source-editor/languages/latex/linter/errors-to-diagnostics'
import { Diagnostic } from '@codemirror/lint'
import { mergeCompatibleOverlappingDiagnostics } from '../../../../../../frontend/js/features/source-editor/languages/latex/linter/merge-overlapping-diagnostics'
const { Parse } = new LintWorker()
describe('LatexLinter', function () {
it('should accept a simple environment match without errors', function () {
const { errors } = Parse('\\begin{foo}\n' + '\\end{foo}\n')
assert.equal(errors.length, 0)
})
it('should accept an invalid \\it* command', function () {
const { errors } = Parse('\\it*hello\n' + '\\bye\n')
assert.equal(errors.length, 0)
})
it('should accept newcomlumntype', function () {
const { errors } = Parse(
'hello\n' +
'\\newcolumntype{M}[1]{>{\\begin{varwidth}[t]{#1}}l<{\\end{varwidth}}}\n' +
'bye'
)
assert.equal(errors.length, 0)
})
it('should accept newenvironment', function () {
const { errors } = Parse(
'\\newenvironment{Algorithm}[2][tbh]%\n' +
'{\\begin{myalgo}[#1]\n' +
'\\centering\n' +
'\\part{title}\\begin{minipage}{#2}\n' +
'\\begin{algorithm}[H]}%\n' +
'{\\end{algorithm}\n' +
'\\end{minipage}\n' +
'\\end{myalgo}}'
)
assert.equal(errors.length, 0)
})
it('should accept newenvironment II', function () {
const { errors } = Parse(
'\\newenvironment{claimproof}[1][\\myproofname]{\\begin{proof}[#1]\\renewcommand*{\\qedsymbol}{\\(\\diamondsuit\\)}}{\\end{proof}}'
)
assert.equal(errors.length, 0)
})
it('should accept superscript inside math mode', function () {
const { errors } = Parse('this is $a^b$ test')
assert.equal(errors.length, 0)
})
it('should accept subscript inside math mode', function () {
const { errors } = Parse('this is $a_b$ test')
assert.equal(errors.length, 0)
})
it('should return an error for superscript outside math mode', function () {
const { errors } = Parse('this is a^b test')
assert.equal(errors.length, 1)
assert.equal(errors[0].text, '^ must be inside math mode')
assert.equal(errors[0].type, 'error')
})
it('should return an error subscript outside math mode', function () {
const { errors } = Parse('this is a_b test')
assert.equal(errors.length, 1)
assert.equal(errors[0].text, '_ must be inside math mode')
assert.equal(errors[0].type, 'error')
})
it('should accept math mode inside \\hbox outside math mode', function () {
const { errors } = Parse('this is \\hbox{for every $bar$}')
assert.equal(errors.length, 0)
})
it('should accept math mode inside \\hbox inside math mode', function () {
const { errors } = Parse('this is $foo = \\hbox{for every $bar$}$ test')
assert.equal(errors.length, 0)
})
it('should accept math mode inside \\text inside math mode', function () {
const { errors } = Parse('this is $foo = \\text{for every $bar$}$ test')
assert.equal(errors.length, 0)
})
it('should accept verbatim', function () {
const { errors } = Parse(
'this is text\n' +
'\\begin{verbatim}\n' +
'this is verbatim\n' +
'\\end{verbatim}\n' +
'this is more text\n'
)
assert.equal(errors.length, 0)
})
it('should accept verbatim with environment inside', function () {
const { errors } = Parse(
'this is text\n' +
'\\begin{verbatim}\n' +
'this is verbatim\n' +
'\\begin{foo}\n' +
'this is verbatim too\n' +
'\\end{foo}\n' +
'\\end{verbatim}\n' +
'this is more text\n'
)
assert.equal(errors.length, 0)
})
it('should accept verbatim with \\begin{verbatim} inside', function () {
const { errors } = Parse(
'this is text\n' +
'\\begin{verbatim}\n' +
'this is verbatim\n' +
'\\begin{verbatim}\n' +
'this is verbatim too\n' +
'\\end{verbatim}\n' +
'this is more text\n'
)
assert.equal(errors.length, 0)
})
it('should accept equation', function () {
const { errors } = Parse(
'this is text\n' +
'\\begin{equation}\n' +
'\\alpha^2 + b^2 = c^2\n' +
'\\end{equation}\n' +
'this is more text\n'
)
assert.equal(errors.length, 0)
})
it('should accept $$', function () {
const { errors } = Parse(
'this is text\n' +
'$$\n' +
'\\alpha^2 + b^2 = c^2\n' +
'$$\n' +
'this is more text\n'
)
assert.equal(errors.length, 0)
})
it('should accept $', function () {
const { errors } = Parse(
'this is text $\\alpha^2 + b^2 = c^2$' + ' this is more text\n'
)
assert.equal(errors.length, 0)
})
it('should accept \\[', function () {
const { errors } = Parse(
'this is text\n' +
'\\[\n' +
'\\alpha^2 + b^2 = c^2\n' +
'\\]\n' +
'this is more text\n'
)
assert.equal(errors.length, 0)
})
it('should accept \\(', function () {
const { errors } = Parse(
'this is text \\(\\alpha^2 + b^2 = c^2\\)' + ' this is more text\n'
)
assert.equal(errors.length, 0)
})
it('should accept \\begin{foo}', function () {
const { errors } = Parse(
'this is text\n' +
'\\begin{foo}\n' +
'this is foo\n' +
'\\end{foo}\n' +
'this is more text\n'
)
assert.equal(errors.length, 0)
})
it('should accept \\begin{foo_bar}', function () {
const { errors } = Parse(
'this is text\n' +
'\\begin{foo_bar}\n' +
'this is foo bar\n' +
'\\end{foo_bar}\n' +
'this is more text\n'
)
assert.equal(errors.length, 0)
})
it('should accept \\begin{foo} \\begin{bar}', function () {
const { errors } = Parse(
'this is text\n' +
'\\begin{foo}\n' +
'\\begin{bar}\n' +
'\\begin{baz}\n' +
'this is foo bar baz\n' +
'\\end{baz}\n' +
'\\end{bar}\n' +
'\\end{foo}\n' +
'this is more text\n'
)
assert.equal(errors.length, 0)
})
it('should accept \\verb|...|', function () {
const { errors } = Parse('this is text \\verb|hello| and more\n')
assert.equal(errors.length, 0)
})
it('should accept \\verb|...| with special chars', function () {
const { errors } = Parse('this is text \\verb|{}()^_@$xhello| and more\n')
assert.equal(errors.length, 0)
})
it('should accept \\url|...|', function () {
const { errors } = Parse(
'this is text \\url|http://www.overleaf.com/| and more\n'
)
assert.equal(errors.length, 0)
})
it('should accept \\url{...}', function () {
const { errors } = Parse(
'this is text \\url{http://www.overleaf.com/} and more\n'
)
assert.equal(errors.length, 0)
})
it('should accept \\url{...} with % chars', function () {
const { errors } = Parse(
'this is text \\url{http://www.overleaf.com/hello%20world} and more\n'
)
assert.equal(errors.length, 0)
})
it('should accept \\href{...}{...}', function () {
const { errors } = Parse(
'this is text \\href{http://www.overleaf.com/}{test} and more\n'
)
assert.equal(errors.length, 0)
})
it('should accept \\href{...}{...} with dollarsign in url', function () {
const { errors } = Parse(
'this is text \\href{http://www.overleaf.com/foo=$bar}{test} and more\n'
)
assert.equal(errors.length, 0)
})
it('should not accept \\href|...|{...}', function () {
const { errors } = Parse(
'this is text \\href|http://www.overleaf.com|{test} and more\n'
)
assert.equal(errors.length, 1)
assert.equal(errors[0].text, 'invalid href command')
assert.equal(errors[0].type, 'error')
})
it('should catch error in text argument of \\href{...}{...}', function () {
const { errors } = Parse(
'this is text \\href{http://www.overleaf.com/foo=$bar}{i have made an $error} and more\n'
)
assert.equal(errors.length, 2)
assert.equal(errors[0].text, 'unclosed $ found at close group }')
assert.equal(errors[0].type, 'error')
assert.equal(errors[1].text, 'unexpected close group } after $')
assert.equal(errors[1].type, 'error')
})
it('should accept \\left( and \\right)', function () {
const { errors } = Parse('math $\\left( x + y \\right) = y + x$ and more\n')
assert.equal(errors.length, 0)
})
it('should accept \\left( and \\right.', function () {
const { errors } = Parse('math $\\left( x + y \\right. = y + x$ and more\n')
assert.equal(errors.length, 0)
})
it('should accept \\left. and \\right)', function () {
const { errors } = Parse('math $\\left. x + y \\right) = y + x$ and more\n')
assert.equal(errors.length, 0)
})
it('should accept complex math nesting', function () {
const { errors } = Parse(
'math $\\left( {x + {y + z} + x} \\right\\} = \\left[y + x\\right.$ and more\n'
)
assert.equal(errors.length, 0)
})
it('should accept math toggling $a$$b$', function () {
const { errors } = Parse('math $a$$b$ and more\n')
assert.equal(errors.length, 0)
})
it('should accept math toggling $$display$$$inline$', function () {
const { errors } = Parse('math $$display$$$inline$ and more\n')
assert.equal(errors.length, 0)
})
it('should accept math definition commands', function () {
const { errors } = Parse(
'\\let\\originalleft\\left\n' +
'\\let\\originalright\\right\n' +
'\\renewcommand{\\left}{\\mathopen{}\\mathclose\\bgroup\\originalleft}\n' +
'\\renewcommand{\\right}{\\aftergroup\\egroup\\originalright}\n'
)
assert.equal(errors.length, 0)
})
it('should accept math reflectbox commands', function () {
const { errors } = Parse('$\\reflectbox{$alpha$}$\n')
assert.equal(errors.length, 0)
})
it('should accept math scalebox commands', function () {
const { errors } = Parse('$\\scalebox{2}{$alpha$}$\n')
assert.equal(errors.length, 0)
})
it('should accept math rotatebox commands', function () {
const { errors } = Parse('$\\rotatebox{60}{$alpha$}$\n')
assert.equal(errors.length, 0)
})
it('should accept math resizebox commands', function () {
const { errors } = Parse('$\\resizebox{2}{3}{$alpha$}$\n')
assert.equal(errors.length, 0)
})
it('should accept all math box commands', function () {
const { errors } = Parse(
'\\[ \\left(\n' +
'\\shiftright{2ex}{\\raisebox{-2ex}{\\scalebox{2}{$\\ast$}}}\n' +
'\\reflectbox{$ddots$}\n' +
'\\right). \\]\n'
)
assert.equal(errors.length, 0)
})
it('should accept math tag commands', function () {
const { errors } = Parse('$\\tag{$alpha$}$\n')
assert.equal(errors.length, 0)
})
it('should accept math \\def commands', function () {
const { errors } = Parse(
'\\def\\peb[#1]{{\\left\\lfloor #1\\right\\rfloor}}'
)
assert.equal(errors.length, 0)
})
it('should accept math \\def commands II', function () {
const { errors } = Parse('\\def\\foo#1{\\gamma^#1}')
assert.equal(errors.length, 0)
})
it('should accept DeclareMathOperator', function () {
const { errors } = Parse('\\DeclareMathOperator{\\var}{\\Delta^2\\!}')
assert.equal(errors.length, 0)
})
it('should accept DeclarePairedDelimiter', function () {
const { errors } = Parse(
'\\DeclarePairedDelimiter{\\spro}{\\left(}{\\right)^{\\ast}}'
)
assert.equal(errors.length, 0)
})
it('should accept nested user-defined math commands', function () {
const { errors } = Parse(
'$\\foo{$\\alpha \\bar{x^y}{\\cite{hello}}$}{\\gamma}{$\\beta\\baz{\\alpha}$}{\\cite{foo}}$'
)
assert.equal(errors.length, 0)
})
it('should accept nested user-defined math commands II', function () {
const { errors } = Parse(
'\\foo{$\\alpha \\bar{x^y}{\\cite{hello}}$}{\\gamma}{$\\beta\\baz{\\alpha}$}{\\cite{foo}}'
)
assert.equal(errors.length, 0)
})
it('should accept newenvironment with multiple parameters', function () {
const { errors } = Parse(
'\\newenvironment{case}[1][\\textsc{Case}]\n' +
'{\\begin{trivlist}\\item[\\hskip \\labelsep {\\textsc{#1}}]}{\\end{trivlist}}'
)
assert.equal(errors.length, 0)
})
it('should accept newenvironment with no parameters', function () {
const { errors } = Parse(
'\\newenvironment{case}{\\begin{trivlist}\\item[\\hskip \\labelsep {\\textsc{#1}}]}{\\end{trivlist}}'
)
assert.equal(errors.length, 0)
})
it('should accept tikzfeynman', function () {
const { errors } = Parse(
'\\begin{equation*}\n' +
'\\feynmandiagram[layered layout, medium, horizontal=a to b] {\n' +
' a [particle=\\(H\\)] -- [scalar] b [dot] -- [photon] f1 [particle=\\(W^{\\pm}\\)],\n' +
' b -- [boson, edge label=\\(W^{\\mp}\\)] c [dot],\n' +
' c -- [fermion] f2 [particle=\\(f\\)],\n' +
" c -- [anti fermion] f3 [particle=\\(\\bar{f}'\\)],\n" +
' };this is a change\n' +
'\\end{equation*}'
)
assert.equal(errors.length, 0)
})
it('should return errors from malformed \\end', function () {
const { errors } = Parse(
'this is text\n' +
'\\begin{foo}\n' +
'\\begin{bar}\n' +
'this is foo bar baz\n' +
'\\end{bar\n' +
'\\end{foo}\n' +
'this is more text\n'
)
assert.equal(errors.length, 4)
assert.equal(errors[0].text, 'unclosed \\begin{bar} found at \\end{foo}')
assert.equal(errors[1].text, 'invalid environment command \\end{bar')
assert.equal(errors[2].text, 'unclosed open group { found at \\end{foo}')
assert.equal(errors[3].text, 'unexpected \\end{foo} after \\begin{bar}')
})
it('should accept \\newcommand*', function () {
const { errors } = Parse('\\newcommand*{\\foo}{\\bar}')
assert.equal(errors.length, 0)
})
it('should accept incomplete \\newcommand*', function () {
const { errors } = Parse('\\newcommand*{\\beq' + '}')
assert.equal(errors.length, 0)
})
it('should accept a plain hyperref command', function () {
const { errors } = Parse('\\hyperref{http://www.overleaf.com/}')
assert.equal(errors.length, 0)
})
it('should accept a hyperref command with underscores in the url ', function () {
const { errors } = Parse('\\hyperref{http://www.overleaf.com/my_page.html}')
assert.equal(errors.length, 0)
})
it('should accept a hyperref command with category, name and text arguments ', function () {
const { errors } = Parse(
'\\hyperref{http://www.overleaf.com/}{category}{name}{text}'
)
assert.equal(errors.length, 0)
})
it('should accept an underscore in a hyperref label', function () {
const { errors } = Parse('\\hyperref[foo_bar]{foo bar}')
assert.equal(errors.length, 0)
})
it('should reject a $ in a hyperref label', function () {
const { errors } = Parse('\\hyperref[foo$bar]{foo bar}')
assert.equal(errors.length, 1)
})
it('should reject an unclosed hyperref label', function () {
const { errors } = Parse('\\hyperref[foo_bar{foo bar}')
assert.equal(errors.length, 2)
assert.equal(errors[0].text, 'invalid hyperref label')
assert.equal(errors[1].text, 'unexpected close group }')
})
it('should accept a hyperref command without an optional argument', function () {
const { errors } = Parse('{\\hyperref{hello}}')
assert.equal(errors.length, 0)
})
it('should accept a hyperref command without an optional argument and multiple other arguments', function () {
const { errors } = Parse('{\\hyperref{}{}{fig411}}')
assert.equal(errors.length, 0)
})
it('should accept a hyperref command without an optional argument in an unclosed group', function () {
const { errors } = Parse('{\\hyperref{}{}{fig411}')
assert.equal(errors.length, 1)
assert.equal(errors[0].text, 'unclosed group {')
})
it('should accept documentclass with no options', function () {
const { errors } = Parse('\\documentclass{article}')
assert.equal(errors.length, 0)
})
it('should accept documentclass with options', function () {
const { errors } = Parse('\\documentclass[a4paper]{article}')
assert.equal(errors.length, 0)
})
it('should accept documentclass with underscore in options', function () {
const { errors } = Parse(
'\\documentclass[my_custom_document_class_option]{my-custom-class}'
)
assert.equal(errors.length, 0)
})
// %novalidate
// %begin novalidate
// %end novalidate
// \begin{foo}
// \begin{new_theorem}
// \begin{foo invalid environment command
// \newcommand{\foo}{\bar}
// \newcommand[1]{\foo}{\bar #1}
// \renewcommand...
// \def
// \DeclareRobustCommand
// \newcolumntype
// \newenvironment
// \renewenvironment
// \verb|....|
// \url|...|
// \url{...}
// \left( \right)
// \left. \right.
// $...$
// $$....$$
// $...$$...$
// $a^b$ vs a^b
// $$a^b$$ vs a^b
// Matrix for envs for {} left/right \[ \] \( \) $ $$ begin end
// begin equation
// align(*)
// equation(*)
// ]
// array(*)
// eqnarray(*)
// split
// aligned
// cases
// pmatrix
// gathered
// matrix
// alignedat
// smallmatrix
// subarray
// vmatrix
// shortintertext
it('should return math mode contexts', function () {
const { contexts } = Parse(
'\\begin{document}\n' +
'$$\n' +
'\\begin{array}\n' +
'\\left( \\foo{bar} \\right] & 2\n' +
'\\end{array}\n' +
'$$\n' +
'\\end{document}'
)
assert.equal(contexts.length, 1)
assert.equal(contexts[0].type, 'math')
assert.equal(contexts[0].range.start.row, 1)
assert.equal(contexts[0].range.start.column, 0)
assert.equal(contexts[0].range.end.row, 5)
assert.equal(contexts[0].range.end.column, 2)
})
it('should remove error when cursor is inside incomplete command', function () {
const { errors } = Parse('\\begin{}')
const diagnostics = errorsToDiagnostics(errors, 7, 9)
assert.equal(errors.length, 1)
assert.equal(diagnostics.length, 0)
})
it('should show an error when cursor is outside incomplete command', function () {
const { errors } = Parse('\\begin{}')
const diagnostics = errorsToDiagnostics(errors, 6, 9)
assert.equal(errors.length, 1)
assert.equal(diagnostics.length, 1)
assert.equal(diagnostics[0].from, 0)
assert.equal(diagnostics[0].to, 6)
})
it('should adjust an error range when the cursor is inside that range', function () {
const { errors } = Parse('\\begin{}')
const diagnostics = errorsToDiagnostics(errors, 4, 7)
assert.equal(errors.length, 1)
assert.equal(errors[0].startPos, 0)
assert.equal(errors[0].endPos, 7)
assert.equal(diagnostics.length, 1)
assert.equal(diagnostics[0].from, 0)
assert.equal(diagnostics[0].to, 4)
})
it('should reject an error when part of the error range is outside of the document boundaries', function () {
const { errors } = Parse('\\begin{}')
const diagnostics = errorsToDiagnostics(errors, 8, 6)
assert.equal(errors.length, 1)
assert.equal(diagnostics.length, 0)
})
it('should merge two overlapping identical diagnostics', function () {
const diagnostics: Diagnostic[] = [
{
from: 0,
to: 2,
message: 'Message 1',
severity: 'error',
},
{
from: 1,
to: 3,
message: 'Message 1',
severity: 'error',
},
]
const mergedDiagnostics = mergeCompatibleOverlappingDiagnostics(diagnostics)
assert.deepEqual(mergedDiagnostics, [
{
from: 0,
to: 3,
message: 'Message 1',
severity: 'error',
},
])
})
it('should merge two touching identical diagnostics', function () {
const diagnostics: Diagnostic[] = [
{
from: 0,
to: 2,
message: 'Message 1',
severity: 'error',
},
{
from: 2,
to: 3,
message: 'Message 1',
severity: 'error',
},
]
const mergedDiagnostics = mergeCompatibleOverlappingDiagnostics(diagnostics)
assert.deepEqual(mergedDiagnostics, [
{
from: 0,
to: 3,
message: 'Message 1',
severity: 'error',
},
])
})
it('should not merge two overlapping diagnostics with different messages', function () {
const diagnostics: Diagnostic[] = [
{
from: 0,
to: 2,
message: 'Message 1',
severity: 'error',
},
{
from: 1,
to: 3,
message: 'Message 2',
severity: 'error',
},
]
const mergedDiagnostics = mergeCompatibleOverlappingDiagnostics(diagnostics)
assert.deepEqual(diagnostics, mergedDiagnostics)
})
it('should not merge two overlapping diagnostics with different severities', function () {
const diagnostics: Diagnostic[] = [
{
from: 0,
to: 2,
message: 'Message 1',
severity: 'error',
},
{
from: 1,
to: 3,
message: 'Message 1',
severity: 'warning',
},
]
const mergedDiagnostics = mergeCompatibleOverlappingDiagnostics(diagnostics)
assert.deepEqual(diagnostics, mergedDiagnostics)
})
it('should merge three overlapping identical diagnostics', function () {
const diagnostics: Diagnostic[] = [
{
from: 0,
to: 2,
message: 'Message 1',
severity: 'error',
},
{
from: 1,
to: 4,
message: 'Message 1',
severity: 'error',
},
{
from: 3,
to: 5,
message: 'Message 1',
severity: 'error',
},
]
const mergedDiagnostics = mergeCompatibleOverlappingDiagnostics(diagnostics)
assert.deepEqual(mergedDiagnostics, [
{
from: 0,
to: 5,
message: 'Message 1',
severity: 'error',
},
])
})
it('should merge two separate sets of overlapping identical diagnostics', function () {
const diagnostics: Diagnostic[] = [
{
from: 0,
to: 2,
message: 'Message 1',
severity: 'error',
},
{
from: 2,
to: 3,
message: 'Message 1',
severity: 'error',
},
{
from: 2,
to: 5,
message: 'Message 2',
severity: 'error',
},
{
from: 4,
to: 6,
message: 'Message 3',
severity: 'error',
},
{
from: 5,
to: 7,
message: 'Message 3',
severity: 'error',
},
]
const mergedDiagnostics = mergeCompatibleOverlappingDiagnostics(diagnostics)
assert.deepEqual(mergedDiagnostics, [
{
from: 0,
to: 3,
message: 'Message 1',
severity: 'error',
},
{
from: 2,
to: 5,
message: 'Message 2',
severity: 'error',
},
{
from: 4,
to: 7,
message: 'Message 3',
severity: 'error',
},
])
})
})

View File

@@ -0,0 +1,537 @@
import { LanguageSupport } from '@codemirror/language'
import { EditorState, Text } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { expect } from 'chai'
import { documentOutline } from '../../../../../../frontend/js/features/source-editor/languages/latex/document-outline'
import {
FlatOutline,
getNestingLevel,
} from '../../../../../../frontend/js/features/source-editor/utils/tree-query'
import { LaTeXLanguage } from '../../../../../../frontend/js/features/source-editor/languages/latex/latex-language'
import {
Book,
Chapter,
Paragraph,
Part,
Section,
SubParagraph,
SubSection,
SubSubSection,
} from '../../../../../../frontend/js/features/source-editor/lezer-latex/latex.terms.mjs'
const latex = new LanguageSupport(LaTeXLanguage, documentOutline.extension)
const makeView = (lines: string[]): EditorView => {
const text = Text.of(lines)
const view = new EditorView({
state: EditorState.create({
doc: text,
extensions: [latex],
}),
})
return view
}
const BOOK_LEVEL = getNestingLevel(Book)
const PART_LEVEL = getNestingLevel(Part)
const CHAPTER_LEVEL = getNestingLevel(Chapter)
const SECTION_LEVEL = getNestingLevel(Section)
const SUB_SECTION_LEVEL = getNestingLevel(SubSection)
const SUB_SUB_SECTION_LEVEL = getNestingLevel(SubSubSection)
const PARAGRAPH_LEVEL = getNestingLevel(Paragraph)
const SUB_PARAGRAPH_LEVEL = getNestingLevel(SubParagraph)
const FRAME_LEVEL = getNestingLevel('frame')
const insertText = (view: EditorView, position: number, text: string) => {
view.dispatch({
changes: [{ from: position, insert: text }],
})
}
const deleteText = (view: EditorView, position: number, length: number) => {
view.dispatch({
changes: [{ from: position - length, to: position }],
})
}
const getOutline = (view: EditorView): FlatOutline | null => {
return view.state.field(documentOutline)?.items || null
}
describe('CodeMirror LaTeX-FileOutline', function () {
describe('with no update', function () {
describe('an empty document', function () {
let view: EditorView, content: string[]
beforeEach(function () {
content = ['']
view = makeView(content)
})
it('should have empty outline', function () {
const outline = getOutline(view)
expect(outline).to.be.empty
})
})
describe('a document with nested sections', function () {
let view: EditorView, content: string[]
beforeEach(function () {
content = [
'line 1',
'\\section{sec title}',
'content',
'\\subsection{subsec title}',
]
view = makeView(content)
})
it('should have outline with different levels', function () {
const outline = getOutline(view)
expect(outline).to.be.deep.equal([
{
from: 7,
to: 26,
level: SECTION_LEVEL,
title: 'sec title',
line: 2,
},
{
from: 35,
to: 60,
level: SUB_SECTION_LEVEL,
title: 'subsec title',
line: 4,
},
])
})
})
describe('a document with sibling sections', function () {
let view: EditorView, content: string[]
beforeEach(function () {
content = [
'line 1',
'\\section{sec title 1}',
'content',
'\\section{sec title 2}',
]
view = makeView(content)
})
it('should have outline with same levels for siblings', function () {
const outline = getOutline(view)
expect(outline).to.be.deep.equal([
{
from: 7,
to: 28,
level: SECTION_LEVEL,
title: 'sec title 1',
line: 2,
},
{
from: 37,
to: 58,
level: SECTION_LEVEL,
title: 'sec title 2',
line: 4,
},
])
})
})
})
describe('with change to title', function () {
let view: EditorView, content: string[]
beforeEach(function () {
content = ['\\section{title }']
view = makeView(content)
const initialOutline = getOutline(view)
expect(initialOutline).to.deep.equal([
{
from: 0,
to: 16,
title: 'title ',
line: 1,
level: SECTION_LEVEL,
},
])
})
describe('for appending to title', function () {
beforeEach(function () {
insertText(view, 15, '1')
})
it('should update title in outline', function () {
const updatedOutline = getOutline(view)
expect(updatedOutline).to.deep.equal([
{
from: 0,
to: 17,
title: 'title 1',
line: 1,
level: SECTION_LEVEL,
},
])
})
})
describe('for removing from title', function () {
beforeEach(function () {
deleteText(view, 15, 1)
})
it('should update title in outline', function () {
const updatedOutline = getOutline(view)
expect(updatedOutline).to.deep.equal([
{
from: 0,
to: 15,
title: 'title',
line: 1,
level: SECTION_LEVEL,
},
])
})
})
})
describe('for moving section', function () {
let view: EditorView, content: string[]
beforeEach(function () {
content = ['\\section{title}', '\\subsection{subtitle}']
view = makeView(content)
const initialOutline = getOutline(view)
expect(initialOutline).to.deep.equal([
{
from: 0,
to: 15,
title: 'title',
line: 1,
level: SECTION_LEVEL,
},
{
from: 16,
to: 37,
title: 'subtitle',
line: 2,
level: SUB_SECTION_LEVEL,
},
])
insertText(view, 15, '\n')
})
it('should update position for moved section', function () {
const updatedOutline = getOutline(view)
expect(updatedOutline).to.deep.equal([
{
from: 0,
to: 15,
title: 'title',
line: 1,
level: SECTION_LEVEL,
},
{
from: 17,
to: 38,
title: 'subtitle',
line: 3,
level: SUB_SECTION_LEVEL,
},
])
})
})
describe('for removing a section', function () {
let view: EditorView, content: string[]
beforeEach(function () {
content = ['\\section{title}']
view = makeView(content)
const initialOutline = getOutline(view)
expect(initialOutline).to.deep.equal([
{
from: 0,
to: 15,
title: 'title',
line: 1,
level: SECTION_LEVEL,
},
])
deleteText(view, 4, 1)
})
it('should remove the section from the outline', function () {
const updatedOutline = getOutline(view)
expect(updatedOutline).to.be.empty
})
})
describe('for changing parent section', function () {
let view: EditorView, content: string[]
beforeEach(function () {
content = [
'\\section{section}',
'%\\subsection{subsection}', // initially commented out
'\\subsubsection{subsubsection}',
]
view = makeView(content)
const initialOutline = getOutline(view)
expect(initialOutline).to.deep.equal([
{
from: 0,
to: 17,
title: 'section',
line: 1,
level: SECTION_LEVEL,
},
{
from: 43,
to: 72,
title: 'subsubsection',
line: 3,
level: SUB_SUB_SECTION_LEVEL,
},
])
// Remove the %
deleteText(view, 19, 1)
})
it('should be nested properly', function () {
const updatedOutline = getOutline(view)
expect(updatedOutline).to.deep.equal([
{
from: 0,
to: 17,
title: 'section',
line: 1,
level: SECTION_LEVEL,
},
{
from: 18,
to: 41,
title: 'subsection',
line: 2,
level: SUB_SECTION_LEVEL,
},
{
from: 42,
to: 71,
title: 'subsubsection',
line: 3,
level: SUB_SUB_SECTION_LEVEL,
},
])
})
})
describe('for a sectioning command inside a newcommand or renewcommand', function () {
let view: EditorView, content: string[]
beforeEach(function () {
content = [
'\\section{section}',
'\\newcommand{\\test}{\\section{should not display}}',
'\\renewcommand{\\test}{\\section{should still not display}}',
]
view = makeView(content)
})
it('should not include them in the outline', function () {
const outline = getOutline(view)
expect(outline?.length).to.equal(1)
expect(outline).to.deep.equal([
{
from: 0,
to: 17,
title: 'section',
line: 1,
level: SECTION_LEVEL,
},
])
})
})
describe('for all section types', function () {
let view: EditorView, content: string[]
beforeEach(function () {
content = [
'\\book{book}',
'\\part{part}',
'\\chapter{chapter}',
'\\section{section}',
'\\subsection{subsection}',
'\\subsubsection{subsubsection}',
'\\paragraph{paragraph}',
'\\subparagraph{subparagraph}',
]
view = makeView(content)
})
it('should include them in the file outline', function () {
const outline = getOutline(view)
expect(outline).to.deep.equal([
{
from: 0,
to: 11,
title: 'book',
line: 1,
level: BOOK_LEVEL,
},
{
from: 12,
to: 23,
title: 'part',
line: 2,
level: PART_LEVEL,
},
{
from: 24,
to: 41,
title: 'chapter',
line: 3,
level: CHAPTER_LEVEL,
},
{
from: 42,
to: 59,
title: 'section',
line: 4,
level: SECTION_LEVEL,
},
{
from: 60,
to: 83,
title: 'subsection',
line: 5,
level: SUB_SECTION_LEVEL,
},
{
from: 84,
to: 113,
title: 'subsubsection',
line: 6,
level: SUB_SUB_SECTION_LEVEL,
},
{
from: 114,
to: 135,
title: 'paragraph',
line: 7,
level: PARAGRAPH_LEVEL,
},
{
from: 136,
to: 163,
title: 'subparagraph',
line: 8,
level: SUB_PARAGRAPH_LEVEL,
},
])
})
})
describe('sectioning commands with optional arguments', function () {
let view: EditorView, content: string[]
beforeEach(function () {
content = ['\\section[short title]{section}']
view = makeView(content)
})
it('should use the long argument as title', function () {
const outline = getOutline(view)
expect(outline).to.deep.equal([
{
from: 0,
to: 30,
title: 'section',
line: 1,
level: SECTION_LEVEL,
},
])
})
})
describe('for labels using texorpdfstring', function () {
let view: EditorView, content: string[]
beforeEach(function () {
content = [
'\\section{The \\texorpdfstring{function $f(x) = x^2$}{function f(x) = x^2}: Properties of \\texorpdfstring{$x$}{x}.}',
]
view = makeView(content)
})
it('should use the text argument as title', function () {
const outline = getOutline(view)
expect(outline).to.deep.equal([
{
from: 0,
to: 113,
title: 'The function f(x) = x^2: Properties of x.',
line: 1,
level: SECTION_LEVEL,
},
])
})
})
describe('for ill-formed \\def command', function () {
let view: EditorView, content: string[]
beforeEach(function () {
content = ['\\def\\x{', '\\section{test}', '\\subsection{test2}']
view = makeView(content)
})
it('still shows an outline', function () {
const outline = getOutline(view)
expect(outline).to.deep.equal([
{
from: 8,
to: 22,
title: 'test',
line: 2,
level: SECTION_LEVEL,
},
{
from: 23,
to: 41,
title: 'test2',
line: 3,
level: SUB_SECTION_LEVEL,
},
])
})
})
describe('for beamer frames', function () {
describe('with titles', function () {
let view: EditorView, content: string[]
beforeEach(function () {
content = ['\\begin{frame}{frame title}{}', '\\end{frame}']
view = makeView(content)
})
it('should show up in the file outline', function () {
const outline = getOutline(view)
expect(outline).to.deep.equal([
{
from: 0,
to: 28,
title: 'frame title',
line: 1,
level: FRAME_LEVEL,
},
])
})
})
describe('without titles', function () {
let view: EditorView, content: string[]
beforeEach(function () {
content = ['\\begin{frame}', '\\end{frame}']
view = makeView(content)
})
it('should not show up in the file outline', function () {
const outline = getOutline(view)
expect(outline).to.be.empty
})
})
})
})