From da249369ee7aecca5b7f1c1cfe1e2183817b36af Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 2 May 2026 18:47:54 +0200 Subject: [PATCH] test(geschichten): cover DocumentMultiSelect search, chip add/remove MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browser-based component spec mirroring PersonTypeahead.svelte.spec.ts: renders empty input, surfaces pre-selected chips with formatted date, emits hidden documentIds inputs for each chip, debounces the search against /api/documents/search, adds a chip on click, hides already- selected docs from new dropdown results, and removes a chip on × click. Closes Felix's review B2 on PR #382. Co-Authored-By: Claude Opus 4.7 --- .../DocumentMultiSelect.svelte.spec.ts | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 frontend/src/lib/components/DocumentMultiSelect.svelte.spec.ts diff --git a/frontend/src/lib/components/DocumentMultiSelect.svelte.spec.ts b/frontend/src/lib/components/DocumentMultiSelect.svelte.spec.ts new file mode 100644 index 00000000..b5650fbd --- /dev/null +++ b/frontend/src/lib/components/DocumentMultiSelect.svelte.spec.ts @@ -0,0 +1,126 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page, userEvent } from 'vitest/browser'; +import DocumentMultiSelect from './DocumentMultiSelect.svelte'; + +const waitForDebounce = () => new Promise((r) => setTimeout(r, 350)); + +const docFactory = (id: string, title: string, date = '1880-01-01') => ({ + id, + title, + documentDate: date, + originalFilename: `${title}.pdf`, + status: 'UPLOADED', + metadataComplete: false, + scriptType: 'UNKNOWN' as const, + createdAt: '2024-01-01T00:00:00', + updatedAt: '2024-01-01T00:00:00' +}); + +function mockSearchResponse(items: ReturnType[]) { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ items: items.map((document) => ({ document })) }) + }) + ); +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('DocumentMultiSelect — rendering', () => { + it('renders an empty chip-input by default', async () => { + render(DocumentMultiSelect); + await expect.element(page.getByPlaceholder('Dokument suchen…')).toBeInTheDocument(); + }); + + it('renders pre-selected documents as chips with their date', async () => { + render(DocumentMultiSelect, { + selectedDocuments: [docFactory('d1', 'Brief vom 1. Mai', '1882-05-01')] + }); + await expect.element(page.getByText(/Brief vom 1\. Mai/)).toBeInTheDocument(); + await expect.element(page.getByText(/01\.05\.1882/)).toBeInTheDocument(); + }); + + it('emits a hidden documentIds input for each pre-selected document', async () => { + render(DocumentMultiSelect, { + selectedDocuments: [docFactory('d1', 'A'), docFactory('d2', 'B')] + }); + const inputs = document.querySelectorAll( + 'input[type="hidden"][name="documentIds"]' + ); + expect(inputs).toHaveLength(2); + expect([inputs[0].value, inputs[1].value].sort()).toEqual(['d1', 'd2']); + }); +}); + +describe('DocumentMultiSelect — search and select', () => { + it('queries /api/documents/search after debounce and shows results', async () => { + mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]); + render(DocumentMultiSelect); + + await userEvent.fill(page.getByPlaceholder('Dokument suchen…'), 'Eug'); + await waitForDebounce(); + + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.stringMatching(/^\/api\/documents\/search\?q=Eug/) + ); + await expect.element(page.getByText(/Brief von Eugenie/)).toBeInTheDocument(); + }); + + it('adds a chip when a search result is clicked', async () => { + mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]); + render(DocumentMultiSelect); + + await userEvent.fill(page.getByPlaceholder('Dokument suchen…'), 'Eug'); + await waitForDebounce(); + await userEvent.click(page.getByText(/Brief von Eugenie/)); + + // After selection the search field clears and the chip is rendered + const hidden = document.querySelector( + 'input[type="hidden"][name="documentIds"]' + ); + expect(hidden?.value).toBe('d1'); + }); + + it('hides already-selected documents from new search results', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + items: [ + { document: docFactory('d1', 'Already attached') }, + { document: docFactory('d2', 'Not attached') } + ] + }) + }); + vi.stubGlobal('fetch', fetchMock); + + render(DocumentMultiSelect, { + selectedDocuments: [docFactory('d1', 'Already attached')] + }); + + await userEvent.fill(page.getByPlaceholder('Dokument suchen…'), 'attached'); + await waitForDebounce(); + + // "Not attached" appears in the dropdown; "Already attached" only as the chip. + const matches = await page.getByText(/Already attached/).all(); + expect(matches.length).toBe(1); // chip only, not in dropdown + await expect.element(page.getByText(/Not attached/)).toBeInTheDocument(); + }); +}); + +describe('DocumentMultiSelect — remove', () => { + it('removes a chip when its × button is clicked', async () => { + render(DocumentMultiSelect, { + selectedDocuments: [docFactory('d1', 'Brief A')] + }); + await userEvent.click(page.getByLabelText('Entfernen')); + expect( + document.querySelector('input[type="hidden"][name="documentIds"]') + ).toBeNull(); + }); +});