Files
familienarchiv/frontend/src/lib/document/transcription/TranscriptionEditView.svelte.spec.ts
Marcel 1782526c99 test(transcription): gate second click on button re-enabled to fix race
Adds an await for the button to become non-disabled between the two
dispatchEvent calls in 'clears error on next successful call'. This
ensures the first async rejection has fully settled and Svelte has
flushed markingAllReviewed before the second click fires.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 21:29:31 +02:00

445 lines
17 KiB
TypeScript

import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import TranscriptionEditView from './TranscriptionEditView.svelte';
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
import { m } from '$lib/paraglide/messages.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,
mentionedPersons: []
};
const block2 = {
id: 'b2',
annotationId: 'a2',
documentId: 'doc-1',
text: 'Block zwei',
label: null,
sortOrder: 1,
version: 0,
source: 'OCR' as const,
reviewed: true,
mentionedPersons: []
};
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('passes the block mentionedPersons array as the 3rd save argument', async () => {
const onSaveBlock = vi.fn().mockResolvedValue(undefined);
const blockWithMention = {
...block1,
// text must contain the @displayName token so deserialize() creates a mention node;
// fill() replaces the whole content with plain text and would destroy the node
text: '@Auguste Raddatz',
mentionedPersons: [{ personId: 'p-aug', displayName: 'Auguste Raddatz' }]
};
renderView({ blocks: [blockWithMention], onSaveBlock });
// type() focuses the element (cursor at position 0) then inserts without replacing the
// existing mention node. Fake timers interfere with keyboard CDP so use real timers
// + vi.waitFor to catch the 1500 ms debounce.
await userEvent.type(page.getByRole('textbox').first(), 'Hallo ');
await vi.waitFor(
() =>
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Hallo @Auguste Raddatz', [
{ personId: 'p-aug', displayName: 'Auguste Raddatz' }
]),
{ timeout: 3000 }
);
});
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.
// PersonMentionEditor uses a contenteditable div (role=textbox), not a <textarea>.
const el = document.querySelector('[role="textbox"]') as HTMLElement;
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: m.transcription_mark_all_reviewed() }))
.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: m.transcription_mark_all_reviewed() }))
.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: m.transcription_mark_all_reviewed() }))
.toBeDisabled();
});
it('calls onMarkAllReviewed exactly once when button is clicked', async () => {
const onMarkAllReviewed = vi.fn().mockResolvedValue(undefined);
renderView({
blocks: [unreviewedBlock1, unreviewedBlock2],
onMarkAllReviewed
});
// userEvent.click() via Playwright CDP doesn't reliably trigger Svelte 5 onclick
// handlers when a TipTap editor is mounted in the same component tree.
const btn = (await page
.getByRole('button', { name: m.transcription_mark_all_reviewed() })
.element()) as HTMLButtonElement;
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
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
});
// Same CDP click workaround: dispatch from browser JS to reliably fire Svelte 5 onclick
const btnEl = (await page
.getByRole('button', { name: m.transcription_mark_all_reviewed() })
.element()) as HTMLButtonElement;
btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await expect
.element(page.getByRole('button', { name: m.transcription_mark_all_reviewed() }))
.toBeDisabled();
resolveMarkAll();
});
it('shows error message when onMarkAllReviewed callback rejects', async () => {
const onMarkAllReviewed = vi.fn().mockRejectedValue(new Error('INTERNAL_ERROR'));
renderView({ blocks: [unreviewedBlock1, unreviewedBlock2], onMarkAllReviewed });
const btnEl = (await page
.getByRole('button', { name: m.transcription_mark_all_reviewed() })
.element()) as HTMLButtonElement;
btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await expect.element(page.getByRole('alert')).toBeInTheDocument();
await expect
.element(page.getByRole('alert'))
.toHaveTextContent(m.transcription_mark_all_reviewed_error());
});
it('clears error when dismiss button is clicked', async () => {
const onMarkAllReviewed = vi.fn().mockRejectedValue(new Error('INTERNAL_ERROR'));
renderView({ blocks: [unreviewedBlock1, unreviewedBlock2], onMarkAllReviewed });
const btnEl = (await page
.getByRole('button', { name: m.transcription_mark_all_reviewed() })
.element()) as HTMLButtonElement;
btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await expect.element(page.getByRole('alert')).toBeInTheDocument();
const dismissEl = (await page
.getByRole('button', { name: m.comp_dismiss() })
.element()) as HTMLButtonElement;
dismissEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await expect.element(page.getByRole('alert')).not.toBeInTheDocument();
});
it('clears error on next successful markAllReviewed call', async () => {
const onMarkAllReviewed = vi
.fn()
.mockRejectedValueOnce(new Error('INTERNAL_ERROR'))
.mockResolvedValue(undefined);
renderView({ blocks: [unreviewedBlock1, unreviewedBlock2], onMarkAllReviewed });
const btnEl = (await page
.getByRole('button', { name: m.transcription_mark_all_reviewed() })
.element()) as HTMLButtonElement;
btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await expect.element(page.getByRole('alert')).toBeInTheDocument();
// Wait for the button to be re-enabled before the second click — ensures the first
// async rejection has fully settled and Svelte has flushed state changes
await expect
.element(page.getByRole('button', { name: m.transcription_mark_all_reviewed() }))
.not.toBeDisabled();
btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await expect.element(page.getByRole('alert')).not.toBeInTheDocument();
});
it('re-enables button after markAllReviewed failure', async () => {
const onMarkAllReviewed = vi.fn().mockRejectedValue(new Error('INTERNAL_ERROR'));
renderView({ blocks: [unreviewedBlock1, unreviewedBlock2], onMarkAllReviewed });
const btnEl = (await page
.getByRole('button', { name: m.transcription_mark_all_reviewed() })
.element()) as HTMLButtonElement;
btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await expect.element(page.getByRole('alert')).toBeInTheDocument();
await expect
.element(page.getByRole('button', { name: m.transcription_mark_all_reviewed() }))
.not.toBeDisabled();
});
});