Files
familienarchiv/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts
Marcel 69ac183fe8
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m31s
CI / OCR Service Tests (push) Successful in 37s
CI / Backend Unit Tests (push) Failing after 3m1s
CI / Unit & Component Tests (pull_request) Failing after 3m13s
CI / OCR Service Tests (pull_request) Successful in 30s
CI / Backend Unit Tests (pull_request) Failing after 2m57s
feat(transcription): add bulk "Alle als fertig markieren" action to transcription panel
Adds a single-transaction backend endpoint PUT /api/documents/{id}/transcription-blocks/review-all
that marks all blocks as reviewed atomically. Emits N individual BLOCK_REVIEWED audit events (one
per previously-unreviewed block). The frontend button is disabled (not hidden) when all blocks are
already reviewed, and shows a spinner during the operation.

Closes #345

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 20:53:47 +02:00

335 lines
12 KiB
TypeScript

import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import TranscriptionEditView from './TranscriptionEditView.svelte';
import { createConfirmService, CONFIRM_KEY } from '$lib/services/confirm.svelte.js';
afterEach(cleanup);
const block1 = {
id: 'b1',
annotationId: 'a1',
documentId: 'doc-1',
text: 'Block eins',
label: null,
sortOrder: 0,
version: 0,
source: 'MANUAL' as const,
reviewed: false
};
const block2 = {
id: 'b2',
annotationId: 'a2',
documentId: 'doc-1',
text: 'Block zwei',
label: null,
sortOrder: 1,
version: 0,
source: 'OCR' as const,
reviewed: true
};
function renderView(overrides: Record<string, unknown> = {}, service = createConfirmService()) {
return {
...render(TranscriptionEditView, {
props: {
documentId: 'doc-1',
blocks: [block1, block2],
canComment: true,
currentUserId: 'user-1',
onBlockFocus: vi.fn(),
onSaveBlock: vi.fn(),
onDeleteBlock: vi.fn(),
onReviewToggle: vi.fn(),
...overrides
},
context: new Map([[CONFIRM_KEY, service]])
}),
service
};
}
const unreviewedBlock1 = { ...block1, reviewed: false };
const unreviewedBlock2 = { ...block2, reviewed: false };
const reviewedBlock1 = { ...block1, reviewed: true };
const reviewedBlock2 = { ...block2, reviewed: true };
describe('TranscriptionEditView — rendering', () => {
it('renders blocks in sort order', async () => {
renderView();
const textareas = page.getByRole('textbox').all();
expect(textareas.length).toBeGreaterThanOrEqual(2);
});
it('shows next-block CTA after block list', async () => {
renderView();
await expect.element(page.getByText(/Block 3/)).toBeInTheDocument();
});
it('shows coach card when no blocks', async () => {
renderView({ blocks: [] });
await expect
.element(page.getByRole('heading', { level: 2 }))
.toHaveTextContent('Erste Transkription?');
});
it('hides training footer when no blocks', async () => {
renderView({ blocks: [], canWrite: true });
await expect.element(page.getByText('Für Training vormerken')).not.toBeInTheDocument();
});
it('shows training footer when blocks exist', async () => {
renderView({ blocks: [block1], canWrite: true });
await expect.element(page.getByText('Für Training vormerken')).toBeInTheDocument();
});
});
describe('TranscriptionEditView — annotation sync', () => {
it('activates block matching activeAnnotationId', async () => {
renderView({ activeAnnotationId: 'a2' });
// Block 2 (annotation a2) should have turquoise border
const block = document.querySelector('[data-block-id="b2"]')!;
expect(block.className).toContain('border-turquoise');
});
it('does not activate any block when activeAnnotationId is null', async () => {
renderView({ activeAnnotationId: null });
const block1 = document.querySelector('[data-block-id="b1"]')!;
const block2 = document.querySelector('[data-block-id="b2"]')!;
expect(block1.className).not.toContain('border-turquoise');
expect(block2.className).not.toContain('border-turquoise');
});
});
describe('TranscriptionEditView — reorder', () => {
it('renders move-up button disabled on first block', async () => {
renderView();
const upButtons = page.getByRole('button', { name: 'Nach oben' }).all();
// First block's up button should be disabled
await expect.element(upButtons[0]).toBeDisabled();
});
it('renders move-down button disabled on last block', async () => {
renderView();
const downButtons = page.getByRole('button', { name: 'Nach unten' }).all();
// Last block's down button should be disabled
await expect.element(downButtons[downButtons.length - 1]).toBeDisabled();
});
it('has a drag handle on each block', async () => {
renderView();
const handles = document.querySelectorAll('[data-drag-handle]');
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 — locator.blur() not available, use native DOM
const el = document.querySelector('textarea') as HTMLTextAreaElement;
el.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
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);
const { service } = renderView({ onDeleteBlock });
const deleteBtn = document.querySelector('button[aria-label="Löschen"]') as HTMLButtonElement;
deleteBtn.click();
await vi.waitFor(() => expect(service.options).not.toBeNull());
service.settle(true);
await vi.waitFor(() => expect(service.options).toBeNull());
expect(onDeleteBlock).toHaveBeenCalledWith('b1');
});
it('does not call onDeleteBlock when deletion is cancelled', async () => {
const onDeleteBlock = vi.fn();
const { service } = renderView({ onDeleteBlock });
const deleteBtn = document.querySelector('button[aria-label="Löschen"]') as HTMLButtonElement;
deleteBtn.click();
await vi.waitFor(() => expect(service.options).not.toBeNull());
service.settle(false);
await vi.waitFor(() => expect(service.options).toBeNull());
expect(onDeleteBlock).not.toHaveBeenCalled();
});
});
// ─── Review progress counter ──────────────────────────────────────────────────
describe('TranscriptionEditView — review progress counter', () => {
it('shows reviewed count and total when blocks exist', async () => {
// block1: reviewed=false, block2: reviewed=true → "1 / 2 geprüft"
renderView();
await expect.element(page.getByText(/1 \/ 2 geprüft/)).toBeInTheDocument();
});
it('shows 0 reviewed when no blocks are reviewed', async () => {
renderView({ blocks: [block1] }); // block1.reviewed = false
await expect.element(page.getByText(/0 \/ 1 geprüft/)).toBeInTheDocument();
});
it('does not show progress counter when there are no blocks', async () => {
renderView({ blocks: [] });
await expect.element(page.getByText(/geprüft/)).not.toBeInTheDocument();
});
});
// ─── Bulk mark all as reviewed ────────────────────────────────────────────────
describe('TranscriptionEditView — mark all reviewed', () => {
it('shows "Alle als fertig markieren" button when onMarkAllReviewed is provided and blocks are unreviewed', async () => {
renderView({
blocks: [unreviewedBlock1, unreviewedBlock2],
onMarkAllReviewed: vi.fn().mockResolvedValue(undefined)
});
await expect
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
.toBeInTheDocument();
});
it('does not show "Alle als fertig markieren" button when onMarkAllReviewed is not provided', async () => {
renderView({ blocks: [unreviewedBlock1, unreviewedBlock2] });
await expect
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
.not.toBeInTheDocument();
});
it('disables button when all blocks are already reviewed', async () => {
renderView({
blocks: [reviewedBlock1, reviewedBlock2],
onMarkAllReviewed: vi.fn().mockResolvedValue(undefined)
});
await expect
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
.toBeDisabled();
});
it('calls onMarkAllReviewed exactly once when button is clicked', async () => {
const onMarkAllReviewed = vi.fn().mockResolvedValue(undefined);
renderView({
blocks: [unreviewedBlock1, unreviewedBlock2],
onMarkAllReviewed
});
await page.getByRole('button', { name: /Alle als fertig markieren/ }).click();
await vi.waitFor(() => expect(onMarkAllReviewed).toHaveBeenCalledTimes(1));
});
it('disables button while operation is in-flight', async () => {
let resolveMarkAll!: () => void;
const onMarkAllReviewed = vi
.fn()
.mockReturnValue(new Promise<void>((r) => (resolveMarkAll = r)));
renderView({
blocks: [unreviewedBlock1, unreviewedBlock2],
onMarkAllReviewed
});
const btn = page.getByRole('button', { name: /Alle als fertig markieren/ });
await btn.click();
await expect.element(btn).toBeDisabled();
resolveMarkAll();
});
});