/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import React from 'react'; import { useVim } from './vim.js'; import type { TextBuffer } from '../components/shared/text-buffer.js'; import { textBufferReducer } from '../components/shared/text-buffer.js'; // Mock the VimModeContext const mockVimContext = { vimEnabled: true, vimMode: 'NORMAL' as const, toggleVimEnabled: vi.fn(), setVimMode: vi.fn(), }; vi.mock('../contexts/VimModeContext.js', () => ({ useVimMode: () => mockVimContext, VimModeProvider: ({ children }: { children: React.ReactNode }) => children, })); // Test constants const TEST_SEQUENCES = { ESCAPE: { sequence: '\u001b', name: 'escape' }, LEFT: { sequence: 'h' }, RIGHT: { sequence: 'l' }, UP: { sequence: 'k' }, DOWN: { sequence: 'j' }, INSERT: { sequence: 'i' }, APPEND: { sequence: 'a' }, DELETE_CHAR: { sequence: 'x' }, DELETE: { sequence: 'd' }, CHANGE: { sequence: 'c' }, WORD_FORWARD: { sequence: 'w' }, WORD_BACKWARD: { sequence: 'b' }, WORD_END: { sequence: 'e' }, LINE_START: { sequence: '0' }, LINE_END: { sequence: '$' }, REPEAT: { sequence: '.' }, } as const; describe('useVim hook', () => { let mockBuffer: Partial; let mockHandleFinalSubmit: vi.Mock; const createMockBuffer = ( text = 'hello world', cursor: [number, number] = [0, 5], ) => { const cursorState = { pos: cursor }; const lines = text.split('\n'); return { lines, get cursor() { return cursorState.pos; }, set cursor(newPos: [number, number]) { cursorState.pos = newPos; }, text, move: vi.fn().mockImplementation((direction: string) => { let [row, col] = cursorState.pos; const _line = lines[row] || ''; if (direction === 'left') { col = Math.max(0, col - 1); } else if (direction === 'right') { col = Math.min(line.length, col + 1); } else if (direction === 'home') { col = 0; } else if (direction === 'end') { col = line.length; } cursorState.pos = [row, col]; }), del: vi.fn(), moveToOffset: vi.fn(), insert: vi.fn(), newline: vi.fn(), replaceRangeByOffset: vi.fn(), handleInput: vi.fn(), setText: vi.fn(), // Vim-specific methods vimDeleteWordForward: vi.fn(), vimDeleteWordBackward: vi.fn(), vimDeleteWordEnd: vi.fn(), vimChangeWordForward: vi.fn(), vimChangeWordBackward: vi.fn(), vimChangeWordEnd: vi.fn(), vimDeleteLine: vi.fn(), vimChangeLine: vi.fn(), vimDeleteToEndOfLine: vi.fn(), vimChangeToEndOfLine: vi.fn(), vimChangeMovement: vi.fn(), vimMoveLeft: vi.fn(), vimMoveRight: vi.fn(), vimMoveUp: vi.fn(), vimMoveDown: vi.fn(), vimMoveWordForward: vi.fn(), vimMoveWordBackward: vi.fn(), vimMoveWordEnd: vi.fn(), vimDeleteChar: vi.fn(), vimInsertAtCursor: vi.fn(), vimAppendAtCursor: vi.fn().mockImplementation(() => { // Append moves cursor right (vim 'a' behavior - position after current char) const [row, col] = cursorState.pos; const _line = lines[row] || ''; // In vim, 'a' moves cursor to position after current character // This allows inserting at the end of the line cursorState.pos = [row, col + 1]; }), vimOpenLineBelow: vi.fn(), vimOpenLineAbove: vi.fn(), vimAppendAtLineEnd: vi.fn(), vimInsertAtLineStart: vi.fn(), vimMoveToLineStart: vi.fn(), vimMoveToLineEnd: vi.fn(), vimMoveToFirstNonWhitespace: vi.fn(), vimMoveToFirstLine: vi.fn(), vimMoveToLastLine: vi.fn(), vimMoveToLine: vi.fn(), vimEscapeInsertMode: vi.fn().mockImplementation(() => { // Escape moves cursor left unless at beginning of line const [row, col] = cursorState.pos; if (col > 0) { cursorState.pos = [row, col - 1]; } }), }; }; const _createMockSettings = (vimMode = true) => ({ getValue: vi.fn().mockReturnValue(vimMode), setValue: vi.fn(), merged: { vimMode }, }); const renderVimHook = (buffer?: Partial) => renderHook(() => useVim((buffer || mockBuffer) as TextBuffer, mockHandleFinalSubmit), ); const exitInsertMode = (result: { current: { handleInput: (input: { sequence: string; name: string }) => void; }; }) => { act(() => { result.current.handleInput({ sequence: '\u001b', name: 'escape' }); }); }; beforeEach(() => { vi.clearAllMocks(); mockHandleFinalSubmit = vi.fn(); mockBuffer = createMockBuffer(); // Reset mock context to default state mockVimContext.vimEnabled = true; mockVimContext.vimMode = 'NORMAL'; mockVimContext.toggleVimEnabled.mockClear(); mockVimContext.setVimMode.mockClear(); }); describe('Mode switching', () => { it('should start in NORMAL mode', () => { const { result } = renderVimHook(); expect(result.current.mode).toBe('NORMAL'); }); it('should switch to INSERT mode with i command', () => { const { result } = renderVimHook(); act(() => { result.current.handleInput(TEST_SEQUENCES.INSERT); }); expect(result.current.mode).toBe('INSERT'); expect(mockVimContext.setVimMode).toHaveBeenCalledWith('INSERT'); }); it('should switch back to NORMAL mode with Escape', () => { const { result } = renderVimHook(); act(() => { result.current.handleInput(TEST_SEQUENCES.INSERT); }); expect(result.current.mode).toBe('INSERT'); exitInsertMode(result); expect(result.current.mode).toBe('NORMAL'); }); it('should properly handle escape followed immediately by a command', () => { const testBuffer = createMockBuffer('hello world test', [0, 6]); const { result } = renderVimHook(testBuffer); act(() => { result.current.handleInput({ sequence: 'i' }); }); expect(result.current.mode).toBe('INSERT'); vi.clearAllMocks(); exitInsertMode(result); expect(result.current.mode).toBe('NORMAL'); act(() => { result.current.handleInput({ sequence: 'b' }); }); expect(testBuffer.vimMoveWordBackward).toHaveBeenCalledWith(1); }); }); describe('Navigation commands', () => { it('should handle h (left movement)', () => { const { result } = renderVimHook(); act(() => { result.current.handleInput({ sequence: 'h' }); }); expect(mockBuffer.vimMoveLeft).toHaveBeenCalledWith(1); }); it('should handle l (right movement)', () => { const { result } = renderVimHook(); act(() => { result.current.handleInput({ sequence: 'l' }); }); expect(mockBuffer.vimMoveRight).toHaveBeenCalledWith(1); }); it('should handle j (down movement)', () => { const testBuffer = createMockBuffer('first line\nsecond line'); const { result } = renderVimHook(testBuffer); act(() => { result.current.handleInput({ sequence: 'j' }); }); expect(testBuffer.vimMoveDown).toHaveBeenCalledWith(1); }); it('should handle k (up movement)', () => { const testBuffer = createMockBuffer('first line\nsecond line'); const { result } = renderVimHook(testBuffer); act(() => { result.current.handleInput({ sequence: 'k' }); }); expect(testBuffer.vimMoveUp).toHaveBeenCalledWith(1); }); it('should handle 0 (move to start of line)', () => { const { result } = renderVimHook(); act(() => { result.current.handleInput({ sequence: '0' }); }); expect(mockBuffer.vimMoveToLineStart).toHaveBeenCalled(); }); it('should handle $ (move to end of line)', () => { const { result } = renderVimHook(); act(() => { result.current.handleInput({ sequence: '$' }); }); expect(mockBuffer.vimMoveToLineEnd).toHaveBeenCalled(); }); }); describe('Mode switching commands', () => { it('should handle a (append after cursor)', () => { const { result } = renderVimHook(); act(() => { result.current.handleInput({ sequence: 'a' }); }); expect(mockBuffer.vimAppendAtCursor).toHaveBeenCalled(); expect(result.current.mode).toBe('INSERT'); }); it('should handle A (append at end of line)', () => { const { result } = renderVimHook(); act(() => { result.current.handleInput({ sequence: 'A' }); }); expect(mockBuffer.vimAppendAtLineEnd).toHaveBeenCalled(); expect(result.current.mode).toBe('INSERT'); }); it('should handle o (open line below)', () => { const { result } = renderVimHook(); act(() => { result.current.handleInput({ sequence: 'o' }); }); expect(mockBuffer.vimOpenLineBelow).toHaveBeenCalled(); expect(result.current.mode).toBe('INSERT'); }); it('should handle O (open line above)', () => { const { result } = renderVimHook(); act(() => { result.current.handleInput({ sequence: 'O' }); }); expect(mockBuffer.vimOpenLineAbove).toHaveBeenCalled(); expect(result.current.mode).toBe('INSERT'); }); }); describe('Edit commands', () => { it('should handle x (delete character)', () => { const { result } = renderVimHook(); vi.clearAllMocks(); act(() => { result.current.handleInput({ sequence: 'x' }); }); expect(mockBuffer.vimDeleteChar).toHaveBeenCalledWith(1); }); it('should move cursor left when deleting last character on line (vim behavior)', () => { const testBuffer = createMockBuffer('hello', [0, 4]); const { result } = renderVimHook(testBuffer); act(() => { result.current.handleInput({ sequence: 'x' }); }); expect(testBuffer.vimDeleteChar).toHaveBeenCalledWith(1); }); it('should handle first d key (sets pending state)', () => { const { result } = renderVimHook(); act(() => { result.current.handleInput({ sequence: 'd' }); }); expect(mockBuffer.replaceRangeByOffset).not.toHaveBeenCalled(); }); }); describe('Count handling', () => { it('should handle count input and return to count 0 after command', () => { const { result } = renderVimHook(); act(() => { const handled = result.current.handleInput({ sequence: '3' }); expect(handled).toBe(true); }); act(() => { const handled = result.current.handleInput({ sequence: 'h' }); expect(handled).toBe(true); }); expect(mockBuffer.vimMoveLeft).toHaveBeenCalledWith(3); }); it('should only delete 1 character with x command when no count is specified', () => { const testBuffer = createMockBuffer(); const { result } = renderVimHook(testBuffer); act(() => { result.current.handleInput({ sequence: 'x' }); }); expect(testBuffer.vimDeleteChar).toHaveBeenCalledWith(1); }); }); describe('Word movement', () => { it('should properly initialize vim hook with word movement support', () => { const testBuffer = createMockBuffer('cat elephant mouse', [0, 0]); const { result } = renderVimHook(testBuffer); expect(result.current.vimModeEnabled).toBe(true); expect(result.current.mode).toBe('NORMAL'); expect(result.current.handleInput).toBeDefined(); }); it('should support vim mode and basic operations across multiple lines', () => { const testBuffer = createMockBuffer( 'first line word\nsecond line word', [0, 11], ); const { result } = renderVimHook(testBuffer); expect(result.current.vimModeEnabled).toBe(true); expect(result.current.mode).toBe('NORMAL'); expect(result.current.handleInput).toBeDefined(); expect(testBuffer.replaceRangeByOffset).toBeDefined(); expect(testBuffer.moveToOffset).toBeDefined(); }); it('should handle w (next word)', () => { const testBuffer = createMockBuffer('hello world test'); const { result } = renderVimHook(testBuffer); act(() => { result.current.handleInput({ sequence: 'w' }); }); expect(testBuffer.vimMoveWordForward).toHaveBeenCalledWith(1); }); it('should handle b (previous word)', () => { const testBuffer = createMockBuffer('hello world test', [0, 6]); const { result } = renderVimHook(testBuffer); act(() => { result.current.handleInput({ sequence: 'b' }); }); expect(testBuffer.vimMoveWordBackward).toHaveBeenCalledWith(1); }); it('should handle e (end of word)', () => { const testBuffer = createMockBuffer('hello world test'); const { result } = renderVimHook(testBuffer); act(() => { result.current.handleInput({ sequence: 'e' }); }); expect(testBuffer.vimMoveWordEnd).toHaveBeenCalledWith(1); }); it('should handle w when cursor is on the last word', () => { const testBuffer = createMockBuffer('hello world', [0, 8]); const { result } = renderVimHook(testBuffer); act(() => { result.current.handleInput({ sequence: 'w' }); }); expect(testBuffer.vimMoveWordForward).toHaveBeenCalledWith(1); }); it('should handle first c key (sets pending change state)', () => { const { result } = renderVimHook(); act(() => { result.current.handleInput({ sequence: 'c' }); }); expect(result.current.mode).toBe('NORMAL'); expect(mockBuffer.del).not.toHaveBeenCalled(); }); it('should clear pending state on invalid command sequence (df)', () => { const { result } = renderVimHook(); act(() => { result.current.handleInput({ sequence: 'd' }); result.current.handleInput({ sequence: 'f' }); }); expect(mockBuffer.replaceRangeByOffset).not.toHaveBeenCalled(); expect(mockBuffer.del).not.toHaveBeenCalled(); }); it('should clear pending state with Escape in NORMAL mode', () => { const { result } = renderVimHook(); act(() => { result.current.handleInput({ sequence: 'd' }); }); exitInsertMode(result); expect(mockBuffer.replaceRangeByOffset).not.toHaveBeenCalled(); }); }); describe('Disabled vim mode', () => { it('should not respond to vim commands when disabled', () => { mockVimContext.vimEnabled = false; const { result } = renderVimHook(mockBuffer); act(() => { result.current.handleInput({ sequence: 'h' }); }); expect(mockBuffer.move).not.toHaveBeenCalled(); }); }); // These tests are no longer applicable at the hook level describe('Command repeat system', () => { it('should repeat x command from current cursor position', () => { const testBuffer = createMockBuffer('abcd\nefgh\nijkl', [0, 1]); const { result } = renderVimHook(testBuffer); act(() => { result.current.handleInput({ sequence: 'x' }); }); expect(testBuffer.vimDeleteChar).toHaveBeenCalledWith(1); testBuffer.cursor = [1, 2]; act(() => { result.current.handleInput({ sequence: '.' }); }); expect(testBuffer.vimDeleteChar).toHaveBeenCalledWith(1); }); it('should repeat dd command from current position', () => { const testBuffer = createMockBuffer('line1\nline2\nline3', [1, 0]); const { result } = renderVimHook(testBuffer); act(() => { result.current.handleInput({ sequence: 'd' }); }); act(() => { result.current.handleInput({ sequence: 'd' }); }); expect(testBuffer.vimDeleteLine).toHaveBeenCalledTimes(1); testBuffer.cursor = [0, 0]; act(() => { result.current.handleInput({ sequence: '.' }); }); expect(testBuffer.vimDeleteLine).toHaveBeenCalledTimes(2); }); it('should repeat ce command from current position', () => { const testBuffer = createMockBuffer('word', [0, 0]); const { result } = renderVimHook(testBuffer); act(() => { result.current.handleInput({ sequence: 'c' }); }); act(() => { result.current.handleInput({ sequence: 'e' }); }); expect(testBuffer.vimChangeWordEnd).toHaveBeenCalledTimes(1); // Exit INSERT mode to complete the command exitInsertMode(result); testBuffer.cursor = [0, 2]; act(() => { result.current.handleInput({ sequence: '.' }); }); expect(testBuffer.vimChangeWordEnd).toHaveBeenCalledTimes(2); }); it('should repeat cc command from current position', () => { const testBuffer = createMockBuffer('line1\nline2\nline3', [1, 2]); const { result } = renderVimHook(testBuffer); act(() => { result.current.handleInput({ sequence: 'c' }); }); act(() => { result.current.handleInput({ sequence: 'c' }); }); expect(testBuffer.vimChangeLine).toHaveBeenCalledTimes(1); // Exit INSERT mode to complete the command exitInsertMode(result); testBuffer.cursor = [0, 1]; act(() => { result.current.handleInput({ sequence: '.' }); }); expect(testBuffer.vimChangeLine).toHaveBeenCalledTimes(2); }); it('should repeat cw command from current position', () => { const testBuffer = createMockBuffer('hello world test', [0, 6]); const { result } = renderVimHook(testBuffer); act(() => { result.current.handleInput({ sequence: 'c' }); }); act(() => { result.current.handleInput({ sequence: 'w' }); }); expect(testBuffer.vimChangeWordForward).toHaveBeenCalledTimes(1); // Exit INSERT mode to complete the command exitInsertMode(result); testBuffer.cursor = [0, 0]; act(() => { result.current.handleInput({ sequence: '.' }); }); expect(testBuffer.vimChangeWordForward).toHaveBeenCalledTimes(2); }); it('should repeat D command from current position', () => { const testBuffer = createMockBuffer('hello world test', [0, 6]); const { result } = renderVimHook(testBuffer); act(() => { result.current.handleInput({ sequence: 'D' }); }); expect(testBuffer.vimDeleteToEndOfLine).toHaveBeenCalledTimes(1); testBuffer.cursor = [0, 2]; vi.clearAllMocks(); // Clear all mocks instead of just one method act(() => { result.current.handleInput({ sequence: '.' }); }); expect(testBuffer.vimDeleteToEndOfLine).toHaveBeenCalledTimes(1); }); it('should repeat C command from current position', () => { const testBuffer = createMockBuffer('hello world test', [0, 6]); const { result } = renderVimHook(testBuffer); act(() => { result.current.handleInput({ sequence: 'C' }); }); expect(testBuffer.vimChangeToEndOfLine).toHaveBeenCalledTimes(1); // Exit INSERT mode to complete the command exitInsertMode(result); testBuffer.cursor = [0, 2]; act(() => { result.current.handleInput({ sequence: '.' }); }); expect(testBuffer.vimChangeToEndOfLine).toHaveBeenCalledTimes(2); }); it('should repeat command after cursor movement', () => { const testBuffer = createMockBuffer('test text', [0, 0]); const { result } = renderVimHook(testBuffer); act(() => { result.current.handleInput({ sequence: 'x' }); }); expect(testBuffer.vimDeleteChar).toHaveBeenCalledWith(1); testBuffer.cursor = [0, 2]; act(() => { result.current.handleInput({ sequence: '.' }); }); expect(testBuffer.vimDeleteChar).toHaveBeenCalledWith(1); }); it('should move cursor to the correct position after exiting INSERT mode with "a"', () => { const testBuffer = createMockBuffer('hello world', [0, 10]); const { result } = renderVimHook(testBuffer); act(() => { result.current.handleInput({ sequence: 'a' }); }); expect(result.current.mode).toBe('INSERT'); expect(testBuffer.cursor).toEqual([0, 11]); exitInsertMode(result); expect(result.current.mode).toBe('NORMAL'); expect(testBuffer.cursor).toEqual([0, 10]); }); }); describe('Special characters and edge cases', () => { it('should handle ^ (move to first non-whitespace character)', () => { const testBuffer = createMockBuffer(' hello world', [0, 5]); const { result } = renderVimHook(testBuffer); act(() => { result.current.handleInput({ sequence: '^' }); }); expect(testBuffer.vimMoveToFirstNonWhitespace).toHaveBeenCalled(); }); it('should handle G without count (go to last line)', () => { const testBuffer = createMockBuffer('line1\nline2\nline3', [0, 0]); const { result } = renderVimHook(testBuffer); act(() => { result.current.handleInput({ sequence: 'G' }); }); expect(testBuffer.vimMoveToLastLine).toHaveBeenCalled(); }); it('should handle gg (go to first line)', () => { const testBuffer = createMockBuffer('line1\nline2\nline3', [2, 0]); const { result } = renderVimHook(testBuffer); // First 'g' sets pending state act(() => { result.current.handleInput({ sequence: 'g' }); }); // Second 'g' executes the command act(() => { result.current.handleInput({ sequence: 'g' }); }); expect(testBuffer.vimMoveToFirstLine).toHaveBeenCalled(); }); it('should handle count with movement commands', () => { const testBuffer = createMockBuffer('hello world test', [0, 0]); const { result } = renderVimHook(testBuffer); act(() => { result.current.handleInput({ sequence: '3' }); }); act(() => { result.current.handleInput(TEST_SEQUENCES.WORD_FORWARD); }); expect(testBuffer.vimMoveWordForward).toHaveBeenCalledWith(3); }); }); describe('Vim word operations', () => { describe('dw (delete word forward)', () => { it('should delete from cursor to start of next word', () => { const testBuffer = createMockBuffer('hello world test', [0, 0]); const { result } = renderVimHook(testBuffer); act(() => { result.current.handleInput({ sequence: 'd' }); }); act(() => { result.current.handleInput({ sequence: 'w' }); }); expect(testBuffer.vimDeleteWordForward).toHaveBeenCalledWith(1); }); it('should actually delete the complete word including trailing space', () => { // This test uses the real text-buffer reducer instead of mocks const initialState = { lines: ['hello world test'], cursorRow: 0, cursorCol: 0, preferredCol: null, undoStack: [], redoStack: [], clipboard: null, selectionAnchor: null, }; const result = textBufferReducer(initialState, { type: 'vim_delete_word_forward', payload: { count: 1 }, }); // Should delete "hello " (word + space), leaving "world test" expect(result.lines).toEqual(['world test']); expect(result.cursorRow).toBe(0); expect(result.cursorCol).toBe(0); }); it('should delete word from middle of word correctly', () => { const initialState = { lines: ['hello world test'], cursorRow: 0, cursorCol: 2, // cursor on 'l' in "hello" preferredCol: null, undoStack: [], redoStack: [], clipboard: null, selectionAnchor: null, }; const result = textBufferReducer(initialState, { type: 'vim_delete_word_forward', payload: { count: 1 }, }); // Should delete "llo " (rest of word + space), leaving "he world test" expect(result.lines).toEqual(['heworld test']); expect(result.cursorRow).toBe(0); expect(result.cursorCol).toBe(2); }); it('should handle dw at end of line', () => { const initialState = { lines: ['hello world'], cursorRow: 0, cursorCol: 6, // cursor on 'w' in "world" preferredCol: null, undoStack: [], redoStack: [], clipboard: null, selectionAnchor: null, }; const result = textBufferReducer(initialState, { type: 'vim_delete_word_forward', payload: { count: 1 }, }); // Should delete "world" (no trailing space at end), leaving "hello " expect(result.lines).toEqual(['hello ']); expect(result.cursorRow).toBe(0); expect(result.cursorCol).toBe(6); }); it('should delete multiple words with count', () => { const testBuffer = createMockBuffer('one two three four', [0, 0]); const { result } = renderVimHook(testBuffer); act(() => { result.current.handleInput({ sequence: '2' }); }); act(() => { result.current.handleInput({ sequence: 'd' }); }); act(() => { result.current.handleInput({ sequence: 'w' }); }); expect(testBuffer.vimDeleteWordForward).toHaveBeenCalledWith(2); }); it('should record command for repeat with dot', () => { const testBuffer = createMockBuffer('hello world test', [0, 0]); const { result } = renderVimHook(testBuffer); // Execute dw act(() => { result.current.handleInput({ sequence: 'd' }); }); act(() => { result.current.handleInput({ sequence: 'w' }); }); vi.clearAllMocks(); // Execute dot repeat act(() => { result.current.handleInput({ sequence: '.' }); }); expect(testBuffer.vimDeleteWordForward).toHaveBeenCalledWith(1); }); }); describe('de (delete word end)', () => { it('should delete from cursor to end of current word', () => { const testBuffer = createMockBuffer('hello world test', [0, 1]); const { result } = renderVimHook(testBuffer); act(() => { result.current.handleInput({ sequence: 'd' }); }); act(() => { result.current.handleInput({ sequence: 'e' }); }); expect(testBuffer.vimDeleteWordEnd).toHaveBeenCalledWith(1); }); it('should handle count with de', () => { const testBuffer = createMockBuffer('one two three four', [0, 0]); const { result } = renderVimHook(testBuffer); act(() => { result.current.handleInput({ sequence: '3' }); }); act(() => { result.current.handleInput({ sequence: 'd' }); }); act(() => { result.current.handleInput({ sequence: 'e' }); }); expect(testBuffer.vimDeleteWordEnd).toHaveBeenCalledWith(3); }); }); describe('cw (change word forward)', () => { it('should change from cursor to start of next word and enter INSERT mode', () => { const testBuffer = createMockBuffer('hello world test', [0, 0]); const { result } = renderVimHook(testBuffer); act(() => { result.current.handleInput({ sequence: 'c' }); }); act(() => { result.current.handleInput({ sequence: 'w' }); }); expect(testBuffer.vimChangeWordForward).toHaveBeenCalledWith(1); expect(result.current.mode).toBe('INSERT'); expect(mockVimContext.setVimMode).toHaveBeenCalledWith('INSERT'); }); it('should handle count with cw', () => { const testBuffer = createMockBuffer('one two three four', [0, 0]); const { result } = renderVimHook(testBuffer); act(() => { result.current.handleInput({ sequence: '2' }); }); act(() => { result.current.handleInput({ sequence: 'c' }); }); act(() => { result.current.handleInput({ sequence: 'w' }); }); expect(testBuffer.vimChangeWordForward).toHaveBeenCalledWith(2); expect(result.current.mode).toBe('INSERT'); }); it('should be repeatable with dot', () => { const testBuffer = createMockBuffer('hello world test more', [0, 0]); const { result } = renderVimHook(testBuffer); // Execute cw act(() => { result.current.handleInput({ sequence: 'c' }); }); act(() => { result.current.handleInput({ sequence: 'w' }); }); // Exit INSERT mode exitInsertMode(result); vi.clearAllMocks(); mockVimContext.setVimMode.mockClear(); // Execute dot repeat act(() => { result.current.handleInput({ sequence: '.' }); }); expect(testBuffer.vimChangeWordForward).toHaveBeenCalledWith(1); expect(result.current.mode).toBe('INSERT'); }); }); describe('ce (change word end)', () => { it('should change from cursor to end of word and enter INSERT mode', () => { const testBuffer = createMockBuffer('hello world test', [0, 1]); const { result } = renderVimHook(testBuffer); act(() => { result.current.handleInput({ sequence: 'c' }); }); act(() => { result.current.handleInput({ sequence: 'e' }); }); expect(testBuffer.vimChangeWordEnd).toHaveBeenCalledWith(1); expect(result.current.mode).toBe('INSERT'); }); it('should handle count with ce', () => { const testBuffer = createMockBuffer('one two three four', [0, 0]); const { result } = renderVimHook(testBuffer); act(() => { result.current.handleInput({ sequence: '2' }); }); act(() => { result.current.handleInput({ sequence: 'c' }); }); act(() => { result.current.handleInput({ sequence: 'e' }); }); expect(testBuffer.vimChangeWordEnd).toHaveBeenCalledWith(2); expect(result.current.mode).toBe('INSERT'); }); }); describe('cc (change line)', () => { it('should change entire line and enter INSERT mode', () => { const testBuffer = createMockBuffer('hello world\nsecond line', [0, 5]); const { result } = renderVimHook(testBuffer); act(() => { result.current.handleInput({ sequence: 'c' }); }); act(() => { result.current.handleInput({ sequence: 'c' }); }); expect(testBuffer.vimChangeLine).toHaveBeenCalledWith(1); expect(result.current.mode).toBe('INSERT'); }); it('should change multiple lines with count', () => { const testBuffer = createMockBuffer( 'line1\nline2\nline3\nline4', [1, 0], ); const { result } = renderVimHook(testBuffer); act(() => { result.current.handleInput({ sequence: '3' }); }); act(() => { result.current.handleInput({ sequence: 'c' }); }); act(() => { result.current.handleInput({ sequence: 'c' }); }); expect(testBuffer.vimChangeLine).toHaveBeenCalledWith(3); expect(result.current.mode).toBe('INSERT'); }); it('should be repeatable with dot', () => { const testBuffer = createMockBuffer('line1\nline2\nline3', [0, 0]); const { result } = renderVimHook(testBuffer); // Execute cc act(() => { result.current.handleInput({ sequence: 'c' }); }); act(() => { result.current.handleInput({ sequence: 'c' }); }); // Exit INSERT mode exitInsertMode(result); vi.clearAllMocks(); mockVimContext.setVimMode.mockClear(); // Execute dot repeat act(() => { result.current.handleInput({ sequence: '.' }); }); expect(testBuffer.vimChangeLine).toHaveBeenCalledWith(1); expect(result.current.mode).toBe('INSERT'); }); }); describe('db (delete word backward)', () => { it('should delete from cursor to start of previous word', () => { const testBuffer = createMockBuffer('hello world test', [0, 11]); const { result } = renderVimHook(testBuffer); act(() => { result.current.handleInput({ sequence: 'd' }); }); act(() => { result.current.handleInput({ sequence: 'b' }); }); expect(testBuffer.vimDeleteWordBackward).toHaveBeenCalledWith(1); }); it('should handle count with db', () => { const testBuffer = createMockBuffer('one two three four', [0, 18]); const { result } = renderVimHook(testBuffer); act(() => { result.current.handleInput({ sequence: '2' }); }); act(() => { result.current.handleInput({ sequence: 'd' }); }); act(() => { result.current.handleInput({ sequence: 'b' }); }); expect(testBuffer.vimDeleteWordBackward).toHaveBeenCalledWith(2); }); }); describe('cb (change word backward)', () => { it('should change from cursor to start of previous word and enter INSERT mode', () => { const testBuffer = createMockBuffer('hello world test', [0, 11]); const { result } = renderVimHook(testBuffer); act(() => { result.current.handleInput({ sequence: 'c' }); }); act(() => { result.current.handleInput({ sequence: 'b' }); }); expect(testBuffer.vimChangeWordBackward).toHaveBeenCalledWith(1); expect(result.current.mode).toBe('INSERT'); }); it('should handle count with cb', () => { const testBuffer = createMockBuffer('one two three four', [0, 18]); const { result } = renderVimHook(testBuffer); act(() => { result.current.handleInput({ sequence: '3' }); }); act(() => { result.current.handleInput({ sequence: 'c' }); }); act(() => { result.current.handleInput({ sequence: 'b' }); }); expect(testBuffer.vimChangeWordBackward).toHaveBeenCalledWith(3); expect(result.current.mode).toBe('INSERT'); }); }); describe('Pending state handling', () => { it('should clear pending delete state after dw', () => { const testBuffer = createMockBuffer('hello world', [0, 0]); const { result } = renderVimHook(testBuffer); // Press 'd' to enter pending delete state act(() => { result.current.handleInput({ sequence: 'd' }); }); // Complete with 'w' act(() => { result.current.handleInput({ sequence: 'w' }); }); // Next 'd' should start a new pending state, not continue the previous one act(() => { result.current.handleInput({ sequence: 'd' }); }); // This should trigger dd (delete line), not an error act(() => { result.current.handleInput({ sequence: 'd' }); }); expect(testBuffer.vimDeleteLine).toHaveBeenCalledWith(1); }); it('should clear pending change state after cw', () => { const testBuffer = createMockBuffer('hello world', [0, 0]); const { result } = renderVimHook(testBuffer); // Execute cw act(() => { result.current.handleInput({ sequence: 'c' }); }); act(() => { result.current.handleInput({ sequence: 'w' }); }); // Exit INSERT mode exitInsertMode(result); // Next 'c' should start a new pending state act(() => { result.current.handleInput({ sequence: 'c' }); }); act(() => { result.current.handleInput({ sequence: 'c' }); }); expect(testBuffer.vimChangeLine).toHaveBeenCalledWith(1); }); it('should clear pending state with escape', () => { const testBuffer = createMockBuffer('hello world', [0, 0]); const { result } = renderVimHook(testBuffer); // Enter pending delete state act(() => { result.current.handleInput({ sequence: 'd' }); }); // Press escape to clear pending state act(() => { result.current.handleInput({ name: 'escape' }); }); // Now 'w' should just move cursor, not delete act(() => { result.current.handleInput({ sequence: 'w' }); }); expect(testBuffer.vimDeleteWordForward).not.toHaveBeenCalled(); // w should move to next word after clearing pending state expect(testBuffer.vimMoveWordForward).toHaveBeenCalledWith(1); }); }); describe('NORMAL mode escape behavior', () => { it('should pass escape through when no pending operator is active', () => { mockVimContext.vimMode = 'NORMAL'; const { result } = renderVimHook(); const handled = result.current.handleInput({ name: 'escape' }); expect(handled).toBe(false); }); it('should handle escape and clear pending operator', () => { mockVimContext.vimMode = 'NORMAL'; const { result } = renderVimHook(); act(() => { result.current.handleInput({ sequence: 'd' }); }); let handled: boolean | undefined; act(() => { handled = result.current.handleInput({ name: 'escape' }); }); expect(handled).toBe(true); }); }); }); describe('Shell command pass-through', () => { it('should pass through ctrl+r in INSERT mode', () => { mockVimContext.vimMode = 'INSERT'; const { result } = renderVimHook(); const handled = result.current.handleInput({ name: 'r', ctrl: true }); expect(handled).toBe(false); }); it('should pass through ! in INSERT mode when buffer is empty', () => { mockVimContext.vimMode = 'INSERT'; const emptyBuffer = createMockBuffer(''); const { result } = renderVimHook(emptyBuffer); const handled = result.current.handleInput({ sequence: '!' }); expect(handled).toBe(false); }); it('should handle ! as input in INSERT mode when buffer is not empty', () => { mockVimContext.vimMode = 'INSERT'; const nonEmptyBuffer = createMockBuffer('not empty'); const { result } = renderVimHook(nonEmptyBuffer); const key = { sequence: '!', name: '!' }; act(() => { result.current.handleInput(key); }); expect(nonEmptyBuffer.handleInput).toHaveBeenCalledWith( expect.objectContaining(key), ); }); }); // Line operations (dd, cc) are tested in text-buffer.test.ts describe('Reducer-based integration tests', () => { describe('de (delete word end)', () => { it('should delete from cursor to end of current word', () => { const initialState = { lines: ['hello world test'], cursorRow: 0, cursorCol: 1, // cursor on 'e' in "hello" preferredCol: null, undoStack: [], redoStack: [], clipboard: null, selectionAnchor: null, }; const result = textBufferReducer(initialState, { type: 'vim_delete_word_end', payload: { count: 1 }, }); // Should delete "ello" (from cursor to end of word), leaving "h world test" expect(result.lines).toEqual(['h world test']); expect(result.cursorRow).toBe(0); expect(result.cursorCol).toBe(1); }); it('should delete multiple word ends with count', () => { const initialState = { lines: ['hello world test more'], cursorRow: 0, cursorCol: 1, // cursor on 'e' in "hello" preferredCol: null, undoStack: [], redoStack: [], clipboard: null, selectionAnchor: null, }; const result = textBufferReducer(initialState, { type: 'vim_delete_word_end', payload: { count: 2 }, }); // Should delete "ello world" (to end of second word), leaving "h test more" expect(result.lines).toEqual(['h test more']); expect(result.cursorRow).toBe(0); expect(result.cursorCol).toBe(1); }); }); describe('db (delete word backward)', () => { it('should delete from cursor to start of previous word', () => { const initialState = { lines: ['hello world test'], cursorRow: 0, cursorCol: 11, // cursor on 't' in "test" preferredCol: null, undoStack: [], redoStack: [], clipboard: null, selectionAnchor: null, }; const result = textBufferReducer(initialState, { type: 'vim_delete_word_backward', payload: { count: 1 }, }); // Should delete "world" (previous word only), leaving "hello test" expect(result.lines).toEqual(['hello test']); expect(result.cursorRow).toBe(0); expect(result.cursorCol).toBe(6); }); it('should delete multiple words backward with count', () => { const initialState = { lines: ['hello world test more'], cursorRow: 0, cursorCol: 17, // cursor on 'm' in "more" preferredCol: null, undoStack: [], redoStack: [], clipboard: null, selectionAnchor: null, }; const result = textBufferReducer(initialState, { type: 'vim_delete_word_backward', payload: { count: 2 }, }); // Should delete "world test " (two words backward), leaving "hello more" expect(result.lines).toEqual(['hello more']); expect(result.cursorRow).toBe(0); expect(result.cursorCol).toBe(6); }); }); describe('cw (change word forward)', () => { it('should delete from cursor to start of next word', () => { const initialState = { lines: ['hello world test'], cursorRow: 0, cursorCol: 0, // cursor on 'h' in "hello" preferredCol: null, undoStack: [], redoStack: [], clipboard: null, selectionAnchor: null, }; const result = textBufferReducer(initialState, { type: 'vim_change_word_forward', payload: { count: 1 }, }); // Should delete "hello " (word + space), leaving "world test" expect(result.lines).toEqual(['world test']); expect(result.cursorRow).toBe(0); expect(result.cursorCol).toBe(0); }); it('should change multiple words with count', () => { const initialState = { lines: ['hello world test more'], cursorRow: 0, cursorCol: 0, preferredCol: null, undoStack: [], redoStack: [], clipboard: null, selectionAnchor: null, }; const result = textBufferReducer(initialState, { type: 'vim_change_word_forward', payload: { count: 2 }, }); // Should delete "hello world " (two words), leaving "test more" expect(result.lines).toEqual(['test more']); expect(result.cursorRow).toBe(0); expect(result.cursorCol).toBe(0); }); }); describe('ce (change word end)', () => { it('should change from cursor to end of current word', () => { const initialState = { lines: ['hello world test'], cursorRow: 0, cursorCol: 1, // cursor on 'e' in "hello" preferredCol: null, undoStack: [], redoStack: [], clipboard: null, selectionAnchor: null, }; const result = textBufferReducer(initialState, { type: 'vim_change_word_end', payload: { count: 1 }, }); // Should delete "ello" (from cursor to end of word), leaving "h world test" expect(result.lines).toEqual(['h world test']); expect(result.cursorRow).toBe(0); expect(result.cursorCol).toBe(1); }); it('should change multiple word ends with count', () => { const initialState = { lines: ['hello world test'], cursorRow: 0, cursorCol: 1, // cursor on 'e' in "hello" preferredCol: null, undoStack: [], redoStack: [], clipboard: null, selectionAnchor: null, }; const result = textBufferReducer(initialState, { type: 'vim_change_word_end', payload: { count: 2 }, }); // Should delete "ello world" (to end of second word), leaving "h test" expect(result.lines).toEqual(['h test']); expect(result.cursorRow).toBe(0); expect(result.cursorCol).toBe(1); }); }); describe('cb (change word backward)', () => { it('should change from cursor to start of previous word', () => { const initialState = { lines: ['hello world test'], cursorRow: 0, cursorCol: 11, // cursor on 't' in "test" preferredCol: null, undoStack: [], redoStack: [], clipboard: null, selectionAnchor: null, }; const result = textBufferReducer(initialState, { type: 'vim_change_word_backward', payload: { count: 1 }, }); // Should delete "world" (previous word only), leaving "hello test" expect(result.lines).toEqual(['hello test']); expect(result.cursorRow).toBe(0); expect(result.cursorCol).toBe(6); }); }); describe('cc (change line)', () => { it('should clear the line and place cursor at the start', () => { const initialState = { lines: [' hello world'], cursorRow: 0, cursorCol: 5, // cursor on 'o' preferredCol: null, undoStack: [], redoStack: [], clipboard: null, selectionAnchor: null, }; const result = textBufferReducer(initialState, { type: 'vim_change_line', payload: { count: 1 }, }); expect(result.lines).toEqual(['']); expect(result.cursorRow).toBe(0); expect(result.cursorCol).toBe(0); }); }); describe('dd (delete line)', () => { it('should delete the current line', () => { const initialState = { lines: ['line1', 'line2', 'line3'], cursorRow: 1, cursorCol: 2, preferredCol: null, undoStack: [], redoStack: [], clipboard: null, selectionAnchor: null, }; const result = textBufferReducer(initialState, { type: 'vim_delete_line', payload: { count: 1 }, }); expect(result.lines).toEqual(['line1', 'line3']); expect(result.cursorRow).toBe(1); expect(result.cursorCol).toBe(0); }); it('should delete multiple lines with count', () => { const initialState = { lines: ['line1', 'line2', 'line3', 'line4'], cursorRow: 1, cursorCol: 2, preferredCol: null, undoStack: [], redoStack: [], clipboard: null, selectionAnchor: null, }; const result = textBufferReducer(initialState, { type: 'vim_delete_line', payload: { count: 2 }, }); // Should delete lines 1 and 2 expect(result.lines).toEqual(['line1', 'line4']); expect(result.cursorRow).toBe(1); expect(result.cursorCol).toBe(0); }); it('should handle deleting last line', () => { const initialState = { lines: ['only line'], cursorRow: 0, cursorCol: 3, preferredCol: null, undoStack: [], redoStack: [], clipboard: null, selectionAnchor: null, }; const result = textBufferReducer(initialState, { type: 'vim_delete_line', payload: { count: 1 }, }); // Should leave an empty line when deleting the only line expect(result.lines).toEqual(['']); expect(result.cursorRow).toBe(0); expect(result.cursorCol).toBe(0); }); }); describe('D (delete to end of line)', () => { it('should delete from cursor to end of line', () => { const initialState = { lines: ['hello world test'], cursorRow: 0, cursorCol: 6, // cursor on 'w' in "world" preferredCol: null, undoStack: [], redoStack: [], clipboard: null, selectionAnchor: null, }; const result = textBufferReducer(initialState, { type: 'vim_delete_to_end_of_line', }); // Should delete "world test", leaving "hello " expect(result.lines).toEqual(['hello ']); expect(result.cursorRow).toBe(0); expect(result.cursorCol).toBe(6); }); it('should handle D at end of line', () => { const initialState = { lines: ['hello world'], cursorRow: 0, cursorCol: 11, // cursor at end preferredCol: null, undoStack: [], redoStack: [], clipboard: null, selectionAnchor: null, }; const result = textBufferReducer(initialState, { type: 'vim_delete_to_end_of_line', }); // Should not change anything when at end of line expect(result.lines).toEqual(['hello world']); expect(result.cursorRow).toBe(0); expect(result.cursorCol).toBe(11); }); }); describe('C (change to end of line)', () => { it('should change from cursor to end of line', () => { const initialState = { lines: ['hello world test'], cursorRow: 0, cursorCol: 6, // cursor on 'w' in "world" preferredCol: null, undoStack: [], redoStack: [], clipboard: null, selectionAnchor: null, }; const result = textBufferReducer(initialState, { type: 'vim_change_to_end_of_line', }); // Should delete "world test", leaving "hello " expect(result.lines).toEqual(['hello ']); expect(result.cursorRow).toBe(0); expect(result.cursorCol).toBe(6); }); it('should handle C at beginning of line', () => { const initialState = { lines: ['hello world'], cursorRow: 0, cursorCol: 0, preferredCol: null, undoStack: [], redoStack: [], clipboard: null, selectionAnchor: null, }; const result = textBufferReducer(initialState, { type: 'vim_change_to_end_of_line', }); // Should delete entire line content expect(result.lines).toEqual(['']); expect(result.cursorRow).toBe(0); expect(result.cursorCol).toBe(0); }); }); }); });