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 = {}, 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 }; } 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 empty state when no blocks', async () => { renderView({ blocks: [] }); await expect.element(page.getByText(/Zeichnen Sie Bereiche/)).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((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(); }); });