From 03b180fe88913e5d7d3798f92456a89ea4d1ce68 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 24 Apr 2026 21:39:03 +0200 Subject: [PATCH] test(e2e): add transcribe-coach, richtlinien, and help-popover E2E specs; reducedMotion global default Co-Authored-By: Claude Sonnet 4.6 --- frontend/e2e/help-popover.spec.ts | 30 ++++++++ frontend/e2e/helpers/upload-empty-document.ts | 10 +++ frontend/e2e/richtlinien.spec.ts | 68 +++++++++++++++++++ frontend/e2e/transcribe-coach.spec.ts | 65 ++++++++++++++++++ frontend/playwright.config.ts | 1 + 5 files changed, 174 insertions(+) create mode 100644 frontend/e2e/help-popover.spec.ts create mode 100644 frontend/e2e/helpers/upload-empty-document.ts create mode 100644 frontend/e2e/richtlinien.spec.ts create mode 100644 frontend/e2e/transcribe-coach.spec.ts diff --git a/frontend/e2e/help-popover.spec.ts b/frontend/e2e/help-popover.spec.ts new file mode 100644 index 00000000..bc0f77c8 --- /dev/null +++ b/frontend/e2e/help-popover.spec.ts @@ -0,0 +1,30 @@ +import { test, expect } from '@playwright/test'; +import { createEmptyDocument } from './helpers/upload-empty-document.js'; + +test.describe('Help chip — Read/Edit panel header', () => { + let docId: string; + + test.beforeAll(async ({ request }) => { + docId = await createEmptyDocument(request); + }); + + test('opens popover on click, closes on Esc, returns focus to chip', async ({ page }) => { + await page.goto(`/documents/${docId}`); + await page.getByRole('button', { name: 'Transkribieren' }).click(); + + // Find and click the (?) help chip + const helpBtn = page.locator('button[aria-expanded]'); + await expect(helpBtn).toBeVisible({ timeout: 5000 }); + await helpBtn.click(); + + // Popover should open + await expect(page.locator('[role="tooltip"]')).toBeVisible(); + + // Press Esc + await page.keyboard.press('Escape'); + await expect(page.locator('[role="tooltip"]')).not.toBeVisible(); + + // Focus should have returned to the chip + await expect(helpBtn).toBeFocused(); + }); +}); diff --git a/frontend/e2e/helpers/upload-empty-document.ts b/frontend/e2e/helpers/upload-empty-document.ts new file mode 100644 index 00000000..caba3615 --- /dev/null +++ b/frontend/e2e/helpers/upload-empty-document.ts @@ -0,0 +1,10 @@ +import type { APIRequestContext } from '@playwright/test'; + +export async function createEmptyDocument(request: APIRequestContext): Promise { + const res = await request.post('/api/documents', { + multipart: { title: 'E2E Transcribe Coach Test' } + }); + if (!res.ok()) throw new Error(`Create document failed: ${res.status()}`); + const doc = await res.json(); + return doc.id as string; +} diff --git a/frontend/e2e/richtlinien.spec.ts b/frontend/e2e/richtlinien.spec.ts new file mode 100644 index 00000000..1b5e52ea --- /dev/null +++ b/frontend/e2e/richtlinien.spec.ts @@ -0,0 +1,68 @@ +import { test, expect } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; + +function buildAxe(page: Parameters[0]['page']) { + return new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']); +} + +test.describe('Richtlinien page — content', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/hilfe/transkription'); + }); + + test('renders h1 title, intro, five rules, four chips, closing card', async ({ page }) => { + await expect( + page.getByRole('heading', { level: 1, name: /Transkriptions-Richtlinien/ }) + ).toBeVisible(); + await expect(page.getByText(/Damit alle Briefe einheitlich/)).toBeVisible(); + await expect(page.getByText('Nicht lesbare Wörter')).toBeVisible(); + await expect(page.getByText('Durchgestrichene Wörter')).toBeVisible(); + await expect(page.getByText(/Das lange s/)).toBeVisible(); + await expect(page.getByText('Unsichere Namen')).toBeVisible(); + await expect(page.getByText(/Dialekt/)).toBeVisible(); + await expect(page.getByText('Abkürzungen')).toBeVisible(); + await expect(page.getByText('Datumsformate')).toBeVisible(); + await expect(page.getByText(/Fehlt eine Regel/)).toBeVisible(); + }); + + test('Wikipedia link opens in new tab with annotation', async ({ page }) => { + const wikiLink = page.getByRole('link', { name: /Wikipedia/ }); + await expect(wikiLink).toHaveAttribute('target', '_blank'); + await expect(wikiLink).toHaveAttribute('rel', 'noopener noreferrer'); + await expect(wikiLink).toHaveAttribute('referrerpolicy', 'no-referrer'); + await expect(wikiLink).toContainText(/öffnet in neuem Tab/); + }); +}); + +test.describe('Richtlinien page — accessibility', () => { + for (const viewport of [320, 768, 1440]) { + test(`axe: light theme at ${viewport}px — no WCAG 2.1 AA violations`, async ({ page }) => { + await page.setViewportSize({ width: viewport, height: 800 }); + await page.goto('/hilfe/transkription'); + const a11y = await buildAxe(page).analyze(); + expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0); + }); + + test(`axe: dark theme at ${viewport}px — no WCAG 2.1 AA violations`, async ({ page }) => { + await page.setViewportSize({ width: viewport, height: 800 }); + await page.goto('/hilfe/transkription'); + await page.getByRole('button', { name: /Farbmodus|theme/i }).click(); + const a11y = await buildAxe(page).analyze(); + expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0); + }); + } +}); + +test.describe('Richtlinien page — print media', () => { + test('print snapshot hides nav, annotation chip, and new-tab spans', async ({ page }) => { + await page.emulateMedia({ media: 'print' }); + await page.goto('/hilfe/transkription'); + + const nav = page.locator('.app-nav'); + if ((await nav.count()) > 0) { + await expect(nav).toBeHidden(); + } + + await page.screenshot({ path: 'test-results/e2e/richtlinien-print.png', fullPage: true }); + }); +}); diff --git a/frontend/e2e/transcribe-coach.spec.ts b/frontend/e2e/transcribe-coach.spec.ts new file mode 100644 index 00000000..b54c7215 --- /dev/null +++ b/frontend/e2e/transcribe-coach.spec.ts @@ -0,0 +1,65 @@ +import { test, expect } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; +import { createEmptyDocument } from './helpers/upload-empty-document.js'; + +function buildAxe(page: Parameters[0]['page']) { + return new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']); +} + +test.describe('Transcribe coach — empty state', () => { + let docId: string; + + test.beforeAll(async ({ request }) => { + docId = await createEmptyDocument(request); + }); + + test('shows coach card (title, preamble, three step bodies, animation region)', async ({ + page + }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.goto(`/documents/${docId}`); + await page.getByRole('button', { name: 'Transkribieren' }).click(); + + await expect(page.getByRole('heading', { level: 2, name: /Erste Transkription/ })).toBeVisible({ + timeout: 5000 + }); + await expect(page.getByText(/Kurrent-Erkenner lernt noch/)).toBeVisible(); + await expect(page.getByText(/Rahmen ziehen/)).toBeVisible(); + await expect(page.getByText(/Text eingeben/)).toBeVisible(); + await expect(page.getByText(/Speichert automatisch/)).toBeVisible(); + await expect(page.getByRole('img', { name: /Rahmen ziehen|Animation/i })).toBeVisible(); + }); + + test('training footer is NOT visible on empty doc', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.goto(`/documents/${docId}`); + await page.getByRole('button', { name: 'Transkribieren' }).click(); + await expect(page.getByText('Für Training vormerken')).not.toBeVisible({ timeout: 3000 }); + }); + + test('axe: panel empty state — light theme — no WCAG 2.1 AA violations', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.goto(`/documents/${docId}`); + await page.getByRole('button', { name: 'Transkribieren' }).click(); + await expect(page.getByRole('heading', { level: 2, name: /Erste Transkription/ })).toBeVisible({ + timeout: 5000 + }); + + const a11y = await buildAxe(page).analyze(); + expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0); + }); + + test('axe: panel empty state — dark theme — no WCAG 2.1 AA violations', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.goto(`/documents/${docId}`); + // Toggle dark theme + await page.getByRole('button', { name: /Farbmodus|theme/i }).click(); + await page.getByRole('button', { name: 'Transkribieren' }).click(); + await expect(page.getByRole('heading', { level: 2, name: /Erste Transkription/ })).toBeVisible({ + timeout: 5000 + }); + + const a11y = await buildAxe(page).analyze(); + expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0); + }); +}); diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 87e0c57a..d7bbd635 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -26,6 +26,7 @@ export default defineConfig({ use: { baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:3000', locale: 'de-DE', // ensures Accept-Language: de is sent so locale detection defaults to German + reducedMotion: 'reduce', // prevents SMIL/CSS animations from flaking tests screenshot: 'on', // always capture screenshots video: 'retain-on-failure', trace: 'retain-on-failure'