veza/apps/web/src/components/search/Search.test.tsx
2025-12-12 21:34:34 -05:00

418 lines
11 KiB
TypeScript

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<string, string> = {};
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(<Search onSearch={mockOnSearch} placeholder="Search..." />);
const input = screen.getByPlaceholderText('Search...');
expect(input).toBeInTheDocument();
});
it('calls onSearch with debounced query', async () => {
const user = userEvent.setup();
render(
<Search
onSearch={mockOnSearch}
debounceDelay={300}
fetchSuggestions={mockFetchSuggestions}
/>,
);
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(
<Search
onSearch={mockOnSearch}
fetchSuggestions={mockFetchSuggestions}
showSuggestions={true}
/>,
);
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(
<Search
onSearch={mockOnSearch}
onResultSelect={mockOnResultSelect}
fetchSuggestions={mockFetchSuggestions}
showSuggestions={true}
/>,
);
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(<Search onSearch={mockOnSearch} showHistory={true} />);
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(<Search onSearch={mockOnSearch} showHistory={true} />);
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(
<Search onSearch={mockOnSearch} showHistory={true} maxHistoryItems={3} />,
);
// 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(<Search onSearch={mockOnSearch} showHistory={true} />);
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(<Search onSearch={mockOnSearch} />);
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(<Search onSearch={mockOnSearch} />);
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(
<Search
onSearch={mockOnSearch}
fetchSuggestions={mockFetchSuggestions}
showSuggestions={true}
/>,
);
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(
<Search
onSearch={mockOnSearch}
onResultSelect={mockOnResultSelect}
fetchSuggestions={mockFetchSuggestions}
showSuggestions={true}
/>,
);
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(
<Search
onSearch={mockOnSearch}
fetchSuggestions={mockFetchSuggestions}
showSuggestions={true}
/>,
);
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<SearchResult[]> => {
await new Promise((resolve) => setTimeout(resolve, 100));
return mockSuggestions;
},
);
const user = userEvent.setup();
render(
<Search
onSearch={mockOnSearch}
fetchSuggestions={slowFetchSuggestions}
showSuggestions={true}
/>,
);
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(
<Search
onSearch={mockOnSearch}
fetchSuggestions={emptyFetchSuggestions}
showSuggestions={true}
/>,
);
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(
<Search
onSearch={mockOnSearch}
fetchSuggestions={mockFetchSuggestions}
showSuggestions={false}
/>,
);
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(<Search onSearch={mockOnSearch} showHistory={false} />);
const input = screen.getByPlaceholderText('Rechercher...');
input.focus();
expect(screen.queryByText('previous search')).not.toBeInTheDocument();
});
it('applies custom className', () => {
const { container } = render(
<Search onSearch={mockOnSearch} className="custom-class" />,
);
const searchContainer = container.firstChild;
expect(searchContainer).toHaveClass('custom-class');
});
});