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'; afterEach(cleanup); const block1 = { id: 'b1', annotationId: 'a1', documentId: 'doc-1', text: 'Block eins', label: null, sortOrder: 0, version: 0 }; const block2 = { id: 'b2', annotationId: 'a2', documentId: 'doc-1', text: 'Block zwei', label: null, sortOrder: 1, version: 0 }; function renderView(overrides: Record = {}) { return render(TranscriptionEditView, { documentId: 'doc-1', blocks: [block1, block2], canComment: true, currentUserId: 'user-1', onBlockFocus: vi.fn(), onSaveBlock: vi.fn(), onDeleteBlock: vi.fn(), ...overrides }); } 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(/Markiere einen Bereich/)).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 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(); }); });