Commit 5afdc37 changed the empty state from transcription_empty_cta
('Markiere einen Bereich…') to transcription_empty_draw_hint
('Zeichnen Sie Bereiche…') but left the spec asserting the old text.
Updated the locator to match the current component output.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
260 lines
9.0 KiB
TypeScript
260 lines
9.0 KiB
TypeScript
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<string, unknown> = {}, 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<void>((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();
|
|
});
|
|
});
|