first commit
This commit is contained in:
@@ -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'))
|
||||
})
|
||||
}
|
@@ -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}}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
@@ -0,0 +1,3 @@
|
||||
import { isMac } from '@/shared/utils/os'
|
||||
|
||||
export const metaKey = isMac ? 'meta' : 'ctrl'
|
@@ -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,
|
||||
}
|
||||
}
|
@@ -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),
|
||||
}
|
||||
}
|
@@ -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>
|
||||
)
|
Reference in New Issue
Block a user