418 lines
11 KiB
TypeScript
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');
|
|
});
|
|
});
|