From 230f23e37ce63e690c87381ab62d3aa55bc72a93 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 17:58:15 +0200 Subject: [PATCH] test(search): add NL search happy-path Playwright E2E (#739) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mock POST /api/search/nl (delayed fixture: 2-name directional + applied keyword), assert loading announcement → chips render → axe-clean in light and dark → removing the keyword chip re-runs a keyword GET with the remaining sender+receiver params. Adds a data-testid wrapper on the NL results region for axe scoping. Co-Authored-By: Claude Opus 4.8 --- frontend/e2e/nl-search.spec.ts | 83 ++++++++++++++++++++++ frontend/src/routes/documents/+page.svelte | 82 ++++++++++----------- 2 files changed, 125 insertions(+), 40 deletions(-) create mode 100644 frontend/e2e/nl-search.spec.ts diff --git a/frontend/e2e/nl-search.spec.ts b/frontend/e2e/nl-search.spec.ts new file mode 100644 index 00000000..bd869582 --- /dev/null +++ b/frontend/e2e/nl-search.spec.ts @@ -0,0 +1,83 @@ +import AxeBuilder from '@axe-core/playwright'; +import { test, expect } from '@playwright/test'; + +// NL search is mocked at the network boundary — Ollama is not required in CI. +// CSRF enforcement is bypassed by page.route (the real request is never sent), +// so it is only verified in manual full-stack runs (see issue #739 DevOps notes). +const interpretation = { + resolvedPersons: [ + { id: '11111111-1111-1111-1111-111111111111', displayName: 'Walter Raddatz' }, + { id: '22222222-2222-2222-2222-222222222222', displayName: 'Emma Raddatz' } + ], + ambiguousPersons: [], + dateFrom: '1914-01-01', + dateTo: '1918-12-31', + keywords: ['krieg'], + rawQuery: 'Was hat Walter an Emma im Krieg geschrieben?', + keywordsApplied: true +}; + +const nlResponse = { + result: { + items: [], + totalElements: 0, + pageNumber: 0, + pageSize: 20, + totalPages: 0, + undatedCount: 0 + }, + interpretation +}; + +test.describe('NL (smart) search — happy path', () => { + test('toggle → loading → chips → remove chip re-runs keyword search; axe clean light + dark', async ({ + page + }) => { + // Deliberate delay so the loading state is assertable before the response arrives. + await page.route('**/api/search/nl', async (route) => { + await new Promise((resolve) => setTimeout(resolve, 150)); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(nlResponse) + }); + }); + + await page.goto('/documents'); + await page.waitForSelector('[data-hydrated]'); + + // Switch to smart mode via the toggle pill (keyword label = "Text"). + await page.getByRole('button', { name: /Text/ }).click(); + + const input = page.getByPlaceholder('Titel, Personen, Tags durchsuchen…'); + await input.fill('Was hat Walter an Emma im Krieg geschrieben?'); + await input.press('Enter'); + + // Loading panel announced to screen readers. + await expect(page.getByText(/Archiv wird befragt/)).toBeVisible(); + + // Directional chip (Walter → Emma) + keyword chip render once the fixture resolves. + await expect(page.getByText('→')).toBeVisible(); + await expect(page.getByText('Stichwort: krieg')).toBeVisible(); + + // Accessibility — light mode. + const lightScan = await new AxeBuilder({ page }) + .include('[data-testid="smart-search-results"]') + .analyze(); + expect(lightScan.violations).toEqual([]); + + // Accessibility — dark mode. + await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark')); + const darkScan = await new AxeBuilder({ page }) + .include('[data-testid="smart-search-results"]') + .analyze(); + expect(darkScan.violations).toEqual([]); + await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'light')); + + // Removing the keyword chip re-runs a keyword GET with the remaining resolved + // params (sender + receiver from the directional pair). + await page.getByRole('button', { name: 'Filter entfernen: Stichwort: krieg' }).click(); + await page.waitForURL(/senderId=11111111-1111-1111-1111-111111111111/); + await expect(page).toHaveURL(/receiverId=22222222-2222-2222-2222-222222222222/); + }); +}); diff --git a/frontend/src/routes/documents/+page.svelte b/frontend/src/routes/documents/+page.svelte index 2d287140..b9a2ece5 100644 --- a/frontend/src/routes/documents/+page.svelte +++ b/frontend/src/routes/documents/+page.svelte @@ -422,48 +422,50 @@ $effect(() => { {#if showNlView} - {#if nlLoading} - - {:else if nlError} - - {:else if nlInterpretation} - {#key nlInterpretation} -
- {#if nlIsAmbiguous} - - {:else} - - {/if} -
+
+ {#if nlLoading} + + {:else if nlError} + + {:else if nlInterpretation} + {#key nlInterpretation} +
+ {#if nlIsAmbiguous} + + {:else} + + {/if} +
- {#if !nlIsAmbiguous} - {#if nlHasResults} -

- {m.docs_result_count({ count: nlResult?.totalElements ?? 0 })} -

- - {:else} -
-

{m.search_empty_nl()}

- -
+ {#if !nlIsAmbiguous} + {#if nlHasResults} +

+ {m.docs_result_count({ count: nlResult?.totalElements ?? 0 })} +

+ + {:else} +
+

{m.search_empty_nl()}

+ +
+ {/if} {/if} - {/if} - {/key} - {/if} + {/key} + {/if} +
{:else}