test: increase coverage
This commit is contained in:
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<TranscriptionBlock> 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<TranscriptionBlock> 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<TranscriptionBlock> result = blockRepository.findByDocumentIdOrderBySortOrderAsc(other.getId());
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── findByIdAndDocumentId ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findByIdAndDocumentId_returnsBlock_whenBothMatch() {
|
||||||
|
TranscriptionBlock saved = blockRepository.save(block("Liebe Tante,", 0));
|
||||||
|
|
||||||
|
Optional<TranscriptionBlock> 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<TranscriptionBlock> found = blockRepository.findByIdAndDocumentId(saved.getId(), UUID.randomUUID());
|
||||||
|
|
||||||
|
assertThat(found).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findByIdAndDocumentId_returnsEmpty_whenBlockIdDoesNotExist() {
|
||||||
|
Optional<TranscriptionBlock> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
272
frontend/e2e/annotations.spec.ts
Normal file
272
frontend/e2e/annotations.spec.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
296
frontend/e2e/transcription.spec.ts
Normal file
296
frontend/e2e/transcription.spec.ts
Normal file
@@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,6 +12,7 @@ const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
|
|||||||
|
|
||||||
export default defineConfig(
|
export default defineConfig(
|
||||||
includeIgnoreFile(gitignorePath),
|
includeIgnoreFile(gitignorePath),
|
||||||
|
{ ignores: ['src/paraglide/**'] },
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
...ts.configs.recommended,
|
...ts.configs.recommended,
|
||||||
...svelte.configs.recommended,
|
...svelte.configs.recommended,
|
||||||
|
|||||||
@@ -159,3 +159,59 @@ describe('TranscriptionBlock — reorder controls', () => {
|
|||||||
expect(onMoveDown).toHaveBeenCalled();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -93,3 +93,132 @@ describe('TranscriptionEditView — reorder', () => {
|
|||||||
expect(handles.length).toBe(2);
|
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
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user