test: increase coverage

This commit is contained in:
Marcel
2026-04-06 11:20:57 +02:00
parent f359c19e4c
commit e89d8a4ca9
7 changed files with 1306 additions and 0 deletions

View File

@@ -0,0 +1,272 @@
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);
});
});

View File

@@ -0,0 +1,296 @@
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' });
});
});

View File

@@ -12,6 +12,7 @@ const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
export default defineConfig(
includeIgnoreFile(gitignorePath),
{ ignores: ['src/paraglide/**'] },
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,

View File

@@ -159,3 +159,59 @@ describe('TranscriptionBlock — reorder controls', () => {
expect(onMoveDown).toHaveBeenCalled();
});
});
// ─── Delete confirmation ──────────────────────────────────────────────────────
describe('TranscriptionBlock — delete confirmation', () => {
it('does not call onDeleteClick when user cancels confirm dialog', async () => {
const onDeleteClick = vi.fn();
vi.spyOn(window, 'confirm').mockReturnValue(false);
renderBlock({ onDeleteClick });
const deleteBtn = page.getByRole('button', { name: 'Löschen' });
await deleteBtn.click();
expect(onDeleteClick).not.toHaveBeenCalled();
vi.restoreAllMocks();
});
it('calls onDeleteClick when user confirms deletion', async () => {
const onDeleteClick = vi.fn();
vi.spyOn(window, 'confirm').mockReturnValue(true);
renderBlock({ onDeleteClick });
const deleteBtn = page.getByRole('button', { name: 'Löschen' });
await deleteBtn.click();
expect(onDeleteClick).toHaveBeenCalledOnce();
vi.restoreAllMocks();
});
});
// ─── Quote selection ─────────────────────────────────────────────────────────
describe('TranscriptionBlock — quote selection', () => {
it('shows quote hint after text is selected in textarea', async () => {
renderBlock({ text: 'Breslau, den 12. August' });
const textarea = page.getByRole('textbox');
// Select all text via keyboard shortcut to trigger mouseup with selection
await textarea.click();
await textarea.selectText();
// Fire mouseup to trigger the selection handler
await textarea.dispatchEvent('mouseup');
await expect.element(page.getByText(/Zitat/)).toBeInTheDocument();
});
});
// ─── Fading state ────────────────────────────────────────────────────────────
describe('TranscriptionBlock — fading save state', () => {
it('shows Gespeichert text in fading state (opacity-0 fade-out)', async () => {
renderBlock({ saveState: 'fading' });
const indicator = page.getByText(/Gespeichert/);
await expect.element(indicator).toBeInTheDocument();
// The fading class sets opacity-0
const el = document.querySelector('.opacity-0');
expect(el).not.toBeNull();
});
});

View File

@@ -93,3 +93,132 @@ describe('TranscriptionEditView — reorder', () => {
expect(handles.length).toBe(2);
});
});
// ─── Auto-save debounce ───────────────────────────────────────────────────────
describe('TranscriptionEditView — auto-save debounce', () => {
it('calls onSaveBlock after 1500ms debounce when text changes', async () => {
vi.useFakeTimers();
const onSaveBlock = vi.fn().mockResolvedValue(undefined);
renderView({ onSaveBlock });
const textarea = page.getByRole('textbox').first();
await textarea.fill('Neue Zeile');
// Not called immediately
expect(onSaveBlock).not.toHaveBeenCalled();
// Advance past debounce
vi.advanceTimersByTime(1500);
await vi.runAllTimersAsync();
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Neue Zeile');
vi.useRealTimers();
});
it('resets debounce timer on rapid successive changes', async () => {
vi.useFakeTimers();
const onSaveBlock = vi.fn().mockResolvedValue(undefined);
renderView({ onSaveBlock });
const textarea = page.getByRole('textbox').first();
await textarea.fill('First');
vi.advanceTimersByTime(500);
await textarea.fill('Second');
vi.advanceTimersByTime(500);
// 1000ms elapsed since first change — should not have saved yet
expect(onSaveBlock).not.toHaveBeenCalled();
vi.advanceTimersByTime(1000);
await vi.runAllTimersAsync();
// Only one save with the final value
expect(onSaveBlock).toHaveBeenCalledTimes(1);
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Second');
vi.useRealTimers();
});
});
// ─── Save state transitions ───────────────────────────────────────────────────
describe('TranscriptionEditView — save state indicators', () => {
it('shows saving indicator while onSaveBlock is in-flight', async () => {
vi.useFakeTimers();
let resolveSave!: () => void;
const onSaveBlock = vi.fn().mockReturnValue(new Promise<void>((r) => (resolveSave = r)));
renderView({ onSaveBlock });
await page.getByRole('textbox').first().fill('Hello');
vi.advanceTimersByTime(1500);
await vi.runAllTimersAsync();
await expect.element(page.getByText('Speichere...')).toBeInTheDocument();
resolveSave();
vi.useRealTimers();
});
it('shows error state when onSaveBlock rejects', async () => {
vi.useFakeTimers();
const onSaveBlock = vi.fn().mockRejectedValue(new Error('network'));
renderView({ onSaveBlock });
await page.getByRole('textbox').first().fill('Fails');
vi.advanceTimersByTime(1500);
await vi.runAllTimersAsync();
await expect.element(page.getByText('Nicht gespeichert')).toBeInTheDocument();
await expect.element(page.getByText('Erneut versuchen')).toBeInTheDocument();
vi.useRealTimers();
});
});
// ─── Flush on blur ────────────────────────────────────────────────────────────
describe('TranscriptionEditView — flush on blur', () => {
it('flushes pending save immediately on textarea blur before debounce expires', async () => {
vi.useFakeTimers();
const onSaveBlock = vi.fn().mockResolvedValue(undefined);
renderView({ onSaveBlock });
const textarea = page.getByRole('textbox').first();
await textarea.fill('Blur text');
// Blur before 1500ms debounce fires
await textarea.blur();
await vi.runAllTimersAsync();
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Blur text');
vi.useRealTimers();
});
});
// ─── onDeleteBlock callback ───────────────────────────────────────────────────
describe('TranscriptionEditView — delete block', () => {
it('calls onDeleteBlock with correct blockId when delete is confirmed', async () => {
const onDeleteBlock = vi.fn().mockResolvedValue(undefined);
vi.spyOn(window, 'confirm').mockReturnValue(true);
renderView({ onDeleteBlock });
const deleteBtn = page.getByRole('button', { name: 'Löschen' }).first();
await deleteBtn.click();
expect(onDeleteBlock).toHaveBeenCalledWith('b1');
vi.restoreAllMocks();
});
it('does not call onDeleteBlock when deletion is cancelled', async () => {
const onDeleteBlock = vi.fn();
vi.spyOn(window, 'confirm').mockReturnValue(false);
renderView({ onDeleteBlock });
const deleteBtn = page.getByRole('button', { name: 'Löschen' }).first();
await deleteBtn.click();
expect(onDeleteBlock).not.toHaveBeenCalled();
vi.restoreAllMocks();
});
});