import { describe, it, expect, vi, afterEach } from 'vitest'; import { createTranscriptionBlocks } from './useTranscriptionBlocks.svelte'; import type { TranscriptionBlockData } from '$lib/shared/types'; afterEach(() => { vi.restoreAllMocks(); }); const baseBlock = (overrides: Partial = {}): TranscriptionBlockData => ({ id: 'b-1', annotationId: 'ann-1', text: 'Hello', sortOrder: 1, reviewed: false, mentionedPersons: [], updatedAt: '2026-01-01T00:00:00Z', ...overrides }) as TranscriptionBlockData; function makeFetch(handlers: Record Response | Promise>) { return vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { const u = url.toString(); const method = init?.method ?? 'GET'; for (const [match, fn] of Object.entries(handlers)) { if (u.includes(match) && (match.includes(':') || true)) { return fn(); } } const key = `${method} ${u}`; for (const [match, fn] of Object.entries(handlers)) { if (key.includes(match)) return fn(); } return new Response('not found', { status: 404 }); }); } describe('createTranscriptionBlocks — initial state', () => { it('starts with no blocks, no derived metadata', () => { const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1' }); expect(ctrl.blocks).toEqual([]); expect(ctrl.hasBlocks).toBe(false); expect(ctrl.blockNumbers).toEqual({}); expect(ctrl.lastEditedAt).toBeNull(); expect(ctrl.annotationReloadKey).toBe(0); }); }); describe('createTranscriptionBlocks.load', () => { it('fetches and stores blocks on success', async () => { const fetchImpl = makeFetch({ '/api/documents/doc-1/transcription-blocks': () => new Response( JSON.stringify([baseBlock({ id: 'b1' }), baseBlock({ id: 'b2', sortOrder: 2 })]), { status: 200, headers: { 'Content-Type': 'application/json' } } ) }); const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); await ctrl.load(); expect(ctrl.blocks).toHaveLength(2); expect(ctrl.hasBlocks).toBe(true); }); it('is a no-op when documentId is empty', async () => { const fetchImpl = vi.fn(); const ctrl = createTranscriptionBlocks({ documentId: () => '', fetchImpl }); await ctrl.load(); expect(fetchImpl).not.toHaveBeenCalled(); }); it('keeps blocks empty on non-OK response', async () => { const fetchImpl = makeFetch({ 'transcription-blocks': () => new Response('boom', { status: 500 }) }); const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); await ctrl.load(); expect(ctrl.blocks).toEqual([]); }); it('swallows network errors during load', async () => { const fetchImpl = vi.fn(async () => { throw new Error('network'); }); const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); await expect(ctrl.load()).resolves.toBeUndefined(); expect(ctrl.blocks).toEqual([]); }); }); describe('createTranscriptionBlocks — derived state', () => { it('computes blockNumbers in sortOrder', async () => { const fetchImpl = makeFetch({ 'transcription-blocks': () => new Response( JSON.stringify([ baseBlock({ id: 'b3', annotationId: 'a3', sortOrder: 3 }), baseBlock({ id: 'b1', annotationId: 'a1', sortOrder: 1 }), baseBlock({ id: 'b2', annotationId: 'a2', sortOrder: 2 }) ]), { status: 200, headers: { 'Content-Type': 'application/json' } } ) }); const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); await ctrl.load(); expect(ctrl.blockNumbers).toEqual({ a1: 1, a2: 2, a3: 3 }); }); it('lastEditedAt picks the most recent updatedAt', async () => { const fetchImpl = makeFetch({ 'transcription-blocks': () => new Response( JSON.stringify([ baseBlock({ id: 'b1', updatedAt: '2026-04-15T10:00:00Z' }), baseBlock({ id: 'b2', updatedAt: '2026-04-20T10:00:00Z' }), baseBlock({ id: 'b3', updatedAt: '2026-04-10T10:00:00Z' }) ]), { status: 200, headers: { 'Content-Type': 'application/json' } } ) }); const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); await ctrl.load(); expect(ctrl.lastEditedAt).toBe(new Date('2026-04-20T10:00:00Z').toISOString()); }); it('lastEditedAt is null when no block has updatedAt', async () => { const fetchImpl = makeFetch({ 'transcription-blocks': () => new Response(JSON.stringify([baseBlock({ id: 'b1', updatedAt: undefined })]), { status: 200, headers: { 'Content-Type': 'application/json' } }) }); const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); await ctrl.load(); expect(ctrl.lastEditedAt).toBeNull(); }); }); describe('createTranscriptionBlocks.delete', () => { it('removes the block locally and bumps annotationReloadKey on success', async () => { const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { const u = url.toString(); const method = init?.method ?? 'GET'; if (u.includes('/transcription-blocks/b-1') && method === 'DELETE') { return new Response(null, { status: 204 }); } if (u.endsWith('/transcription-blocks')) { return new Response(JSON.stringify([baseBlock({ id: 'b-1' }), baseBlock({ id: 'b-2' })]), { status: 200, headers: { 'Content-Type': 'application/json' } }); } return new Response('', { status: 404 }); }); const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); await ctrl.load(); expect(ctrl.blocks).toHaveLength(2); const keyBefore = ctrl.annotationReloadKey; await ctrl.delete('b-1'); expect(ctrl.blocks).toHaveLength(1); expect(ctrl.blocks[0].id).toBe('b-2'); expect(ctrl.annotationReloadKey).toBe(keyBefore + 1); }); it('throws on non-OK delete response', async () => { const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { const method = init?.method ?? 'GET'; if (method === 'DELETE') return new Response('boom', { status: 500 }); return new Response('[]', { status: 200, headers: { 'Content-Type': 'application/json' } }); }); const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); await expect(ctrl.delete('b-1')).rejects.toThrow(); }); }); describe('createTranscriptionBlocks.reviewToggle', () => { it('updates the block after a successful PUT', async () => { let updated = false; const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { const u = url.toString(); const method = init?.method ?? 'GET'; if (u.includes('/review') && method === 'PUT') { updated = true; return new Response(JSON.stringify(baseBlock({ id: 'b-1', reviewed: true })), { status: 200, headers: { 'Content-Type': 'application/json' } }); } return new Response(JSON.stringify([baseBlock({ id: 'b-1', reviewed: false })]), { status: 200, headers: { 'Content-Type': 'application/json' } }); }); const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); await ctrl.load(); await ctrl.reviewToggle('b-1'); expect(updated).toBe(true); expect(ctrl.blocks[0].reviewed).toBe(true); }); it('is a no-op when PUT returns non-OK', async () => { const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { const method = init?.method ?? 'GET'; if (method === 'PUT') return new Response('', { status: 500 }); return new Response(JSON.stringify([baseBlock({ reviewed: false })]), { status: 200, headers: { 'Content-Type': 'application/json' } }); }); const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); await ctrl.load(); await ctrl.reviewToggle('b-1'); expect(ctrl.blocks[0].reviewed).toBe(false); }); }); describe('createTranscriptionBlocks.markAllReviewed', () => { it('updates each matching block', async () => { const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { const u = url.toString(); const method = init?.method ?? 'GET'; if (u.includes('/review-all') && method === 'PUT') { return new Response( JSON.stringify([ { id: 'b-1', reviewed: true }, { id: 'b-2', reviewed: true } ]), { status: 200, headers: { 'Content-Type': 'application/json' } } ); } return new Response( JSON.stringify([ baseBlock({ id: 'b-1', reviewed: false }), baseBlock({ id: 'b-2', reviewed: false }) ]), { status: 200, headers: { 'Content-Type': 'application/json' } } ); }); const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); await ctrl.load(); await ctrl.markAllReviewed(); expect(ctrl.blocks.every((b) => b.reviewed)).toBe(true); }); it('throws and leaves blocks unchanged when PUT returns non-OK', async () => { const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { const u = url.toString(); const method = init?.method ?? 'GET'; if (u.includes('/review-all') && method === 'PUT') { return new Response(JSON.stringify({ code: 'INTERNAL_ERROR' }), { status: 500, headers: { 'Content-Type': 'application/json' } }); } return new Response(JSON.stringify([baseBlock({ id: 'b-1', reviewed: false })]), { status: 200, headers: { 'Content-Type': 'application/json' } }); }); const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); await ctrl.load(); await expect(ctrl.markAllReviewed()).rejects.toThrow('INTERNAL_ERROR'); expect(ctrl.blocks[0].reviewed).toBe(false); }); it('throws INTERNAL_ERROR when PUT returns non-JSON body (e.g. nginx 502)', async () => { const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { const u = url.toString(); const method = init?.method ?? 'GET'; if (u.includes('/review-all') && method === 'PUT') { return new Response('Bad Gateway', { status: 502 }); } return new Response(JSON.stringify([baseBlock({ id: 'b-1', reviewed: false })]), { status: 200, headers: { 'Content-Type': 'application/json' } }); }); const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); await ctrl.load(); await expect(ctrl.markAllReviewed()).rejects.toThrow('INTERNAL_ERROR'); expect(ctrl.blocks[0].reviewed).toBe(false); }); }); describe('createTranscriptionBlocks.createFromDraw', () => { it('appends a created block on 200', async () => { const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { const u = url.toString(); const method = init?.method ?? 'GET'; if (u.endsWith('/transcription-blocks') && method === 'POST') { return new Response(JSON.stringify(baseBlock({ id: 'b-new', annotationId: 'ann-new' })), { status: 200, headers: { 'Content-Type': 'application/json' } }); } return new Response('[]', { status: 200, headers: { 'Content-Type': 'application/json' } }); }); const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); await ctrl.load(); const created = await ctrl.createFromDraw({ x: 0.1, y: 0.1, width: 0.1, height: 0.1, pageNumber: 1 }); expect(created?.id).toBe('b-new'); expect(ctrl.blocks.find((b) => b.id === 'b-new')).toBeDefined(); }); it('returns null and does not append on non-OK response', async () => { const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { const method = init?.method ?? 'GET'; if (method === 'POST') return new Response('boom', { status: 500 }); return new Response('[]', { status: 200, headers: { 'Content-Type': 'application/json' } }); }); const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); await ctrl.load(); const created = await ctrl.createFromDraw({ x: 0, y: 0, width: 0.1, height: 0.1, pageNumber: 1 }); expect(created).toBeNull(); expect(ctrl.blocks).toHaveLength(0); }); it('returns null on network error', async () => { const fetchImpl = vi.fn(async () => { throw new Error('network'); }); const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); const created = await ctrl.createFromDraw({ x: 0, y: 0, width: 0.1, height: 0.1, pageNumber: 1 }); expect(created).toBeNull(); }); }); describe('createTranscriptionBlocks.toggleTrainingLabel', () => { it('PATCHes the training-labels endpoint', async () => { const fetchImpl = vi.fn(async () => new Response('', { status: 200 })); const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); await ctrl.toggleTrainingLabel('KURRENT_RECOGNITION', true); expect(fetchImpl).toHaveBeenCalledWith( '/api/documents/doc-1/training-labels', expect.objectContaining({ method: 'PATCH' }) ); }); it('throws on non-OK response', async () => { const fetchImpl = vi.fn(async () => new Response('boom', { status: 500 })); const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); await expect(ctrl.toggleTrainingLabel('X', true)).rejects.toThrow(); }); }); describe('createTranscriptionBlocks.deleteAnnotation', () => { it('deletes the linked block when one exists', async () => { let blockDeleted = false; const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { const u = url.toString(); const method = init?.method ?? 'GET'; if (u.includes('/transcription-blocks/b-1') && method === 'DELETE') { blockDeleted = true; return new Response(null, { status: 204 }); } if (u.endsWith('/transcription-blocks')) { return new Response(JSON.stringify([baseBlock({ id: 'b-1', annotationId: 'ann-1' })]), { status: 200, headers: { 'Content-Type': 'application/json' } }); } return new Response('', { status: 200 }); }); const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); await ctrl.load(); await ctrl.deleteAnnotation('ann-1'); expect(blockDeleted).toBe(true); expect(ctrl.blocks).toHaveLength(0); }); it('deletes the bare annotation when no block is linked', async () => { let annotationDeleted = false; const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { const u = url.toString(); const method = init?.method ?? 'GET'; if (u.includes('/annotations/ann-orphan') && method === 'DELETE') { annotationDeleted = true; return new Response(null, { status: 204 }); } return new Response('[]', { status: 200, headers: { 'Content-Type': 'application/json' } }); }); const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); await ctrl.load(); const keyBefore = ctrl.annotationReloadKey; await ctrl.deleteAnnotation('ann-orphan'); expect(annotationDeleted).toBe(true); expect(ctrl.annotationReloadKey).toBe(keyBefore + 1); }); it('throws when the bare-annotation DELETE fails', async () => { const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { const u = url.toString(); const method = init?.method ?? 'GET'; if (u.includes('/annotations/') && method === 'DELETE') { return new Response('boom', { status: 500 }); } return new Response('[]', { status: 200, headers: { 'Content-Type': 'application/json' } }); }); const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); await ctrl.load(); await expect(ctrl.deleteAnnotation('ann-orphan')).rejects.toThrow(); }); }); describe('createTranscriptionBlocks.findByAnnotationId', () => { it('returns the block whose annotationId matches', async () => { const fetchImpl = makeFetch({ 'transcription-blocks': () => new Response( JSON.stringify([ baseBlock({ id: 'b1', annotationId: 'ann-a' }), baseBlock({ id: 'b2', annotationId: 'ann-b' }) ]), { status: 200, headers: { 'Content-Type': 'application/json' } } ) }); const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); await ctrl.load(); expect(ctrl.findByAnnotationId('ann-b')?.id).toBe('b2'); expect(ctrl.findByAnnotationId('ann-missing')).toBeUndefined(); }); }); describe('createTranscriptionBlocks.bumpAnnotationReloadKey', () => { it('increments annotationReloadKey by 1', () => { const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1' }); expect(ctrl.annotationReloadKey).toBe(0); ctrl.bumpAnnotationReloadKey(); expect(ctrl.annotationReloadKey).toBe(1); ctrl.bumpAnnotationReloadKey(); expect(ctrl.annotationReloadKey).toBe(2); }); });