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>
445 lines
17 KiB
TypeScript
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();
|
|
});
|
|
});
|