import { render, screen } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import userEvent from '@testing-library/user-event'; import { Search, SearchResult } from './Search'; // Mock localStorage const localStorageMock = (() => { let store: Record = {}; return { getItem: (key: string) => store[key] || null, setItem: (key: string, value: string) => { store[key] = value.toString(); }, removeItem: (key: string) => { delete store[key]; }, clear: () => { store = {}; }, }; })(); Object.defineProperty(window, 'localStorage', { value: localStorageMock, }); describe('Search Component', () => { const mockOnSearch = vi.fn(); const mockOnResultSelect = vi.fn(); const mockSuggestions: SearchResult[] = [ { id: '1', type: 'track', title: 'Track 1', subtitle: 'Artist 1' }, { id: '2', type: 'user', title: 'User 1', subtitle: '@user1' }, { id: '3', type: 'playlist', title: 'Playlist 1', subtitle: '10 tracks' }, ]; const mockFetchSuggestions = vi.fn(async (query: string) => { return mockSuggestions.filter((s) => s.title.toLowerCase().includes(query.toLowerCase()), ); }); beforeEach(() => { vi.clearAllMocks(); localStorageMock.clear(); }); it('renders search input with placeholder', () => { render(); const input = screen.getByPlaceholderText('Search...'); expect(input).toBeInTheDocument(); }); it('calls onSearch with debounced query', async () => { const user = userEvent.setup(); render( , ); const input = screen.getByPlaceholderText('Rechercher...'); await user.type(input, 'test'); // Attendre le debounce await waitFor( () => { expect(mockOnSearch).toHaveBeenCalledWith('test'); }, { timeout: 1000 }, ); }); it('shows suggestions when fetchSuggestions is provided', async () => { const user = userEvent.setup(); render( , ); const input = screen.getByPlaceholderText('Rechercher...'); await user.type(input, 'track'); await waitFor( () => { expect(mockFetchSuggestions).toHaveBeenCalled(); }, { timeout: 1000 }, ); await waitFor( () => { expect(screen.getByText('Track 1')).toBeInTheDocument(); }, { timeout: 1000 }, ); }); it('calls onResultSelect when suggestion is clicked', async () => { const user = userEvent.setup(); render( , ); const input = screen.getByPlaceholderText('Rechercher...'); await user.type(input, 'track'); await waitFor( () => { expect(screen.getByText('Track 1')).toBeInTheDocument(); }, { timeout: 1000 }, ); const suggestion = screen.getByText('Track 1'); await user.click(suggestion); expect(mockOnResultSelect).toHaveBeenCalledWith(mockSuggestions[0]); }); it('shows history when input is empty and showHistory is true', async () => { localStorageMock.setItem( 'veza_search_history', JSON.stringify(['previous search 1', 'previous search 2']), ); render(); const input = screen.getByPlaceholderText('Rechercher...'); input.focus(); await waitFor(() => { expect(screen.getByText('previous search 1')).toBeInTheDocument(); expect(screen.getByText('previous search 2')).toBeInTheDocument(); }); }); it('adds query to history when searching', async () => { const user = userEvent.setup(); render(); const input = screen.getByPlaceholderText('Rechercher...'); await user.type(input, 'new search'); await waitFor( () => { const history = JSON.parse( localStorageMock.getItem('veza_search_history') || '[]', ); expect(history).toContain('new search'); }, { timeout: 1000 }, ); }); it('limits history items to maxHistoryItems', async () => { const user = userEvent.setup(); render( , ); // Ajouter plus de 3 items const input = screen.getByPlaceholderText('Rechercher...'); for (let i = 1; i <= 4; i++) { await user.clear(input); await user.type(input, `search ${i}`); await new Promise((resolve) => setTimeout(resolve, 350)); } await waitFor( () => { const history = JSON.parse( localStorageMock.getItem('veza_search_history') || '[]', ); expect(history.length).toBeLessThanOrEqual(3); }, { timeout: 2000 }, ); }); it('clears history when clear button is clicked', async () => { localStorageMock.setItem( 'veza_search_history', JSON.stringify(['search 1', 'search 2']), ); const user = userEvent.setup(); render(); const input = screen.getByPlaceholderText('Rechercher...'); input.focus(); const clearButton = screen.getByText('Effacer'); await user.click(clearButton); await waitFor(() => { const history = localStorageMock.getItem('veza_search_history'); expect(history).toBeNull(); }); }); it('shows clear button when query has value', async () => { const user = userEvent.setup(); render(); const input = screen.getByPlaceholderText('Rechercher...'); await user.type(input, 'test'); await waitFor(() => { const clearButton = screen.getByLabelText('Clear search'); expect(clearButton).toBeInTheDocument(); }); }); it('clears input when clear button is clicked', async () => { const user = userEvent.setup({ delay: null }); render(); const input = screen.getByPlaceholderText('Rechercher...'); await user.type(input, 'test'); const clearButton = screen.getByLabelText('Clear search'); await user.click(clearButton); expect(input).toHaveValue(''); }); it('navigates suggestions with arrow keys', async () => { const user = userEvent.setup(); render( , ); const input = screen.getByPlaceholderText('Rechercher...'); await user.type(input, 'track'); await waitFor( () => { expect(screen.getByText('Track 1')).toBeInTheDocument(); }, { timeout: 1000 }, ); await user.keyboard('{ArrowDown}'); await user.keyboard('{ArrowDown}'); // Le deuxième élément devrait être actif await waitFor(() => { const secondSuggestion = screen.getByText('User 1'); expect(secondSuggestion.closest('button')).toHaveClass('bg-accent'); }); }); it('selects suggestion with Enter key', async () => { const user = userEvent.setup(); render( , ); const input = screen.getByPlaceholderText('Rechercher...'); await user.type(input, 'track'); await waitFor( () => { expect(screen.getByText('Track 1')).toBeInTheDocument(); }, { timeout: 1000 }, ); await user.keyboard('{ArrowDown}'); await user.keyboard('{Enter}'); await waitFor(() => { expect(mockOnResultSelect).toHaveBeenCalledWith(mockSuggestions[0]); }); }); it('closes dropdown with Escape key', async () => { const user = userEvent.setup(); render( , ); const input = screen.getByPlaceholderText('Rechercher...'); await user.type(input, 'track'); await waitFor( () => { expect(screen.getByText('Track 1')).toBeInTheDocument(); }, { timeout: 1000 }, ); await user.keyboard('{Escape}'); await waitFor(() => { expect(screen.queryByText('Track 1')).not.toBeInTheDocument(); }); }); it('shows loading state when fetching suggestions', async () => { const slowFetchSuggestions = vi.fn( async (_query: string): Promise => { await new Promise((resolve) => setTimeout(resolve, 100)); return mockSuggestions; }, ); const user = userEvent.setup(); render( , ); const input = screen.getByPlaceholderText('Rechercher...'); await user.type(input, 'track'); await waitFor( () => { expect(screen.getByText('Recherche en cours...')).toBeInTheDocument(); }, { timeout: 1000 }, ); }); it('shows no results message when suggestions are empty', async () => { const emptyFetchSuggestions = vi.fn(async () => []); const user = userEvent.setup(); render( , ); const input = screen.getByPlaceholderText('Rechercher...'); await user.type(input, 'nonexistent'); await waitFor( () => { expect(screen.getByText('Aucun résultat trouvé')).toBeInTheDocument(); }, { timeout: 1000 }, ); }); it('does not show suggestions when showSuggestions is false', async () => { const user = userEvent.setup(); render( , ); const input = screen.getByPlaceholderText('Rechercher...'); await user.type(input, 'track'); // Attendre un peu pour s'assurer que les suggestions ne s'affichent pas await new Promise((resolve) => setTimeout(resolve, 400)); expect(screen.queryByText('Track 1')).not.toBeInTheDocument(); }); it('does not show history when showHistory is false', () => { localStorageMock.setItem( 'veza_search_history', JSON.stringify(['previous search']), ); render(); const input = screen.getByPlaceholderText('Rechercher...'); input.focus(); expect(screen.queryByText('previous search')).not.toBeInTheDocument(); }); it('applies custom className', () => { const { container } = render( , ); const searchContainer = container.firstChild; expect(searchContainer).toHaveClass('custom-class'); }); });