refactor: move document transcription, annotation, viewer sub-packages
- transcription/: TranscriptionBlock, Column, EditView, PanelHeader, ReadView, Section + transcriptionMarkers, blockConflictMerge, saveBlockWithConflictRetry + useBlockAutoSave, useBlockDragDrop hooks - annotation/: AnnotationLayer, AnnotationShape, AnnotationEditOverlay - viewer/: PdfViewer, PdfControls + useFileLoader, usePdfRenderer hooks Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
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<string, unknown> = {}) {
|
||||
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<string, unknown> = {}) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user