297 lines
12 KiB
TypeScript
297 lines
12 KiB
TypeScript
import { test, expect } 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-backed transcription system — issue #176.
|
|
*
|
|
* Strategy:
|
|
* - Transcription blocks are created via API in beforeAll (no need to draw on canvas in CI).
|
|
* - Browser tests verify rendering, editing, auto-save feedback, reordering, deletion, and a11y.
|
|
*/
|
|
|
|
let docHref: string;
|
|
let docId: string;
|
|
|
|
test.describe('Transcription panel', () => {
|
|
test.beforeAll(async ({ request }) => {
|
|
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
|
|
|
// 1. Create a document with a PDF so the Transkription tab is meaningful.
|
|
const createRes = await request.post('/api/documents', {
|
|
multipart: {
|
|
title: 'E2E Transkription 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}`;
|
|
|
|
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)
|
|
}
|
|
}
|
|
});
|
|
|
|
// 2. Create a document_annotation so we can attach blocks to it.
|
|
const annotARes = await request.post(`/api/documents/${docId}/annotations`, {
|
|
data: { pageNumber: 1, x: 0.1, y: 0.1, width: 0.2, height: 0.1, color: '#00C7B1' }
|
|
});
|
|
if (!annotARes.ok()) throw new Error(`Create annotation A failed: ${annotARes.status()}`);
|
|
const annotA = await annotARes.json();
|
|
|
|
const annotBRes = await request.post(`/api/documents/${docId}/annotations`, {
|
|
data: { pageNumber: 1, x: 0.1, y: 0.3, width: 0.2, height: 0.1, color: '#00C7B1' }
|
|
});
|
|
if (!annotBRes.ok()) throw new Error(`Create annotation B failed: ${annotBRes.status()}`);
|
|
const annotB = await annotBRes.json();
|
|
|
|
// 3. Create two transcription blocks via API.
|
|
const blockARes = await request.post(`/api/documents/${docId}/transcription-blocks`, {
|
|
data: {
|
|
pageNumber: 1,
|
|
x: annotA.x,
|
|
y: annotA.y,
|
|
width: annotA.width,
|
|
height: annotA.height,
|
|
text: 'Liebe Mutter,',
|
|
label: 'Anrede'
|
|
}
|
|
});
|
|
if (!blockARes.ok()) throw new Error(`Create block A failed: ${blockARes.status()}`);
|
|
await blockARes.json();
|
|
|
|
const blockBRes = await request.post(`/api/documents/${docId}/transcription-blocks`, {
|
|
data: {
|
|
pageNumber: 1,
|
|
x: annotB.x,
|
|
y: annotB.y,
|
|
width: annotB.width,
|
|
height: annotB.height,
|
|
text: 'ich schreibe dir aus Breslau.',
|
|
label: null
|
|
}
|
|
});
|
|
if (!blockBRes.ok()) throw new Error(`Create block B failed: ${blockBRes.status()}`);
|
|
await blockBRes.json();
|
|
});
|
|
|
|
// ─── Tab visibility ────────────────────────────────────────────────────────
|
|
|
|
test('Transkription tab is visible in the bottom panel tab bar', async ({ page }) => {
|
|
test.setTimeout(30_000);
|
|
await page.goto(docHref);
|
|
await page.waitForSelector('[data-hydrated]');
|
|
|
|
await expect(page.getByRole('button', { name: 'Transkription' })).toBeVisible();
|
|
await page.screenshot({ path: 'test-results/e2e/transcription-tab-visible.png' });
|
|
});
|
|
|
|
// ─── Block rendering ──────────────────────────────────────────────────────
|
|
|
|
test('blocks are rendered in sort order with correct text and label', async ({ page }) => {
|
|
test.setTimeout(30_000);
|
|
await page.goto(docHref);
|
|
await page.waitForSelector('[data-hydrated]');
|
|
await page.getByRole('button', { name: 'Transkription' }).click();
|
|
await page.waitForSelector('[data-testid="bottom-panel-content"]');
|
|
|
|
await expect(page.getByText('Liebe Mutter,')).toBeVisible();
|
|
await expect(page.getByText('ich schreibe dir aus Breslau.')).toBeVisible();
|
|
// Label for block A
|
|
await expect(page.getByText('Anrede')).toBeVisible();
|
|
|
|
await page.screenshot({ path: 'test-results/e2e/transcription-blocks-rendered.png' });
|
|
});
|
|
|
|
test('block numbers are rendered in turquoise badge', async ({ page }) => {
|
|
test.setTimeout(30_000);
|
|
await page.goto(docHref);
|
|
await page.waitForSelector('[data-hydrated]');
|
|
await page.getByRole('button', { name: 'Transkription' }).click();
|
|
await page.waitForSelector('[data-testid="bottom-panel-content"]');
|
|
|
|
// Block 1 and 2 badges must be visible
|
|
await expect(page.getByText('1').first()).toBeVisible();
|
|
await expect(page.getByText('2').first()).toBeVisible();
|
|
});
|
|
|
|
test('next-block CTA shows Block 3 hint after two blocks', async ({ page }) => {
|
|
test.setTimeout(30_000);
|
|
await page.goto(docHref);
|
|
await page.waitForSelector('[data-hydrated]');
|
|
await page.getByRole('button', { name: 'Transkription' }).click();
|
|
await page.waitForSelector('[data-testid="bottom-panel-content"]');
|
|
|
|
await expect(page.getByText(/Block 3/)).toBeVisible();
|
|
});
|
|
|
|
// ─── Text editing & auto-save feedback ────────────────────────────────────
|
|
|
|
test('editing a block shows "Speichere..." then "Gespeichert" indicator', async ({ page }) => {
|
|
test.setTimeout(30_000);
|
|
await page.goto(docHref);
|
|
await page.waitForSelector('[data-hydrated]');
|
|
await page.getByRole('button', { name: 'Transkription' }).click();
|
|
await page.waitForSelector('[data-testid="bottom-panel-content"]');
|
|
|
|
const firstTextarea = page.getByRole('textbox').first();
|
|
await firstTextarea.click();
|
|
await firstTextarea.fill('Liebe Mutter, ich bin wohlauf.');
|
|
|
|
// "Speichere..." should appear (debounce triggers after 1.5s)
|
|
await expect(page.getByText(/Speichere\.\.\./)).toBeVisible({ timeout: 5000 });
|
|
// After save completes, "Gespeichert ✓" appears
|
|
await expect(page.getByText(/Gespeichert/)).toBeVisible({ timeout: 8000 });
|
|
|
|
await page.screenshot({ path: 'test-results/e2e/transcription-autosave.png' });
|
|
});
|
|
|
|
test('edited text persists after page reload', async ({ page }) => {
|
|
test.setTimeout(40_000);
|
|
await page.goto(docHref);
|
|
await page.waitForSelector('[data-hydrated]');
|
|
await page.getByRole('button', { name: 'Transkription' }).click();
|
|
await page.waitForSelector('[data-testid="bottom-panel-content"]');
|
|
|
|
const firstTextarea = page.getByRole('textbox').first();
|
|
await firstTextarea.fill('Persistierter Text');
|
|
|
|
// Wait for auto-save to complete
|
|
await expect(page.getByText(/Gespeichert/)).toBeVisible({ timeout: 8000 });
|
|
|
|
// Reload
|
|
await page.reload();
|
|
await page.waitForSelector('[data-hydrated]');
|
|
await page.getByRole('button', { name: 'Transkription' }).click();
|
|
|
|
await expect(page.getByText('Persistierter Text')).toBeVisible();
|
|
});
|
|
|
|
// ─── Block reordering ─────────────────────────────────────────────────────
|
|
|
|
test('move-up button is disabled on the first block', async ({ page }) => {
|
|
test.setTimeout(30_000);
|
|
await page.goto(docHref);
|
|
await page.waitForSelector('[data-hydrated]');
|
|
await page.getByRole('button', { name: 'Transkription' }).click();
|
|
await page.waitForSelector('[data-testid="bottom-panel-content"]');
|
|
|
|
const upButtons = page.getByRole('button', { name: 'Nach oben' });
|
|
await expect(upButtons.first()).toBeDisabled();
|
|
});
|
|
|
|
test('move-down button is disabled on the last block', async ({ page }) => {
|
|
test.setTimeout(30_000);
|
|
await page.goto(docHref);
|
|
await page.waitForSelector('[data-hydrated]');
|
|
await page.getByRole('button', { name: 'Transkription' }).click();
|
|
await page.waitForSelector('[data-testid="bottom-panel-content"]');
|
|
|
|
const downButtons = page.getByRole('button', { name: 'Nach unten' });
|
|
await expect(downButtons.last()).toBeDisabled();
|
|
});
|
|
|
|
test('clicking move-down on the first block swaps block order', async ({ page }) => {
|
|
test.setTimeout(30_000);
|
|
await page.goto(docHref);
|
|
await page.waitForSelector('[data-hydrated]');
|
|
await page.getByRole('button', { name: 'Transkription' }).click();
|
|
await page.waitForSelector('[data-testid="bottom-panel-content"]');
|
|
|
|
const textareas = page.getByRole('textbox');
|
|
const before = await textareas.first().inputValue();
|
|
|
|
const downButtons = page.getByRole('button', { name: 'Nach unten' });
|
|
await downButtons.first().click();
|
|
|
|
// After reorder, the block that was second should now appear first
|
|
const after = await textareas.first().inputValue();
|
|
expect(after).not.toBe(before);
|
|
|
|
await page.screenshot({ path: 'test-results/e2e/transcription-reorder.png' });
|
|
});
|
|
|
|
// ─── Block deletion ───────────────────────────────────────────────────────
|
|
|
|
test('cancelling delete confirmation keeps the block', async ({ page }) => {
|
|
test.setTimeout(30_000);
|
|
await page.goto(docHref);
|
|
await page.waitForSelector('[data-hydrated]');
|
|
await page.getByRole('button', { name: 'Transkription' }).click();
|
|
await page.waitForSelector('[data-testid="bottom-panel-content"]');
|
|
|
|
// Dismiss the confirm dialog automatically
|
|
page.once('dialog', (dialog) => dialog.dismiss());
|
|
|
|
const deleteBtn = page.getByRole('button', { name: 'Löschen' }).first();
|
|
await deleteBtn.click();
|
|
|
|
// Block should still be present
|
|
await expect(page.getByRole('textbox').first()).toBeVisible();
|
|
});
|
|
|
|
// ─── Comment thread ───────────────────────────────────────────────────────
|
|
|
|
test('clicking Kommentieren button opens comment compose in the block', async ({ page }) => {
|
|
test.setTimeout(30_000);
|
|
await page.goto(docHref);
|
|
await page.waitForSelector('[data-hydrated]');
|
|
await page.getByRole('button', { name: 'Transkription' }).click();
|
|
await page.waitForSelector('[data-testid="bottom-panel-content"]');
|
|
|
|
await page.getByText('Kommentieren').first().click();
|
|
|
|
await expect(page.getByPlaceholder(/Kommentar/)).toBeVisible();
|
|
|
|
await page.screenshot({ path: 'test-results/e2e/transcription-comment-open.png' });
|
|
});
|
|
|
|
// ─── Accessibility ────────────────────────────────────────────────────────
|
|
|
|
test('transcription panel passes axe accessibility check', async ({ page }) => {
|
|
test.setTimeout(30_000);
|
|
await page.goto(docHref);
|
|
await page.waitForSelector('[data-hydrated]');
|
|
await page.getByRole('button', { name: 'Transkription' }).click();
|
|
await page.waitForSelector('[data-testid="bottom-panel-content"]');
|
|
|
|
const results = await new AxeBuilder({ page }).analyze();
|
|
expect(results.violations).toHaveLength(0);
|
|
});
|
|
|
|
// ─── Empty state ──────────────────────────────────────────────────────────
|
|
|
|
test('shows empty state when document has no transcription blocks', async ({ page, request }) => {
|
|
test.setTimeout(30_000);
|
|
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
|
|
|
const emptyDocRes = await request.post('/api/documents', {
|
|
multipart: { title: 'E2E Empty Transcription Test' }
|
|
});
|
|
if (!emptyDocRes.ok()) throw new Error(`Create empty doc failed: ${emptyDocRes.status()}`);
|
|
const emptyDoc = await emptyDocRes.json();
|
|
|
|
await page.goto(`${baseURL}/documents/${emptyDoc.id}`);
|
|
await page.waitForSelector('[data-hydrated]');
|
|
await page.getByRole('button', { name: 'Transkription' }).click();
|
|
await page.waitForSelector('[data-testid="bottom-panel-content"]');
|
|
|
|
await expect(page.getByText(/Markiere einen Bereich/)).toBeVisible();
|
|
await page.screenshot({ path: 'test-results/e2e/transcription-empty-state.png' });
|
|
});
|
|
});
|