import { renderHook, act } from '@testing-library/react' import { useCommandPaletteSearch, CommandItem } from '../useCommandPaletteSearch' const Home = () => null Home.displayName = 'Home' const Settings = () => null Settings.displayName = 'Settings' const COMMANDS: CommandItem[] = [ { id: 'nav-home', label: 'Go to Home', detail: 'Dashboard overview', group: 'Navigation', onSelect: '/home', icon: Home, }, { id: 'nav-settings', label: 'Go to Settings', detail: 'Account preferences', group: 'Navigation', onSelect: '/settings', icon: Settings, }, { id: 'action-invite', label: 'Invite User', detail: 'Send invite email', group: 'Actions', onSelect: jest.fn(), shortcut: '⌘I', }, { id: 'action-export', label: 'Export Data', group: 'Actions', onSelect: jest.fn(), }, ] function makeOpts(overrides: Partial[0]> = {}) { return { commands: COMMANDS, onExecute: jest.fn(), isOpen: true, ...overrides, } } function makeKeyEvent(key: string): React.KeyboardEvent { return { key, preventDefault: jest.fn() } as unknown as React.KeyboardEvent } describe('useCommandPaletteSearch', () => { describe('initial state', () => { it('returns empty query on open', () => { const { result } = renderHook(() => useCommandPaletteSearch(makeOpts())) expect(result.current.query).toBe('') }) it('selects first item (index 0) on open', () => { const { result } = renderHook(() => useCommandPaletteSearch(makeOpts())) expect(result.current.selectedIndex).toBe(0) }) it('exposes setQuery and onKeyDown functions', () => { const { result } = renderHook(() => useCommandPaletteSearch(makeOpts())) expect(typeof result.current.setQuery).toBe('function') expect(typeof result.current.onKeyDown).toBe('function') }) }) describe('filtering', () => { it('shows all commands when query is empty', () => { const { result } = renderHook(() => useCommandPaletteSearch(makeOpts())) const allItems = result.current.filteredGroups.flatMap((g) => g.items) expect(allItems).toHaveLength(COMMANDS.length) }) it('narrows results by label match (case-insensitive)', () => { const { result } = renderHook(() => useCommandPaletteSearch(makeOpts())) act(() => { result.current.setQuery('invite') }) const items = result.current.filteredGroups.flatMap((g) => g.items) expect(items).toHaveLength(1) expect(items[0].id).toBe('action-invite') }) it('matches on detail field', () => { const { result } = renderHook(() => useCommandPaletteSearch(makeOpts())) act(() => { result.current.setQuery('dashboard') }) const items = result.current.filteredGroups.flatMap((g) => g.items) expect(items).toHaveLength(1) expect(items[0].id).toBe('nav-home') }) it('is case-insensitive', () => { const { result } = renderHook(() => useCommandPaletteSearch(makeOpts())) act(() => { result.current.setQuery('EXPORT') }) const items = result.current.filteredGroups.flatMap((g) => g.items) expect(items).toHaveLength(1) expect(items[0].id).toBe('action-export') }) it('returns empty groups when no commands match', () => { const { result } = renderHook(() => useCommandPaletteSearch(makeOpts())) act(() => { result.current.setQuery('zzznomatch') }) expect(result.current.filteredGroups).toHaveLength(0) }) it('resets selection to 0 when query changes', () => { const { result } = renderHook(() => useCommandPaletteSearch(makeOpts())) // Move selection down first act(() => { result.current.onKeyDown(makeKeyEvent('ArrowDown')) }) expect(result.current.selectedIndex).toBe(1) // Now type to filter act(() => { result.current.setQuery('Settings') }) expect(result.current.selectedIndex).toBe(0) }) }) describe('grouping', () => { it('groups items by their group field', () => { const { result } = renderHook(() => useCommandPaletteSearch(makeOpts())) const labels = result.current.filteredGroups.map((g) => g.label) expect(labels).toContain('Navigation') expect(labels).toContain('Actions') }) it('places each item in the correct group', () => { const { result } = renderHook(() => useCommandPaletteSearch(makeOpts())) const nav = result.current.filteredGroups.find((g) => g.label === 'Navigation')! expect(nav.items.map((i) => i.id)).toEqual(['nav-home', 'nav-settings']) const actions = result.current.filteredGroups.find((g) => g.label === 'Actions')! expect(actions.items.map((i) => i.id)).toEqual(['action-invite', 'action-export']) }) it('preserves group insertion order', () => { const { result } = renderHook(() => useCommandPaletteSearch(makeOpts())) const labels = result.current.filteredGroups.map((g) => g.label) expect(labels[0]).toBe('Navigation') expect(labels[1]).toBe('Actions') }) it('produces a single group when all matches share a group', () => { const { result } = renderHook(() => useCommandPaletteSearch(makeOpts())) act(() => { result.current.setQuery('Go to') }) expect(result.current.filteredGroups).toHaveLength(1) expect(result.current.filteredGroups[0].label).toBe('Navigation') }) }) describe('keyboard navigation — ArrowDown', () => { it('moves selectedIndex down by one', () => { const { result } = renderHook(() => useCommandPaletteSearch(makeOpts())) act(() => { result.current.onKeyDown(makeKeyEvent('ArrowDown')) }) expect(result.current.selectedIndex).toBe(1) }) it('calls preventDefault on ArrowDown', () => { const { result } = renderHook(() => useCommandPaletteSearch(makeOpts())) const e = makeKeyEvent('ArrowDown') act(() => { result.current.onKeyDown(e) }) expect(e.preventDefault).toHaveBeenCalled() }) it('clamps at the last item', () => { const { result } = renderHook(() => useCommandPaletteSearch(makeOpts())) const lastIndex = COMMANDS.length - 1 // Jump to end then try to go further for (let i = 0; i <= lastIndex + 2; i++) { act(() => { result.current.onKeyDown(makeKeyEvent('ArrowDown')) }) } expect(result.current.selectedIndex).toBe(lastIndex) }) }) describe('keyboard navigation — ArrowUp', () => { it('moves selectedIndex up by one', () => { const { result } = renderHook(() => useCommandPaletteSearch(makeOpts())) // Go down first so we have room to go up act(() => { result.current.onKeyDown(makeKeyEvent('ArrowDown')) }) act(() => { result.current.onKeyDown(makeKeyEvent('ArrowDown')) }) act(() => { result.current.onKeyDown(makeKeyEvent('ArrowUp')) }) expect(result.current.selectedIndex).toBe(1) }) it('calls preventDefault on ArrowUp', () => { const { result } = renderHook(() => useCommandPaletteSearch(makeOpts())) act(() => { result.current.onKeyDown(makeKeyEvent('ArrowDown')) }) const e = makeKeyEvent('ArrowUp') act(() => { result.current.onKeyDown(e) }) expect(e.preventDefault).toHaveBeenCalled() }) it('clamps at index 0', () => { const { result } = renderHook(() => useCommandPaletteSearch(makeOpts())) act(() => { result.current.onKeyDown(makeKeyEvent('ArrowUp')) }) act(() => { result.current.onKeyDown(makeKeyEvent('ArrowUp')) }) expect(result.current.selectedIndex).toBe(0) }) }) describe('keyboard navigation — Enter', () => { it('calls onExecute with the currently selected item', () => { const onExecute = jest.fn() const { result } = renderHook(() => useCommandPaletteSearch(makeOpts({ onExecute }))) // selectedIndex is 0 → nav-home const e = makeKeyEvent('Enter') act(() => { result.current.onKeyDown(e) }) expect(onExecute).toHaveBeenCalledTimes(1) expect(onExecute).toHaveBeenCalledWith(COMMANDS[0]) }) it('calls preventDefault on Enter', () => { const { result } = renderHook(() => useCommandPaletteSearch(makeOpts())) const e = makeKeyEvent('Enter') act(() => { result.current.onKeyDown(e) }) expect(e.preventDefault).toHaveBeenCalled() }) it('executes the correct item after navigating down', () => { const onExecute = jest.fn() const { result } = renderHook(() => useCommandPaletteSearch(makeOpts({ onExecute }))) act(() => { result.current.onKeyDown(makeKeyEvent('ArrowDown')) }) act(() => { result.current.onKeyDown(makeKeyEvent('ArrowDown')) }) act(() => { result.current.onKeyDown(makeKeyEvent('Enter')) }) expect(onExecute).toHaveBeenCalledWith(COMMANDS[2]) }) it('does not call onExecute when filtered list is empty', () => { const onExecute = jest.fn() const { result } = renderHook(() => useCommandPaletteSearch(makeOpts({ onExecute }))) act(() => { result.current.setQuery('zzznomatch') }) act(() => { result.current.onKeyDown(makeKeyEvent('Enter')) }) expect(onExecute).not.toHaveBeenCalled() }) }) describe('isOpen lifecycle', () => { it('resets query when palette opens', () => { const { result, rerender } = renderHook( (props: Parameters[0]) => useCommandPaletteSearch(props), { initialProps: makeOpts({ isOpen: true }) } ) act(() => { result.current.setQuery('home') }) expect(result.current.query).toBe('home') // Close then re-open rerender(makeOpts({ isOpen: false })) rerender(makeOpts({ isOpen: true })) expect(result.current.query).toBe('') }) it('resets selectedIndex when palette opens', () => { const { result, rerender } = renderHook( (props: Parameters[0]) => useCommandPaletteSearch(props), { initialProps: makeOpts({ isOpen: true }) } ) act(() => { result.current.onKeyDown(makeKeyEvent('ArrowDown')) }) expect(result.current.selectedIndex).toBe(1) rerender(makeOpts({ isOpen: false })) rerender(makeOpts({ isOpen: true })) expect(result.current.selectedIndex).toBe(0) }) }) })