Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263// src/plugin/__tests__/editor.test.tsimport { describe, it, expect } from 'vitest'import { Editor } from '../editor'import type { EditorPosition, EditorRange, EditorSelection, EditorSelectionOrCaret, EditorTransaction, EditorCommandName } from '../types'
import type { EditorChange } from '../types'
// Minimal test implementation that tracks transactions for testingclass TestEditor extends Editor { private _value = 'line1\nline2\nline3' private _cursor: EditorPosition = { line: 0, ch: 0 } lastTransaction: EditorTransaction | null = null
refresh(): void {} getValue(): string { return this._value } setValue(content: string): void { this._value = content } getLine(line: number): string { return this._value.split('\n')[line] ?? '' } lineCount(): number { return this._value.split('\n').length } lastLine(): number { return this.lineCount() - 1 } getSelection(): string { return '' } getRange(_from: EditorPosition, _to: EditorPosition): string { return '' } replaceSelection(_replacement: string): void {} replaceRange(replacement: string, from: EditorPosition, to?: EditorPosition): void { const lines = this._value.split('\n') const end = to ?? from const startLine = lines[from.line] ?? '' const endLine = lines[end.line] ?? '' const newLine = startLine.slice(0, from.ch) + replacement + endLine.slice(end.ch) lines.splice(from.line, end.line - from.line + 1, newLine) this._value = lines.join('\n') } getCursor(): EditorPosition { return this._cursor } listSelections(): EditorSelection[] { return [{ anchor: this._cursor, head: this._cursor }] } setSelection(anchor: EditorPosition, head?: EditorPosition): void { this._cursor = head ?? anchor } setSelections(_ranges: EditorSelectionOrCaret[]): void {} focus(): void {} blur(): void {} hasFocus(): boolean { return false } getScrollInfo(): { top: number; left: number } { return { top: 0, left: 0 } } scrollTo(_x?: number | null, _y?: number | null): void {} scrollIntoView(_range: EditorRange): void {} undo(): void {} redo(): void {} exec(_command: EditorCommandName): void {} transaction(tx: EditorTransaction): void { this.lastTransaction = tx // Apply changes for processLines tests if (tx.changes) { for (const change of tx.changes) { this.replaceRange(change.text, change.from, change.to) } } } wordAt(_pos: EditorPosition): EditorRange | null { return null } posToOffset(_pos: EditorPosition): number { return 0 } offsetToPos(_offset: number): EditorPosition { return { line: 0, ch: 0 } }}
describe('Editor', () => { it('getDoc returns self', () => { const editor = new TestEditor() expect(editor.getDoc()).toBe(editor) })
it('setLine updates a line via replaceRange', () => { const editor = new TestEditor() editor.setLine(1, 'updated') expect(editor.getLine(1)).toBe('updated') })
it('somethingSelected returns false when no selection', () => { const editor = new TestEditor() expect(editor.somethingSelected()).toBe(false) })
it('setCursor accepts position object', () => { const editor = new TestEditor() expect(() => editor.setCursor({ line: 0, ch: 0 })).not.toThrow() })
it('setCursor accepts line and ch arguments', () => { const editor = new TestEditor() expect(() => editor.setCursor(0, 5)).not.toThrow() })
it('lineCount returns correct number of lines', () => { const editor = new TestEditor() expect(editor.lineCount()).toBe(3) })
it('lastLine returns index of last line', () => { const editor = new TestEditor() expect(editor.lastLine()).toBe(2) })
describe('processLines', () => { it('calls read and write for each line', () => { const editor = new TestEditor() const readCalls: Array<{ line: number; text: string }> = [] const writeCalls: Array<{ line: number; text: string; value: string | null }> = []
editor.processLines<string>( (line, text) => { readCalls.push({ line, text }) return text.toUpperCase() }, (line, text, value) => { writeCalls.push({ line, text, value }) } )
expect(readCalls).toEqual([ { line: 0, text: 'line1' }, { line: 1, text: 'line2' }, { line: 2, text: 'line3' }, ]) expect(writeCalls).toEqual([ { line: 0, text: 'line1', value: 'LINE1' }, { line: 1, text: 'line2', value: 'LINE2' }, { line: 2, text: 'line3', value: 'LINE3' }, ]) })
it('applies changes atomically via transaction', () => { const editor = new TestEditor() editor.setValue('aaa\nbbb\nccc')
editor.processLines<string>( (_line, text) => text, (line, text): EditorChange | void => { if (line === 1) { return { from: { line, ch: 0 }, to: { line, ch: text.length }, text: 'XXX', } } } )
// Transaction should have been called with changes expect(editor.lastTransaction?.changes).toHaveLength(1) expect(editor.lastTransaction?.changes?.[0].text).toBe('XXX') })
it('skips empty lines when ignoreEmpty is true', () => { const editor = new TestEditor() editor.setValue('line1\n\nline3') const processedLines: number[] = []
editor.processLines<null>( (line) => { processedLines.push(line) return null }, () => {}, true // ignoreEmpty )
expect(processedLines).toEqual([0, 2]) // line 1 is empty, skipped })
it('processes empty lines when ignoreEmpty is false/undefined', () => { const editor = new TestEditor() editor.setValue('line1\n\nline3') const processedLines: number[] = []
editor.processLines<null>( (line) => { processedLines.push(line) return null }, () => {} )
expect(processedLines).toEqual([0, 1, 2]) // all lines processed })
it('calls write even when read returns null', () => { const editor = new TestEditor() const writeCalls: Array<{ line: number; value: number | null }> = []
editor.processLines<number>( (_line, _text) => null, // always return null (line, _text, value) => { writeCalls.push({ line, value }) } )
// write should still be called for all lines with null value expect(writeCalls).toEqual([ { line: 0, value: null }, { line: 1, value: null }, { line: 2, value: null }, ]) })
it('does not call transaction when no changes are returned', () => { const editor = new TestEditor() editor.lastTransaction = null
editor.processLines<null>( () => null, () => {} // no changes returned )
expect(editor.lastTransaction).toBeNull() })
it('handles single line document', () => { const editor = new TestEditor() editor.setValue('single') const lines: string[] = []
editor.processLines<string>( (_, text) => { lines.push(text) return text }, () => {} )
expect(lines).toEqual(['single']) })
it('handles empty document', () => { const editor = new TestEditor() editor.setValue('') const lines: string[] = []
editor.processLines<string>( (_, text) => { lines.push(text) return text }, () => {} )
// Empty document has one empty line expect(lines).toEqual(['']) })
it('collects multiple changes and applies in single transaction', () => { const editor = new TestEditor() editor.setValue('a\nb\nc')
editor.processLines<string>( (_, text) => text, (line, text): EditorChange | void => { // Change every line return { from: { line, ch: 0 }, to: { line, ch: text.length }, text: text.toUpperCase(), } } )
expect(editor.lastTransaction?.changes).toHaveLength(3) }) })})