2025-05-16 11:58:37 -07:00
|
|
|
/**
|
|
|
|
|
* @license
|
|
|
|
|
* Copyright 2025 Google LLC
|
|
|
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
|
|
|
import { renderHook, act } from '@testing-library/react';
|
|
|
|
|
import { useTextBuffer, Viewport, TextBuffer } from './text-buffer.js';
|
|
|
|
|
|
|
|
|
|
// Helper to get the state from the hook
|
|
|
|
|
const getBufferState = (result: { current: TextBuffer }) => ({
|
|
|
|
|
text: result.current.text,
|
|
|
|
|
lines: [...result.current.lines], // Clone for safety
|
|
|
|
|
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 }; // Default viewport for tests
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('Initialization', () => {
|
|
|
|
|
it('should initialize with empty text and cursor at (0,0) by default', () => {
|
|
|
|
|
const { result } = renderHook(() => useTextBuffer({ viewport }));
|
|
|
|
|
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 }),
|
|
|
|
|
);
|
|
|
|
|
const state = getBufferState(result);
|
|
|
|
|
expect(state.text).toBe('hello');
|
|
|
|
|
expect(state.lines).toEqual(['hello']);
|
|
|
|
|
expect(state.cursor).toEqual([0, 0]); // Default cursor if offset not given
|
|
|
|
|
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, // Should be at 'o' in 'world'
|
|
|
|
|
viewport,
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
const state = getBufferState(result);
|
|
|
|
|
expect(state.text).toBe('hello\nworld');
|
|
|
|
|
expect(state.lines).toEqual(['hello', 'world']);
|
|
|
|
|
expect(state.cursor).toEqual([1, 1]); // Logical cursor at 'o' in "world"
|
|
|
|
|
expect(state.allVisualLines).toEqual(['hello', 'world']);
|
|
|
|
|
expect(state.viewportVisualLines).toEqual(['hello', 'world']);
|
|
|
|
|
expect(state.visualCursor[0]).toBe(1); // On the second visual line
|
|
|
|
|
expect(state.visualCursor[1]).toBe(1); // At 'o' in "world"
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should wrap visual lines', () => {
|
|
|
|
|
const { result } = renderHook(() =>
|
|
|
|
|
useTextBuffer({
|
|
|
|
|
initialText: 'The quick brown fox jumps over the lazy dog.',
|
|
|
|
|
initialCursorOffset: 2, // After '好'
|
|
|
|
|
viewport: { width: 15, height: 4 },
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
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 },
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
const state = getBufferState(result);
|
|
|
|
|
// Including multiple spaces at the end of the lines like this is
|
|
|
|
|
// consistent with Google docs behavior and makes it intuitive to edit
|
|
|
|
|
// the spaces as needed.
|
|
|
|
|
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', // 4 chars, 12 bytes
|
|
|
|
|
viewport: { width: 15, height: 2 },
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
const state = getBufferState(result);
|
|
|
|
|
// Including multiple spaces at the end of the lines like this is
|
|
|
|
|
// consistent with Google docs behavior and makes it intuitive to edit
|
|
|
|
|
// the spaces as needed.
|
|
|
|
|
expect(state.allVisualLines).toEqual(['123456789012345', 'ABCDEFG']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should initialize with multi-byte unicode characters and correct cursor offset', () => {
|
|
|
|
|
const { result } = renderHook(() =>
|
|
|
|
|
useTextBuffer({
|
|
|
|
|
initialText: '你好世界', // 4 chars, 12 bytes
|
|
|
|
|
initialCursorOffset: 2, // After '好'
|
|
|
|
|
viewport: { width: 5, height: 2 },
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
const state = getBufferState(result);
|
|
|
|
|
expect(state.text).toBe('你好世界');
|
|
|
|
|
expect(state.lines).toEqual(['你好世界']);
|
|
|
|
|
expect(state.cursor).toEqual([0, 2]);
|
|
|
|
|
// Visual: "你好" (width 4), "世"界" (width 4) with viewport width 5
|
|
|
|
|
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 }));
|
|
|
|
|
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('newline: should create a new line and move cursor', () => {
|
|
|
|
|
const { result } = renderHook(() =>
|
|
|
|
|
useTextBuffer({ initialText: 'ab', viewport }),
|
|
|
|
|
);
|
|
|
|
|
act(() => result.current.move('end')); // cursor at [0,2]
|
|
|
|
|
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', '']); // viewport height 3
|
|
|
|
|
expect(state.visualCursor).toEqual([1, 0]); // On the new visual line
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('backspace: should delete char to the left or merge lines', () => {
|
|
|
|
|
const { result } = renderHook(() =>
|
|
|
|
|
useTextBuffer({ initialText: 'a\nb', viewport }),
|
|
|
|
|
);
|
|
|
|
|
act(() => {
|
|
|
|
|
result.current.move('down');
|
|
|
|
|
});
|
|
|
|
|
act(() => {
|
|
|
|
|
result.current.move('end'); // cursor to [1,1] (end of 'b')
|
|
|
|
|
});
|
|
|
|
|
act(() => result.current.backspace()); // delete 'b'
|
|
|
|
|
let state = getBufferState(result);
|
|
|
|
|
expect(state.text).toBe('a\n');
|
|
|
|
|
expect(state.cursor).toEqual([1, 0]);
|
|
|
|
|
|
|
|
|
|
act(() => result.current.backspace()); // merge lines
|
|
|
|
|
state = getBufferState(result);
|
|
|
|
|
expect(state.text).toBe('a');
|
|
|
|
|
expect(state.cursor).toEqual([0, 1]); // cursor after 'a'
|
|
|
|
|
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 }),
|
|
|
|
|
);
|
|
|
|
|
// cursor at [0,0]
|
|
|
|
|
act(() => result.current.del()); // delete 'a'
|
|
|
|
|
let state = getBufferState(result);
|
|
|
|
|
expect(state.text).toBe('\nb');
|
|
|
|
|
expect(state.cursor).toEqual([0, 0]);
|
|
|
|
|
|
|
|
|
|
act(() => result.current.del()); // merge lines (deletes newline)
|
|
|
|
|
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('Cursor Movement', () => {
|
|
|
|
|
it('move: left/right should work within and across visual lines (due to wrapping)', () => {
|
|
|
|
|
// Text: "long line1next line2" (20 chars)
|
|
|
|
|
// Viewport width 5. Word wrapping should produce:
|
|
|
|
|
// "long " (5)
|
|
|
|
|
// "line1" (5)
|
|
|
|
|
// "next " (5)
|
|
|
|
|
// "line2" (5)
|
|
|
|
|
const { result } = renderHook(() =>
|
|
|
|
|
useTextBuffer({
|
|
|
|
|
initialText: 'long line1next line2', // Corrected: was 'long line1next line2'
|
|
|
|
|
viewport: { width: 5, height: 4 },
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
// Initial cursor [0,0] logical, visual [0,0] ("l" of "long ")
|
|
|
|
|
|
|
|
|
|
act(() => result.current.move('right')); // visual [0,1] ("o")
|
|
|
|
|
expect(getBufferState(result).visualCursor).toEqual([0, 1]);
|
|
|
|
|
act(() => result.current.move('right')); // visual [0,2] ("n")
|
|
|
|
|
act(() => result.current.move('right')); // visual [0,3] ("g")
|
|
|
|
|
act(() => result.current.move('right')); // visual [0,4] (" ")
|
|
|
|
|
expect(getBufferState(result).visualCursor).toEqual([0, 4]);
|
|
|
|
|
|
|
|
|
|
act(() => result.current.move('right')); // visual [1,0] ("l" of "line1")
|
|
|
|
|
expect(getBufferState(result).visualCursor).toEqual([1, 0]);
|
|
|
|
|
expect(getBufferState(result).cursor).toEqual([0, 5]); // logical cursor
|
|
|
|
|
|
|
|
|
|
act(() => result.current.move('left')); // visual [0,4] (" " of "long ")
|
|
|
|
|
expect(getBufferState(result).visualCursor).toEqual([0, 4]);
|
|
|
|
|
expect(getBufferState(result).cursor).toEqual([0, 4]); // logical cursor
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('move: up/down should preserve preferred visual column', () => {
|
|
|
|
|
const text = 'abcde\nxy\n12345';
|
|
|
|
|
const { result } = renderHook(() =>
|
|
|
|
|
useTextBuffer({ initialText: text, viewport }),
|
|
|
|
|
);
|
|
|
|
|
expect(result.current.allVisualLines).toEqual(['abcde', 'xy', '12345']);
|
|
|
|
|
// Place cursor at the end of "abcde" -> logical [0,5]
|
|
|
|
|
act(() => {
|
|
|
|
|
result.current.move('home'); // to [0,0]
|
|
|
|
|
});
|
|
|
|
|
for (let i = 0; i < 5; i++) {
|
|
|
|
|
act(() => {
|
|
|
|
|
result.current.move('right'); // to [0,5]
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
expect(getBufferState(result).cursor).toEqual([0, 5]);
|
|
|
|
|
expect(getBufferState(result).visualCursor).toEqual([0, 5]);
|
|
|
|
|
|
|
|
|
|
// Set preferredCol by moving up then down to the same spot, then test.
|
|
|
|
|
act(() => {
|
|
|
|
|
result.current.move('down'); // to xy, logical [1,2], visual [1,2], preferredCol should be 5
|
|
|
|
|
});
|
|
|
|
|
let state = getBufferState(result);
|
|
|
|
|
expect(state.cursor).toEqual([1, 2]); // Logical cursor at end of 'xy'
|
|
|
|
|
expect(state.visualCursor).toEqual([1, 2]); // Visual cursor at end of 'xy'
|
|
|
|
|
expect(state.preferredCol).toBe(5);
|
|
|
|
|
|
|
|
|
|
act(() => result.current.move('down')); // to '12345', preferredCol=5.
|
|
|
|
|
state = getBufferState(result);
|
|
|
|
|
expect(state.cursor).toEqual([2, 5]); // Logical cursor at end of '12345'
|
|
|
|
|
expect(state.visualCursor).toEqual([2, 5]); // Visual cursor at end of '12345'
|
|
|
|
|
expect(state.preferredCol).toBe(5); // Preferred col is maintained
|
|
|
|
|
|
|
|
|
|
act(() => result.current.move('left')); // preferredCol should reset
|
|
|
|
|
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 } }),
|
|
|
|
|
);
|
|
|
|
|
expect(result.current.allVisualLines).toEqual([
|
|
|
|
|
'line',
|
|
|
|
|
'one',
|
|
|
|
|
'secon',
|
|
|
|
|
'd',
|
|
|
|
|
'line',
|
|
|
|
|
]);
|
|
|
|
|
// Initial cursor [0,0] (start of "line")
|
|
|
|
|
act(() => result.current.move('down')); // visual cursor from [0,0] to [1,0] ("o" of "one")
|
|
|
|
|
act(() => result.current.move('right')); // visual cursor to [1,1] ("n" of "one")
|
|
|
|
|
expect(getBufferState(result).visualCursor).toEqual([1, 1]);
|
|
|
|
|
|
|
|
|
|
act(() => result.current.move('home')); // visual cursor to [1,0] (start of "one")
|
|
|
|
|
expect(getBufferState(result).visualCursor).toEqual([1, 0]);
|
|
|
|
|
|
|
|
|
|
act(() => result.current.move('end')); // visual cursor to [1,3] (end of "one")
|
|
|
|
|
expect(getBufferState(result).visualCursor).toEqual([1, 3]); // "one" is 3 chars
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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.', // 33 chars
|
|
|
|
|
viewport: { width: 10, height: 5 },
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
const state = getBufferState(result);
|
|
|
|
|
// Expected visual lines with word wrapping (viewport width 10):
|
|
|
|
|
// "This is a"
|
|
|
|
|
// "very long"
|
|
|
|
|
// "line of"
|
|
|
|
|
// "text."
|
|
|
|
|
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 }, // Can show 3 visual lines
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
// Initial: l1, l2, l3 visible. visualScrollRow = 0. visualCursor = [0,0]
|
|
|
|
|
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')); // vc=[1,0]
|
|
|
|
|
act(() => result.current.move('down')); // vc=[2,0] (l3)
|
|
|
|
|
expect(getBufferState(result).visualScrollRow).toBe(0);
|
|
|
|
|
|
|
|
|
|
act(() => result.current.move('down')); // vc=[3,0] (l4) - scroll should happen
|
|
|
|
|
// Now: l2, l3, l4 visible. visualScrollRow = 1.
|
|
|
|
|
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')); // vc=[2,0] (l3)
|
|
|
|
|
act(() => result.current.move('up')); // vc=[1,0] (l2)
|
|
|
|
|
expect(getBufferState(result).visualScrollRow).toBe(1);
|
|
|
|
|
|
|
|
|
|
act(() => result.current.move('up')); // vc=[0,0] (l1) - scroll up
|
|
|
|
|
// Now: l1, l2, l3 visible. visualScrollRow = 0
|
|
|
|
|
state = getBufferState(result); // Assign to the existing `state` variable
|
|
|
|
|
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 }));
|
|
|
|
|
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 }),
|
|
|
|
|
);
|
|
|
|
|
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 }));
|
|
|
|
|
act(() => result.current.insert('你好'));
|
|
|
|
|
const state = getBufferState(result);
|
|
|
|
|
expect(state.text).toBe('你好');
|
|
|
|
|
expect(state.cursor).toEqual([0, 2]); // Cursor is 2 (char count)
|
|
|
|
|
expect(state.visualCursor).toEqual([0, 2]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('backspace: should correctly delete multi-byte unicode characters', () => {
|
|
|
|
|
const { result } = renderHook(() =>
|
|
|
|
|
useTextBuffer({ initialText: '你好', viewport }),
|
|
|
|
|
);
|
|
|
|
|
act(() => result.current.move('end')); // cursor at [0,2]
|
|
|
|
|
act(() => result.current.backspace()); // delete '好'
|
|
|
|
|
let state = getBufferState(result);
|
|
|
|
|
expect(state.text).toBe('你');
|
|
|
|
|
expect(state.cursor).toEqual([0, 1]);
|
|
|
|
|
|
|
|
|
|
act(() => result.current.backspace()); // delete '你'
|
|
|
|
|
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 },
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
// Initial: visualCursor [0,0]
|
|
|
|
|
act(() => result.current.move('right')); // visualCursor [0,1] (after 🐶)
|
|
|
|
|
let state = getBufferState(result);
|
|
|
|
|
expect(state.cursor).toEqual([0, 1]);
|
|
|
|
|
expect(state.visualCursor).toEqual([0, 1]);
|
|
|
|
|
|
|
|
|
|
act(() => result.current.move('right')); // visualCursor [0,2] (after 🐱)
|
|
|
|
|
state = getBufferState(result);
|
|
|
|
|
expect(state.cursor).toEqual([0, 2]);
|
|
|
|
|
expect(state.visualCursor).toEqual([0, 2]);
|
|
|
|
|
|
|
|
|
|
act(() => result.current.move('left')); // visualCursor [0,1] (before 🐱 / after 🐶)
|
|
|
|
|
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 }));
|
|
|
|
|
act(() => result.current.handleInput('h', {}));
|
|
|
|
|
act(() => result.current.handleInput('i', {}));
|
|
|
|
|
expect(getBufferState(result).text).toBe('hi');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should handle "Enter" key as newline', () => {
|
|
|
|
|
const { result } = renderHook(() => useTextBuffer({ viewport }));
|
|
|
|
|
act(() => result.current.handleInput(undefined, { return: true }));
|
|
|
|
|
expect(getBufferState(result).lines).toEqual(['', '']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should handle "Backspace" key', () => {
|
|
|
|
|
const { result } = renderHook(() =>
|
|
|
|
|
useTextBuffer({ initialText: 'a', viewport }),
|
|
|
|
|
);
|
|
|
|
|
act(() => result.current.move('end'));
|
|
|
|
|
act(() => result.current.handleInput(undefined, { backspace: true }));
|
|
|
|
|
expect(getBufferState(result).text).toBe('');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should handle arrow keys for movement', () => {
|
|
|
|
|
const { result } = renderHook(() =>
|
|
|
|
|
useTextBuffer({ initialText: 'ab', viewport }),
|
|
|
|
|
);
|
|
|
|
|
act(() => result.current.move('end')); // cursor [0,2]
|
|
|
|
|
act(() => result.current.handleInput(undefined, { leftArrow: true })); // cursor [0,1]
|
|
|
|
|
expect(getBufferState(result).cursor).toEqual([0, 1]);
|
|
|
|
|
act(() => result.current.handleInput(undefined, { rightArrow: true })); // cursor [0,2]
|
|
|
|
|
expect(getBufferState(result).cursor).toEqual([0, 2]);
|
|
|
|
|
});
|
2025-05-16 13:17:48 -07:00
|
|
|
|
|
|
|
|
it('should strip ANSI escape codes when pasting text', () => {
|
|
|
|
|
const { result } = renderHook(() => useTextBuffer({ viewport }));
|
|
|
|
|
const textWithAnsi = '\x1B[31mHello\x1B[0m \x1B[32mWorld\x1B[0m';
|
|
|
|
|
// Simulate pasting by calling handleInput with a string longer than 1 char
|
|
|
|
|
act(() => result.current.handleInput(textWithAnsi, {}));
|
|
|
|
|
expect(getBufferState(result).text).toBe('Hello World');
|
|
|
|
|
});
|
2025-05-16 11:58:37 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// More tests would be needed for:
|
|
|
|
|
// - setText, replaceRange
|
|
|
|
|
// - deleteWordLeft, deleteWordRight
|
|
|
|
|
// - More complex undo/redo scenarios
|
|
|
|
|
// - Selection and clipboard (copy/paste) - might need clipboard API mocks or internal state check
|
|
|
|
|
// - openInExternalEditor (heavy mocking of fs, child_process, os)
|
|
|
|
|
// - All edge cases for visual scrolling and wrapping with different viewport sizes and text content.
|
|
|
|
|
});
|