test(transcribe): e2e coverage for shortcuts + cheatsheet a11y (#327)
Seeds a two-block document via API (annotations.spec pattern) and drives the keyboard: ? opens the cheatsheet, Esc closes it then a second Esc closes the panel (Esc ladder), e toggles read/edit, and j/k walk the regions forward and back. Adds an axe-core pass over the open dialog asserting no critical violations and aria-modal. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
163
frontend/e2e/transcribe-shortcuts.spec.ts
Normal file
163
frontend/e2e/transcribe-shortcuts.spec.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user