import { describe, it, expect, vi, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; import TranscriptionBlockHost from './TranscriptionBlock.test-host.svelte'; import type { ConfirmService } from '$lib/services/confirm.svelte.js'; afterEach(cleanup); const BASE_PROPS = { blockId: 'block-1', documentId: 'doc-1', blockNumber: 3, text: 'Liebe Mutter,', label: null, active: false, saveState: 'idle' as const, canComment: true, currentUserId: 'user-1', onTextChange: vi.fn(), onFocus: vi.fn(), onDeleteClick: vi.fn(), onRetry: vi.fn() }; // Renders TranscriptionBlock via the host, which provides ConfirmService context. function renderBlock(overrides: Record = {}) { return render(TranscriptionBlockHost, { ...BASE_PROPS, onServiceReady: () => {}, ...overrides }); } // ─── Rendering ─────────────────────────────────────────────────────────────── describe('TranscriptionBlock — rendering', () => { it('renders block number in turquoise badge', async () => { renderBlock(); await expect.element(page.getByText('3')).toBeInTheDocument(); }); it('renders text in textarea', async () => { renderBlock(); await expect.element(page.getByText('Liebe Mutter,')).toBeInTheDocument(); }); it('renders optional label when provided', async () => { renderBlock({ label: 'Anrede' }); await expect.element(page.getByText('Anrede')).toBeInTheDocument(); }); it('does not render label when null', async () => { renderBlock({ label: null }); const label = page.getByText('Anrede'); await expect.element(label).not.toBeInTheDocument(); }); }); // ─── Save states ───────────────────────────────────────────────────────────── describe('TranscriptionBlock — save states', () => { it('shows nothing in idle state', async () => { renderBlock({ saveState: 'idle' }); const saving = page.getByText('Speichere...'); await expect.element(saving).not.toBeInTheDocument(); }); it('shows "Speichere..." in saving state', async () => { renderBlock({ saveState: 'saving' }); await expect.element(page.getByText('Speichere...')).toBeInTheDocument(); }); it('shows "Gespeichert" in saved state', async () => { renderBlock({ saveState: 'saved' }); await expect.element(page.getByText(/Gespeichert/)).toBeInTheDocument(); }); it('shows error with retry button in error state', async () => { const onRetry = vi.fn(); renderBlock({ saveState: 'error', onRetry }); await expect.element(page.getByText('Nicht gespeichert')).toBeInTheDocument(); const retryBtn = page.getByText('Erneut versuchen'); await expect.element(retryBtn).toBeInTheDocument(); }); }); // ─── Active state ──────────────────────────────────────────────────────────── describe('TranscriptionBlock — active border', () => { it('has turquoise left border when active', async () => { renderBlock({ active: true }); await expect.element(page.getByRole('textbox')).toBeInTheDocument(); const block = document.querySelector('[data-block-id="block-1"]')!; expect(block.className).toContain('border-turquoise'); }); it('has error left border when save failed', async () => { renderBlock({ saveState: 'error' }); await expect.element(page.getByRole('textbox')).toBeInTheDocument(); const block = document.querySelector('[data-block-id="block-1"]')!; expect(block.className).toContain('border-error'); }); }); // ─── Interactions ──────────────────────────────────────────────────────────── describe('TranscriptionBlock — interactions', () => { it('calls onTextChange when typing in textarea', async () => { const onTextChange = vi.fn(); renderBlock({ onTextChange }); const textarea = page.getByRole('textbox'); await textarea.fill('Neue Zeile'); expect(onTextChange).toHaveBeenCalled(); }); it('calls onFocus when textarea is focused', async () => { const onFocus = vi.fn(); renderBlock({ onFocus }); const textarea = page.getByRole('textbox'); await textarea.click(); expect(onFocus).toHaveBeenCalled(); }); it('shows Kommentieren button when no comments exist', async () => { renderBlock(); const btn = page.getByText('Kommentieren'); await expect.element(btn).toBeInTheDocument(); }); }); // ─── Reorder controls ──────────────────────────────────────────────────────── describe('TranscriptionBlock — reorder controls', () => { it('shows a drag handle element', async () => { renderBlock(); const handle = document.querySelector('[data-drag-handle]'); expect(handle).not.toBeNull(); }); it('disables move-up button when isFirst', async () => { renderBlock({ isFirst: true }); const btn = page.getByRole('button', { name: 'Nach oben' }); await expect.element(btn).toBeDisabled(); }); it('disables move-down button when isLast', async () => { renderBlock({ isLast: true }); const btn = page.getByRole('button', { name: 'Nach unten' }); await expect.element(btn).toBeDisabled(); }); it('calls onMoveUp when up arrow clicked', async () => { const onMoveUp = vi.fn(); renderBlock({ onMoveUp, isFirst: false }); const btn = page.getByRole('button', { name: 'Nach oben' }); await btn.click(); expect(onMoveUp).toHaveBeenCalled(); }); it('calls onMoveDown when down arrow clicked', async () => { const onMoveDown = vi.fn(); renderBlock({ onMoveDown, isLast: false }); const btn = page.getByRole('button', { name: 'Nach unten' }); await btn.click(); expect(onMoveDown).toHaveBeenCalled(); }); }); // ─── Delete confirmation ────────────────────────────────────────────────────── function renderBlockWithService(overrides: Record = {}) { let service!: ConfirmService; render(TranscriptionBlockHost, { blockId: 'block-1', documentId: 'doc-1', blockNumber: 3, text: 'Liebe Mutter,', label: null, active: false, saveState: 'idle' as const, canComment: true, currentUserId: 'user-1', onTextChange: vi.fn(), onFocus: vi.fn(), onDeleteClick: vi.fn(), onRetry: vi.fn(), onServiceReady: (s: ConfirmService) => { service = s; }, ...overrides }); return { service }; } describe('TranscriptionBlock — delete confirmation', () => { it('does not call onDeleteClick when user cancels via confirm service', async () => { const onDeleteClick = vi.fn(); const { service } = renderBlockWithService({ onDeleteClick }); // Use native DOM click so the async handler starts but yields at the await, // letting the test observe service.options and settle the promise. 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(onDeleteClick).not.toHaveBeenCalled(); }); it('calls onDeleteClick when user confirms via confirm service', async () => { const onDeleteClick = vi.fn(); const { service } = renderBlockWithService({ onDeleteClick }); 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(onDeleteClick).toHaveBeenCalledOnce(); }); }); // ─── Quote selection ───────────────────────────────────────────────────────── describe('TranscriptionBlock — quote selection', () => { it('shows quote hint after text is selected in the editor', async () => { renderBlock({ text: 'Breslau, den 12. August' }); await page.getByRole('textbox').click(); // Select all text in the contenteditable via the native Selection API. // Tiptap fires selectionUpdate which the block forwards as onSelectionChange. const editorEl = document.querySelector('[role="textbox"]') as HTMLElement; const range = document.createRange(); range.selectNodeContents(editorEl); const selection = window.getSelection()!; selection.removeAllRanges(); selection.addRange(range); editorEl.dispatchEvent(new Event('input', { bubbles: true })); await expect.element(page.getByText(/Zitat/)).toBeInTheDocument(); }); }); // ─── Fading state ──────────────────────────────────────────────────────────── describe('TranscriptionBlock — fading save state', () => { it('shows Gespeichert text in fading state (opacity-0 fade-out)', async () => { renderBlock({ saveState: 'fading' }); const indicator = page.getByText(/Gespeichert/); await expect.element(indicator).toBeInTheDocument(); // The fading class sets opacity-0 const el = document.querySelector('.opacity-0'); expect(el).not.toBeNull(); }); });