| |
| |
| |
| |
| |
|
|
| import { render } from '../../../test-utils/render.js'; |
| import { waitFor } from '../../../test-utils/async.js'; |
| import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; |
| import { act } from 'react'; |
| import { TextInput } from './TextInput.js'; |
| import { useKeypress } from '../../hooks/useKeypress.js'; |
| import { useTextBuffer, type TextBuffer } from './text-buffer.js'; |
| import { useMouseClick } from '../../hooks/useMouseClick.js'; |
|
|
| |
| vi.mock('../../hooks/useKeypress.js', () => ({ |
| useKeypress: vi.fn(), |
| })); |
|
|
| vi.mock('../../hooks/useMouseClick.js', () => ({ |
| useMouseClick: vi.fn(), |
| })); |
|
|
| vi.mock('./text-buffer.js', async (importOriginal) => { |
| const actual = await importOriginal<typeof import('./text-buffer.js')>(); |
| const mockTextBuffer = { |
| text: '', |
| lines: [''], |
| cursor: [0, 0], |
| visualCursor: [0, 0], |
| viewportVisualLines: [''], |
| handleInput: vi.fn((key) => { |
| |
| if (key.sequence) { |
| mockTextBuffer.text += key.sequence; |
| mockTextBuffer.viewportVisualLines = [mockTextBuffer.text]; |
| mockTextBuffer.visualCursor[1] = mockTextBuffer.text.length; |
| } else if (key.name === 'backspace') { |
| mockTextBuffer.text = mockTextBuffer.text.slice(0, -1); |
| mockTextBuffer.viewportVisualLines = [mockTextBuffer.text]; |
| mockTextBuffer.visualCursor[1] = mockTextBuffer.text.length; |
| } else if (key.name === 'left') { |
| mockTextBuffer.visualCursor[1] = Math.max( |
| 0, |
| mockTextBuffer.visualCursor[1] - 1, |
| ); |
| } else if (key.name === 'right') { |
| mockTextBuffer.visualCursor[1] = Math.min( |
| mockTextBuffer.text.length, |
| mockTextBuffer.visualCursor[1] + 1, |
| ); |
| } |
| }), |
| setText: vi.fn((newText, cursorPosition) => { |
| mockTextBuffer.text = newText; |
| mockTextBuffer.viewportVisualLines = [newText]; |
| if (typeof cursorPosition === 'number') { |
| mockTextBuffer.visualCursor[1] = cursorPosition; |
| } else if (cursorPosition === 'start') { |
| mockTextBuffer.visualCursor[1] = 0; |
| } else { |
| mockTextBuffer.visualCursor[1] = newText.length; |
| } |
| }), |
| }; |
|
|
| return { |
| ...actual, |
| useTextBuffer: vi.fn(() => mockTextBuffer as unknown as TextBuffer), |
| TextBuffer: vi.fn(() => mockTextBuffer as unknown as TextBuffer), |
| }; |
| }); |
|
|
| const mockedUseKeypress = useKeypress as Mock; |
| const mockedUseTextBuffer = useTextBuffer as Mock; |
| const mockedUseMouseClick = useMouseClick as Mock; |
|
|
| describe('TextInput', () => { |
| const onCancel = vi.fn(); |
| const onSubmit = vi.fn(); |
| let mockBuffer: TextBuffer; |
|
|
| beforeEach(() => { |
| vi.resetAllMocks(); |
| |
| const buffer = { |
| text: '', |
| lines: [''], |
| cursor: [0, 0], |
| visualCursor: [0, 0], |
| viewportVisualLines: [''], |
| visualScrollRow: 0, |
| pastedContent: {} as Record<string, string>, |
| handleInput: vi.fn((key) => { |
| if (key.sequence) { |
| buffer.text += key.sequence; |
| buffer.viewportVisualLines = [buffer.text]; |
| buffer.visualCursor[1] = buffer.text.length; |
| } else if (key.name === 'backspace') { |
| buffer.text = buffer.text.slice(0, -1); |
| buffer.viewportVisualLines = [buffer.text]; |
| buffer.visualCursor[1] = buffer.text.length; |
| } else if (key.name === 'left') { |
| buffer.visualCursor[1] = Math.max(0, buffer.visualCursor[1] - 1); |
| } else if (key.name === 'right') { |
| buffer.visualCursor[1] = Math.min( |
| buffer.text.length, |
| buffer.visualCursor[1] + 1, |
| ); |
| } |
| }), |
| setText: vi.fn((newText, cursorPosition) => { |
| buffer.text = newText; |
| buffer.viewportVisualLines = [newText]; |
| if (typeof cursorPosition === 'number') { |
| buffer.visualCursor[1] = cursorPosition; |
| } else if (cursorPosition === 'start') { |
| buffer.visualCursor[1] = 0; |
| } else { |
| buffer.visualCursor[1] = newText.length; |
| } |
| }), |
| }; |
| mockBuffer = buffer as unknown as TextBuffer; |
| mockedUseTextBuffer.mockReturnValue(mockBuffer); |
| }); |
|
|
| it('renders with an initial value', async () => { |
| const buffer = { |
| text: 'test', |
| lines: ['test'], |
| cursor: [0, 4], |
| visualCursor: [0, 4], |
| viewportVisualLines: ['test'], |
| handleInput: vi.fn(), |
| setText: vi.fn(), |
| }; |
| const { lastFrame, unmount } = await render( |
| <TextInput |
| buffer={buffer as unknown as TextBuffer} |
| onSubmit={onSubmit} |
| onCancel={onCancel} |
| />, |
| ); |
| expect(lastFrame()).toContain('test'); |
| unmount(); |
| }); |
|
|
| it('renders a placeholder', async () => { |
| const buffer = { |
| text: '', |
| lines: [''], |
| cursor: [0, 0], |
| visualCursor: [0, 0], |
| viewportVisualLines: [''], |
| handleInput: vi.fn(), |
| setText: vi.fn(), |
| }; |
| const { lastFrame, unmount } = await render( |
| <TextInput |
| buffer={buffer as unknown as TextBuffer} |
| placeholder="testing" |
| onSubmit={onSubmit} |
| onCancel={onCancel} |
| />, |
| ); |
| expect(lastFrame()).toContain('testing'); |
| unmount(); |
| }); |
|
|
| it('handles character input', async () => { |
| const { waitUntilReady, unmount } = await render( |
| <TextInput buffer={mockBuffer} onSubmit={onSubmit} onCancel={onCancel} />, |
| ); |
| const keypressHandler = mockedUseKeypress.mock.calls[0][0]; |
|
|
| await act(async () => { |
| keypressHandler({ |
| name: 'a', |
| shift: false, |
| alt: false, |
| ctrl: false, |
| cmd: false, |
| sequence: 'a', |
| }); |
| }); |
| await waitUntilReady(); |
|
|
| expect(mockBuffer.handleInput).toHaveBeenCalledWith({ |
| name: 'a', |
| shift: false, |
| alt: false, |
| ctrl: false, |
| cmd: false, |
| sequence: 'a', |
| }); |
| expect(mockBuffer.text).toBe('a'); |
| unmount(); |
| }); |
|
|
| it('handles backspace', async () => { |
| mockBuffer.setText('test'); |
| const { waitUntilReady, unmount } = await render( |
| <TextInput buffer={mockBuffer} onSubmit={onSubmit} onCancel={onCancel} />, |
| ); |
| const keypressHandler = mockedUseKeypress.mock.calls[0][0]; |
|
|
| await act(async () => { |
| keypressHandler({ |
| name: 'backspace', |
| shift: false, |
| alt: false, |
| ctrl: false, |
| cmd: false, |
| sequence: '', |
| }); |
| }); |
| await waitUntilReady(); |
|
|
| expect(mockBuffer.handleInput).toHaveBeenCalledWith({ |
| name: 'backspace', |
| shift: false, |
| alt: false, |
| ctrl: false, |
| cmd: false, |
| sequence: '', |
| }); |
| expect(mockBuffer.text).toBe('tes'); |
| unmount(); |
| }); |
|
|
| it('handles left arrow', async () => { |
| mockBuffer.setText('test'); |
| const { waitUntilReady, unmount } = await render( |
| <TextInput buffer={mockBuffer} onSubmit={onSubmit} onCancel={onCancel} />, |
| ); |
| const keypressHandler = mockedUseKeypress.mock.calls[0][0]; |
|
|
| await act(async () => { |
| keypressHandler({ |
| name: 'left', |
| shift: false, |
| alt: false, |
| ctrl: false, |
| cmd: false, |
| sequence: '', |
| }); |
| }); |
| await waitUntilReady(); |
|
|
| |
| expect(mockBuffer.visualCursor[1]).toBe(3); |
| unmount(); |
| }); |
|
|
| it('handles right arrow', async () => { |
| mockBuffer.setText('test'); |
| mockBuffer.visualCursor[1] = 2; |
| const { waitUntilReady, unmount } = await render( |
| <TextInput buffer={mockBuffer} onSubmit={onSubmit} onCancel={onCancel} />, |
| ); |
| const keypressHandler = mockedUseKeypress.mock.calls[0][0]; |
|
|
| await act(async () => { |
| keypressHandler({ |
| name: 'right', |
| shift: false, |
| alt: false, |
| ctrl: false, |
| cmd: false, |
| sequence: '', |
| }); |
| }); |
| await waitUntilReady(); |
|
|
| expect(mockBuffer.visualCursor[1]).toBe(3); |
| unmount(); |
| }); |
|
|
| it('calls onSubmit on return', async () => { |
| mockBuffer.setText('test'); |
| const { waitUntilReady, unmount } = await render( |
| <TextInput buffer={mockBuffer} onSubmit={onSubmit} onCancel={onCancel} />, |
| ); |
| const keypressHandler = mockedUseKeypress.mock.calls[0][0]; |
|
|
| await act(async () => { |
| keypressHandler({ |
| name: 'enter', |
| shift: false, |
| alt: false, |
| ctrl: false, |
| cmd: false, |
| sequence: '', |
| }); |
| }); |
| await waitUntilReady(); |
|
|
| expect(onSubmit).toHaveBeenCalledWith('test'); |
| unmount(); |
| }); |
|
|
| it('expands paste placeholder to real content on submit', async () => { |
| const placeholder = '[Pasted Text: 6 lines]'; |
| const realContent = 'line1\nline2\nline3\nline4\nline5\nline6'; |
| mockBuffer.setText(placeholder); |
| mockBuffer.pastedContent = { [placeholder]: realContent }; |
| const { waitUntilReady, unmount } = await render( |
| <TextInput buffer={mockBuffer} onSubmit={onSubmit} onCancel={onCancel} />, |
| ); |
| const keypressHandler = mockedUseKeypress.mock.calls[0][0]; |
|
|
| await act(async () => { |
| keypressHandler({ |
| name: 'enter', |
| shift: false, |
| alt: false, |
| ctrl: false, |
| cmd: false, |
| sequence: '', |
| }); |
| }); |
| await waitUntilReady(); |
|
|
| expect(onSubmit).toHaveBeenCalledWith(realContent); |
| unmount(); |
| }); |
|
|
| it('submits text unchanged when pastedContent is empty', async () => { |
| mockBuffer.setText('normal text'); |
| mockBuffer.pastedContent = {}; |
| const { waitUntilReady, unmount } = await render( |
| <TextInput buffer={mockBuffer} onSubmit={onSubmit} onCancel={onCancel} />, |
| ); |
| const keypressHandler = mockedUseKeypress.mock.calls[0][0]; |
|
|
| await act(async () => { |
| keypressHandler({ |
| name: 'enter', |
| shift: false, |
| alt: false, |
| ctrl: false, |
| cmd: false, |
| sequence: '', |
| }); |
| }); |
| await waitUntilReady(); |
|
|
| expect(onSubmit).toHaveBeenCalledWith('normal text'); |
| unmount(); |
| }); |
|
|
| it('calls onCancel on escape', async () => { |
| vi.useFakeTimers(); |
| const { waitUntilReady, unmount } = await render( |
| <TextInput buffer={mockBuffer} onCancel={onCancel} onSubmit={onSubmit} />, |
| ); |
| const keypressHandler = mockedUseKeypress.mock.calls[0][0]; |
|
|
| await act(async () => { |
| keypressHandler({ |
| name: 'escape', |
| shift: false, |
| alt: false, |
| ctrl: false, |
| cmd: false, |
| sequence: '', |
| }); |
| }); |
| |
| await act(async () => { |
| await waitUntilReady(); |
| }); |
|
|
| await waitFor(() => { |
| expect(onCancel).toHaveBeenCalled(); |
| }); |
| vi.useRealTimers(); |
| unmount(); |
| }); |
|
|
| it('renders the input value', async () => { |
| mockBuffer.setText('secret'); |
| const { lastFrame, unmount } = await render( |
| <TextInput buffer={mockBuffer} onSubmit={onSubmit} onCancel={onCancel} />, |
| ); |
| expect(lastFrame()).toContain('secret'); |
| unmount(); |
| }); |
|
|
| it('does not show cursor when not focused', async () => { |
| mockBuffer.setText('test'); |
| const { lastFrame, unmount } = await render( |
| <TextInput |
| buffer={mockBuffer} |
| focus={false} |
| onSubmit={onSubmit} |
| onCancel={onCancel} |
| />, |
| ); |
| expect(lastFrame()).not.toContain('\u001b[7m'); |
| unmount(); |
| }); |
|
|
| it('renders multiple lines when text wraps', async () => { |
| mockBuffer.text = 'line1\nline2'; |
| mockBuffer.viewportVisualLines = ['line1', 'line2']; |
|
|
| const { lastFrame, unmount } = await render( |
| <TextInput buffer={mockBuffer} onSubmit={onSubmit} onCancel={onCancel} />, |
| ); |
|
|
| expect(lastFrame()).toContain('line1'); |
| expect(lastFrame()).toContain('line2'); |
| unmount(); |
| }); |
|
|
| it('registers mouse click handler for free-form text input', async () => { |
| const { unmount } = await render( |
| <TextInput buffer={mockBuffer} onSubmit={onSubmit} onCancel={onCancel} />, |
| ); |
|
|
| expect(mockedUseMouseClick).toHaveBeenCalledWith( |
| expect.any(Object), |
| expect.any(Function), |
| expect.objectContaining({ isActive: true, name: 'left-press' }), |
| ); |
| unmount(); |
| }); |
|
|
| it('registers mouse click handler for placeholder view', async () => { |
| mockBuffer.text = ''; |
| const { unmount } = await render( |
| <TextInput |
| buffer={mockBuffer} |
| placeholder="test" |
| onSubmit={onSubmit} |
| onCancel={onCancel} |
| />, |
| ); |
|
|
| expect(mockedUseMouseClick).toHaveBeenCalledWith( |
| expect.any(Object), |
| expect.any(Function), |
| expect.objectContaining({ isActive: true, name: 'left-press' }), |
| ); |
| unmount(); |
| }); |
| }); |
|
|