diff --git a/frontend/e2e/transcribe-shortcuts.spec.ts b/frontend/e2e/transcribe-shortcuts.spec.ts new file mode 100644 index 00000000..40b03bd4 --- /dev/null +++ b/frontend/e2e/transcribe-shortcuts.spec.ts @@ -0,0 +1,163 @@ +import { test, expect, type Page } from '@playwright/test'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import fs from 'fs'; +import { AxeBuilder } from '@axe-core/playwright'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf'); + +/** + * E2E tests for the transcribe keyboard shortcuts + cheatsheet overlay — #327. + * + * Strategy mirrors annotations.spec: seed a document with two transcription + * blocks via API in beforeAll (no OCR, no manual drawing), then drive the + * keyboard. j/k navigation is exercised in read mode so no editable can trap + * focus — the active region's resize overlay renders regardless of read/edit. + */ + +const RESIZE_AREA_LABEL = 'Annotationsgröße und -position ändern'; + +let docHref: string; +let docId: string; +let annotAId: string; +let annotBId: string; + +test.describe('Transcribe keyboard shortcuts', () => { + test.beforeAll(async ({ request }) => { + const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000'; + + const createRes = await request.post('/api/documents', { + multipart: { title: 'E2E Shortcuts Test', documentDate: '1945-05-08' } + }); + if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`); + const doc = await createRes.json(); + docId = doc.id; + docHref = `${baseURL}/documents/${docId}`; + + const uploadRes = await request.put(`/api/documents/${docId}`, { + multipart: { + title: doc.title, + documentDate: '1945-05-08', + file: { + name: 'minimal.pdf', + mimeType: 'application/pdf', + buffer: fs.readFileSync(PDF_FIXTURE) + } + } + }); + if (!uploadRes.ok()) throw new Error(`Upload PDF failed: ${uploadRes.status()}`); + + const blockARes = await request.post(`/api/documents/${docId}/transcription-blocks`, { + data: { + pageNumber: 1, + x: 0.1, + y: 0.1, + width: 0.3, + height: 0.1, + text: 'Erste Zeile.', + label: 'Anrede' + } + }); + if (!blockARes.ok()) throw new Error(`Create block A failed: ${blockARes.status()}`); + annotAId = (await blockARes.json()).annotationId; + + const blockBRes = await request.post(`/api/documents/${docId}/transcription-blocks`, { + data: { + pageNumber: 1, + x: 0.1, + y: 0.35, + width: 0.3, + height: 0.1, + text: 'Zweite Zeile.', + label: null + } + }); + if (!blockBRes.ok()) throw new Error(`Create block B failed: ${blockBRes.status()}`); + annotBId = (await blockBRes.json()).annotationId; + }); + + async function openTranscribe(page: Page) { + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: 'Transkribieren' }).click(); + await page.locator('.tabular-nums').waitFor({ timeout: 15_000 }); + await page.locator(`[data-testid="annotation-${annotAId}"]`).waitFor({ timeout: 10_000 }); + } + + function activeRegionOverlay(page: Page, annotationId: string) { + return page.locator(`[data-testid="annotation-${annotationId}"]`).getByLabel(RESIZE_AREA_LABEL); + } + + test('? opens the cheatsheet; Esc closes it, then a second Esc closes the panel', async ({ + page + }) => { + test.setTimeout(30_000); + await openTranscribe(page); + + await page.keyboard.press('?'); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await expect(dialog.getByRole('heading', { name: 'Tastaturkürzel' })).toBeVisible(); + + await page.keyboard.press('Escape'); + await expect(dialog).not.toBeVisible(); + + // Panel still open after closing only the cheatsheet (Esc ladder rung 1). + await expect(page.getByRole('button', { name: 'Fertig' })).toBeVisible(); + + await page.keyboard.press('Escape'); + await expect(page.getByRole('button', { name: 'Transkribieren' })).toBeVisible(); + }); + + test('e toggles between read and edit mode', async ({ page }) => { + test.setTimeout(30_000); + await openTranscribe(page); + + // The "mark for training" section renders only in the edit view. + const editMarker = page.getByText('Für Training vormerken'); + + // Default for a writer with existing blocks is read mode. + await expect(editMarker).toHaveCount(0); + + await page.keyboard.press('e'); + await expect(editMarker).toBeVisible(); + + await page.keyboard.press('e'); + await expect(editMarker).toHaveCount(0); + }); + + test('j and k walk forward and back through the regions', async ({ page }) => { + test.setTimeout(30_000); + await openTranscribe(page); + + await page.keyboard.press('j'); + await expect(activeRegionOverlay(page, annotAId)).toBeVisible(); + + await page.keyboard.press('j'); + await expect(activeRegionOverlay(page, annotBId)).toBeVisible(); + await expect(activeRegionOverlay(page, annotAId)).toHaveCount(0); + + await page.keyboard.press('k'); + await expect(activeRegionOverlay(page, annotAId)).toBeVisible(); + }); + + test('the open cheatsheet has no critical accessibility violations', async ({ page }) => { + test.setTimeout(30_000); + await openTranscribe(page); + + await page.keyboard.press('?'); + await expect(page.getByRole('dialog')).toBeVisible(); + + const results = await new AxeBuilder({ page }) + .include('dialog') + .withTags(['wcag2a', 'wcag2aa']) + .analyze(); + const critical = results.violations.filter((v) => v.impact === 'critical'); + expect(critical).toEqual([]); + + // The dialog exposes a modal role with an accessible name (labelled heading). + const dialog = page.getByRole('dialog'); + await expect(dialog).toHaveAttribute('aria-modal', 'true'); + }); +});