273 lines
10 KiB
TypeScript
273 lines
10 KiB
TypeScript
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 annotation overlay and transcribe-mode UI — issue #176.
|
|
*
|
|
* Strategy:
|
|
* - Transcription blocks are seeded via API in beforeAll — no canvas drawing in CI.
|
|
* - Browser tests verify transcribe-mode toggling, annotation overlay rendering,
|
|
* the visibility toggle, and scroll-sync between annotations and blocks.
|
|
*/
|
|
|
|
let docHref: string;
|
|
let docId: string;
|
|
let annotAId: string;
|
|
let annotBId: string;
|
|
let blockAId: string;
|
|
|
|
test.describe('Annotation overlay and transcribe mode', () => {
|
|
test.beforeAll(async ({ request }) => {
|
|
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
|
|
|
// 1. Create a document and upload a PDF so the annotation layer is active.
|
|
const createRes = await request.post('/api/documents', {
|
|
multipart: { title: 'E2E Annotation 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()}`);
|
|
|
|
// 2. Create two transcription blocks (each brings its own annotation).
|
|
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()}`);
|
|
const blockA = await blockARes.json();
|
|
blockAId = blockA.id;
|
|
annotAId = blockA.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()}`);
|
|
const blockB = await blockBRes.json();
|
|
annotBId = blockB.annotationId;
|
|
});
|
|
|
|
/**
|
|
* Navigate to the document, enter transcribe mode, and wait until the PDF
|
|
* has fully rendered (page counter appears) and the annotation rect is visible.
|
|
* Centralises the timing gate used by multiple tests.
|
|
*/
|
|
async function openTranscribeMode(page: Page, annotationId: string) {
|
|
await page.goto(docHref);
|
|
await page.waitForSelector('[data-hydrated]');
|
|
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
|
// Wait for the PDF to finish loading — the page counter only renders when totalPages > 0
|
|
await page.locator('.tabular-nums').waitFor({ timeout: 15_000 });
|
|
// Wait for annotation rect (annotations API) and at least one block textarea (blocks API)
|
|
// to be ready — these are two independent fetches.
|
|
await Promise.all([
|
|
page.locator(`[data-testid="annotation-${annotationId}"]`).waitFor({ timeout: 10_000 }),
|
|
page.getByRole('textbox').first().waitFor({ timeout: 10_000 })
|
|
]);
|
|
}
|
|
|
|
// ─── Transcribe mode toggle ────────────────────────────────────────────────
|
|
|
|
test('Transkribieren button is visible on a PDF document', async ({ page }) => {
|
|
test.setTimeout(30_000);
|
|
await page.goto(docHref);
|
|
await page.waitForSelector('[data-hydrated]');
|
|
|
|
await expect(page.getByRole('button', { name: 'Transkribieren' })).toBeVisible();
|
|
await page.screenshot({ path: 'test-results/e2e/annotation-transcribe-btn.png' });
|
|
});
|
|
|
|
test('clicking Transkribieren enters transcribe mode and shows the Fertig button', async ({
|
|
page
|
|
}) => {
|
|
test.setTimeout(30_000);
|
|
await page.goto(docHref);
|
|
await page.waitForSelector('[data-hydrated]');
|
|
|
|
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
|
|
|
await expect(page.getByRole('button', { name: 'Fertig' })).toBeVisible();
|
|
await expect(page.getByRole('button', { name: 'Transkribieren' })).not.toBeVisible();
|
|
|
|
await page.screenshot({ path: 'test-results/e2e/annotation-transcribe-mode-active.png' });
|
|
});
|
|
|
|
test('clicking Fertig exits transcribe mode and restores the Transkribieren button', async ({
|
|
page
|
|
}) => {
|
|
test.setTimeout(30_000);
|
|
await page.goto(docHref);
|
|
await page.waitForSelector('[data-hydrated]');
|
|
|
|
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
|
await expect(page.getByRole('button', { name: 'Fertig' })).toBeVisible();
|
|
|
|
await page.getByRole('button', { name: 'Fertig' }).click();
|
|
|
|
await expect(page.getByRole('button', { name: 'Transkribieren' })).toBeVisible();
|
|
await expect(page.getByRole('button', { name: 'Fertig' })).not.toBeVisible();
|
|
});
|
|
|
|
test('pressing Escape exits transcribe mode', async ({ page }) => {
|
|
test.setTimeout(30_000);
|
|
await page.goto(docHref);
|
|
await page.waitForSelector('[data-hydrated]');
|
|
|
|
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
|
await expect(page.getByRole('button', { name: 'Fertig' })).toBeVisible();
|
|
|
|
await page.keyboard.press('Escape');
|
|
|
|
await expect(page.getByRole('button', { name: 'Transkribieren' })).toBeVisible();
|
|
});
|
|
|
|
// ─── Annotation overlay rendering ─────────────────────────────────────────
|
|
|
|
test('annotation rects are rendered on the PDF after entering transcribe mode', async ({
|
|
page
|
|
}) => {
|
|
test.setTimeout(40_000);
|
|
await openTranscribeMode(page, annotAId);
|
|
|
|
await expect(page.locator(`[data-testid="annotation-${annotAId}"]`)).toBeVisible();
|
|
await expect(page.locator(`[data-testid="annotation-${annotBId}"]`)).toBeVisible();
|
|
|
|
await page.screenshot({ path: 'test-results/e2e/annotation-rects-rendered.png' });
|
|
});
|
|
|
|
test('numbered badges appear on annotation rects', async ({ page }) => {
|
|
test.setTimeout(40_000);
|
|
await openTranscribeMode(page, annotAId);
|
|
|
|
const annotA = page.locator(`[data-testid="annotation-${annotAId}"]`);
|
|
await expect(annotA.locator('div', { hasText: '1' })).toBeVisible();
|
|
|
|
await page.screenshot({ path: 'test-results/e2e/annotation-numbered-badges.png' });
|
|
});
|
|
|
|
// ─── Annotation visibility toggle ─────────────────────────────────────────
|
|
|
|
test('annotation visibility toggle button appears when annotations exist', async ({ page }) => {
|
|
test.setTimeout(40_000);
|
|
await openTranscribeMode(page, annotAId);
|
|
|
|
await expect(page.getByRole('button', { name: 'Annotierungen verbergen' })).toBeVisible();
|
|
});
|
|
|
|
test('clicking the visibility toggle hides annotation rects', async ({ page }) => {
|
|
test.setTimeout(40_000);
|
|
await openTranscribeMode(page, annotAId);
|
|
|
|
await page.getByRole('button', { name: 'Annotierungen verbergen' }).click();
|
|
|
|
await expect(page.locator(`[data-testid="annotation-${annotAId}"]`)).not.toBeVisible();
|
|
await expect(page.getByRole('button', { name: 'Annotierungen anzeigen' })).toBeVisible();
|
|
|
|
await page.screenshot({ path: 'test-results/e2e/annotation-hidden.png' });
|
|
});
|
|
|
|
test('clicking the visibility toggle again restores annotation rects', async ({ page }) => {
|
|
test.setTimeout(40_000);
|
|
await openTranscribeMode(page, annotAId);
|
|
|
|
await page.getByRole('button', { name: 'Annotierungen verbergen' }).click();
|
|
await page.getByRole('button', { name: 'Annotierungen anzeigen' }).click();
|
|
|
|
await expect(page.locator(`[data-testid="annotation-${annotAId}"]`)).toBeVisible();
|
|
});
|
|
|
|
// ─── Scroll-sync: annotation → block ──────────────────────────────────────
|
|
|
|
test('clicking an annotation rect scrolls the matching block into view in the right panel', async ({
|
|
page
|
|
}) => {
|
|
test.setTimeout(40_000);
|
|
await openTranscribeMode(page, annotAId);
|
|
|
|
await page.locator(`[data-testid="annotation-${annotAId}"]`).click();
|
|
|
|
await expect(page.locator(`[data-block-id="${blockAId}"]`)).toBeVisible({ timeout: 5_000 });
|
|
|
|
await page.screenshot({ path: 'test-results/e2e/annotation-click-scroll-sync.png' });
|
|
});
|
|
|
|
test('clicking annotation B activates the corresponding block in the panel', async ({ page }) => {
|
|
test.setTimeout(40_000);
|
|
await openTranscribeMode(page, annotBId);
|
|
|
|
await page.locator(`[data-testid="annotation-${annotBId}"]`).click();
|
|
|
|
// Block B's annotation should become active (full opacity), A's should dim
|
|
await expect(page.locator(`[data-testid="annotation-${annotBId}"]`)).toHaveCSS('opacity', '1');
|
|
await expect(page.locator(`[data-testid="annotation-${annotAId}"]`)).toHaveCSS(
|
|
'opacity',
|
|
'0.3'
|
|
);
|
|
});
|
|
|
|
// ─── Scroll-sync: block → annotation (dimming) ────────────────────────────
|
|
|
|
test('focusing a block dims all other annotation rects', async ({ page }) => {
|
|
test.setTimeout(40_000);
|
|
await openTranscribeMode(page, annotAId);
|
|
|
|
// Focus block A's textarea to set it as active
|
|
await page.getByRole('textbox').first().click();
|
|
|
|
// Non-active annotation (B) must be dimmed
|
|
await expect(page.locator(`[data-testid="annotation-${annotBId}"]`)).toHaveCSS(
|
|
'opacity',
|
|
'0.3'
|
|
);
|
|
|
|
// Active annotation (A) must be at full opacity
|
|
await expect(page.locator(`[data-testid="annotation-${annotAId}"]`)).toHaveCSS('opacity', '1');
|
|
|
|
await page.screenshot({ path: 'test-results/e2e/annotation-dimming.png' });
|
|
});
|
|
|
|
// ─── Accessibility ─────────────────────────────────────────────────────────
|
|
|
|
test('transcribe mode passes axe accessibility check', async ({ page }) => {
|
|
test.setTimeout(40_000);
|
|
await openTranscribeMode(page, annotAId);
|
|
|
|
const results = await new AxeBuilder({ page }).analyze();
|
|
expect(results.violations).toHaveLength(0);
|
|
});
|
|
});
|