import { act, renderHook, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type { ReactNode } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useFilePicker } from '@/components/inputarea/hooks/useFilePicker'; import { useChatStore } from '@/stores'; const getProjectFiles = vi.fn(); vi.mock('@/services', () => ({ bridge: { getProjectFiles: (...args: unknown[]) => getProjectFiles(...args), }, })); const noop = () => {}; const at = (query: string) => ({ trigger: '@' as const, start: 0, query }); type Token = ReturnType | null; function createWrapper() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); return ({ children }: { children: ReactNode }) => ( {children} ); } beforeEach(() => { getProjectFiles.mockReset(); getProjectFiles.mockResolvedValue([]); useChatStore.setState({ isStreaming: false, draftMedia: [] }); }); describe('enabled gating', () => { it('does not search without an active token', async () => { renderHook(() => useFilePicker(null, noop, noop, noop), { wrapper: createWrapper() }); await new Promise((r) => setTimeout(r, 150)); expect(getProjectFiles).not.toHaveBeenCalled(); }); }); describe('search', () => { it('searches with the current query and returns results', async () => { getProjectFiles.mockResolvedValue([{ name: 'app.ts', path: 'src/app.ts', isDirectory: false }]); const { result } = renderHook(() => useFilePicker(at('app'), noop, noop, noop), { wrapper: createWrapper() }); await waitFor(() => { expect(result.current.fileItems).toHaveLength(1); }); expect(result.current.fileItems[0]).toMatchObject({ name: 'app.ts', path: 'src/app.ts' }); }); it('caps the displayed items at 50', async () => { getProjectFiles.mockResolvedValue( Array.from({ length: 60 }, (_, i) => ({ name: `f${i}.ts`, path: `f${i}.ts`, isDirectory: false })), ); const { result } = renderHook(() => useFilePicker(at('f'), noop, noop, noop), { wrapper: createWrapper() }); await waitFor(() => { expect(result.current.fileItems).toHaveLength(50); }); }); it('debounces rapid query changes into a single request after 100ms', async () => { const { rerender } = renderHook(({ token }) => useFilePicker(token, noop, noop, noop), { initialProps: { token: at('') as Token }, wrapper: createWrapper(), }); await waitFor(() => { expect(getProjectFiles).toHaveBeenCalledTimes(1); }); getProjectFiles.mockClear(); rerender({ token: at('a') }); rerender({ token: at('ap') }); rerender({ token: at('app') }); expect(getProjectFiles).not.toHaveBeenCalled(); await waitFor(() => { expect(getProjectFiles).toHaveBeenCalledTimes(1); }); expect(getProjectFiles).toHaveBeenCalledWith({ query: 'app' }); }); }); describe('media option', () => { it('shows the media option only for an empty query', async () => { const { result, rerender } = renderHook(({ token }) => useFilePicker(token, noop, noop, noop), { initialProps: { token: at('') as Token }, wrapper: createWrapper(), }); expect(result.current.showMediaOption).toBe(true); expect(result.current.fileMenuHeaderCount).toBe(1); rerender({ token: at('a') }); expect(result.current.showMediaOption).toBe(false); expect(result.current.fileMenuHeaderCount).toBe(0); }); it('hides the media option when media cannot be added', () => { useChatStore.setState({ isStreaming: true }); const { result } = renderHook(() => useFilePicker(at(''), noop, noop, noop), { wrapper: createWrapper() }); expect(result.current.showMediaOption).toBe(false); expect(result.current.fileMenuHeaderCount).toBe(0); }); }); describe('keyboard navigation', () => { const key = (result: { current: ReturnType }, k: string) => { act(() => { result.current.handleFileMenuKey({ key: k, preventDefault: vi.fn() } as unknown as React.KeyboardEvent); }); }; it('moves selectedIndex within the list bounds with ArrowDown and ArrowUp', async () => { getProjectFiles.mockResolvedValue([ { name: 'a.ts', path: 'a.ts', isDirectory: false }, { name: 'b.ts', path: 'b.ts', isDirectory: false }, ]); const { result } = renderHook(() => useFilePicker(at('a'), noop, noop, noop), { wrapper: createWrapper() }); await waitFor(() => { expect(result.current.fileItems).toHaveLength(2); }); const maxIndex = result.current.fileMenuHeaderCount + result.current.fileItems.length - 1; expect(result.current.selectedIndex).toBe(0); key(result, 'ArrowUp'); expect(result.current.selectedIndex).toBe(0); key(result, 'ArrowDown'); key(result, 'ArrowDown'); key(result, 'ArrowDown'); expect(result.current.selectedIndex).toBe(maxIndex); key(result, 'ArrowUp'); expect(result.current.selectedIndex).toBe(maxIndex - 1); }); it('lets Enter fall through when there is no selectable entry', async () => { getProjectFiles.mockResolvedValue([]); const { result } = renderHook(() => useFilePicker(at('zzz'), noop, noop, noop), { wrapper: createWrapper() }); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); let handled = true; act(() => { handled = result.current.handleFileMenuKey({ key: 'Enter', preventDefault: vi.fn() } as unknown as React.KeyboardEvent); }); expect(handled).toBe(false); }); it('clamps the selection when fresh results shrink the list', async () => { getProjectFiles.mockResolvedValue([ { name: 'a.ts', path: 'a.ts', isDirectory: false }, { name: 'b.ts', path: 'b.ts', isDirectory: false }, ]); const { result, rerender } = renderHook(({ token }) => useFilePicker(token, noop, noop, noop), { initialProps: { token: at('a') as Token }, wrapper: createWrapper(), }); await waitFor(() => { expect(result.current.fileItems).toHaveLength(2); }); getProjectFiles.mockResolvedValue([{ name: 'app.ts', path: 'app.ts', isDirectory: false }]); rerender({ token: at('ap') }); act(() => { result.current.setSelectedIndex(1); }); await waitFor(() => { expect(result.current.fileItems).toHaveLength(1); }); expect(result.current.selectedIndex).toBe(0); }); it('calls onPickMedia when Enter selects the media option', async () => { const onPickMedia = vi.fn(); const { result } = renderHook(() => useFilePicker(at(''), noop, onPickMedia, noop), { wrapper: createWrapper() }); expect(result.current.selectedIndex).toBe(0); key(result, 'Enter'); expect(onPickMedia).toHaveBeenCalledTimes(1); }); it('ignores confirmation while results are stale for the current query', async () => { getProjectFiles.mockResolvedValue([{ name: 'a.ts', path: 'src/a.ts', isDirectory: false }]); const onInsertFile = vi.fn(); const { result, rerender } = renderHook(({ token }) => useFilePicker(token, onInsertFile, noop, noop), { initialProps: { token: at('a') as Token }, wrapper: createWrapper(), }); await waitFor(() => { expect(result.current.fileItems).toHaveLength(1); }); let resolveNext: (value: unknown) => void = noop; getProjectFiles.mockImplementation(() => new Promise((resolve) => { resolveNext = resolve; })); rerender({ token: at('ap') }); expect(result.current.isStale).toBe(true); key(result, 'Enter'); act(() => { result.current.handleSelectItem(result.current.fileItems[0]!); }); expect(onInsertFile).not.toHaveBeenCalled(); await waitFor(() => { expect(getProjectFiles).toHaveBeenCalledWith({ query: 'ap' }); }); act(() => { resolveNext([{ name: 'app.ts', path: 'src/app.ts', isDirectory: false }]); }); await waitFor(() => { expect(result.current.fileItems[0]?.name).toBe('app.ts'); }); expect(result.current.isStale).toBe(false); key(result, 'Enter'); expect(onInsertFile).toHaveBeenCalledWith('src/app.ts'); }); it('calls onInsertFile when Enter selects a file', async () => { getProjectFiles.mockResolvedValue([{ name: 'a.ts', path: 'src/a.ts', isDirectory: false }]); const onInsertFile = vi.fn(); const { result } = renderHook(() => useFilePicker(at('a'), onInsertFile, noop, noop), { wrapper: createWrapper() }); await waitFor(() => { expect(result.current.fileItems).toHaveLength(1); }); act(() => { result.current.setSelectedIndex(result.current.fileMenuHeaderCount); }); key(result, 'Enter'); expect(onInsertFile).toHaveBeenCalledWith('src/a.ts'); }); it('calls onInsertFile with the directory path when Enter selects a directory', async () => { getProjectFiles.mockResolvedValue([{ name: 'src', path: 'src', isDirectory: true }]); const onInsertFile = vi.fn(); const { result } = renderHook(() => useFilePicker(at('sr'), onInsertFile, noop, noop), { wrapper: createWrapper() }); await waitFor(() => { expect(result.current.fileItems).toHaveLength(1); }); act(() => { result.current.setSelectedIndex(result.current.fileMenuHeaderCount); }); key(result, 'Enter'); expect(onInsertFile).toHaveBeenCalledWith('src'); }); });