import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/svelte'; vi.mock('$lib/api/config', () => ({ getApiBaseUrl: () => 'http://test.classeo.local:18000/api' })); const mockAuthenticatedFetch = vi.fn(); vi.mock('$lib/auth', () => ({ authenticatedFetch: (...args: unknown[]) => mockAuthenticatedFetch(...args) })); import ParentSearchInput from '$lib/components/molecules/ParentSearchInput/ParentSearchInput.svelte'; function mockApiResponse(members: Record[]) { mockAuthenticatedFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ member: members }) }); } const PARENT_DUPONT = { id: 'uuid-1', firstName: 'Jean', lastName: 'Dupont', email: 'jean@test.com' }; const PARENT_MARTIN = { id: 'uuid-2', firstName: 'Marie', lastName: 'Martin', email: 'marie@test.com' }; const PARENT_NO_NAME = { id: 'uuid-3', firstName: '', lastName: '', email: 'noname@test.com' }; describe('ParentSearchInput', () => { beforeEach(() => { vi.useFakeTimers(); vi.clearAllMocks(); }); afterEach(() => { vi.useRealTimers(); }); it('renders with combobox role', () => { render(ParentSearchInput, { props: { onSelect: vi.fn() } }); expect(screen.getByRole('combobox')).toBeTruthy(); }); it('renders with default placeholder', () => { render(ParentSearchInput, { props: { onSelect: vi.fn() } }); expect(screen.getByPlaceholderText('Rechercher un parent par nom ou email...')).toBeTruthy(); }); it('renders with custom placeholder', () => { render(ParentSearchInput, { props: { onSelect: vi.fn(), placeholder: 'Chercher...' } }); expect(screen.getByPlaceholderText('Chercher...')).toBeTruthy(); }); it('does not search with less than 2 characters', async () => { render(ParentSearchInput, { props: { onSelect: vi.fn() } }); const input = screen.getByRole('combobox'); await fireEvent.input(input, { target: { value: 'a' } }); vi.advanceTimersByTime(300); expect(mockAuthenticatedFetch).not.toHaveBeenCalled(); }); it('debounces and searches after 300ms with 2+ characters', async () => { mockApiResponse([PARENT_DUPONT]); render(ParentSearchInput, { props: { onSelect: vi.fn() } }); const input = screen.getByRole('combobox'); await fireEvent.input(input, { target: { value: 'dup' } }); // Not called immediately expect(mockAuthenticatedFetch).not.toHaveBeenCalled(); vi.advanceTimersByTime(300); await vi.runAllTimersAsync(); expect(mockAuthenticatedFetch).toHaveBeenCalledOnce(); const url = mockAuthenticatedFetch.mock.calls[0]![0] as string; expect(url).toContain('role=ROLE_PARENT'); expect(url).toContain('search=dup'); expect(url).toContain('itemsPerPage=10'); }); it('displays results in dropdown', async () => { mockApiResponse([PARENT_DUPONT, PARENT_MARTIN]); render(ParentSearchInput, { props: { onSelect: vi.fn() } }); const input = screen.getByRole('combobox'); await fireEvent.input(input, { target: { value: 'test' } }); vi.advanceTimersByTime(300); await vi.runAllTimersAsync(); const options = screen.getAllByRole('option'); expect(options).toHaveLength(2); expect(options[0]!.textContent).toContain('Jean Dupont'); expect(options[0]!.textContent).toContain('jean@test.com'); expect(options[1]!.textContent).toContain('Marie Martin'); }); it('shows "Aucun parent trouvé" when no results', async () => { mockApiResponse([]); render(ParentSearchInput, { props: { onSelect: vi.fn() } }); const input = screen.getByRole('combobox'); await fireEvent.input(input, { target: { value: 'xyz' } }); vi.advanceTimersByTime(300); await vi.runAllTimersAsync(); expect(screen.getByText('Aucun parent trouvé')).toBeTruthy(); }); it('calls onSelect when clicking a result', async () => { mockApiResponse([PARENT_DUPONT]); const onSelect = vi.fn(); render(ParentSearchInput, { props: { onSelect } }); const input = screen.getByRole('combobox'); await fireEvent.input(input, { target: { value: 'dup' } }); vi.advanceTimersByTime(300); await vi.runAllTimersAsync(); const option = screen.getAllByRole('option')[0]!; await fireEvent.click(option); expect(onSelect).toHaveBeenCalledOnce(); expect(onSelect).toHaveBeenCalledWith(PARENT_DUPONT); }); it('closes dropdown after selection', async () => { mockApiResponse([PARENT_DUPONT]); render(ParentSearchInput, { props: { onSelect: vi.fn() } }); const input = screen.getByRole('combobox'); await fireEvent.input(input, { target: { value: 'dup' } }); vi.advanceTimersByTime(300); await vi.runAllTimersAsync(); await fireEvent.click(screen.getAllByRole('option')[0]!); expect(screen.queryByRole('listbox')).toBeNull(); }); it('updates input value with selected parent name', async () => { mockApiResponse([PARENT_DUPONT]); render(ParentSearchInput, { props: { onSelect: vi.fn() } }); const input = screen.getByRole('combobox') as HTMLInputElement; await fireEvent.input(input, { target: { value: 'dup' } }); vi.advanceTimersByTime(300); await vi.runAllTimersAsync(); await fireEvent.click(screen.getAllByRole('option')[0]!); expect(input.value).toBe('Jean Dupont'); }); it('shows email as fallback when names are empty', async () => { mockApiResponse([PARENT_NO_NAME]); render(ParentSearchInput, { props: { onSelect: vi.fn() } }); const input = screen.getByRole('combobox'); await fireEvent.input(input, { target: { value: 'noname' } }); vi.advanceTimersByTime(300); await vi.runAllTimersAsync(); const option = screen.getAllByRole('option')[0]!; expect(option.textContent).toContain('noname@test.com'); }); it('sets input to email when selecting parent without name', async () => { mockApiResponse([PARENT_NO_NAME]); render(ParentSearchInput, { props: { onSelect: vi.fn() } }); const input = screen.getByRole('combobox') as HTMLInputElement; await fireEvent.input(input, { target: { value: 'noname' } }); vi.advanceTimersByTime(300); await vi.runAllTimersAsync(); await fireEvent.click(screen.getAllByRole('option')[0]!); expect(input.value).toBe('noname@test.com'); }); it('closes dropdown on Escape', async () => { mockApiResponse([PARENT_DUPONT]); render(ParentSearchInput, { props: { onSelect: vi.fn() } }); const input = screen.getByRole('combobox'); await fireEvent.input(input, { target: { value: 'dup' } }); vi.advanceTimersByTime(300); await vi.runAllTimersAsync(); expect(screen.getByRole('listbox')).toBeTruthy(); await fireEvent.keyDown(input, { key: 'Escape' }); expect(screen.queryByRole('listbox')).toBeNull(); }); it('navigates options with ArrowDown', async () => { mockApiResponse([PARENT_DUPONT, PARENT_MARTIN]); render(ParentSearchInput, { props: { onSelect: vi.fn() } }); const input = screen.getByRole('combobox'); await fireEvent.input(input, { target: { value: 'test' } }); vi.advanceTimersByTime(300); await vi.runAllTimersAsync(); await fireEvent.keyDown(input, { key: 'ArrowDown' }); const options = screen.getAllByRole('option'); expect(options[0]!.getAttribute('aria-selected')).toBe('true'); expect(options[1]!.getAttribute('aria-selected')).toBe('false'); await fireEvent.keyDown(input, { key: 'ArrowDown' }); expect(options[0]!.getAttribute('aria-selected')).toBe('false'); expect(options[1]!.getAttribute('aria-selected')).toBe('true'); }); it('navigates options with ArrowUp', async () => { mockApiResponse([PARENT_DUPONT, PARENT_MARTIN]); render(ParentSearchInput, { props: { onSelect: vi.fn() } }); const input = screen.getByRole('combobox'); await fireEvent.input(input, { target: { value: 'test' } }); vi.advanceTimersByTime(300); await vi.runAllTimersAsync(); // ArrowUp from -1 wraps to last item await fireEvent.keyDown(input, { key: 'ArrowUp' }); const options = screen.getAllByRole('option'); expect(options[1]!.getAttribute('aria-selected')).toBe('true'); await fireEvent.keyDown(input, { key: 'ArrowUp' }); expect(options[0]!.getAttribute('aria-selected')).toBe('true'); expect(options[1]!.getAttribute('aria-selected')).toBe('false'); }); it('selects option with Enter key', async () => { mockApiResponse([PARENT_DUPONT, PARENT_MARTIN]); const onSelect = vi.fn(); render(ParentSearchInput, { props: { onSelect } }); const input = screen.getByRole('combobox') as HTMLInputElement; await fireEvent.input(input, { target: { value: 'test' } }); vi.advanceTimersByTime(300); await vi.runAllTimersAsync(); // Navigate to first option and press Enter await fireEvent.keyDown(input, { key: 'ArrowDown' }); await fireEvent.keyDown(input, { key: 'Enter' }); expect(onSelect).toHaveBeenCalledOnce(); expect(onSelect).toHaveBeenCalledWith(PARENT_DUPONT); expect(input.value).toBe('Jean Dupont'); expect(screen.queryByRole('listbox')).toBeNull(); }); it('wraps ArrowDown from last to first option', async () => { mockApiResponse([PARENT_DUPONT, PARENT_MARTIN]); render(ParentSearchInput, { props: { onSelect: vi.fn() } }); const input = screen.getByRole('combobox'); await fireEvent.input(input, { target: { value: 'test' } }); vi.advanceTimersByTime(300); await vi.runAllTimersAsync(); // Navigate to last, then one more wraps to first await fireEvent.keyDown(input, { key: 'ArrowDown' }); // index 0 await fireEvent.keyDown(input, { key: 'ArrowDown' }); // index 1 await fireEvent.keyDown(input, { key: 'ArrowDown' }); // wraps to 0 const options = screen.getAllByRole('option'); expect(options[0]!.getAttribute('aria-selected')).toBe('true'); expect(options[1]!.getAttribute('aria-selected')).toBe('false'); }); it('does not select with Enter when no option is active', async () => { mockApiResponse([PARENT_DUPONT]); const onSelect = vi.fn(); render(ParentSearchInput, { props: { onSelect } }); const input = screen.getByRole('combobox'); await fireEvent.input(input, { target: { value: 'dup' } }); vi.advanceTimersByTime(300); await vi.runAllTimersAsync(); // Press Enter without navigating (activeIndex = -1) await fireEvent.keyDown(input, { key: 'Enter' }); expect(onSelect).not.toHaveBeenCalled(); expect(screen.getByRole('listbox')).toBeTruthy(); }); it('filters out excluded IDs from results', async () => { mockApiResponse([PARENT_DUPONT, PARENT_MARTIN]); render(ParentSearchInput, { props: { onSelect: vi.fn(), excludeIds: ['uuid-1'] } }); const input = screen.getByRole('combobox'); await fireEvent.input(input, { target: { value: 'test' } }); vi.advanceTimersByTime(300); await vi.runAllTimersAsync(); const options = screen.getAllByRole('option'); expect(options).toHaveLength(1); expect(options[0]!.textContent).toContain('Marie Martin'); }); it('calls onClear when user retypes after selection', async () => { mockApiResponse([PARENT_DUPONT]); const onClear = vi.fn(); const onSelect = vi.fn(); render(ParentSearchInput, { props: { onSelect, onClear } }); const input = screen.getByRole('combobox'); await fireEvent.input(input, { target: { value: 'dup' } }); vi.advanceTimersByTime(300); await vi.runAllTimersAsync(); // Select a parent await fireEvent.click(screen.getAllByRole('option')[0]!); expect(onSelect).toHaveBeenCalledOnce(); expect(onClear).not.toHaveBeenCalled(); // Retype in the input — should trigger onClear mockApiResponse([PARENT_DUPONT]); await fireEvent.input(input, { target: { value: 'mar' } }); expect(onClear).toHaveBeenCalledOnce(); }); it('does not call onClear when typing without prior selection', async () => { const onClear = vi.fn(); render(ParentSearchInput, { props: { onSelect: vi.fn(), onClear } }); const input = screen.getByRole('combobox'); await fireEvent.input(input, { target: { value: 'test' } }); expect(onClear).not.toHaveBeenCalled(); }); it('sets aria-expanded correctly', async () => { mockApiResponse([PARENT_DUPONT]); render(ParentSearchInput, { props: { onSelect: vi.fn() } }); const input = screen.getByRole('combobox'); expect(input.getAttribute('aria-expanded')).toBe('false'); await fireEvent.input(input, { target: { value: 'dup' } }); vi.advanceTimersByTime(300); await vi.runAllTimersAsync(); expect(input.getAttribute('aria-expanded')).toBe('true'); }); });