summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/components/shared/text-buffer.test.ts
diff options
context:
space:
mode:
authorJacob Richman <[email protected]>2025-05-16 11:58:37 -0700
committerGitHub <[email protected]>2025-05-16 11:58:37 -0700
commitc692a0c583f343225c4435ad14fb1b5a8d0e1b01 (patch)
tree33ac89c1e67939f55f6da59bc7d8776db1b17448 /packages/cli/src/ui/components/shared/text-buffer.test.ts
parent968e09f0b50d17f7c591baa977666b991a1e59b7 (diff)
Support auto wrapping of in the multiline editor. (#383)
Diffstat (limited to 'packages/cli/src/ui/components/shared/text-buffer.test.ts')
-rw-r--r--packages/cli/src/ui/components/shared/text-buffer.test.ts507
1 files changed, 507 insertions, 0 deletions
diff --git a/packages/cli/src/ui/components/shared/text-buffer.test.ts b/packages/cli/src/ui/components/shared/text-buffer.test.ts
new file mode 100644
index 00000000..8e35e3e9
--- /dev/null
+++ b/packages/cli/src/ui/components/shared/text-buffer.test.ts
@@ -0,0 +1,507 @@
+/**
+ * @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]);
+ });
+ });
+
+ // 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.
+});