diff --git a/frontend/e2e/zeitstrahl-filter.spec.ts b/frontend/e2e/zeitstrahl-filter.spec.ts new file mode 100644 index 00000000..f43a6075 --- /dev/null +++ b/frontend/e2e/zeitstrahl-filter.spec.ts @@ -0,0 +1,93 @@ +import AxeBuilder from '@axe-core/playwright'; +import { test, expect, type APIRequestContext } from '@playwright/test'; + +/** + * Global /zeitstrahl layer filter (#780). Runs against the real stack with the + * seeded admin session (auth.setup). Covers the primary journey (hide the + * Letters layer → letter cards vanish + the trigger reports one active filter → + * reset restores everything) and a 375px axe pass with the collapsible open in + * both light and dark mode. + * + * #779 (the /zeitstrahl route) is merged, so this spec is NOT skipped. Per + * e2e/CLAUDE.md, E2E is not yet wired into CI — this axe gate runs locally only + * for now. + */ + +const stamp = () => new Date().toISOString().replace(/[^0-9]/g, ''); + +async function createPerson(request: APIRequestContext, firstName: string, lastName: string) { + const res = await request.post('/api/persons', { + data: { personType: 'PERSON', firstName, lastName } + }); + if (!res.ok()) throw new Error(`create person failed: ${res.status()}`); + return (await res.json()).id as string; +} + +/** Seeds one dated letter so the timeline has content (and a LetterCard to hide). */ +async function seedDatedLetter(request: APIRequestContext, isoDate: string, title: string) { + const senderId = await createPerson(request, 'Filter-Test', `Absender ${stamp()}`); + const receiverId = await createPerson(request, 'Filter-Test', `Empfaenger ${stamp()}`); + + const createRes = await request.post('/api/documents', { multipart: { title } }); + if (!createRes.ok()) throw new Error(`create document failed: ${createRes.status()}`); + const docId = (await createRes.json()).id as string; + + const put = await request.put(`/api/documents/${docId}`, { + multipart: { + title, + documentDate: isoDate, + metaDatePrecision: 'DAY', + senderId, + receiverIds: receiverId + } + }); + if (!put.ok()) throw new Error(`update document failed: ${put.status()}`); +} + +test.describe('Zeitstrahl — layer filter (#780)', () => { + test('hiding the Letters layer removes letter cards and reports the active count; reset restores', async ({ + page, + request + }) => { + // A sparse year keeps the seeded letter an individual card (not a dense strip). + const title = `E2E Filter Brief ${stamp()}`; + await seedDatedLetter(request, '1903-03-03', title); + + await page.goto('/zeitstrahl'); + await page.waitForSelector('[data-hydrated]'); + await expect(page.getByText(title)).toBeVisible(); + + await page.getByTestId('timeline-filter-trigger').click(); + await page.getByTestId('timeline-filter-letters').click(); + + await expect(page.getByText(title)).toHaveCount(0); + await expect(page.getByTestId('timeline-filter-trigger')).toContainText('1 aktiv'); + + await page.getByTestId('timeline-filter-reset').click(); + await expect(page.getByText(title)).toBeVisible(); + }); + + test('no wcag2a/wcag2aa violations at 375px with the filter bar open (light + dark)', async ({ + page, + request + }) => { + await seedDatedLetter(request, '1915-06-15', `E2E Filter A11y ${stamp()}`); + + await page.setViewportSize({ width: 375, height: 800 }); + await page.goto('/zeitstrahl'); + await page.waitForSelector('[data-hydrated]'); + + // Open the collapsible so axe scans the toggles, not just the trigger. + await page.getByTestId('timeline-filter-trigger').click(); + await expect(page.getByTestId('timeline-filter-personal')).toBeVisible(); + + const scan = () => new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze(); + + const light = await scan(); + expect(light.violations, JSON.stringify(light.violations, null, 2)).toEqual([]); + + await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark')); + const dark = await scan(); + expect(dark.violations, JSON.stringify(dark.violations, null, 2)).toEqual([]); + }); +});