Replaces captureTextarea + handleTextareaMouseUp (which read selection bounds off a real <textarea>) with an onSelectionChange callback prop on PersonMentionEditor, wired to Tiptap's selectionUpdate event. The editor emits the selected text directly so the parent no longer needs DOM access. Tests are updated to drive the contenteditable via the Selection API instead of the now-deleted textarea. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
256 lines
9.5 KiB
TypeScript
256 lines
9.5 KiB
TypeScript
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();
|
|
});
|
|
});
|