|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import { describe, it, expect, beforeEach } from 'vitest'; |
|
|
import { renderHook, act } from '@testing-library/react'; |
|
|
import { |
|
|
useTextBuffer, |
|
|
Viewport, |
|
|
TextBuffer, |
|
|
offsetToLogicalPos, |
|
|
} from './text-buffer.js'; |
|
|
|
|
|
|
|
|
const getBufferState = (result: { current: TextBuffer }) => ({ |
|
|
text: result.current.text, |
|
|
lines: [...result.current.lines], |
|
|
cursor: [...result.current.cursor] as [number, number], |
|
|
allVisualLines: [...result.current.allVisualLines], |
|
|
viewportVisualLines: [...result.current.viewportVisualLines], |
|
|
visualCursor: [...result.current.visualCursor] as [number, number], |
|
|
visualScrollRow: result.current.visualScrollRow, |
|
|
preferredCol: result.current.preferredCol, |
|
|
}); |
|
|
|
|
|
describe('useTextBuffer', () => { |
|
|
let viewport: Viewport; |
|
|
|
|
|
beforeEach(() => { |
|
|
viewport = { width: 10, height: 3 }; |
|
|
}); |
|
|
|
|
|
describe('Initialization', () => { |
|
|
it('should initialize with empty text and cursor at (0,0) by default', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ viewport, isValidPath: () => false }), |
|
|
); |
|
|
const state = getBufferState(result); |
|
|
expect(state.text).toBe(''); |
|
|
expect(state.lines).toEqual(['']); |
|
|
expect(state.cursor).toEqual([0, 0]); |
|
|
expect(state.allVisualLines).toEqual(['']); |
|
|
expect(state.viewportVisualLines).toEqual(['']); |
|
|
expect(state.visualCursor).toEqual([0, 0]); |
|
|
expect(state.visualScrollRow).toBe(0); |
|
|
}); |
|
|
|
|
|
it('should initialize with provided initialText', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ |
|
|
initialText: 'hello', |
|
|
viewport, |
|
|
isValidPath: () => false, |
|
|
}), |
|
|
); |
|
|
const state = getBufferState(result); |
|
|
expect(state.text).toBe('hello'); |
|
|
expect(state.lines).toEqual(['hello']); |
|
|
expect(state.cursor).toEqual([0, 0]); |
|
|
expect(state.allVisualLines).toEqual(['hello']); |
|
|
expect(state.viewportVisualLines).toEqual(['hello']); |
|
|
expect(state.visualCursor).toEqual([0, 0]); |
|
|
}); |
|
|
|
|
|
it('should initialize with initialText and initialCursorOffset', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ |
|
|
initialText: 'hello\nworld', |
|
|
initialCursorOffset: 7, |
|
|
viewport, |
|
|
isValidPath: () => false, |
|
|
}), |
|
|
); |
|
|
const state = getBufferState(result); |
|
|
expect(state.text).toBe('hello\nworld'); |
|
|
expect(state.lines).toEqual(['hello', 'world']); |
|
|
expect(state.cursor).toEqual([1, 1]); |
|
|
expect(state.allVisualLines).toEqual(['hello', 'world']); |
|
|
expect(state.viewportVisualLines).toEqual(['hello', 'world']); |
|
|
expect(state.visualCursor[0]).toBe(1); |
|
|
expect(state.visualCursor[1]).toBe(1); |
|
|
}); |
|
|
|
|
|
it('should wrap visual lines', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ |
|
|
initialText: 'The quick brown fox jumps over the lazy dog.', |
|
|
initialCursorOffset: 2, |
|
|
viewport: { width: 15, height: 4 }, |
|
|
isValidPath: () => false, |
|
|
}), |
|
|
); |
|
|
const state = getBufferState(result); |
|
|
expect(state.allVisualLines).toEqual([ |
|
|
'The quick', |
|
|
'brown fox', |
|
|
'jumps over the', |
|
|
'lazy dog.', |
|
|
]); |
|
|
}); |
|
|
|
|
|
it('should wrap visual lines with multiple spaces', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ |
|
|
initialText: 'The quick brown fox jumps over the lazy dog.', |
|
|
viewport: { width: 15, height: 4 }, |
|
|
isValidPath: () => false, |
|
|
}), |
|
|
); |
|
|
const state = getBufferState(result); |
|
|
|
|
|
|
|
|
|
|
|
expect(state.allVisualLines).toEqual([ |
|
|
'The quick ', |
|
|
'brown fox ', |
|
|
'jumps over the', |
|
|
'lazy dog.', |
|
|
]); |
|
|
}); |
|
|
|
|
|
it('should wrap visual lines even without spaces', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ |
|
|
initialText: '123456789012345ABCDEFG', |
|
|
viewport: { width: 15, height: 2 }, |
|
|
isValidPath: () => false, |
|
|
}), |
|
|
); |
|
|
const state = getBufferState(result); |
|
|
|
|
|
|
|
|
|
|
|
expect(state.allVisualLines).toEqual(['123456789012345', 'ABCDEFG']); |
|
|
}); |
|
|
|
|
|
it('should initialize with multi-byte unicode characters and correct cursor offset', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ |
|
|
initialText: '你好世界', |
|
|
initialCursorOffset: 2, |
|
|
viewport: { width: 5, height: 2 }, |
|
|
isValidPath: () => false, |
|
|
}), |
|
|
); |
|
|
const state = getBufferState(result); |
|
|
expect(state.text).toBe('你好世界'); |
|
|
expect(state.lines).toEqual(['你好世界']); |
|
|
expect(state.cursor).toEqual([0, 2]); |
|
|
|
|
|
expect(state.allVisualLines).toEqual(['你好', '世界']); |
|
|
expect(state.visualCursor).toEqual([1, 0]); |
|
|
}); |
|
|
}); |
|
|
|
|
|
describe('Basic Editing', () => { |
|
|
it('insert: should insert a character and update cursor', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ viewport, isValidPath: () => false }), |
|
|
); |
|
|
act(() => result.current.insert('a')); |
|
|
let state = getBufferState(result); |
|
|
expect(state.text).toBe('a'); |
|
|
expect(state.cursor).toEqual([0, 1]); |
|
|
expect(state.visualCursor).toEqual([0, 1]); |
|
|
|
|
|
act(() => result.current.insert('b')); |
|
|
state = getBufferState(result); |
|
|
expect(state.text).toBe('ab'); |
|
|
expect(state.cursor).toEqual([0, 2]); |
|
|
expect(state.visualCursor).toEqual([0, 2]); |
|
|
}); |
|
|
|
|
|
it('insert: should insert text in the middle of a line', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ |
|
|
initialText: 'abc', |
|
|
viewport, |
|
|
isValidPath: () => false, |
|
|
}), |
|
|
); |
|
|
act(() => result.current.move('right')); |
|
|
act(() => result.current.insert('-NEW-')); |
|
|
const state = getBufferState(result); |
|
|
expect(state.text).toBe('a-NEW-bc'); |
|
|
expect(state.cursor).toEqual([0, 6]); |
|
|
}); |
|
|
|
|
|
it('newline: should create a new line and move cursor', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ |
|
|
initialText: 'ab', |
|
|
viewport, |
|
|
isValidPath: () => false, |
|
|
}), |
|
|
); |
|
|
act(() => result.current.move('end')); |
|
|
act(() => result.current.newline()); |
|
|
const state = getBufferState(result); |
|
|
expect(state.text).toBe('ab\n'); |
|
|
expect(state.lines).toEqual(['ab', '']); |
|
|
expect(state.cursor).toEqual([1, 0]); |
|
|
expect(state.allVisualLines).toEqual(['ab', '']); |
|
|
expect(state.viewportVisualLines).toEqual(['ab', '']); |
|
|
expect(state.visualCursor).toEqual([1, 0]); |
|
|
}); |
|
|
|
|
|
it('backspace: should delete char to the left or merge lines', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ |
|
|
initialText: 'a\nb', |
|
|
viewport, |
|
|
isValidPath: () => false, |
|
|
}), |
|
|
); |
|
|
act(() => { |
|
|
result.current.move('down'); |
|
|
}); |
|
|
act(() => { |
|
|
result.current.move('end'); |
|
|
}); |
|
|
act(() => result.current.backspace()); |
|
|
let state = getBufferState(result); |
|
|
expect(state.text).toBe('a\n'); |
|
|
expect(state.cursor).toEqual([1, 0]); |
|
|
|
|
|
act(() => result.current.backspace()); |
|
|
state = getBufferState(result); |
|
|
expect(state.text).toBe('a'); |
|
|
expect(state.cursor).toEqual([0, 1]); |
|
|
expect(state.allVisualLines).toEqual(['a']); |
|
|
expect(state.viewportVisualLines).toEqual(['a']); |
|
|
expect(state.visualCursor).toEqual([0, 1]); |
|
|
}); |
|
|
|
|
|
it('del: should delete char to the right or merge lines', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ |
|
|
initialText: 'a\nb', |
|
|
viewport, |
|
|
isValidPath: () => false, |
|
|
}), |
|
|
); |
|
|
|
|
|
act(() => result.current.del()); |
|
|
let state = getBufferState(result); |
|
|
expect(state.text).toBe('\nb'); |
|
|
expect(state.cursor).toEqual([0, 0]); |
|
|
|
|
|
act(() => result.current.del()); |
|
|
state = getBufferState(result); |
|
|
expect(state.text).toBe('b'); |
|
|
expect(state.cursor).toEqual([0, 0]); |
|
|
expect(state.allVisualLines).toEqual(['b']); |
|
|
expect(state.viewportVisualLines).toEqual(['b']); |
|
|
expect(state.visualCursor).toEqual([0, 0]); |
|
|
}); |
|
|
}); |
|
|
|
|
|
describe('Drag and Drop File Paths', () => { |
|
|
it('should prepend @ to a valid file path on insert', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ viewport, isValidPath: () => true }), |
|
|
); |
|
|
const filePath = '/path/to/a/valid/file.txt'; |
|
|
act(() => result.current.insert(filePath)); |
|
|
expect(getBufferState(result).text).toBe(`@${filePath}`); |
|
|
}); |
|
|
|
|
|
it('should not prepend @ to an invalid file path on insert', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ viewport, isValidPath: () => false }), |
|
|
); |
|
|
const notAPath = 'this is just some long text'; |
|
|
act(() => result.current.insert(notAPath)); |
|
|
expect(getBufferState(result).text).toBe(notAPath); |
|
|
}); |
|
|
|
|
|
it('should handle quoted paths', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ viewport, isValidPath: () => true }), |
|
|
); |
|
|
const filePath = "'/path/to/a/valid/file.txt'"; |
|
|
act(() => result.current.insert(filePath)); |
|
|
expect(getBufferState(result).text).toBe(`@/path/to/a/valid/file.txt`); |
|
|
}); |
|
|
|
|
|
it('should not prepend @ to short text that is not a path', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ viewport, isValidPath: () => true }), |
|
|
); |
|
|
const shortText = 'ab'; |
|
|
act(() => result.current.insert(shortText)); |
|
|
expect(getBufferState(result).text).toBe(shortText); |
|
|
}); |
|
|
}); |
|
|
|
|
|
describe('Cursor Movement', () => { |
|
|
it('move: left/right should work within and across visual lines (due to wrapping)', () => { |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ |
|
|
initialText: 'long line1next line2', |
|
|
viewport: { width: 5, height: 4 }, |
|
|
isValidPath: () => false, |
|
|
}), |
|
|
); |
|
|
|
|
|
|
|
|
act(() => result.current.move('right')); |
|
|
expect(getBufferState(result).visualCursor).toEqual([0, 1]); |
|
|
act(() => result.current.move('right')); |
|
|
act(() => result.current.move('right')); |
|
|
act(() => result.current.move('right')); |
|
|
expect(getBufferState(result).visualCursor).toEqual([0, 4]); |
|
|
|
|
|
act(() => result.current.move('right')); |
|
|
expect(getBufferState(result).visualCursor).toEqual([1, 0]); |
|
|
expect(getBufferState(result).cursor).toEqual([0, 5]); |
|
|
|
|
|
act(() => result.current.move('left')); |
|
|
expect(getBufferState(result).visualCursor).toEqual([0, 4]); |
|
|
expect(getBufferState(result).cursor).toEqual([0, 4]); |
|
|
}); |
|
|
|
|
|
it('move: up/down should preserve preferred visual column', () => { |
|
|
const text = 'abcde\nxy\n12345'; |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ |
|
|
initialText: text, |
|
|
viewport, |
|
|
isValidPath: () => false, |
|
|
}), |
|
|
); |
|
|
expect(result.current.allVisualLines).toEqual(['abcde', 'xy', '12345']); |
|
|
|
|
|
act(() => { |
|
|
result.current.move('home'); |
|
|
}); |
|
|
for (let i = 0; i < 5; i++) { |
|
|
act(() => { |
|
|
result.current.move('right'); |
|
|
}); |
|
|
} |
|
|
expect(getBufferState(result).cursor).toEqual([0, 5]); |
|
|
expect(getBufferState(result).visualCursor).toEqual([0, 5]); |
|
|
|
|
|
|
|
|
act(() => { |
|
|
result.current.move('down'); |
|
|
}); |
|
|
let state = getBufferState(result); |
|
|
expect(state.cursor).toEqual([1, 2]); |
|
|
expect(state.visualCursor).toEqual([1, 2]); |
|
|
expect(state.preferredCol).toBe(5); |
|
|
|
|
|
act(() => result.current.move('down')); |
|
|
state = getBufferState(result); |
|
|
expect(state.cursor).toEqual([2, 5]); |
|
|
expect(state.visualCursor).toEqual([2, 5]); |
|
|
expect(state.preferredCol).toBe(5); |
|
|
|
|
|
act(() => result.current.move('left')); |
|
|
state = getBufferState(result); |
|
|
expect(state.preferredCol).toBe(null); |
|
|
}); |
|
|
|
|
|
it('move: home/end should go to visual line start/end', () => { |
|
|
const initialText = 'line one\nsecond line'; |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ |
|
|
initialText, |
|
|
viewport: { width: 5, height: 5 }, |
|
|
isValidPath: () => false, |
|
|
}), |
|
|
); |
|
|
expect(result.current.allVisualLines).toEqual([ |
|
|
'line', |
|
|
'one', |
|
|
'secon', |
|
|
'd', |
|
|
'line', |
|
|
]); |
|
|
|
|
|
act(() => result.current.move('down')); |
|
|
act(() => result.current.move('right')); |
|
|
expect(getBufferState(result).visualCursor).toEqual([1, 1]); |
|
|
|
|
|
act(() => result.current.move('home')); |
|
|
expect(getBufferState(result).visualCursor).toEqual([1, 0]); |
|
|
|
|
|
act(() => result.current.move('end')); |
|
|
expect(getBufferState(result).visualCursor).toEqual([1, 3]); |
|
|
}); |
|
|
}); |
|
|
|
|
|
describe('Visual Layout & Viewport', () => { |
|
|
it('should wrap long lines correctly into visualLines', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ |
|
|
initialText: 'This is a very long line of text.', |
|
|
viewport: { width: 10, height: 5 }, |
|
|
isValidPath: () => false, |
|
|
}), |
|
|
); |
|
|
const state = getBufferState(result); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
expect(state.allVisualLines.length).toBe(4); |
|
|
expect(state.allVisualLines[0]).toBe('This is a'); |
|
|
expect(state.allVisualLines[1]).toBe('very long'); |
|
|
expect(state.allVisualLines[2]).toBe('line of'); |
|
|
expect(state.allVisualLines[3]).toBe('text.'); |
|
|
}); |
|
|
|
|
|
it('should update visualScrollRow when visualCursor moves out of viewport', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ |
|
|
initialText: 'l1\nl2\nl3\nl4\nl5', |
|
|
viewport: { width: 5, height: 3 }, |
|
|
isValidPath: () => false, |
|
|
}), |
|
|
); |
|
|
|
|
|
expect(getBufferState(result).visualScrollRow).toBe(0); |
|
|
expect(getBufferState(result).allVisualLines).toEqual([ |
|
|
'l1', |
|
|
'l2', |
|
|
'l3', |
|
|
'l4', |
|
|
'l5', |
|
|
]); |
|
|
expect(getBufferState(result).viewportVisualLines).toEqual([ |
|
|
'l1', |
|
|
'l2', |
|
|
'l3', |
|
|
]); |
|
|
|
|
|
act(() => result.current.move('down')); |
|
|
act(() => result.current.move('down')); |
|
|
expect(getBufferState(result).visualScrollRow).toBe(0); |
|
|
|
|
|
act(() => result.current.move('down')); |
|
|
|
|
|
let state = getBufferState(result); |
|
|
expect(state.visualScrollRow).toBe(1); |
|
|
expect(state.allVisualLines).toEqual(['l1', 'l2', 'l3', 'l4', 'l5']); |
|
|
expect(state.viewportVisualLines).toEqual(['l2', 'l3', 'l4']); |
|
|
expect(state.visualCursor).toEqual([3, 0]); |
|
|
|
|
|
act(() => result.current.move('up')); |
|
|
act(() => result.current.move('up')); |
|
|
expect(getBufferState(result).visualScrollRow).toBe(1); |
|
|
|
|
|
act(() => result.current.move('up')); |
|
|
|
|
|
state = getBufferState(result); |
|
|
expect(state.visualScrollRow).toBe(0); |
|
|
expect(state.allVisualLines).toEqual(['l1', 'l2', 'l3', 'l4', 'l5']); |
|
|
expect(state.viewportVisualLines).toEqual(['l1', 'l2', 'l3']); |
|
|
expect(state.visualCursor).toEqual([0, 0]); |
|
|
}); |
|
|
}); |
|
|
|
|
|
describe('Undo/Redo', () => { |
|
|
it('should undo and redo an insert operation', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ viewport, isValidPath: () => false }), |
|
|
); |
|
|
act(() => result.current.insert('a')); |
|
|
expect(getBufferState(result).text).toBe('a'); |
|
|
|
|
|
act(() => result.current.undo()); |
|
|
expect(getBufferState(result).text).toBe(''); |
|
|
expect(getBufferState(result).cursor).toEqual([0, 0]); |
|
|
|
|
|
act(() => result.current.redo()); |
|
|
expect(getBufferState(result).text).toBe('a'); |
|
|
expect(getBufferState(result).cursor).toEqual([0, 1]); |
|
|
}); |
|
|
|
|
|
it('should undo and redo a newline operation', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ |
|
|
initialText: 'test', |
|
|
viewport, |
|
|
isValidPath: () => false, |
|
|
}), |
|
|
); |
|
|
act(() => result.current.move('end')); |
|
|
act(() => result.current.newline()); |
|
|
expect(getBufferState(result).text).toBe('test\n'); |
|
|
|
|
|
act(() => result.current.undo()); |
|
|
expect(getBufferState(result).text).toBe('test'); |
|
|
expect(getBufferState(result).cursor).toEqual([0, 4]); |
|
|
|
|
|
act(() => result.current.redo()); |
|
|
expect(getBufferState(result).text).toBe('test\n'); |
|
|
expect(getBufferState(result).cursor).toEqual([1, 0]); |
|
|
}); |
|
|
}); |
|
|
|
|
|
describe('Unicode Handling', () => { |
|
|
it('insert: should correctly handle multi-byte unicode characters', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ viewport, isValidPath: () => false }), |
|
|
); |
|
|
act(() => result.current.insert('你好')); |
|
|
const state = getBufferState(result); |
|
|
expect(state.text).toBe('你好'); |
|
|
expect(state.cursor).toEqual([0, 2]); |
|
|
expect(state.visualCursor).toEqual([0, 2]); |
|
|
}); |
|
|
|
|
|
it('backspace: should correctly delete multi-byte unicode characters', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ |
|
|
initialText: '你好', |
|
|
viewport, |
|
|
isValidPath: () => false, |
|
|
}), |
|
|
); |
|
|
act(() => result.current.move('end')); |
|
|
act(() => result.current.backspace()); |
|
|
let state = getBufferState(result); |
|
|
expect(state.text).toBe('你'); |
|
|
expect(state.cursor).toEqual([0, 1]); |
|
|
|
|
|
act(() => result.current.backspace()); |
|
|
state = getBufferState(result); |
|
|
expect(state.text).toBe(''); |
|
|
expect(state.cursor).toEqual([0, 0]); |
|
|
}); |
|
|
|
|
|
it('move: left/right should treat multi-byte chars as single units for visual cursor', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ |
|
|
initialText: '🐶🐱', |
|
|
viewport: { width: 5, height: 1 }, |
|
|
isValidPath: () => false, |
|
|
}), |
|
|
); |
|
|
|
|
|
act(() => result.current.move('right')); |
|
|
let state = getBufferState(result); |
|
|
expect(state.cursor).toEqual([0, 1]); |
|
|
expect(state.visualCursor).toEqual([0, 1]); |
|
|
|
|
|
act(() => result.current.move('right')); |
|
|
state = getBufferState(result); |
|
|
expect(state.cursor).toEqual([0, 2]); |
|
|
expect(state.visualCursor).toEqual([0, 2]); |
|
|
|
|
|
act(() => result.current.move('left')); |
|
|
state = getBufferState(result); |
|
|
expect(state.cursor).toEqual([0, 1]); |
|
|
expect(state.visualCursor).toEqual([0, 1]); |
|
|
}); |
|
|
}); |
|
|
|
|
|
describe('handleInput', () => { |
|
|
it('should insert printable characters', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ viewport, isValidPath: () => false }), |
|
|
); |
|
|
act(() => |
|
|
result.current.handleInput({ |
|
|
name: 'h', |
|
|
ctrl: false, |
|
|
meta: false, |
|
|
shift: false, |
|
|
sequence: 'h', |
|
|
}), |
|
|
); |
|
|
act(() => |
|
|
result.current.handleInput({ |
|
|
name: 'i', |
|
|
ctrl: false, |
|
|
meta: false, |
|
|
shift: false, |
|
|
sequence: 'i', |
|
|
}), |
|
|
); |
|
|
expect(getBufferState(result).text).toBe('hi'); |
|
|
}); |
|
|
|
|
|
it('should handle "Enter" key as newline', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ viewport, isValidPath: () => false }), |
|
|
); |
|
|
act(() => |
|
|
result.current.handleInput({ |
|
|
name: 'return', |
|
|
ctrl: false, |
|
|
meta: false, |
|
|
shift: false, |
|
|
sequence: '\r', |
|
|
}), |
|
|
); |
|
|
expect(getBufferState(result).lines).toEqual(['', '']); |
|
|
}); |
|
|
|
|
|
it('should handle "Backspace" key', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ |
|
|
initialText: 'a', |
|
|
viewport, |
|
|
isValidPath: () => false, |
|
|
}), |
|
|
); |
|
|
act(() => result.current.move('end')); |
|
|
act(() => |
|
|
result.current.handleInput({ |
|
|
name: 'backspace', |
|
|
ctrl: false, |
|
|
meta: false, |
|
|
shift: false, |
|
|
sequence: '\x7f', |
|
|
}), |
|
|
); |
|
|
expect(getBufferState(result).text).toBe(''); |
|
|
}); |
|
|
|
|
|
it('should handle multiple delete characters in one input', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ |
|
|
initialText: 'abcde', |
|
|
viewport, |
|
|
isValidPath: () => false, |
|
|
}), |
|
|
); |
|
|
act(() => result.current.move('end')); |
|
|
expect(getBufferState(result).cursor).toEqual([0, 5]); |
|
|
|
|
|
act(() => { |
|
|
result.current.applyOperations([ |
|
|
{ type: 'backspace' }, |
|
|
{ type: 'backspace' }, |
|
|
{ type: 'backspace' }, |
|
|
]); |
|
|
}); |
|
|
expect(getBufferState(result).text).toBe('ab'); |
|
|
expect(getBufferState(result).cursor).toEqual([0, 2]); |
|
|
}); |
|
|
|
|
|
it('should handle inserts that contain delete characters ', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ |
|
|
initialText: 'abcde', |
|
|
viewport, |
|
|
isValidPath: () => false, |
|
|
}), |
|
|
); |
|
|
act(() => result.current.move('end')); |
|
|
expect(getBufferState(result).cursor).toEqual([0, 5]); |
|
|
|
|
|
act(() => { |
|
|
result.current.applyOperations([ |
|
|
{ type: 'insert', payload: '\x7f\x7f\x7f' }, |
|
|
]); |
|
|
}); |
|
|
expect(getBufferState(result).text).toBe('ab'); |
|
|
expect(getBufferState(result).cursor).toEqual([0, 2]); |
|
|
}); |
|
|
|
|
|
it('should handle inserts with a mix of regular and delete characters ', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ |
|
|
initialText: 'abcde', |
|
|
viewport, |
|
|
isValidPath: () => false, |
|
|
}), |
|
|
); |
|
|
act(() => result.current.move('end')); |
|
|
expect(getBufferState(result).cursor).toEqual([0, 5]); |
|
|
|
|
|
act(() => { |
|
|
result.current.applyOperations([ |
|
|
{ type: 'insert', payload: '\x7fI\x7f\x7fNEW' }, |
|
|
]); |
|
|
}); |
|
|
expect(getBufferState(result).text).toBe('abcNEW'); |
|
|
expect(getBufferState(result).cursor).toEqual([0, 6]); |
|
|
}); |
|
|
|
|
|
it('should handle arrow keys for movement', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ |
|
|
initialText: 'ab', |
|
|
viewport, |
|
|
isValidPath: () => false, |
|
|
}), |
|
|
); |
|
|
act(() => result.current.move('end')); |
|
|
act(() => |
|
|
result.current.handleInput({ |
|
|
name: 'left', |
|
|
ctrl: false, |
|
|
meta: false, |
|
|
shift: false, |
|
|
sequence: '\x1b[D', |
|
|
}), |
|
|
); |
|
|
expect(getBufferState(result).cursor).toEqual([0, 1]); |
|
|
act(() => |
|
|
result.current.handleInput({ |
|
|
name: 'right', |
|
|
ctrl: false, |
|
|
meta: false, |
|
|
shift: false, |
|
|
sequence: '\x1b[C', |
|
|
}), |
|
|
); |
|
|
expect(getBufferState(result).cursor).toEqual([0, 2]); |
|
|
}); |
|
|
|
|
|
it('should strip ANSI escape codes when pasting text', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ viewport, isValidPath: () => false }), |
|
|
); |
|
|
const textWithAnsi = '\x1B[31mHello\x1B[0m \x1B[32mWorld\x1B[0m'; |
|
|
|
|
|
act(() => |
|
|
result.current.handleInput({ |
|
|
name: undefined, |
|
|
ctrl: false, |
|
|
meta: false, |
|
|
shift: false, |
|
|
sequence: textWithAnsi, |
|
|
}), |
|
|
); |
|
|
expect(getBufferState(result).text).toBe('Hello World'); |
|
|
}); |
|
|
|
|
|
it('should handle VSCode terminal Shift+Enter as newline', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ viewport, isValidPath: () => false }), |
|
|
); |
|
|
act(() => |
|
|
result.current.handleInput({ |
|
|
name: 'return', |
|
|
ctrl: false, |
|
|
meta: false, |
|
|
shift: true, |
|
|
sequence: '\r', |
|
|
}), |
|
|
); |
|
|
expect(getBufferState(result).lines).toEqual(['', '']); |
|
|
}); |
|
|
|
|
|
it('should correctly handle repeated pasting of long text', () => { |
|
|
const longText = `not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum. |
|
|
|
|
|
Why do we use it? |
|
|
It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout. The point of using Lorem Ipsum is that it has a more-or-less normal distribution of letters, as opposed to using 'Content here, content here', making it look like readable English. Many desktop publishing packages and web page editors now use Lorem Ipsum as their default model text, and a search for 'lorem ipsum' will uncover many web sites still in their infancy. Various versions have evolved over the years, sometimes by accident, sometimes on purpose (injected humour and the like). |
|
|
|
|
|
Where does it come from? |
|
|
Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words, consectetur, from a Lore |
|
|
`; |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ viewport, isValidPath: () => false }), |
|
|
); |
|
|
|
|
|
|
|
|
act(() => { |
|
|
result.current.applyOperations([ |
|
|
{ type: 'insert', payload: longText }, |
|
|
{ type: 'insert', payload: longText }, |
|
|
{ type: 'insert', payload: longText }, |
|
|
]); |
|
|
}); |
|
|
|
|
|
const state = getBufferState(result); |
|
|
|
|
|
expect(state.lines).toStrictEqual( |
|
|
(longText + longText + longText).split('\n'), |
|
|
); |
|
|
const expectedCursorPos = offsetToLogicalPos( |
|
|
state.text, |
|
|
state.text.length, |
|
|
); |
|
|
expect(state.cursor).toEqual(expectedCursorPos); |
|
|
}); |
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
describe('replaceRange', () => { |
|
|
it('should replace a single-line range with single-line text', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ |
|
|
initialText: '@pac', |
|
|
viewport, |
|
|
isValidPath: () => false, |
|
|
}), |
|
|
); |
|
|
act(() => result.current.replaceRange(0, 1, 0, 4, 'packages')); |
|
|
const state = getBufferState(result); |
|
|
expect(state.text).toBe('@packages'); |
|
|
expect(state.cursor).toEqual([0, 9]); |
|
|
}); |
|
|
|
|
|
it('should replace a multi-line range with single-line text', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ |
|
|
initialText: 'hello\nworld\nagain', |
|
|
viewport, |
|
|
isValidPath: () => false, |
|
|
}), |
|
|
); |
|
|
act(() => result.current.replaceRange(0, 2, 1, 3, ' new ')); |
|
|
const state = getBufferState(result); |
|
|
expect(state.text).toBe('he new ld\nagain'); |
|
|
expect(state.cursor).toEqual([0, 7]); |
|
|
}); |
|
|
|
|
|
it('should delete a range when replacing with an empty string', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ |
|
|
initialText: 'hello world', |
|
|
viewport, |
|
|
isValidPath: () => false, |
|
|
}), |
|
|
); |
|
|
act(() => result.current.replaceRange(0, 5, 0, 11, '')); |
|
|
const state = getBufferState(result); |
|
|
expect(state.text).toBe('hello'); |
|
|
expect(state.cursor).toEqual([0, 5]); |
|
|
}); |
|
|
|
|
|
it('should handle replacing at the beginning of the text', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ |
|
|
initialText: 'world', |
|
|
viewport, |
|
|
isValidPath: () => false, |
|
|
}), |
|
|
); |
|
|
act(() => result.current.replaceRange(0, 0, 0, 0, 'hello ')); |
|
|
const state = getBufferState(result); |
|
|
expect(state.text).toBe('hello world'); |
|
|
expect(state.cursor).toEqual([0, 6]); |
|
|
}); |
|
|
|
|
|
it('should handle replacing at the end of the text', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ |
|
|
initialText: 'hello', |
|
|
viewport, |
|
|
isValidPath: () => false, |
|
|
}), |
|
|
); |
|
|
act(() => result.current.replaceRange(0, 5, 0, 5, ' world')); |
|
|
const state = getBufferState(result); |
|
|
expect(state.text).toBe('hello world'); |
|
|
expect(state.cursor).toEqual([0, 11]); |
|
|
}); |
|
|
|
|
|
it('should handle replacing the entire buffer content', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ |
|
|
initialText: 'old text', |
|
|
viewport, |
|
|
isValidPath: () => false, |
|
|
}), |
|
|
); |
|
|
act(() => result.current.replaceRange(0, 0, 0, 8, 'new text')); |
|
|
const state = getBufferState(result); |
|
|
expect(state.text).toBe('new text'); |
|
|
expect(state.cursor).toEqual([0, 8]); |
|
|
}); |
|
|
|
|
|
it('should correctly replace with unicode characters', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ |
|
|
initialText: 'hello *** world', |
|
|
viewport, |
|
|
isValidPath: () => false, |
|
|
}), |
|
|
); |
|
|
act(() => result.current.replaceRange(0, 6, 0, 9, '你好')); |
|
|
const state = getBufferState(result); |
|
|
expect(state.text).toBe('hello 你好 world'); |
|
|
expect(state.cursor).toEqual([0, 8]); |
|
|
}); |
|
|
|
|
|
it('should handle invalid range by returning false and not changing text', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ |
|
|
initialText: 'test', |
|
|
viewport, |
|
|
isValidPath: () => false, |
|
|
}), |
|
|
); |
|
|
let success = true; |
|
|
act(() => { |
|
|
success = result.current.replaceRange(0, 5, 0, 3, 'fail'); |
|
|
}); |
|
|
expect(success).toBe(false); |
|
|
expect(getBufferState(result).text).toBe('test'); |
|
|
|
|
|
act(() => { |
|
|
success = result.current.replaceRange(1, 0, 0, 0, 'fail'); |
|
|
}); |
|
|
expect(success).toBe(false); |
|
|
expect(getBufferState(result).text).toBe('test'); |
|
|
}); |
|
|
|
|
|
it('replaceRange: multiple lines with a single character', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ |
|
|
initialText: 'first\nsecond\nthird', |
|
|
viewport, |
|
|
isValidPath: () => false, |
|
|
}), |
|
|
); |
|
|
act(() => result.current.replaceRange(0, 2, 2, 3, 'X')); |
|
|
const state = getBufferState(result); |
|
|
expect(state.text).toBe('fiXrd'); |
|
|
expect(state.cursor).toEqual([0, 3]); |
|
|
}); |
|
|
}); |
|
|
|
|
|
describe('Input Sanitization', () => { |
|
|
it('should strip ANSI escape codes from input', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ viewport, isValidPath: () => false }), |
|
|
); |
|
|
const textWithAnsi = '\x1B[31mHello\x1B[0m'; |
|
|
act(() => |
|
|
result.current.handleInput({ |
|
|
name: undefined, |
|
|
ctrl: false, |
|
|
meta: false, |
|
|
shift: false, |
|
|
sequence: textWithAnsi, |
|
|
}), |
|
|
); |
|
|
expect(getBufferState(result).text).toBe('Hello'); |
|
|
}); |
|
|
|
|
|
it('should strip control characters from input', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ viewport, isValidPath: () => false }), |
|
|
); |
|
|
const textWithControlChars = 'H\x07e\x08l\x0Bl\x0Co'; |
|
|
act(() => |
|
|
result.current.handleInput({ |
|
|
name: undefined, |
|
|
ctrl: false, |
|
|
meta: false, |
|
|
shift: false, |
|
|
sequence: textWithControlChars, |
|
|
}), |
|
|
); |
|
|
expect(getBufferState(result).text).toBe('Hello'); |
|
|
}); |
|
|
|
|
|
it('should strip mixed ANSI and control characters from input', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ viewport, isValidPath: () => false }), |
|
|
); |
|
|
const textWithMixed = '\u001B[4mH\u001B[0mello'; |
|
|
act(() => |
|
|
result.current.handleInput({ |
|
|
name: undefined, |
|
|
ctrl: false, |
|
|
meta: false, |
|
|
shift: false, |
|
|
sequence: textWithMixed, |
|
|
}), |
|
|
); |
|
|
expect(getBufferState(result).text).toBe('Hello'); |
|
|
}); |
|
|
|
|
|
it('should not strip standard characters or newlines', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ viewport, isValidPath: () => false }), |
|
|
); |
|
|
const validText = 'Hello World\nThis is a test.'; |
|
|
act(() => |
|
|
result.current.handleInput({ |
|
|
name: undefined, |
|
|
ctrl: false, |
|
|
meta: false, |
|
|
shift: false, |
|
|
sequence: validText, |
|
|
}), |
|
|
); |
|
|
expect(getBufferState(result).text).toBe(validText); |
|
|
}); |
|
|
|
|
|
it('should sanitize pasted text via handleInput', () => { |
|
|
const { result } = renderHook(() => |
|
|
useTextBuffer({ viewport, isValidPath: () => false }), |
|
|
); |
|
|
const pastedText = '\u001B[4mPasted\u001B[4m Text'; |
|
|
act(() => |
|
|
result.current.handleInput({ |
|
|
name: undefined, |
|
|
ctrl: false, |
|
|
meta: false, |
|
|
shift: false, |
|
|
sequence: pastedText, |
|
|
}), |
|
|
); |
|
|
expect(getBufferState(result).text).toBe('Pasted Text'); |
|
|
}); |
|
|
}); |
|
|
}); |
|
|
|
|
|
describe('offsetToLogicalPos', () => { |
|
|
it('should return [0,0] for offset 0', () => { |
|
|
expect(offsetToLogicalPos('any text', 0)).toEqual([0, 0]); |
|
|
}); |
|
|
|
|
|
it('should handle single line text', () => { |
|
|
const text = 'hello'; |
|
|
expect(offsetToLogicalPos(text, 0)).toEqual([0, 0]); |
|
|
expect(offsetToLogicalPos(text, 2)).toEqual([0, 2]); |
|
|
expect(offsetToLogicalPos(text, 5)).toEqual([0, 5]); |
|
|
expect(offsetToLogicalPos(text, 10)).toEqual([0, 5]); |
|
|
}); |
|
|
|
|
|
it('should handle multi-line text', () => { |
|
|
const text = 'hello\nworld\n123'; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
expect(offsetToLogicalPos(text, 0)).toEqual([0, 0]); |
|
|
expect(offsetToLogicalPos(text, 3)).toEqual([0, 3]); |
|
|
expect(offsetToLogicalPos(text, 5)).toEqual([0, 5]); |
|
|
|
|
|
|
|
|
expect(offsetToLogicalPos(text, 6)).toEqual([1, 0]); |
|
|
expect(offsetToLogicalPos(text, 8)).toEqual([1, 2]); |
|
|
expect(offsetToLogicalPos(text, 11)).toEqual([1, 5]); |
|
|
|
|
|
|
|
|
expect(offsetToLogicalPos(text, 12)).toEqual([2, 0]); |
|
|
expect(offsetToLogicalPos(text, 13)).toEqual([2, 1]); |
|
|
expect(offsetToLogicalPos(text, 15)).toEqual([2, 3]); |
|
|
expect(offsetToLogicalPos(text, 20)).toEqual([2, 3]); |
|
|
}); |
|
|
|
|
|
it('should handle empty lines', () => { |
|
|
const text = 'a\n\nc'; |
|
|
expect(offsetToLogicalPos(text, 0)).toEqual([0, 0]); |
|
|
expect(offsetToLogicalPos(text, 1)).toEqual([0, 1]); |
|
|
expect(offsetToLogicalPos(text, 2)).toEqual([1, 0]); |
|
|
expect(offsetToLogicalPos(text, 3)).toEqual([2, 0]); |
|
|
expect(offsetToLogicalPos(text, 4)).toEqual([2, 1]); |
|
|
}); |
|
|
|
|
|
it('should handle text ending with a newline', () => { |
|
|
const text = 'hello\n'; |
|
|
expect(offsetToLogicalPos(text, 5)).toEqual([0, 5]); |
|
|
expect(offsetToLogicalPos(text, 6)).toEqual([1, 0]); |
|
|
|
|
|
expect(offsetToLogicalPos(text, 7)).toEqual([1, 0]); |
|
|
}); |
|
|
|
|
|
it('should handle text starting with a newline', () => { |
|
|
const text = '\nhello'; |
|
|
expect(offsetToLogicalPos(text, 0)).toEqual([0, 0]); |
|
|
expect(offsetToLogicalPos(text, 1)).toEqual([1, 0]); |
|
|
expect(offsetToLogicalPos(text, 3)).toEqual([1, 2]); |
|
|
}); |
|
|
|
|
|
it('should handle empty string input', () => { |
|
|
expect(offsetToLogicalPos('', 0)).toEqual([0, 0]); |
|
|
expect(offsetToLogicalPos('', 5)).toEqual([0, 0]); |
|
|
}); |
|
|
|
|
|
it('should handle multi-byte unicode characters correctly', () => { |
|
|
const text = '你好\n世界'; |
|
|
|
|
|
expect(offsetToLogicalPos(text, 0)).toEqual([0, 0]); |
|
|
expect(offsetToLogicalPos(text, 1)).toEqual([0, 1]); |
|
|
expect(offsetToLogicalPos(text, 2)).toEqual([0, 2]); |
|
|
expect(offsetToLogicalPos(text, 3)).toEqual([1, 0]); |
|
|
expect(offsetToLogicalPos(text, 4)).toEqual([1, 1]); |
|
|
expect(offsetToLogicalPos(text, 5)).toEqual([1, 2]); |
|
|
expect(offsetToLogicalPos(text, 6)).toEqual([1, 2]); |
|
|
}); |
|
|
|
|
|
it('should handle offset exactly at newline character', () => { |
|
|
const text = 'abc\ndef'; |
|
|
|
|
|
|
|
|
expect(offsetToLogicalPos(text, 3)).toEqual([0, 3]); |
|
|
|
|
|
expect(offsetToLogicalPos(text, 4)).toEqual([1, 0]); |
|
|
}); |
|
|
|
|
|
it('should handle offset in the middle of a multi-byte character (should place at start of that char)', () => { |
|
|
|
|
|
|
|
|
|
|
|
const text = '🐶🐱'; |
|
|
expect(offsetToLogicalPos(text, 0)).toEqual([0, 0]); |
|
|
expect(offsetToLogicalPos(text, 1)).toEqual([0, 1]); |
|
|
expect(offsetToLogicalPos(text, 2)).toEqual([0, 2]); |
|
|
}); |
|
|
}); |
|
|
|