import { afterEach, describe, expect, it, vi } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page, userEvent } from 'vitest/browser'; import DocumentPickerDropdown from './DocumentPickerDropdown.svelte'; import { m } from '$lib/paraglide/messages.js'; const waitForDebounce = () => new Promise((r) => setTimeout(r, 350)); const docFactory = (id: string, title: string) => ({ id, title, documentDate: '1880-01-01', metaDatePrecision: 'DAY' as const, metaDateEnd: undefined }); function mockSearchResponse(items: ReturnType[]) { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items }) }) ); } afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); describe('DocumentPickerDropdown — empty query guard', () => { it('does not call fetch on empty query', async () => { const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); render(DocumentPickerDropdown, { onSelect: vi.fn() }); await userEvent.fill(page.getByRole('combobox'), ''); await waitForDebounce(); expect(fetchMock).not.toHaveBeenCalled(); }); }); describe('DocumentPickerDropdown — already-added indicator', () => { it('shows already-added document as aria-disabled with sr-only hint', async () => { mockSearchResponse([docFactory('d1', 'Brief von Eugenie'), docFactory('d2', 'Brief 2')]); render(DocumentPickerDropdown, { alreadyAddedIds: new Set(['d1']), onSelect: vi.fn() }); await userEvent.fill(page.getByRole('combobox'), 'Brief'); await waitForDebounce(); const disabledItem = page .getByText(/Brief von Eugenie/i) .element() .closest('li')!; expect(disabledItem.getAttribute('aria-disabled')).toBe('true'); // Screen-reader text "bereits enthalten" must be present in the item await expect.element(page.getByText(/bereits enthalten/i)).toBeInTheDocument(); }); }); describe('DocumentPickerDropdown — selection', () => { it('calls onSelect with the item when a non-disabled option is clicked', async () => { const onSelect = vi.fn(); mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]); render(DocumentPickerDropdown, { onSelect }); await userEvent.fill(page.getByRole('combobox'), 'Brief'); await waitForDebounce(); await userEvent.click(page.getByText(/Brief von Eugenie/i)); expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'd1' })); }); it('does not call onSelect when an aria-disabled option is clicked', async () => { const onSelect = vi.fn(); mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]); render(DocumentPickerDropdown, { alreadyAddedIds: new Set(['d1']), onSelect }); await userEvent.fill(page.getByRole('combobox'), 'Brief'); await waitForDebounce(); await page.getByText(/Brief von Eugenie/i).click({ force: true }); expect(onSelect).not.toHaveBeenCalled(); }); }); describe('DocumentPickerDropdown — keyboard navigation', () => { it('selects the first option via ArrowDown then Enter', async () => { const onSelect = vi.fn(); mockSearchResponse([docFactory('d1', 'Brief von Eugenie'), docFactory('d2', 'Brief 2')]); render(DocumentPickerDropdown, { onSelect }); await userEvent.fill(page.getByRole('combobox'), 'Brief'); await waitForDebounce(); await userEvent.keyboard('{ArrowDown}'); await userEvent.keyboard('{Enter}'); expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'd1' })); }); it('does not select an aria-disabled option on Enter', async () => { const onSelect = vi.fn(); mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]); render(DocumentPickerDropdown, { alreadyAddedIds: new Set(['d1']), onSelect }); await userEvent.fill(page.getByRole('combobox'), 'Brief'); await waitForDebounce(); await userEvent.keyboard('{ArrowDown}'); await userEvent.keyboard('{Enter}'); expect(onSelect).not.toHaveBeenCalled(); }); it('closes the dropdown on Escape', async () => { mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]); render(DocumentPickerDropdown, { onSelect: vi.fn() }); await userEvent.fill(page.getByRole('combobox'), 'Brief'); await waitForDebounce(); await expect.element(page.getByRole('listbox')).toBeInTheDocument(); await userEvent.keyboard('{Escape}'); await expect.element(page.getByRole('listbox')).not.toBeInTheDocument(); }); it('points aria-activedescendant at the active option', async () => { mockSearchResponse([docFactory('d1', 'Brief von Eugenie'), docFactory('d2', 'Brief 2')]); render(DocumentPickerDropdown, { onSelect: vi.fn() }); const input = page.getByRole('combobox'); await userEvent.fill(input, 'Brief'); await waitForDebounce(); expect(input.element().getAttribute('aria-activedescendant')).toBeNull(); await userEvent.keyboard('{ArrowDown}'); const activeId = input.element().getAttribute('aria-activedescendant'); expect(activeId).toMatch(/-option-0$/); const firstOption = page .getByText(/Brief von Eugenie/i) .element() .closest('li')!; expect(firstOption.id).toBe(activeId); expect(firstOption.getAttribute('aria-selected')).toBe('true'); }); }); describe('DocumentPickerDropdown — no results', () => { it('shows a non-interactive no-results row when the search returns zero hits', async () => { mockSearchResponse([]); render(DocumentPickerDropdown, { onSelect: vi.fn() }); await userEvent.fill(page.getByRole('combobox'), 'xyz'); await waitForDebounce(); await expect.element(page.getByText(m.comp_typeahead_no_results())).toBeInTheDocument(); }); }); describe('DocumentPickerDropdown — search failure', () => { it('shows an error message when the search request fails instead of vanishing', async () => { // 500 from /api/documents/search — must surface, not render as "no results" vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: false, status: 500, json: vi.fn().mockResolvedValue({ code: 'INTERNAL_ERROR' }) }) ); render(DocumentPickerDropdown, { onSelect: vi.fn() }); await userEvent.fill(page.getByRole('combobox'), 'Brief'); await waitForDebounce(); await expect.element(page.getByText(m.comp_typeahead_error())).toBeInTheDocument(); }); }); describe('DocumentPickerDropdown — ARIA listbox integrity', () => { it('does not render a listbox when results are empty (no aria-required-children violation)', async () => { mockSearchResponse([]); render(DocumentPickerDropdown, { onSelect: vi.fn() }); await userEvent.fill(page.getByRole('combobox'), 'xyz'); await waitForDebounce(); // no-results message must be visible, but NOT inside a listbox await expect.element(page.getByText(m.comp_typeahead_no_results())).toBeInTheDocument(); expect(document.querySelector('[role="listbox"]')).toBeNull(); }); it('does not render a listbox when loading (no aria-required-children violation)', async () => { let resolveSearch!: (v: unknown) => void; vi.stubGlobal( 'fetch', vi.fn().mockReturnValue(new Promise((resolve) => (resolveSearch = resolve))) ); render(DocumentPickerDropdown, { onSelect: vi.fn() }); await userEvent.fill(page.getByRole('combobox'), 'Brief'); // While in-flight, no listbox should exist expect(document.querySelector('[role="listbox"]')).toBeNull(); resolveSearch({ ok: true, json: () => Promise.resolve({ items: [] }) }); }); it('option elements do not have tabindex (combobox pattern: focus stays on input)', async () => { mockSearchResponse([docFactory('d1', 'Brief A'), docFactory('d2', 'Brief B')]); render(DocumentPickerDropdown, { onSelect: vi.fn() }); await userEvent.fill(page.getByRole('combobox'), 'Brief'); await waitForDebounce(); const options = document.querySelectorAll('[role="listbox"] [role="option"]'); expect(options.length).toBeGreaterThan(0); options.forEach((opt) => { expect(opt).not.toHaveAttribute('tabindex'); }); }); }); describe('DocumentPickerDropdown — external label wiring (#795)', () => { it('renders a generated default id on the input and keeps the aria-label fallback', async () => { render(DocumentPickerDropdown, { onSelect: vi.fn() }); const input = page.getByRole('combobox').element() as HTMLInputElement; expect(input.id).toMatch(/^doc-picker-input-/); expect(input.getAttribute('aria-label')).not.toBeNull(); }); it('uses the provided inputId and drops the aria-label so an external label wins', async () => { render(DocumentPickerDropdown, { onSelect: vi.fn(), inputId: 'story-doc-picker' }); const input = page.getByRole('combobox').element() as HTMLInputElement; expect(input.id).toBe('story-doc-picker'); expect(input.getAttribute('aria-label')).toBeNull(); }); });