From e89d8a4ca933d7a20bbe6d74e8bf7f20eafe340a Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 6 Apr 2026 11:20:57 +0200 Subject: [PATCH] test: increase coverage --- .../TranscriptionBlockControllerTest.java | 359 ++++++++++++++++++ .../TranscriptionBlockRepositoryTest.java | 193 ++++++++++ frontend/e2e/annotations.spec.ts | 272 +++++++++++++ frontend/e2e/transcription.spec.ts | 296 +++++++++++++++ frontend/eslint.config.js | 1 + .../TranscriptionBlock.svelte.spec.ts | 56 +++ .../TranscriptionEditView.svelte.spec.ts | 129 +++++++ 7 files changed, 1306 insertions(+) create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/controller/TranscriptionBlockControllerTest.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/repository/TranscriptionBlockRepositoryTest.java create mode 100644 frontend/e2e/annotations.spec.ts create mode 100644 frontend/e2e/transcription.spec.ts diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/TranscriptionBlockControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/TranscriptionBlockControllerTest.java new file mode 100644 index 00000000..a891413e --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/TranscriptionBlockControllerTest.java @@ -0,0 +1,359 @@ +package org.raddatz.familienarchiv.controller; + +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.config.SecurityConfig; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.model.AppUser; +import org.raddatz.familienarchiv.model.TranscriptionBlock; +import org.raddatz.familienarchiv.model.TranscriptionBlockVersion; +import org.raddatz.familienarchiv.security.PermissionAspect; +import org.raddatz.familienarchiv.service.CustomUserDetailsService; +import org.raddatz.familienarchiv.service.TranscriptionService; +import org.raddatz.familienarchiv.service.UserService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(TranscriptionBlockController.class) +@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) +class TranscriptionBlockControllerTest { + + @Autowired MockMvc mockMvc; + + @MockitoBean TranscriptionService transcriptionService; + @MockitoBean UserService userService; + @MockitoBean CustomUserDetailsService customUserDetailsService; + + private static final UUID DOC_ID = UUID.randomUUID(); + private static final UUID BLOCK_ID = UUID.randomUUID(); + private static final String URL_BASE = "/api/documents/" + DOC_ID + "/transcription-blocks"; + private static final String URL_BLOCK = URL_BASE + "/" + BLOCK_ID; + private static final String URL_REORDER = URL_BASE + "/reorder"; + private static final String URL_HISTORY = URL_BLOCK + "/history"; + + private static final String CREATE_JSON = + "{\"pageNumber\":1,\"x\":0.1,\"y\":0.2,\"width\":0.3,\"height\":0.4,\"text\":\"Liebe Mutter,\"}"; + private static final String UPDATE_JSON = + "{\"text\":\"Neue Fassung\",\"label\":\"Anrede\"}"; + private static final String REORDER_JSON = + "{\"blockIds\":[\"" + UUID.randomUUID() + "\",\"" + UUID.randomUUID() + "\"]}"; + + private AppUser mockUser() { + return AppUser.builder().id(UUID.randomUUID()).username("user").build(); + } + + private TranscriptionBlock sampleBlock() { + return TranscriptionBlock.builder() + .id(BLOCK_ID).documentId(DOC_ID) + .annotationId(UUID.randomUUID()) + .text("Liebe Mutter,").sortOrder(0).build(); + } + + // ─── GET /api/documents/{id}/transcription-blocks ──────────────────────── + + @Test + void listBlocks_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get(URL_BASE)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void listBlocks_returns403_whenMissingReadAllPermission() throws Exception { + mockMvc.perform(get(URL_BASE)) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void listBlocks_returns200_withBlocks_whenAuthorised() throws Exception { + TranscriptionBlock b = sampleBlock(); + when(transcriptionService.listBlocks(DOC_ID)).thenReturn(List.of(b)); + + mockMvc.perform(get(URL_BASE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].text").value("Liebe Mutter,")); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void listBlocks_returns200_withEmptyArray_whenNoBlocksExist() throws Exception { + when(transcriptionService.listBlocks(any())).thenReturn(List.of()); + + mockMvc.perform(get(URL_BASE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$").isEmpty()); + } + + // ─── GET /api/documents/{id}/transcription-blocks/{blockId} ───────────── + + @Test + void getBlock_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get(URL_BLOCK)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void getBlock_returns403_whenMissingReadAllPermission() throws Exception { + mockMvc.perform(get(URL_BLOCK)) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void getBlock_returns200_withBlockData_whenFound() throws Exception { + when(transcriptionService.getBlock(DOC_ID, BLOCK_ID)).thenReturn(sampleBlock()); + + mockMvc.perform(get(URL_BLOCK)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(BLOCK_ID.toString())) + .andExpect(jsonPath("$.text").value("Liebe Mutter,")) + .andExpect(jsonPath("$.sortOrder").value(0)); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void getBlock_returns404_whenBlockDoesNotExist() throws Exception { + when(transcriptionService.getBlock(any(), any())) + .thenThrow(DomainException.notFound(ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND, "not found")); + + mockMvc.perform(get(URL_BLOCK)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("TRANSCRIPTION_BLOCK_NOT_FOUND")); + } + + // ─── POST /api/documents/{id}/transcription-blocks ─────────────────────── + + @Test + void createBlock_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(post(URL_BASE) + .contentType(MediaType.APPLICATION_JSON) + .content(CREATE_JSON)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void createBlock_returns403_whenMissingWriteAllPermission() throws Exception { + mockMvc.perform(post(URL_BASE) + .contentType(MediaType.APPLICATION_JSON) + .content(CREATE_JSON)) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void createBlock_returns201_withSavedBlock_whenAuthorised() throws Exception { + when(userService.findByUsername(any())).thenReturn(mockUser()); + when(transcriptionService.createBlock(eq(DOC_ID), any(), any())).thenReturn(sampleBlock()); + + mockMvc.perform(post(URL_BASE) + .contentType(MediaType.APPLICATION_JSON) + .content(CREATE_JSON)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.text").value("Liebe Mutter,")) + .andExpect(jsonPath("$.documentId").value(DOC_ID.toString())); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void createBlock_returns401_whenUserNotFoundInDatabase() throws Exception { + when(userService.findByUsername(any())).thenReturn(null); + + mockMvc.perform(post(URL_BASE) + .contentType(MediaType.APPLICATION_JSON) + .content(CREATE_JSON)) + .andExpect(status().isUnauthorized()); + } + + // ─── PUT /api/documents/{id}/transcription-blocks/{blockId} ───────────── + + @Test + void updateBlock_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(put(URL_BLOCK) + .contentType(MediaType.APPLICATION_JSON) + .content(UPDATE_JSON)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void updateBlock_returns403_whenMissingWriteAllPermission() throws Exception { + mockMvc.perform(put(URL_BLOCK) + .contentType(MediaType.APPLICATION_JSON) + .content(UPDATE_JSON)) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void updateBlock_returns200_withUpdatedBlock_whenAuthorised() throws Exception { + TranscriptionBlock updated = sampleBlock(); + updated.setText("Neue Fassung"); + updated.setLabel("Anrede"); + + when(userService.findByUsername(any())).thenReturn(mockUser()); + when(transcriptionService.updateBlock(eq(DOC_ID), eq(BLOCK_ID), any(), any())) + .thenReturn(updated); + + mockMvc.perform(put(URL_BLOCK) + .contentType(MediaType.APPLICATION_JSON) + .content(UPDATE_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.text").value("Neue Fassung")) + .andExpect(jsonPath("$.label").value("Anrede")); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void updateBlock_returns404_whenBlockDoesNotExist() throws Exception { + when(userService.findByUsername(any())).thenReturn(mockUser()); + when(transcriptionService.updateBlock(any(), any(), any(), any())) + .thenThrow(DomainException.notFound(ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND, "not found")); + + mockMvc.perform(put(URL_BLOCK) + .contentType(MediaType.APPLICATION_JSON) + .content(UPDATE_JSON)) + .andExpect(status().isNotFound()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void updateBlock_returns401_whenUserNotFoundInDatabase() throws Exception { + when(userService.findByUsername(any())).thenReturn(null); + + mockMvc.perform(put(URL_BLOCK) + .contentType(MediaType.APPLICATION_JSON) + .content(UPDATE_JSON)) + .andExpect(status().isUnauthorized()); + } + + // ─── DELETE /api/documents/{id}/transcription-blocks/{blockId} ─────────── + + @Test + void deleteBlock_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(delete(URL_BLOCK)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void deleteBlock_returns403_whenMissingWriteAllPermission() throws Exception { + mockMvc.perform(delete(URL_BLOCK)) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void deleteBlock_returns204_whenAuthorised() throws Exception { + mockMvc.perform(delete(URL_BLOCK)) + .andExpect(status().isNoContent()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void deleteBlock_returns404_whenBlockDoesNotExist() throws Exception { + org.mockito.Mockito.doThrow( + DomainException.notFound(ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND, "not found")) + .when(transcriptionService).deleteBlock(any(), any()); + + mockMvc.perform(delete(URL_BLOCK)) + .andExpect(status().isNotFound()); + } + + // ─── PUT /api/documents/{id}/transcription-blocks/reorder ──────────────── + + @Test + void reorderBlocks_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(put(URL_REORDER) + .contentType(MediaType.APPLICATION_JSON) + .content(REORDER_JSON)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void reorderBlocks_returns403_whenMissingWriteAllPermission() throws Exception { + mockMvc.perform(put(URL_REORDER) + .contentType(MediaType.APPLICATION_JSON) + .content(REORDER_JSON)) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void reorderBlocks_returns200_withReorderedBlocks_whenAuthorised() throws Exception { + when(transcriptionService.listBlocks(DOC_ID)).thenReturn(List.of(sampleBlock())); + + mockMvc.perform(put(URL_REORDER) + .contentType(MediaType.APPLICATION_JSON) + .content(REORDER_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()); + } + + // ─── GET /api/documents/{id}/transcription-blocks/{blockId}/history ────── + + @Test + void getBlockHistory_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get(URL_HISTORY)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void getBlockHistory_returns403_whenMissingReadAllPermission() throws Exception { + mockMvc.perform(get(URL_HISTORY)) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void getBlockHistory_returns200_withVersionList_whenAuthorised() throws Exception { + TranscriptionBlockVersion v = TranscriptionBlockVersion.builder() + .id(UUID.randomUUID()).blockId(BLOCK_ID).text("v1").build(); + when(transcriptionService.getBlockHistory(DOC_ID, BLOCK_ID)).thenReturn(List.of(v)); + + mockMvc.perform(get(URL_HISTORY)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].text").value("v1")) + .andExpect(jsonPath("$[0].blockId").value(BLOCK_ID.toString())); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void getBlockHistory_returns404_whenBlockDoesNotExist() throws Exception { + when(transcriptionService.getBlockHistory(any(), any())) + .thenThrow(DomainException.notFound(ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND, "not found")); + + mockMvc.perform(get(URL_HISTORY)) + .andExpect(status().isNotFound()); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void getBlockHistory_returns200_withEmptyList_whenNoVersionsExist() throws Exception { + when(transcriptionService.getBlockHistory(any(), any())).thenReturn(List.of()); + + mockMvc.perform(get(URL_HISTORY)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isEmpty()); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/repository/TranscriptionBlockRepositoryTest.java b/backend/src/test/java/org/raddatz/familienarchiv/repository/TranscriptionBlockRepositoryTest.java new file mode 100644 index 00000000..c948ceed --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/repository/TranscriptionBlockRepositoryTest.java @@ -0,0 +1,193 @@ +package org.raddatz.familienarchiv.repository; + +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.PostgresContainerConfig; +import org.raddatz.familienarchiv.config.FlywayConfig; +import org.raddatz.familienarchiv.model.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import({PostgresContainerConfig.class, FlywayConfig.class}) +class TranscriptionBlockRepositoryTest { + + @Autowired TranscriptionBlockRepository blockRepository; + @Autowired TranscriptionBlockVersionRepository versionRepository; + @Autowired DocumentRepository documentRepository; + @Autowired AnnotationRepository annotationRepository; + @Autowired EntityManager em; + + private UUID documentId; + private UUID annotationId; + + @BeforeEach + void setUp() { + Document doc = documentRepository.save(Document.builder() + .title("Testbrief") + .originalFilename("brief.pdf") + .status(DocumentStatus.UPLOADED) + .build()); + documentId = doc.getId(); + + DocumentAnnotation annotation = annotationRepository.save(DocumentAnnotation.builder() + .documentId(documentId) + .pageNumber(1) + .x(0.1).y(0.2).width(0.3).height(0.4) + .color("#00C7B1") + .build()); + annotationId = annotation.getId(); + } + + // ─── findByDocumentIdOrderBySortOrderAsc ───────────────────────────────── + + @Test + void findByDocumentIdOrderBySortOrderAsc_returnsBlocksInSortOrder() { + blockRepository.save(block("Block B", 1)); + blockRepository.save(block("Block A", 0)); + blockRepository.save(block("Block C", 2)); + + List result = blockRepository.findByDocumentIdOrderBySortOrderAsc(documentId); + + assertThat(result).hasSize(3); + assertThat(result.get(0).getText()).isEqualTo("Block A"); + assertThat(result.get(1).getText()).isEqualTo("Block B"); + assertThat(result.get(2).getText()).isEqualTo("Block C"); + } + + @Test + void findByDocumentIdOrderBySortOrderAsc_returnsEmptyList_whenNoBlocksForDocument() { + UUID otherId = UUID.randomUUID(); + List result = blockRepository.findByDocumentIdOrderBySortOrderAsc(otherId); + assertThat(result).isEmpty(); + } + + @Test + void findByDocumentIdOrderBySortOrderAsc_doesNotReturnBlocksFromOtherDocument() { + blockRepository.save(block("My block", 0)); + + Document other = documentRepository.save(Document.builder() + .title("Anderer Brief").originalFilename("other.pdf").status(DocumentStatus.PLACEHOLDER).build()); + + List result = blockRepository.findByDocumentIdOrderBySortOrderAsc(other.getId()); + assertThat(result).isEmpty(); + } + + // ─── findByIdAndDocumentId ──────────────────────────────────────────────── + + @Test + void findByIdAndDocumentId_returnsBlock_whenBothMatch() { + TranscriptionBlock saved = blockRepository.save(block("Liebe Tante,", 0)); + + Optional found = blockRepository.findByIdAndDocumentId(saved.getId(), documentId); + + assertThat(found).isPresent(); + assertThat(found.get().getText()).isEqualTo("Liebe Tante,"); + } + + @Test + void findByIdAndDocumentId_returnsEmpty_whenDocumentIdDoesNotMatch() { + TranscriptionBlock saved = blockRepository.save(block("Liebe Tante,", 0)); + + Optional found = blockRepository.findByIdAndDocumentId(saved.getId(), UUID.randomUUID()); + + assertThat(found).isEmpty(); + } + + @Test + void findByIdAndDocumentId_returnsEmpty_whenBlockIdDoesNotExist() { + Optional found = blockRepository.findByIdAndDocumentId(UUID.randomUUID(), documentId); + assertThat(found).isEmpty(); + } + + // ─── countByDocumentId ──────────────────────────────────────────────────── + + @Test + void countByDocumentId_returnsZero_whenNoBlocksExist() { + assertThat(blockRepository.countByDocumentId(documentId)).isZero(); + } + + @Test + void countByDocumentId_returnsCorrectCount_afterMultipleSaves() { + blockRepository.save(block("Block 1", 0)); + blockRepository.save(block("Block 2", 1)); + blockRepository.save(block("Block 3", 2)); + + assertThat(blockRepository.countByDocumentId(documentId)).isEqualTo(3); + } + + @Test + void countByDocumentId_doesNotCountBlocksFromOtherDocument() { + blockRepository.save(block("Block 1", 0)); + + UUID otherId = UUID.randomUUID(); + assertThat(blockRepository.countByDocumentId(otherId)).isZero(); + } + + // ─── version (optimistic lock) ──────────────────────────────────────────── + + @Test + void version_startsAtZero_andIncrementsOnEachSave() { + TranscriptionBlock saved = blockRepository.saveAndFlush(block("initial", 0)); + assertThat(saved.getVersion()).isZero(); + + saved.setText("updated"); + TranscriptionBlock updated = blockRepository.saveAndFlush(saved); + assertThat(updated.getVersion()).isEqualTo(1); + } + + // ─── cascade: deleting a block cascades to its versions ────────────────── + + @Test + @Transactional + void delete_cascadesToVersions() { + TranscriptionBlock block = blockRepository.saveAndFlush(block("text", 0)); + versionRepository.saveAndFlush(TranscriptionBlockVersion.builder() + .blockId(block.getId()).text("text").build()); + + assertThat(versionRepository.findByBlockIdOrderByChangedAtDesc(block.getId())).hasSize(1); + + blockRepository.delete(block); + blockRepository.flush(); + em.clear(); + + assertThat(versionRepository.findByBlockIdOrderByChangedAtDesc(block.getId())).isEmpty(); + } + + // ─── cascade: deleting a document cascades to its blocks ───────────────── + + @Test + @Transactional + void deleteDocument_cascadesToBlocks() { + blockRepository.saveAndFlush(block("text", 0)); + assertThat(blockRepository.findByDocumentIdOrderBySortOrderAsc(documentId)).hasSize(1); + + documentRepository.deleteById(documentId); + documentRepository.flush(); + em.clear(); + + assertThat(blockRepository.findByDocumentIdOrderBySortOrderAsc(documentId)).isEmpty(); + } + + // ─── helper ────────────────────────────────────────────────────────────── + + private TranscriptionBlock block(String text, int sortOrder) { + return TranscriptionBlock.builder() + .annotationId(annotationId) + .documentId(documentId) + .text(text) + .sortOrder(sortOrder) + .build(); + } +} diff --git a/frontend/e2e/annotations.spec.ts b/frontend/e2e/annotations.spec.ts new file mode 100644 index 00000000..e4b0bed5 --- /dev/null +++ b/frontend/e2e/annotations.spec.ts @@ -0,0 +1,272 @@ +import { test, expect, type Page } from '@playwright/test'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import fs from 'fs'; +import { AxeBuilder } from '@axe-core/playwright'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf'); + +/** + * E2E tests for the annotation overlay and transcribe-mode UI — issue #176. + * + * Strategy: + * - Transcription blocks are seeded via API in beforeAll — no canvas drawing in CI. + * - Browser tests verify transcribe-mode toggling, annotation overlay rendering, + * the visibility toggle, and scroll-sync between annotations and blocks. + */ + +let docHref: string; +let docId: string; +let annotAId: string; +let annotBId: string; +let blockAId: string; + +test.describe('Annotation overlay and transcribe mode', () => { + test.beforeAll(async ({ request }) => { + const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000'; + + // 1. Create a document and upload a PDF so the annotation layer is active. + const createRes = await request.post('/api/documents', { + multipart: { title: 'E2E Annotation Test', documentDate: '1945-05-08' } + }); + if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`); + const doc = await createRes.json(); + docId = doc.id; + docHref = `${baseURL}/documents/${docId}`; + + const uploadRes = await request.put(`/api/documents/${docId}`, { + multipart: { + title: doc.title, + documentDate: '1945-05-08', + file: { + name: 'minimal.pdf', + mimeType: 'application/pdf', + buffer: fs.readFileSync(PDF_FIXTURE) + } + } + }); + if (!uploadRes.ok()) throw new Error(`Upload PDF failed: ${uploadRes.status()}`); + + // 2. Create two transcription blocks (each brings its own annotation). + const blockARes = await request.post(`/api/documents/${docId}/transcription-blocks`, { + data: { + pageNumber: 1, + x: 0.1, + y: 0.1, + width: 0.3, + height: 0.1, + text: 'Erste Zeile.', + label: 'Anrede' + } + }); + if (!blockARes.ok()) throw new Error(`Create block A failed: ${blockARes.status()}`); + const blockA = await blockARes.json(); + blockAId = blockA.id; + annotAId = blockA.annotationId; + + const blockBRes = await request.post(`/api/documents/${docId}/transcription-blocks`, { + data: { + pageNumber: 1, + x: 0.1, + y: 0.35, + width: 0.3, + height: 0.1, + text: 'Zweite Zeile.', + label: null + } + }); + if (!blockBRes.ok()) throw new Error(`Create block B failed: ${blockBRes.status()}`); + const blockB = await blockBRes.json(); + annotBId = blockB.annotationId; + }); + + /** + * Navigate to the document, enter transcribe mode, and wait until the PDF + * has fully rendered (page counter appears) and the annotation rect is visible. + * Centralises the timing gate used by multiple tests. + */ + async function openTranscribeMode(page: Page, annotationId: string) { + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: 'Transkribieren' }).click(); + // Wait for the PDF to finish loading — the page counter only renders when totalPages > 0 + await page.locator('.tabular-nums').waitFor({ timeout: 15_000 }); + // Wait for annotation rect (annotations API) and at least one block textarea (blocks API) + // to be ready — these are two independent fetches. + await Promise.all([ + page.locator(`[data-testid="annotation-${annotationId}"]`).waitFor({ timeout: 10_000 }), + page.getByRole('textbox').first().waitFor({ timeout: 10_000 }) + ]); + } + + // ─── Transcribe mode toggle ──────────────────────────────────────────────── + + test('Transkribieren button is visible on a PDF document', async ({ page }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + + await expect(page.getByRole('button', { name: 'Transkribieren' })).toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/annotation-transcribe-btn.png' }); + }); + + test('clicking Transkribieren enters transcribe mode and shows the Fertig button', async ({ + page + }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + + await page.getByRole('button', { name: 'Transkribieren' }).click(); + + await expect(page.getByRole('button', { name: 'Fertig' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Transkribieren' })).not.toBeVisible(); + + await page.screenshot({ path: 'test-results/e2e/annotation-transcribe-mode-active.png' }); + }); + + test('clicking Fertig exits transcribe mode and restores the Transkribieren button', async ({ + page + }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + + await page.getByRole('button', { name: 'Transkribieren' }).click(); + await expect(page.getByRole('button', { name: 'Fertig' })).toBeVisible(); + + await page.getByRole('button', { name: 'Fertig' }).click(); + + await expect(page.getByRole('button', { name: 'Transkribieren' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Fertig' })).not.toBeVisible(); + }); + + test('pressing Escape exits transcribe mode', async ({ page }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + + await page.getByRole('button', { name: 'Transkribieren' }).click(); + await expect(page.getByRole('button', { name: 'Fertig' })).toBeVisible(); + + await page.keyboard.press('Escape'); + + await expect(page.getByRole('button', { name: 'Transkribieren' })).toBeVisible(); + }); + + // ─── Annotation overlay rendering ───────────────────────────────────────── + + test('annotation rects are rendered on the PDF after entering transcribe mode', async ({ + page + }) => { + test.setTimeout(40_000); + await openTranscribeMode(page, annotAId); + + await expect(page.locator(`[data-testid="annotation-${annotAId}"]`)).toBeVisible(); + await expect(page.locator(`[data-testid="annotation-${annotBId}"]`)).toBeVisible(); + + await page.screenshot({ path: 'test-results/e2e/annotation-rects-rendered.png' }); + }); + + test('numbered badges appear on annotation rects', async ({ page }) => { + test.setTimeout(40_000); + await openTranscribeMode(page, annotAId); + + const annotA = page.locator(`[data-testid="annotation-${annotAId}"]`); + await expect(annotA.locator('div', { hasText: '1' })).toBeVisible(); + + await page.screenshot({ path: 'test-results/e2e/annotation-numbered-badges.png' }); + }); + + // ─── Annotation visibility toggle ───────────────────────────────────────── + + test('annotation visibility toggle button appears when annotations exist', async ({ page }) => { + test.setTimeout(40_000); + await openTranscribeMode(page, annotAId); + + await expect(page.getByRole('button', { name: 'Annotierungen verbergen' })).toBeVisible(); + }); + + test('clicking the visibility toggle hides annotation rects', async ({ page }) => { + test.setTimeout(40_000); + await openTranscribeMode(page, annotAId); + + await page.getByRole('button', { name: 'Annotierungen verbergen' }).click(); + + await expect(page.locator(`[data-testid="annotation-${annotAId}"]`)).not.toBeVisible(); + await expect(page.getByRole('button', { name: 'Annotierungen anzeigen' })).toBeVisible(); + + await page.screenshot({ path: 'test-results/e2e/annotation-hidden.png' }); + }); + + test('clicking the visibility toggle again restores annotation rects', async ({ page }) => { + test.setTimeout(40_000); + await openTranscribeMode(page, annotAId); + + await page.getByRole('button', { name: 'Annotierungen verbergen' }).click(); + await page.getByRole('button', { name: 'Annotierungen anzeigen' }).click(); + + await expect(page.locator(`[data-testid="annotation-${annotAId}"]`)).toBeVisible(); + }); + + // ─── Scroll-sync: annotation → block ────────────────────────────────────── + + test('clicking an annotation rect scrolls the matching block into view in the right panel', async ({ + page + }) => { + test.setTimeout(40_000); + await openTranscribeMode(page, annotAId); + + await page.locator(`[data-testid="annotation-${annotAId}"]`).click(); + + await expect(page.locator(`[data-block-id="${blockAId}"]`)).toBeVisible({ timeout: 5_000 }); + + await page.screenshot({ path: 'test-results/e2e/annotation-click-scroll-sync.png' }); + }); + + test('clicking annotation B activates the corresponding block in the panel', async ({ page }) => { + test.setTimeout(40_000); + await openTranscribeMode(page, annotBId); + + await page.locator(`[data-testid="annotation-${annotBId}"]`).click(); + + // Block B's annotation should become active (full opacity), A's should dim + await expect(page.locator(`[data-testid="annotation-${annotBId}"]`)).toHaveCSS('opacity', '1'); + await expect(page.locator(`[data-testid="annotation-${annotAId}"]`)).toHaveCSS( + 'opacity', + '0.3' + ); + }); + + // ─── Scroll-sync: block → annotation (dimming) ──────────────────────────── + + test('focusing a block dims all other annotation rects', async ({ page }) => { + test.setTimeout(40_000); + await openTranscribeMode(page, annotAId); + + // Focus block A's textarea to set it as active + await page.getByRole('textbox').first().click(); + + // Non-active annotation (B) must be dimmed + await expect(page.locator(`[data-testid="annotation-${annotBId}"]`)).toHaveCSS( + 'opacity', + '0.3' + ); + + // Active annotation (A) must be at full opacity + await expect(page.locator(`[data-testid="annotation-${annotAId}"]`)).toHaveCSS('opacity', '1'); + + await page.screenshot({ path: 'test-results/e2e/annotation-dimming.png' }); + }); + + // ─── Accessibility ───────────────────────────────────────────────────────── + + test('transcribe mode passes axe accessibility check', async ({ page }) => { + test.setTimeout(40_000); + await openTranscribeMode(page, annotAId); + + const results = await new AxeBuilder({ page }).analyze(); + expect(results.violations).toHaveLength(0); + }); +}); diff --git a/frontend/e2e/transcription.spec.ts b/frontend/e2e/transcription.spec.ts new file mode 100644 index 00000000..8b682905 --- /dev/null +++ b/frontend/e2e/transcription.spec.ts @@ -0,0 +1,296 @@ +import { test, expect } from '@playwright/test'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import fs from 'fs'; +import { AxeBuilder } from '@axe-core/playwright'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf'); + +/** + * E2E tests for the annotation-backed transcription system — issue #176. + * + * Strategy: + * - Transcription blocks are created via API in beforeAll (no need to draw on canvas in CI). + * - Browser tests verify rendering, editing, auto-save feedback, reordering, deletion, and a11y. + */ + +let docHref: string; +let docId: string; + +test.describe('Transcription panel', () => { + test.beforeAll(async ({ request }) => { + const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000'; + + // 1. Create a document with a PDF so the Transkription tab is meaningful. + const createRes = await request.post('/api/documents', { + multipart: { + title: 'E2E Transkription Test', + documentDate: '1945-05-08' + } + }); + if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`); + const doc = await createRes.json(); + docId = doc.id; + docHref = `${baseURL}/documents/${docId}`; + + await request.put(`/api/documents/${docId}`, { + multipart: { + title: doc.title, + documentDate: '1945-05-08', + file: { + name: 'minimal.pdf', + mimeType: 'application/pdf', + buffer: fs.readFileSync(PDF_FIXTURE) + } + } + }); + + // 2. Create a document_annotation so we can attach blocks to it. + const annotARes = await request.post(`/api/documents/${docId}/annotations`, { + data: { pageNumber: 1, x: 0.1, y: 0.1, width: 0.2, height: 0.1, color: '#00C7B1' } + }); + if (!annotARes.ok()) throw new Error(`Create annotation A failed: ${annotARes.status()}`); + const annotA = await annotARes.json(); + + const annotBRes = await request.post(`/api/documents/${docId}/annotations`, { + data: { pageNumber: 1, x: 0.1, y: 0.3, width: 0.2, height: 0.1, color: '#00C7B1' } + }); + if (!annotBRes.ok()) throw new Error(`Create annotation B failed: ${annotBRes.status()}`); + const annotB = await annotBRes.json(); + + // 3. Create two transcription blocks via API. + const blockARes = await request.post(`/api/documents/${docId}/transcription-blocks`, { + data: { + pageNumber: 1, + x: annotA.x, + y: annotA.y, + width: annotA.width, + height: annotA.height, + text: 'Liebe Mutter,', + label: 'Anrede' + } + }); + if (!blockARes.ok()) throw new Error(`Create block A failed: ${blockARes.status()}`); + await blockARes.json(); + + const blockBRes = await request.post(`/api/documents/${docId}/transcription-blocks`, { + data: { + pageNumber: 1, + x: annotB.x, + y: annotB.y, + width: annotB.width, + height: annotB.height, + text: 'ich schreibe dir aus Breslau.', + label: null + } + }); + if (!blockBRes.ok()) throw new Error(`Create block B failed: ${blockBRes.status()}`); + await blockBRes.json(); + }); + + // ─── Tab visibility ──────────────────────────────────────────────────────── + + test('Transkription tab is visible in the bottom panel tab bar', async ({ page }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + + await expect(page.getByRole('button', { name: 'Transkription' })).toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/transcription-tab-visible.png' }); + }); + + // ─── Block rendering ────────────────────────────────────────────────────── + + test('blocks are rendered in sort order with correct text and label', async ({ page }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: 'Transkription' }).click(); + await page.waitForSelector('[data-testid="bottom-panel-content"]'); + + await expect(page.getByText('Liebe Mutter,')).toBeVisible(); + await expect(page.getByText('ich schreibe dir aus Breslau.')).toBeVisible(); + // Label for block A + await expect(page.getByText('Anrede')).toBeVisible(); + + await page.screenshot({ path: 'test-results/e2e/transcription-blocks-rendered.png' }); + }); + + test('block numbers are rendered in turquoise badge', async ({ page }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: 'Transkription' }).click(); + await page.waitForSelector('[data-testid="bottom-panel-content"]'); + + // Block 1 and 2 badges must be visible + await expect(page.getByText('1').first()).toBeVisible(); + await expect(page.getByText('2').first()).toBeVisible(); + }); + + test('next-block CTA shows Block 3 hint after two blocks', async ({ page }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: 'Transkription' }).click(); + await page.waitForSelector('[data-testid="bottom-panel-content"]'); + + await expect(page.getByText(/Block 3/)).toBeVisible(); + }); + + // ─── Text editing & auto-save feedback ──────────────────────────────────── + + test('editing a block shows "Speichere..." then "Gespeichert" indicator', async ({ page }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: 'Transkription' }).click(); + await page.waitForSelector('[data-testid="bottom-panel-content"]'); + + const firstTextarea = page.getByRole('textbox').first(); + await firstTextarea.click(); + await firstTextarea.fill('Liebe Mutter, ich bin wohlauf.'); + + // "Speichere..." should appear (debounce triggers after 1.5s) + await expect(page.getByText(/Speichere\.\.\./)).toBeVisible({ timeout: 5000 }); + // After save completes, "Gespeichert ✓" appears + await expect(page.getByText(/Gespeichert/)).toBeVisible({ timeout: 8000 }); + + await page.screenshot({ path: 'test-results/e2e/transcription-autosave.png' }); + }); + + test('edited text persists after page reload', async ({ page }) => { + test.setTimeout(40_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: 'Transkription' }).click(); + await page.waitForSelector('[data-testid="bottom-panel-content"]'); + + const firstTextarea = page.getByRole('textbox').first(); + await firstTextarea.fill('Persistierter Text'); + + // Wait for auto-save to complete + await expect(page.getByText(/Gespeichert/)).toBeVisible({ timeout: 8000 }); + + // Reload + await page.reload(); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: 'Transkription' }).click(); + + await expect(page.getByText('Persistierter Text')).toBeVisible(); + }); + + // ─── Block reordering ───────────────────────────────────────────────────── + + test('move-up button is disabled on the first block', async ({ page }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: 'Transkription' }).click(); + await page.waitForSelector('[data-testid="bottom-panel-content"]'); + + const upButtons = page.getByRole('button', { name: 'Nach oben' }); + await expect(upButtons.first()).toBeDisabled(); + }); + + test('move-down button is disabled on the last block', async ({ page }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: 'Transkription' }).click(); + await page.waitForSelector('[data-testid="bottom-panel-content"]'); + + const downButtons = page.getByRole('button', { name: 'Nach unten' }); + await expect(downButtons.last()).toBeDisabled(); + }); + + test('clicking move-down on the first block swaps block order', async ({ page }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: 'Transkription' }).click(); + await page.waitForSelector('[data-testid="bottom-panel-content"]'); + + const textareas = page.getByRole('textbox'); + const before = await textareas.first().inputValue(); + + const downButtons = page.getByRole('button', { name: 'Nach unten' }); + await downButtons.first().click(); + + // After reorder, the block that was second should now appear first + const after = await textareas.first().inputValue(); + expect(after).not.toBe(before); + + await page.screenshot({ path: 'test-results/e2e/transcription-reorder.png' }); + }); + + // ─── Block deletion ─────────────────────────────────────────────────────── + + test('cancelling delete confirmation keeps the block', async ({ page }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: 'Transkription' }).click(); + await page.waitForSelector('[data-testid="bottom-panel-content"]'); + + // Dismiss the confirm dialog automatically + page.once('dialog', (dialog) => dialog.dismiss()); + + const deleteBtn = page.getByRole('button', { name: 'Löschen' }).first(); + await deleteBtn.click(); + + // Block should still be present + await expect(page.getByRole('textbox').first()).toBeVisible(); + }); + + // ─── Comment thread ─────────────────────────────────────────────────────── + + test('clicking Kommentieren button opens comment compose in the block', async ({ page }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: 'Transkription' }).click(); + await page.waitForSelector('[data-testid="bottom-panel-content"]'); + + await page.getByText('Kommentieren').first().click(); + + await expect(page.getByPlaceholder(/Kommentar/)).toBeVisible(); + + await page.screenshot({ path: 'test-results/e2e/transcription-comment-open.png' }); + }); + + // ─── Accessibility ──────────────────────────────────────────────────────── + + test('transcription panel passes axe accessibility check', async ({ page }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: 'Transkription' }).click(); + await page.waitForSelector('[data-testid="bottom-panel-content"]'); + + const results = await new AxeBuilder({ page }).analyze(); + expect(results.violations).toHaveLength(0); + }); + + // ─── Empty state ────────────────────────────────────────────────────────── + + test('shows empty state when document has no transcription blocks', async ({ page, request }) => { + test.setTimeout(30_000); + const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000'; + + const emptyDocRes = await request.post('/api/documents', { + multipart: { title: 'E2E Empty Transcription Test' } + }); + if (!emptyDocRes.ok()) throw new Error(`Create empty doc failed: ${emptyDocRes.status()}`); + const emptyDoc = await emptyDocRes.json(); + + await page.goto(`${baseURL}/documents/${emptyDoc.id}`); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: 'Transkription' }).click(); + await page.waitForSelector('[data-testid="bottom-panel-content"]'); + + await expect(page.getByText(/Markiere einen Bereich/)).toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/transcription-empty-state.png' }); + }); +}); diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 1b395dfb..b1d59d3c 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -12,6 +12,7 @@ const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url)); export default defineConfig( includeIgnoreFile(gitignorePath), + { ignores: ['src/paraglide/**'] }, js.configs.recommended, ...ts.configs.recommended, ...svelte.configs.recommended, diff --git a/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts b/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts index 1f305694..b439f327 100644 --- a/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts +++ b/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts @@ -159,3 +159,59 @@ describe('TranscriptionBlock — reorder controls', () => { expect(onMoveDown).toHaveBeenCalled(); }); }); + +// ─── Delete confirmation ────────────────────────────────────────────────────── + +describe('TranscriptionBlock — delete confirmation', () => { + it('does not call onDeleteClick when user cancels confirm dialog', async () => { + const onDeleteClick = vi.fn(); + vi.spyOn(window, 'confirm').mockReturnValue(false); + renderBlock({ onDeleteClick }); + + const deleteBtn = page.getByRole('button', { name: 'Löschen' }); + await deleteBtn.click(); + + expect(onDeleteClick).not.toHaveBeenCalled(); + vi.restoreAllMocks(); + }); + + it('calls onDeleteClick when user confirms deletion', async () => { + const onDeleteClick = vi.fn(); + vi.spyOn(window, 'confirm').mockReturnValue(true); + renderBlock({ onDeleteClick }); + + const deleteBtn = page.getByRole('button', { name: 'Löschen' }); + await deleteBtn.click(); + + expect(onDeleteClick).toHaveBeenCalledOnce(); + vi.restoreAllMocks(); + }); +}); + +// ─── Quote selection ───────────────────────────────────────────────────────── + +describe('TranscriptionBlock — quote selection', () => { + it('shows quote hint after text is selected in textarea', async () => { + renderBlock({ text: 'Breslau, den 12. August' }); + const textarea = page.getByRole('textbox'); + // Select all text via keyboard shortcut to trigger mouseup with selection + await textarea.click(); + await textarea.selectText(); + // Fire mouseup to trigger the selection handler + await textarea.dispatchEvent('mouseup'); + 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(); + }); +}); diff --git a/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts b/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts index b7f8621c..11a4d29b 100644 --- a/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts +++ b/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts @@ -93,3 +93,132 @@ describe('TranscriptionEditView — reorder', () => { 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((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 + await textarea.blur(); + + 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); + vi.spyOn(window, 'confirm').mockReturnValue(true); + renderView({ onDeleteBlock }); + + const deleteBtn = page.getByRole('button', { name: 'Löschen' }).first(); + await deleteBtn.click(); + + expect(onDeleteBlock).toHaveBeenCalledWith('b1'); + vi.restoreAllMocks(); + }); + + it('does not call onDeleteBlock when deletion is cancelled', async () => { + const onDeleteBlock = vi.fn(); + vi.spyOn(window, 'confirm').mockReturnValue(false); + renderView({ onDeleteBlock }); + + const deleteBtn = page.getByRole('button', { name: 'Löschen' }).first(); + await deleteBtn.click(); + + expect(onDeleteBlock).not.toHaveBeenCalled(); + vi.restoreAllMocks(); + }); +});