Files
gemini-cli/packages/cli/src/ui/hooks/useInputHistory.test.ts
2026-02-05 20:38:29 +00:00

484 lines
14 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { act } from 'react';
import { renderHook } from '../../test-utils/render.js';
import { useInputHistory } from './useInputHistory.js';
describe('useInputHistory', () => {
const mockOnSubmit = vi.fn();
const mockOnChange = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
const userMessages = ['message 1', 'message 2', 'message 3'];
it('should initialize with historyIndex -1 and empty originalQueryBeforeNav', () => {
const { result } = renderHook(() =>
useInputHistory({
userMessages: [],
onSubmit: mockOnSubmit,
isActive: true,
currentQuery: '',
currentCursorOffset: 0,
onChange: mockOnChange,
}),
);
// Internal state is not directly testable, but we can infer from behavior.
// Attempting to navigate down should do nothing if historyIndex is -1.
act(() => {
result.current.navigateDown();
});
expect(mockOnChange).not.toHaveBeenCalled();
});
describe('handleSubmit', () => {
it('should call onSubmit with trimmed value and reset history', () => {
const { result } = renderHook(() =>
useInputHistory({
userMessages,
onSubmit: mockOnSubmit,
isActive: true,
currentQuery: ' test query ',
currentCursorOffset: 0,
onChange: mockOnChange,
}),
);
act(() => {
result.current.handleSubmit(' submit value ');
});
expect(mockOnSubmit).toHaveBeenCalledWith('submit value');
// Check if history is reset (e.g., by trying to navigate down)
act(() => {
result.current.navigateDown();
});
expect(mockOnChange).not.toHaveBeenCalled();
});
it('should not call onSubmit if value is empty after trimming', () => {
const { result } = renderHook(() =>
useInputHistory({
userMessages,
onSubmit: mockOnSubmit,
isActive: true,
currentQuery: '',
currentCursorOffset: 0,
onChange: mockOnChange,
}),
);
act(() => {
result.current.handleSubmit(' ');
});
expect(mockOnSubmit).not.toHaveBeenCalled();
});
});
describe('navigateUp', () => {
it('should not navigate if isActive is false', () => {
const { result } = renderHook(() =>
useInputHistory({
userMessages,
onSubmit: mockOnSubmit,
isActive: false,
currentQuery: 'current',
currentCursorOffset: 0,
onChange: mockOnChange,
}),
);
act(() => {
const navigated = result.current.navigateUp();
expect(navigated).toBe(false);
});
expect(mockOnChange).not.toHaveBeenCalled();
});
it('should not navigate if userMessages is empty', () => {
const { result } = renderHook(() =>
useInputHistory({
userMessages: [],
onSubmit: mockOnSubmit,
isActive: true,
currentQuery: 'current',
currentCursorOffset: 0,
onChange: mockOnChange,
}),
);
act(() => {
const navigated = result.current.navigateUp();
expect(navigated).toBe(false);
});
expect(mockOnChange).not.toHaveBeenCalled();
});
it('should call onChange with the last message when navigating up from initial state', () => {
const currentQuery = 'current query';
const { result } = renderHook(() =>
useInputHistory({
userMessages,
onSubmit: mockOnSubmit,
isActive: true,
currentQuery,
currentCursorOffset: 0,
onChange: mockOnChange,
}),
);
act(() => {
result.current.navigateUp();
});
expect(mockOnChange).toHaveBeenCalledWith(userMessages[2], 'start'); // Last message
});
it('should store currentQuery and currentCursorOffset as original state on first navigateUp', () => {
const currentQuery = 'original user input';
const currentCursorOffset = 5;
const { result } = renderHook(() =>
useInputHistory({
userMessages,
onSubmit: mockOnSubmit,
isActive: true,
currentQuery,
currentCursorOffset,
onChange: mockOnChange,
}),
);
act(() => {
result.current.navigateUp(); // historyIndex becomes 0
});
expect(mockOnChange).toHaveBeenCalledWith(userMessages[2], 'start');
// Navigate down to restore original query and cursor position
act(() => {
result.current.navigateDown(); // historyIndex becomes -1
});
expect(mockOnChange).toHaveBeenCalledWith(
currentQuery,
currentCursorOffset,
);
});
it('should navigate through history messages on subsequent navigateUp calls', () => {
const { result } = renderHook(() =>
useInputHistory({
userMessages,
onSubmit: mockOnSubmit,
isActive: true,
currentQuery: '',
currentCursorOffset: 0,
onChange: mockOnChange,
}),
);
act(() => {
result.current.navigateUp(); // Navigates to 'message 3'
});
expect(mockOnChange).toHaveBeenCalledWith(userMessages[2], 'start');
act(() => {
result.current.navigateUp(); // Navigates to 'message 2'
});
expect(mockOnChange).toHaveBeenCalledWith(userMessages[1], 'start');
act(() => {
result.current.navigateUp(); // Navigates to 'message 1'
});
expect(mockOnChange).toHaveBeenCalledWith(userMessages[0], 'start');
});
});
describe('navigateDown', () => {
it('should not navigate if isActive is false', () => {
const initialProps = {
userMessages,
onSubmit: mockOnSubmit,
isActive: true, // Start active to allow setup navigation
currentQuery: 'current',
currentCursorOffset: 0,
onChange: mockOnChange,
};
const { result, rerender } = renderHook(
(props) => useInputHistory(props),
{
initialProps,
},
);
// First navigate up to have something in history
act(() => {
result.current.navigateUp();
});
mockOnChange.mockClear(); // Clear calls from setup
// Set isActive to false for the actual test
rerender({ ...initialProps, isActive: false });
act(() => {
const navigated = result.current.navigateDown();
expect(navigated).toBe(false);
});
expect(mockOnChange).not.toHaveBeenCalled();
});
it('should not navigate if historyIndex is -1 (not in history navigation)', () => {
const { result } = renderHook(() =>
useInputHistory({
userMessages,
onSubmit: mockOnSubmit,
isActive: true,
currentQuery: 'current',
currentCursorOffset: 0,
onChange: mockOnChange,
}),
);
act(() => {
const navigated = result.current.navigateDown();
expect(navigated).toBe(false);
});
expect(mockOnChange).not.toHaveBeenCalled();
});
it('should restore cursor offset only when in middle of compose prompt', () => {
const originalQuery = 'my original input';
const originalCursorOffset = 5; // Middle
const { result } = renderHook(() =>
useInputHistory({
userMessages,
onSubmit: mockOnSubmit,
isActive: true,
currentQuery: originalQuery,
currentCursorOffset: originalCursorOffset,
onChange: mockOnChange,
}),
);
act(() => {
result.current.navigateUp();
});
mockOnChange.mockClear();
act(() => {
result.current.navigateDown();
});
// Should restore middle offset
expect(mockOnChange).toHaveBeenCalledWith(
originalQuery,
originalCursorOffset,
);
});
it('should NOT restore cursor offset if it was at start or end of compose prompt', () => {
const originalQuery = 'my original input';
const { result, rerender } = renderHook(
(props) => useInputHistory(props),
{
initialProps: {
userMessages,
onSubmit: mockOnSubmit,
isActive: true,
currentQuery: originalQuery,
currentCursorOffset: 0, // Start
onChange: mockOnChange,
},
},
);
// Case 1: Start
act(() => {
result.current.navigateUp();
});
mockOnChange.mockClear();
act(() => {
result.current.navigateDown();
});
// Should use 'end' default instead of 0
expect(mockOnChange).toHaveBeenCalledWith(originalQuery, 'end');
// Case 2: End
rerender({
userMessages,
onSubmit: mockOnSubmit,
isActive: true,
currentQuery: originalQuery,
currentCursorOffset: originalQuery.length, // End
onChange: mockOnChange,
});
act(() => {
result.current.navigateUp();
});
mockOnChange.mockClear();
act(() => {
result.current.navigateDown();
});
// Should use 'end' default
expect(mockOnChange).toHaveBeenCalledWith(originalQuery, 'end');
});
it('should remember text edits but use default cursor when navigating between history items', () => {
const originalQuery = 'my original input';
const originalCursorOffset = 5;
const { result, rerender } = renderHook(
(props) => useInputHistory(props),
{
initialProps: {
userMessages,
onSubmit: mockOnSubmit,
isActive: true,
currentQuery: originalQuery,
currentCursorOffset: originalCursorOffset,
onChange: mockOnChange,
},
},
);
// 1. Navigate UP from compose prompt (-1 -> 0)
act(() => {
result.current.navigateUp();
});
expect(mockOnChange).toHaveBeenCalledWith(userMessages[2], 'start');
mockOnChange.mockClear();
// Simulate being at History[0] ('message 3') and editing it
const editedHistoryText = 'message 3 edited';
const editedHistoryOffset = 5;
rerender({
userMessages,
onSubmit: mockOnSubmit,
isActive: true,
currentQuery: editedHistoryText,
currentCursorOffset: editedHistoryOffset,
onChange: mockOnChange,
});
// 2. Navigate UP to next history item (0 -> 1)
act(() => {
result.current.navigateUp();
});
expect(mockOnChange).toHaveBeenCalledWith(userMessages[1], 'start');
mockOnChange.mockClear();
// 3. Navigate DOWN back to History[0] (1 -> 0)
act(() => {
result.current.navigateDown();
});
// Should restore edited text AND the offset because we just came from History[0]
expect(mockOnChange).toHaveBeenCalledWith(
editedHistoryText,
editedHistoryOffset,
);
mockOnChange.mockClear();
// Simulate being at History[0] (restored) and navigating DOWN to compose prompt (0 -> -1)
rerender({
userMessages,
onSubmit: mockOnSubmit,
isActive: true,
currentQuery: editedHistoryText,
currentCursorOffset: editedHistoryOffset,
onChange: mockOnChange,
});
// 4. Navigate DOWN to compose prompt
act(() => {
result.current.navigateDown();
});
// Level -1 should ALWAYS restore its offset if it was in the middle
expect(mockOnChange).toHaveBeenCalledWith(
originalQuery,
originalCursorOffset,
);
});
it('should restore offset for history items ONLY if returning from them immediately', () => {
const originalQuery = 'my original input';
const initialProps = {
userMessages,
onSubmit: mockOnSubmit,
isActive: true,
currentQuery: originalQuery,
currentCursorOffset: 5,
onChange: mockOnChange,
};
const { result, rerender } = renderHook(
(props) => useInputHistory(props),
{
initialProps,
},
);
// -1 -> 0 ('message 3')
act(() => {
result.current.navigateUp();
});
expect(mockOnChange).toHaveBeenCalledWith(userMessages[2], 'start');
const historyOffset = 4;
// Manually update props to reflect current level
rerender({
...initialProps,
currentQuery: userMessages[2],
currentCursorOffset: historyOffset,
});
// 0 -> 1 ('message 2')
act(() => {
result.current.navigateUp();
});
expect(mockOnChange).toHaveBeenCalledWith(userMessages[1], 'start');
rerender({
...initialProps,
currentQuery: userMessages[1],
currentCursorOffset: 0,
});
// 1 -> 2 ('message 1')
act(() => {
result.current.navigateUp();
});
expect(mockOnChange).toHaveBeenCalledWith(userMessages[0], 'start');
rerender({
...initialProps,
currentQuery: userMessages[0],
currentCursorOffset: 0,
});
mockOnChange.mockClear();
// 2 -> 1 ('message 2')
act(() => {
result.current.navigateDown();
});
// 2 -> 1 is immediate back-and-forth.
// But Level 1 offset was 0 (not in middle), so use 'end' default.
expect(mockOnChange).toHaveBeenCalledWith(userMessages[1], 'end');
mockOnChange.mockClear();
// Rerender to reflect Level 1 state
rerender({
...initialProps,
currentQuery: userMessages[1],
currentCursorOffset: userMessages[1].length,
});
// 1 -> 0 ('message 3')
act(() => {
result.current.navigateDown();
});
// 1 -> 0 is NOT immediate (Level 2 was the last jump point).
// So Level 0 SHOULD use default 'end' even though it has a middle offset saved.
expect(mockOnChange).toHaveBeenCalledWith(userMessages[2], 'end');
});
});
});