| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249 |
- /* 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}}`
- )
- }
- }
- }
- })
- }
|