// src/plugin/__tests__/editor.test.ts import { 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 testing class 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( (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( (_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( (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( (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( (_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, () => {} // no changes returned ) expect(editor.lastTransaction).toBeNull() }) it('handles single line document', () => { const editor = new TestEditor() editor.setValue('single') const lines: string[] = [] editor.processLines( (_, 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( (_, 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( (_, 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) }) }) })