From 2d19ca724400e7fd0f48e3ee06cabd778d4ea45f Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 29 Apr 2026 14:58:18 +0200 Subject: [PATCH 01/17] refactor(backend): delete rename-propagation listener and its infrastructure PersonMentionPropagationListener rewrites @DisplayName tokens on person rename. Under the new design, displayName is archival (what the transcriber typed), so the listener would corrupt transcriptions rather than correct them. Deletes PersonMentionPropagationListener, PersonDisplayNameChangedEvent, and the optimistic-lock catch path in PersonService.updatePerson. Removes PERSON_RENAME_CONFLICT from ErrorCode and all tests that exercised the now-deleted code path. Co-Authored-By: Claude Sonnet 4.6 --- .../familienarchiv/exception/ErrorCode.java | 5 - .../model/PersonDisplayNameChangedEvent.java | 23 -- .../familienarchiv/model/PersonMention.java | 1 + .../PersonMentionPropagationListener.java | 77 ------ .../familienarchiv/service/PersonService.java | 18 +- .../controller/PersonControllerTest.java | 15 -- .../PersonMentionPropagationListenerTest.java | 227 ------------------ .../service/PersonServiceTest.java | 130 +--------- 8 files changed, 6 insertions(+), 490 deletions(-) delete mode 100644 backend/src/main/java/org/raddatz/familienarchiv/model/PersonDisplayNameChangedEvent.java delete mode 100644 backend/src/main/java/org/raddatz/familienarchiv/service/PersonMentionPropagationListener.java delete mode 100644 backend/src/test/java/org/raddatz/familienarchiv/service/PersonMentionPropagationListenerTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java index d9a3a30f..517eb1da 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -15,11 +15,6 @@ public enum ErrorCode { ALIAS_NOT_FOUND, /** The submitted personType value is not allowed (e.g. SKIP is import-only). 400 */ INVALID_PERSON_TYPE, - /** A concurrent edit on a referenced transcription block prevented the rename - * from committing (optimistic-lock conflict). The whole rename rolls back; the - * client should refetch and retry. 409 */ - PERSON_RENAME_CONFLICT, - // --- Documents --- /** A document with the given ID does not exist. 404 */ DOCUMENT_NOT_FOUND, diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/PersonDisplayNameChangedEvent.java b/backend/src/main/java/org/raddatz/familienarchiv/model/PersonDisplayNameChangedEvent.java deleted file mode 100644 index 1b748c1a..00000000 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/PersonDisplayNameChangedEvent.java +++ /dev/null @@ -1,23 +0,0 @@ -package org.raddatz.familienarchiv.model; - -import java.util.UUID; - -/** - * Published by PersonService when a save changes Person.getDisplayName() — i.e. - * any mutation to the fields that DisplayNameFormatter consumes (title, - * firstName, lastName). Listeners on the transcription side rewrite block text - * and sidecar entries that reference the old name. - * - *

This is the first custom application event in the codebase. The previous - * only listener (OcrTrainingService.recoverOrphanedRuns) listens to Spring's - * built-in ApplicationReadyEvent. Future cross-domain decoupling should follow - * the same shape: record-typed event in model/, listener in the consuming - * domain's service/ package, synchronous @EventListener inside the publisher's - * transaction unless the workload genuinely needs to defer. - */ -public record PersonDisplayNameChangedEvent( - UUID personId, - String oldDisplayName, - String newDisplayName -) { -} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/PersonMention.java b/backend/src/main/java/org/raddatz/familienarchiv/model/PersonMention.java index 79b232d6..2ca2033e 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/PersonMention.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/PersonMention.java @@ -26,5 +26,6 @@ public class PersonMention { @Size(max = 200) @Column(name = "display_name", nullable = false, length = 200) @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + // Archival: the text the transcriber typed after @. Never updated on person rename. private String displayName; } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonMentionPropagationListener.java b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonMentionPropagationListener.java deleted file mode 100644 index a3c03206..00000000 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonMentionPropagationListener.java +++ /dev/null @@ -1,77 +0,0 @@ -package org.raddatz.familienarchiv.service; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.raddatz.familienarchiv.model.PersonDisplayNameChangedEvent; -import org.raddatz.familienarchiv.model.PersonMention; -import org.raddatz.familienarchiv.model.TranscriptionBlock; -import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -/** - * Transcription-domain consumer of {@link PersonDisplayNameChangedEvent}. When - * Person.getDisplayName() flips during a rename, this listener rewrites every - * transcription block whose sidecar references the renamed person — both the - * literal "@OldName" inside block.text and the displayName carried in the - * {@link PersonMention} entries. - * - *

Synchronous on purpose: the rename and the propagation must commit as one - * transaction so a half-applied rewrite never reaches the archive. If the - * archive grows past tens of thousands of blocks, switch to - * {@code @TransactionalEventListener(AFTER_COMMIT) + @Async} — one annotation - * change. - */ -@Service -@RequiredArgsConstructor -@Slf4j -public class PersonMentionPropagationListener { - - private final TranscriptionBlockRepository blockRepository; - - @EventListener - @Transactional // Joins publisher's transaction — async switch requires @TransactionalEventListener(AFTER_COMMIT) - public void onPersonDisplayNameChanged(PersonDisplayNameChangedEvent event) { - List blocks = - blockRepository.findByPersonIdWithMentionsFetched(event.personId()); - if (blocks.isEmpty()) { - return; - } - - String oldNeedle = "@" + event.oldDisplayName(); - String newNeedle = "@" + event.newDisplayName(); - Pattern boundary = Pattern.compile( - Pattern.quote(oldNeedle) + "(?![\\p{L}0-9\\-]| (?=\\p{Lu}))"); - String replacement = Matcher.quoteReplacement(newNeedle); - - for (TranscriptionBlock block : blocks) { - rewriteBlockText(block, boundary, replacement); - for (PersonMention mention : block.getMentionedPersons()) { - if (mention.getPersonId().equals(event.personId())) { - mention.setDisplayName(event.newDisplayName()); - } - } - } - - blockRepository.saveAllAndFlush(blocks); - - log.info("Propagated rename {} → {} across {} block(s) for person {}", - event.oldDisplayName(), event.newDisplayName(), blocks.size(), event.personId()); - } - - // Match @OldName only at a token boundary: not followed by a letter/digit/hyphen - // (catches @Hans-Peter when renaming Hans) AND not followed by " " - // (catches @Hans Müller when renaming the single-name @Hans). False negatives — - // e.g. "@Hans Bekam" where Bekam is sentence-initial — are accepted as the - // conservative trade-off; the alternative (corruption) is irrecoverable. - private void rewriteBlockText(TranscriptionBlock block, Pattern boundary, String replacement) { - if (block.getText() != null) { - block.setText(boundary.matcher(block.getText()).replaceAll(replacement)); - } - } -} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java index 33873459..e1adbdba 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java @@ -1,7 +1,6 @@ package org.raddatz.familienarchiv.service; import java.util.List; -import java.util.Objects; import java.util.Optional; import java.util.UUID; @@ -13,14 +12,11 @@ import org.raddatz.familienarchiv.dto.PersonUpdateDTO; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.model.Person; -import org.raddatz.familienarchiv.model.PersonDisplayNameChangedEvent; import org.raddatz.familienarchiv.model.PersonNameAlias; import org.raddatz.familienarchiv.model.PersonNameAliasType; import org.raddatz.familienarchiv.model.PersonType; import org.raddatz.familienarchiv.repository.PersonNameAliasRepository; import org.raddatz.familienarchiv.repository.PersonRepository; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -34,7 +30,6 @@ public class PersonService { private final PersonRepository personRepository; private final PersonNameAliasRepository aliasRepository; - private final ApplicationEventPublisher eventPublisher; public List findAll(String q) { if (q == null) { @@ -161,7 +156,6 @@ public class PersonService { validateYears(dto.getBirthYear(), dto.getDeathYear()); Person person = personRepository.findById(id) .orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id)); - String oldDisplayName = person.getDisplayName(); person.setPersonType(dto.getPersonType()); person.setTitle(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim()); person.setFirstName(dto.getFirstName()); @@ -170,17 +164,7 @@ public class PersonService { person.setNotes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim()); person.setBirthYear(dto.getBirthYear()); person.setDeathYear(dto.getDeathYear()); - Person saved = personRepository.save(person); - String newDisplayName = saved.getDisplayName(); - if (!Objects.equals(oldDisplayName, newDisplayName)) { - try { - eventPublisher.publishEvent(new PersonDisplayNameChangedEvent(id, oldDisplayName, newDisplayName)); - } catch (OptimisticLockingFailureException e) { - throw DomainException.conflict(ErrorCode.PERSON_RENAME_CONFLICT, - "A referenced transcription block was modified concurrently — rename rolled back"); - } - } - return saved; + return personRepository.save(person); } @Transactional diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java index 41a4a0ac..9de8a3a1 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java @@ -333,21 +333,6 @@ class PersonControllerTest { .andExpect(jsonPath("$.lastName").value("Müller")); } - @Test - @WithMockUser(authorities = "WRITE_ALL") - void updatePerson_returns409_whenRenameConflict() throws Exception { - UUID id = UUID.randomUUID(); - when(personService.updatePerson(eq(id), any())) - .thenThrow(DomainException.conflict(ErrorCode.PERSON_RENAME_CONFLICT, - "Concurrent block edit during rename")); - - mockMvc.perform(put("/api/persons/{id}", id) - .contentType(MediaType.APPLICATION_JSON) - .content("{\"firstName\":\"Augusta\",\"lastName\":\"Raddatz\",\"personType\":\"PERSON\"}")) - .andExpect(status().isConflict()) - .andExpect(jsonPath("$.code").value("PERSON_RENAME_CONFLICT")); - } - // ─── POST /api/persons/{id}/merge ───────────────────────────────────────── @Test diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonMentionPropagationListenerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonMentionPropagationListenerTest.java deleted file mode 100644 index c6b89716..00000000 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonMentionPropagationListenerTest.java +++ /dev/null @@ -1,227 +0,0 @@ -package org.raddatz.familienarchiv.service; - -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.Document; -import org.raddatz.familienarchiv.model.DocumentAnnotation; -import org.raddatz.familienarchiv.model.DocumentStatus; -import org.raddatz.familienarchiv.model.Person; -import org.raddatz.familienarchiv.model.PersonDisplayNameChangedEvent; -import org.raddatz.familienarchiv.model.PersonMention; -import org.raddatz.familienarchiv.model.TranscriptionBlock; -import org.raddatz.familienarchiv.repository.AnnotationRepository; -import org.raddatz.familienarchiv.repository.DocumentRepository; -import org.raddatz.familienarchiv.repository.PersonRepository; -import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository; -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 java.util.ArrayList; -import java.util.List; -import java.util.UUID; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; - -@DataJpaTest -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) -@Import({PostgresContainerConfig.class, FlywayConfig.class}) -class PersonMentionPropagationListenerTest { - - @Autowired TranscriptionBlockRepository blockRepository; - @Autowired DocumentRepository documentRepository; - @Autowired AnnotationRepository annotationRepository; - @Autowired PersonRepository personRepository; - @Autowired EntityManager em; - - private PersonMentionPropagationListener listener; - - private UUID documentId; - private UUID annotationId; - - @BeforeEach - void setUp() { - listener = new PersonMentionPropagationListener(blockRepository); - - Document doc = documentRepository.save(Document.builder() - .title("Letter").originalFilename("letter.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(); - } - - private TranscriptionBlock saveBlock(String text, List mentions) { - return blockRepository.saveAndFlush(TranscriptionBlock.builder() - .annotationId(annotationId).documentId(documentId) - .text(text).sortOrder(0) - .mentionedPersons(mentions).build()); - } - - private UUID savedPersonId(String firstName, String lastName) { - Person p = personRepository.save(Person.builder() - .firstName(firstName).lastName(lastName).build()); - return p.getId(); - } - - @Test - void rewritesTextAndSidecar_whenSingleBlockReferencesRenamedPerson() { - UUID personId = savedPersonId("Auguste", "Raddatz"); - TranscriptionBlock saved = saveBlock( - "Liebe Tante @Auguste Raddatz, danke für den Brief.", - List.of(new PersonMention(personId, "Auguste Raddatz"))); - em.clear(); - - listener.onPersonDisplayNameChanged( - new PersonDisplayNameChangedEvent(personId, "Auguste Raddatz", "Augusta Raddatz")); - blockRepository.flush(); - em.clear(); - - TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow(); - assertThat(reloaded.getText()).isEqualTo("Liebe Tante @Augusta Raddatz, danke für den Brief."); - assertThat(reloaded.getMentionedPersons()) - .extracting(PersonMention::getDisplayName) - .containsExactly("Augusta Raddatz"); - } - - @Test - void doesNotMatchPartialName_whenAnotherMentionShares_a_substring_with_renamed_person() { - UUID hansPeterId = savedPersonId("Hans-Peter", "Müller"); - UUID hansId = savedPersonId("Hans", "Müller"); - TranscriptionBlock saved = saveBlock( - "Heute hat @Hans-Peter Müller wieder mit @Hans Müller gesprochen.", - List.of( - new PersonMention(hansPeterId, "Hans-Peter Müller"), - new PersonMention(hansId, "Hans Müller"))); - em.clear(); - - listener.onPersonDisplayNameChanged( - new PersonDisplayNameChangedEvent(hansId, "Hans Müller", "Hans Schmidt")); - blockRepository.flush(); - em.clear(); - - TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow(); - assertThat(reloaded.getText()) - .isEqualTo("Heute hat @Hans-Peter Müller wieder mit @Hans Schmidt gesprochen."); - assertThat(reloaded.getMentionedPersons()) - .extracting(PersonMention::getPersonId, PersonMention::getDisplayName) - .containsExactlyInAnyOrder( - org.assertj.core.groups.Tuple.tuple(hansPeterId, "Hans-Peter Müller"), - org.assertj.core.groups.Tuple.tuple(hansId, "Hans Schmidt")); - } - - @Test - void doesNotCorruptCompositeMention_whenRenamingSingleWordPerson() { - UUID hansMüllerId = savedPersonId("Hans", "Müller"); - UUID hansId = savedPersonId(null, "Hans"); - TranscriptionBlock saved = saveBlock( - "@Hans Müller schrieb. Auch @Hans hat geschrieben.", - List.of( - new PersonMention(hansMüllerId, "Hans Müller"), - new PersonMention(hansId, "Hans"))); - em.clear(); - - listener.onPersonDisplayNameChanged( - new PersonDisplayNameChangedEvent(hansId, "Hans", "Henry")); - blockRepository.flush(); - em.clear(); - - TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow(); - assertThat(reloaded.getText()) - .isEqualTo("@Hans Müller schrieb. Auch @Henry hat geschrieben."); - assertThat(reloaded.getMentionedPersons()) - .extracting(PersonMention::getPersonId, PersonMention::getDisplayName) - .containsExactlyInAnyOrder( - org.assertj.core.groups.Tuple.tuple(hansMüllerId, "Hans Müller"), - org.assertj.core.groups.Tuple.tuple(hansId, "Henry")); - } - - @Test - void rewritesAllOccurrences_whenSameMentionAppearsTwiceInBlock() { - UUID personId = savedPersonId("Auguste", "Raddatz"); - TranscriptionBlock saved = saveBlock( - "Heute hat @Auguste Raddatz geschrieben, dann hat @Auguste Raddatz nochmal geschrieben.", - List.of(new PersonMention(personId, "Auguste Raddatz"))); - em.clear(); - - listener.onPersonDisplayNameChanged( - new PersonDisplayNameChangedEvent(personId, "Auguste Raddatz", "Augusta Raddatz")); - blockRepository.flush(); - em.clear(); - - TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow(); - assertThat(reloaded.getText()) - .isEqualTo("Heute hat @Augusta Raddatz geschrieben, dann hat @Augusta Raddatz nochmal geschrieben."); - assertThat(reloaded.getMentionedPersons()) - .extracting(PersonMention::getDisplayName) - .containsExactly("Augusta Raddatz"); - } - - @Test - void propagatesAcross200Blocks_inUnderFiveSeconds_latencyFloor() { - UUID personId = savedPersonId("Auguste", "Raddatz"); - List blockIds = new ArrayList<>(); - for (int i = 0; i < 200; i++) { - TranscriptionBlock saved = blockRepository.save(TranscriptionBlock.builder() - .annotationId(annotationId).documentId(documentId) - .text("Block " + i + " mentions @Auguste Raddatz here.") - .sortOrder(i) - .mentionedPersons(List.of(new PersonMention(personId, "Auguste Raddatz"))) - .build()); - blockIds.add(saved.getId()); - } - blockRepository.flush(); - em.clear(); - - long start = System.nanoTime(); - listener.onPersonDisplayNameChanged( - new PersonDisplayNameChangedEvent(personId, "Auguste Raddatz", "Augusta Raddatz")); - blockRepository.flush(); - long elapsedMs = (System.nanoTime() - start) / 1_000_000; - - assertThat(elapsedMs) - .as("Propagation across 200 blocks must stay under 5s — merge-blocking regression floor") - .isLessThan(5000L); - - em.clear(); - TranscriptionBlock first = blockRepository.findById(blockIds.get(0)).orElseThrow(); - assertThat(first.getText()).contains("@Augusta Raddatz"); - } - - @Test - void doesNotThrow_whenBlockTextIsNull() { - UUID personId = savedPersonId("Auguste", "Raddatz"); - saveBlock(null, List.of(new PersonMention(personId, "Auguste Raddatz"))); - em.clear(); - - assertThatCode(() -> listener.onPersonDisplayNameChanged( - new PersonDisplayNameChangedEvent(personId, "Auguste Raddatz", "Augusta Raddatz"))) - .doesNotThrowAnyException(); - } - - @Test - void leavesUnrelatedBlockUntouched_whenNoSidecarReferencesPerson() { - UUID personId = savedPersonId("Auguste", "Raddatz"); - TranscriptionBlock saved = saveBlock( - "Plain text without any mentions.", - List.of()); - em.clear(); - - listener.onPersonDisplayNameChanged( - new PersonDisplayNameChangedEvent(personId, "Auguste Raddatz", "Augusta Raddatz")); - blockRepository.flush(); - em.clear(); - - TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow(); - assertThat(reloaded.getText()).isEqualTo("Plain text without any mentions."); - assertThat(reloaded.getMentionedPersons()).isEmpty(); - } -} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java index b502ac7c..24827177 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java @@ -2,7 +2,6 @@ package org.raddatz.familienarchiv.service; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -10,22 +9,14 @@ import org.raddatz.familienarchiv.dto.PersonNameAliasDTO; import org.raddatz.familienarchiv.dto.PersonSummaryDTO; import org.raddatz.familienarchiv.dto.PersonUpdateDTO; import org.raddatz.familienarchiv.exception.DomainException; -import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.model.Person; -import org.raddatz.familienarchiv.model.PersonDisplayNameChangedEvent; -import org.raddatz.familienarchiv.model.PersonMention; import org.raddatz.familienarchiv.model.PersonNameAlias; import org.raddatz.familienarchiv.model.PersonNameAliasType; import org.raddatz.familienarchiv.model.PersonType; -import org.raddatz.familienarchiv.model.TranscriptionBlock; import org.raddatz.familienarchiv.repository.PersonNameAliasRepository; import org.raddatz.familienarchiv.repository.PersonRepository; -import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.web.server.ResponseStatusException; -import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -33,14 +24,16 @@ import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class PersonServiceTest { @Mock PersonRepository personRepository; @Mock PersonNameAliasRepository aliasRepository; - @Mock ApplicationEventPublisher eventPublisher; @InjectMocks PersonService personService; // ─── getById ───────────────────────────────────────────────────────────── @@ -252,121 +245,6 @@ class PersonServiceTest { assertThat(result.getAlias()).isEqualTo("Anna Alt"); } - // ─── updatePerson (display-name change event) ──────────────────────────── - - @Test - void updatePerson_publishesEvent_whenTitleChanges() { - UUID id = UUID.randomUUID(); - Person existing = Person.builder() - .id(id).title("Herr").firstName("Auguste").lastName("Raddatz") - .personType(PersonType.PERSON).build(); - String oldName = existing.getDisplayName(); - - when(personRepository.findById(id)).thenReturn(Optional.of(existing)); - when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); - - PersonUpdateDTO dto = new PersonUpdateDTO(); - dto.setPersonType(PersonType.PERSON); - dto.setTitle("Frau"); dto.setFirstName("Auguste"); dto.setLastName("Raddatz"); - - personService.updatePerson(id, dto); - - ArgumentCaptor captor = - ArgumentCaptor.forClass(PersonDisplayNameChangedEvent.class); - verify(eventPublisher).publishEvent(captor.capture()); - - PersonDisplayNameChangedEvent event = captor.getValue(); - assertThat(event.personId()).isEqualTo(id); - assertThat(event.oldDisplayName()).isEqualTo(oldName); - assertThat(event.newDisplayName()) - .isNotEqualTo(oldName) - .contains("Frau"); - } - - @Test - void updatePerson_doesNotPublishEvent_whenDisplayNameFieldsUnchanged() { - UUID id = UUID.randomUUID(); - Person existing = Person.builder() - .id(id).firstName("Auguste").lastName("Raddatz") - .personType(PersonType.PERSON).alias("old alias").build(); - - when(personRepository.findById(id)).thenReturn(Optional.of(existing)); - when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); - - PersonUpdateDTO dto = new PersonUpdateDTO(); - dto.setPersonType(PersonType.PERSON); - dto.setFirstName("Auguste"); dto.setLastName("Raddatz"); - dto.setAlias("new alias"); - - personService.updatePerson(id, dto); - - verify(eventPublisher, never()).publishEvent(any(PersonDisplayNameChangedEvent.class)); - } - - @Test - void updatePerson_throwsConflict_whenBlockSaveAllAndFlushHitsOptimisticLock() { - // Wire a real PersonMentionPropagationListener with a mocked block repository - // that throws on saveAllAndFlush. The publisher mock routes events to the - // listener so the catch path traverses the same call chain as production: - // PersonService → publishEvent → listener → saveAllAndFlush throws → catch. - UUID id = UUID.randomUUID(); - Person existing = Person.builder() - .id(id).firstName("Auguste").lastName("Raddatz") - .personType(PersonType.PERSON).build(); - - TranscriptionBlock referencingBlock = TranscriptionBlock.builder() - .id(UUID.randomUUID()).documentId(UUID.randomUUID()).annotationId(UUID.randomUUID()) - .text("Brief von @Auguste Raddatz").sortOrder(0) - .mentionedPersons(new ArrayList<>(List.of(new PersonMention(id, "Auguste Raddatz")))) - .build(); - - TranscriptionBlockRepository blockRepo = mock(TranscriptionBlockRepository.class); - when(blockRepo.findByPersonIdWithMentionsFetched(id)) - .thenReturn(List.of(referencingBlock)); - when(blockRepo.saveAllAndFlush(any())) - .thenThrow(new ObjectOptimisticLockingFailureException( - TranscriptionBlock.class, referencingBlock.getId())); - - PersonMentionPropagationListener realListener = - new PersonMentionPropagationListener(blockRepo); - - when(personRepository.findById(id)).thenReturn(Optional.of(existing)); - when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); - doAnswer(inv -> { - realListener.onPersonDisplayNameChanged(inv.getArgument(0)); - return null; - }).when(eventPublisher).publishEvent(any(PersonDisplayNameChangedEvent.class)); - - PersonUpdateDTO dto = new PersonUpdateDTO(); - dto.setPersonType(PersonType.PERSON); - dto.setFirstName("Augusta"); dto.setLastName("Raddatz"); - - assertThatThrownBy(() -> personService.updatePerson(id, dto)) - .isInstanceOf(DomainException.class) - .matches(e -> ((DomainException) e).getCode() == ErrorCode.PERSON_RENAME_CONFLICT) - .matches(e -> ((DomainException) e).getStatus().value() == 409); - } - - @Test - void updatePerson_doesNotPublishEvent_whenOnlyNotesChanges() { - UUID id = UUID.randomUUID(); - Person existing = Person.builder() - .id(id).firstName("Auguste").lastName("Raddatz") - .personType(PersonType.PERSON).notes("first note").build(); - - when(personRepository.findById(id)).thenReturn(Optional.of(existing)); - when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); - - PersonUpdateDTO dto = new PersonUpdateDTO(); - dto.setPersonType(PersonType.PERSON); - dto.setFirstName("Auguste"); dto.setLastName("Raddatz"); - dto.setNotes("revised note"); - - personService.updatePerson(id, dto); - - verify(eventPublisher, never()).publishEvent(any(PersonDisplayNameChangedEvent.class)); - } - // ─── findOrCreateByAlias ───────────────────────────────────────────────── @Test -- 2.49.1 From 41a57c0dc83b68bff3b6f0a1915d58f1f59ff490 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 29 Apr 2026 15:00:13 +0200 Subject: [PATCH 02/17] feat(frontend): add Tiptap renovate group, i18n keys, fix geb. literal, remove rename-conflict - renovate.json: group all @tiptap/* packages so version bumps stay in sync - de/en/es.json: add transcription_editor_aria_label and person_born_name_prefix keys - PersonHoverCard: replace hardcoded "geb." with m.person_born_name_prefix() (Leonie #5602) - errors.ts: remove PERSON_RENAME_CONFLICT (backend enum value deleted) Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 2 ++ frontend/messages/en.json | 2 ++ frontend/messages/es.json | 2 ++ frontend/src/lib/components/PersonHoverCard.svelte | 5 ++++- frontend/src/lib/errors.ts | 3 --- renovate.json | 10 ++++++++++ 6 files changed, 20 insertions(+), 4 deletions(-) create mode 100644 renovate.json diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 8ef11f81..7de80fb9 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -427,6 +427,8 @@ "person_mention_popup_empty": "Keine Personen gefunden", "person_mention_btn_label": "Person verlinken", "person_mention_create_new": "Neue Person anlegen", + "transcription_editor_aria_label": "Transkriptionstext", + "person_born_name_prefix": "geb.", "page_title_home": "Archiv", "page_title_persons": "Personen", "page_title_admin": "Administration", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index c0909263..1601022e 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -427,6 +427,8 @@ "person_mention_popup_empty": "No persons found", "person_mention_btn_label": "Link person", "person_mention_create_new": "Create new person", + "transcription_editor_aria_label": "Transcription text", + "person_born_name_prefix": "née", "page_title_home": "Archive", "page_title_persons": "Persons", "page_title_admin": "Administration", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 4b2fcdaf..599aeaed 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -427,6 +427,8 @@ "person_mention_popup_empty": "No se encontraron personas", "person_mention_btn_label": "Vincular persona", "person_mention_create_new": "Crear nueva persona", + "transcription_editor_aria_label": "Texto de transcripción", + "person_born_name_prefix": "n.", "page_title_home": "Archivo", "page_title_persons": "Personas", "page_title_admin": "Administración", diff --git a/frontend/src/lib/components/PersonHoverCard.svelte b/frontend/src/lib/components/PersonHoverCard.svelte index 36a4989d..aac4f883 100644 --- a/frontend/src/lib/components/PersonHoverCard.svelte +++ b/frontend/src/lib/components/PersonHoverCard.svelte @@ -109,7 +109,10 @@ const ariaBusy = $derived(state.status === 'loading');

{dateRange}
{/if} {#if state.person.alias} -
geb. {state.person.alias}
+
+ {m.person_born_name_prefix()} + {state.person.alias} +
{/if} {#if familyChips.length > 0} diff --git a/frontend/src/lib/errors.ts b/frontend/src/lib/errors.ts index 3c80a1da..e57f212e 100644 --- a/frontend/src/lib/errors.ts +++ b/frontend/src/lib/errors.ts @@ -8,7 +8,6 @@ export type ErrorCode = | 'PERSON_NOT_FOUND' | 'ALIAS_NOT_FOUND' | 'INVALID_PERSON_TYPE' - | 'PERSON_RENAME_CONFLICT' | 'DOCUMENT_NOT_FOUND' | 'DOCUMENT_NO_FILE' | 'FILE_NOT_FOUND' @@ -80,8 +79,6 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string { return m.error_alias_not_found(); case 'INVALID_PERSON_TYPE': return m.error_invalid_person_type(); - case 'PERSON_RENAME_CONFLICT': - return m.error_person_rename_conflict(); case 'DOCUMENT_NOT_FOUND': return m.error_document_not_found(); case 'DOCUMENT_NO_FILE': diff --git a/renovate.json b/renovate.json new file mode 100644 index 00000000..bcb6238b --- /dev/null +++ b/renovate.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "packageRules": [ + { + "matchPackagePatterns": ["^@tiptap/"], + "groupName": "tiptap", + "automerge": false + } + ] +} -- 2.49.1 From 5591f958717a98764e8ae94c2eb5fbbb1d2f0620 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 29 Apr 2026 15:01:19 +0200 Subject: [PATCH 03/17] chore(deps): install Tiptap 3.22.5 (core, starter-kit, extension-mention) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exact version pins — all three packages share ProseMirror peer deps and must stay in sync. Renovate grouping in renovate.json ensures they bump together. Co-Authored-By: Claude Sonnet 4.6 --- frontend/package-lock.json | 553 +++++++++++++++++++++++++++++++++++++ frontend/package.json | 3 + 2 files changed, 556 insertions(+) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 01031480..e39be73d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,9 @@ "name": "frontend", "version": "0.0.1", "dependencies": { + "@tiptap/core": "3.22.5", + "@tiptap/extension-mention": "3.22.5", + "@tiptap/starter-kit": "3.22.5", "diff": "^8.0.3", "openapi-fetch": "^0.13.5", "pdfjs-dist": "^5.5.207" @@ -2188,6 +2191,403 @@ "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" } }, + "node_modules/@tiptap/core": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.22.5.tgz", + "integrity": "sha512-L1lhWz6ujGny8LduTJ7MBWYhzigwOvfUJUrJ7IzOJSuy3+OAzisdGDD1GV7LEO/hU0Hr2Mkm1wajRIHExvS9HQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "3.22.5" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.22.5.tgz", + "integrity": "sha512-ajyP5W8fG5Hrru47T/eF3xMKOpNvWofgNJqBTeNuGl02sYxsy9a4EunyFxudsaZP9WW3VOD4SaIWr5+MqpbnOQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.22.5.tgz", + "integrity": "sha512-l/uDtpJISiFFyfctvnODNWBN/XPZI1jVZRacTRDDnSn8+x6KQ7G2qgFYueU7KvVJGDFVT39Iio56mcFRG/Pozg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.22.5.tgz", + "integrity": "sha512-cf54fG9AybU8NgPMv1TOcoqAkELeRc/VpnSCt/rIJZphWQx9nsFmrtkrlCatrIcCaGtNZYwlHlMnC5LVVMu0uA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.22.5" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.22.5.tgz", + "integrity": "sha512-mwDNOJC9rYbDu/JcqrN4dbUQRklJU8Fuk2raxD/IvFw9qUIcPCmxQ2XT9UTKmZz/Ju7Kdy72fss6XpgWv6gLAQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.22.5.tgz", + "integrity": "sha512-d123kCfLdJTi4fue1m0+TNFztDkmIRSZGZmGu6H9KqwG5Q7IzjT9o8lzRsz+pXxYqHvqgYmXoEpM6srbzXx/Ag==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5", + "@tiptap/pm": "3.22.5" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.22.5.tgz", + "integrity": "sha512-8NJERd+pCtvSuEP4C4WMGYmRRCV12ePZL7bC+QUdFlbdXg+kNZS0zZ7hh879tYA0Kidbi8rWWD1Tx+H2ezkmMw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.22.5.tgz", + "integrity": "sha512-Mp40DaFrY3sEUVtFqmxrR0BmU4G3k8GCYYNGqNa9OqWv7BrcFDC03V2n3okESDKt4MKkzhQQmypq+ouLy8dLfA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.22.5" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.22.5.tgz", + "integrity": "sha512-4WkMu7qqjbsm8hCQS+8X+la1wjriN0SKoRdvpfKH33qM50MB34tYJuGLAO+y7TTh4MMMco3AZCKPBL5JVMqNIg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.22.5" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.22.5.tgz", + "integrity": "sha512-n0R2mUVYZU2AVbJhg/WcY9+zx690wVwvsItHJf0DrYbf1tCYHx+PRHUt/AoXk6u8BSmnkb8/FDziS8m3mjfpSg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.22.5.tgz", + "integrity": "sha512-hjyEG4947PAhMBfP1G6B0QAh6+y9mp2C5BQmNjprA05/lQzDAT7KFZzNh8ZVp3ol6aICKq/N1gFOW9Dc/9FUOw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.22.5.tgz", + "integrity": "sha512-vUV0/ugIbXOc8SJib0h8UMhgcqZXWu/dkEhlswZN4VVven1o5enkfxEiDw+OyIJHi5rUkrdhsQ/KTxG/Xb7X8A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5", + "@tiptap/pm": "3.22.5" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.22.5.tgz", + "integrity": "sha512-4T8baSiLkeIymTgEwirxDFt5YgYofkP3m1+MGYdGy2HKcOK+1vpvlPhEO1X5qtZngtJW5S4+njKjinRg52A4PA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.22.5.tgz", + "integrity": "sha512-d671MvF3GPKoS2OVxjIlQ7hIE7MS3hREdR+d4cvnnoiLLD+ZJ6KgDnxmWqF0a1s4qxLWK2KxKRSOIfYGE31QWQ==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5", + "@tiptap/pm": "3.22.5" + } + }, + "node_modules/@tiptap/extension-list": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.22.5.tgz", + "integrity": "sha512-cVO3ZHCgxAWZ4zrFSs81FO2nyCk1wb2EHkpLpW98FzbJLkN9rDkazhW99P3HRWy/CvUldOT+8ecI1YrQtBojMg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5", + "@tiptap/pm": "3.22.5" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.22.5.tgz", + "integrity": "sha512-W7uTmyKLhlsvuTPLv+8WwnsY+mlikBFIoLSvVcBaFt4MwpsZ+DeB6KQg02Y7tbtaAnG7rXu9Fvw2QORh2P728A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.22.5" + } + }, + "node_modules/@tiptap/extension-list-keymap": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.22.5.tgz", + "integrity": "sha512-cGUnxJ0y515e1bVHNjUmbx7oWHoEon59w6BA5N2KwV9iW2mZZchlTX4yxJSOX+ixeVRChsa7YwC3Z1jUZ6AMEg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.22.5" + } + }, + "node_modules/@tiptap/extension-mention": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-mention/-/extension-mention-3.22.5.tgz", + "integrity": "sha512-rGTbTjyxLc5C/6QjfbQF53nMbxjVgJU1VK6Si1i1J2c5DU09COgEFlYvi4YHjb3xz39SprPfG+GTtgD96eg7Ww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5", + "@tiptap/pm": "3.22.5", + "@tiptap/suggestion": "3.22.5" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.22.5.tgz", + "integrity": "sha512-OXdh4k4CNrukwiSdWdEQ49uvgnqvR0Z9aNSP4HI5/kZQ/Te1NtRtYCpUrzWyO/7CtjcCisXHti0o9C/TV8YMbQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.22.5" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.22.5.tgz", + "integrity": "sha512-52KCto4+XKpnBWpIufspWLyq4UWxAWC72ANPdGuIhbi72NRTabiTbTVN40uwGSPkyakeESG0/vKdWJCVvB4f0g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.22.5.tgz", + "integrity": "sha512-42WrrFK5gOom/0znH85x12Mw5IQ/6O6DWdyUWoRIrNA/qJpuHtU8oVU+bIgU2tuomMGHruRjIzgBQv5sBjEtww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.22.5.tgz", + "integrity": "sha512-bzpDOdAEo1JeoVZDIyV0oY0jGXkEG+AzF70SzHoRSjOvFDtKWunyXf9eO1OnOr2/fmMcckT2qwUBNBMQplWBzw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5" + } + }, + "node_modules/@tiptap/extension-underline": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.22.5.tgz", + "integrity": "sha512-9ut09rJD0iEbS6sk7yd2j6IwuFDLTNmDEGTDLodvqAfi+bq7ddsTDv0YviXoZaA9sdHAdTEVr2ITy2m6WK5jpA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5" + } + }, + "node_modules/@tiptap/extensions": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.22.5.tgz", + "integrity": "sha512-Ifg4MzKCj3uRqe3ieTwYnomu2y4p7EXr2avVSKZYfh12i2dyWe2Gkn1KuZDREANVE+gHqFlQjJRYzhJFwzSCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5", + "@tiptap/pm": "3.22.5" + } + }, + "node_modules/@tiptap/pm": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.5.tgz", + "integrity": "sha512-Cr9Mv4igxvI2tKMiahw48sZxva3PfDzypErH8IB82N+9qa9n9ygVMt0BOaDg53hLKxEEVeYr2S/wCcJIVFgBTw==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.3.0", + "prosemirror-commands": "^1.6.2", + "prosemirror-dropcursor": "^1.8.1", + "prosemirror-gapcursor": "^1.3.2", + "prosemirror-history": "^1.4.1", + "prosemirror-keymap": "^1.2.2", + "prosemirror-model": "^1.24.1", + "prosemirror-schema-list": "^1.5.0", + "prosemirror-state": "^1.4.3", + "prosemirror-tables": "^1.6.4", + "prosemirror-transform": "^1.10.2", + "prosemirror-view": "^1.38.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.22.5.tgz", + "integrity": "sha512-LZ/LYbwH6rnDi5DnRyagkuNsYAVyhM+yJvvz+ZuYA0JkPiTXJV86J5PWSKew8M0gVfMHcNVtKjfQCvViFCeIgw==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "^3.22.5", + "@tiptap/extension-blockquote": "^3.22.5", + "@tiptap/extension-bold": "^3.22.5", + "@tiptap/extension-bullet-list": "^3.22.5", + "@tiptap/extension-code": "^3.22.5", + "@tiptap/extension-code-block": "^3.22.5", + "@tiptap/extension-document": "^3.22.5", + "@tiptap/extension-dropcursor": "^3.22.5", + "@tiptap/extension-gapcursor": "^3.22.5", + "@tiptap/extension-hard-break": "^3.22.5", + "@tiptap/extension-heading": "^3.22.5", + "@tiptap/extension-horizontal-rule": "^3.22.5", + "@tiptap/extension-italic": "^3.22.5", + "@tiptap/extension-link": "^3.22.5", + "@tiptap/extension-list": "^3.22.5", + "@tiptap/extension-list-item": "^3.22.5", + "@tiptap/extension-list-keymap": "^3.22.5", + "@tiptap/extension-ordered-list": "^3.22.5", + "@tiptap/extension-paragraph": "^3.22.5", + "@tiptap/extension-strike": "^3.22.5", + "@tiptap/extension-text": "^3.22.5", + "@tiptap/extension-underline": "^3.22.5", + "@tiptap/extensions": "^3.22.5", + "@tiptap/pm": "^3.22.5" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/suggestion": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.22.5.tgz", + "integrity": "sha512-Uv79Ht/o4mx1GWIT65jeQTE67LMrA+K7d8p51XOe9PJw0H0fS3iCdeMJ8tAo3h6QrMJFejdsB7z8jJL9UbAnhA==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5", + "@tiptap/pm": "3.22.5" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -4270,6 +4670,12 @@ "node": ">=10" } }, + "node_modules/linkifyjs": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz", + "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==", + "license": "MIT" + }, "node_modules/locate-character": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", @@ -4499,6 +4905,12 @@ "node": ">= 0.8.0" } }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -4934,6 +5346,135 @@ } } }, + "node_modules/prosemirror-changeset": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz", + "integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", + "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz", + "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.4", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", + "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz", + "integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.41.8", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz", + "integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.20.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5044,6 +5585,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, "node_modules/sade": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", @@ -5761,6 +6308,12 @@ "vitest": "^4.0.0" } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/webpack-virtual-modules": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index e7170513..542411e8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,6 +21,9 @@ "generate:api": "openapi-typescript http://localhost:8080/v3/api-docs -o ./src/lib/generated/api.ts" }, "dependencies": { + "@tiptap/core": "3.22.5", + "@tiptap/extension-mention": "3.22.5", + "@tiptap/starter-kit": "3.22.5", "diff": "^8.0.3", "openapi-fetch": "^0.13.5", "pdfjs-dist": "^5.5.207" -- 2.49.1 From 68cb6e9b76121d72f0c732bceab0fb5bf43cdce3 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 29 Apr 2026 15:03:58 +0200 Subject: [PATCH 04/17] =?UTF-8?q?feat(frontend):=20add=20mentionSerializer?= =?UTF-8?q?=20=E2=80=94=20pure=20serialize/deserialize=20for=20Tiptap=20?= =?UTF-8?q?=E2=86=94=20block=20storage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Converts between the stored format (text + PersonMention sidecar) and Tiptap ProseMirror JSONContent. Round-trip invariant: serialize(deserialize(t,s)).text === t. Handles multi-paragraph text (split/join on \n), sidecar deduplication, and backward compat with old-format full-name sidecar entries. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/utils/mentionSerializer.spec.ts | 138 ++++++++++++++++++ frontend/src/lib/utils/mentionSerializer.ts | 113 ++++++++++++++ 2 files changed, 251 insertions(+) create mode 100644 frontend/src/lib/utils/mentionSerializer.spec.ts create mode 100644 frontend/src/lib/utils/mentionSerializer.ts diff --git a/frontend/src/lib/utils/mentionSerializer.spec.ts b/frontend/src/lib/utils/mentionSerializer.spec.ts new file mode 100644 index 00000000..19b4c4a2 --- /dev/null +++ b/frontend/src/lib/utils/mentionSerializer.spec.ts @@ -0,0 +1,138 @@ +import { describe, it, expect } from 'vitest'; +import { deserialize, serialize } from './mentionSerializer'; +import type { PersonMention } from '$lib/types'; + +// ─── deserialize ───────────────────────────────────────────────────────────── + +describe('deserialize', () => { + it('returns a valid Tiptap doc for an empty string', () => { + const doc = deserialize('', []); + expect(doc.type).toBe('doc'); + expect(doc.content).toHaveLength(1); + expect(doc.content![0].type).toBe('paragraph'); + }); + + it('returns plain text node for text with no mentions', () => { + const doc = deserialize('Hallo Welt', []); + const para = doc.content![0]; + expect(para.content).toHaveLength(1); + expect(para.content![0]).toEqual({ type: 'text', text: 'Hallo Welt' }); + }); + + it('places a mention node at the correct position in the paragraph', () => { + const sidecar: PersonMention[] = [{ personId: 'uuid-x', displayName: 'Clara' }]; + const doc = deserialize('Hallo @Clara Welt', sidecar); + const nodes = doc.content![0].content!; + expect(nodes).toHaveLength(3); + expect(nodes[0]).toEqual({ type: 'text', text: 'Hallo ' }); + expect(nodes[1]).toMatchObject({ + type: 'mention', + attrs: { displayName: 'Clara', personId: 'uuid-x' } + }); + expect(nodes[2]).toEqual({ type: 'text', text: ' Welt' }); + }); + + it('prefers the longer displayName when two sidecar entries share a prefix', () => { + const sidecar: PersonMention[] = [ + { personId: 'uuid-short', displayName: 'Auguste' }, + { personId: 'uuid-long', displayName: 'Auguste Raddatz' } + ]; + const doc = deserialize('@Auguste Raddatz', sidecar); + const nodes = doc.content![0].content!; + expect(nodes).toHaveLength(1); + expect(nodes[0]).toMatchObject({ + type: 'mention', + attrs: { personId: 'uuid-long', displayName: 'Auguste Raddatz' } + }); + }); + + it('splits multiple paragraphs on newline', () => { + const doc = deserialize('Zeile 1\nZeile 2', []); + expect(doc.content).toHaveLength(2); + expect(doc.content![0].content![0]).toEqual({ type: 'text', text: 'Zeile 1' }); + expect(doc.content![1].content![0]).toEqual({ type: 'text', text: 'Zeile 2' }); + }); +}); + +// ─── serialize ─────────────────────────────────────────────────────────────── + +describe('serialize', () => { + it('serializes plain text unchanged', () => { + const doc = deserialize('Hallo Welt', []); + const { text, mentionedPersons } = serialize(doc); + expect(text).toBe('Hallo Welt'); + expect(mentionedPersons).toEqual([]); + }); + + it('serializes mention node back to @displayName', () => { + const sidecar: PersonMention[] = [{ personId: 'uuid-x', displayName: 'Clara' }]; + const doc = deserialize('Hallo @Clara Welt', sidecar); + const { text, mentionedPersons } = serialize(doc); + expect(text).toBe('Hallo @Clara Welt'); + expect(mentionedPersons).toEqual([{ personId: 'uuid-x', displayName: 'Clara' }]); + }); + + it('joins multi-paragraph doc back with newlines', () => { + const doc = deserialize('Zeile 1\nZeile 2', []); + const { text } = serialize(doc); + expect(text).toBe('Zeile 1\nZeile 2'); + }); + + it('de-duplicates repeated mentions in the sidecar', () => { + const sidecar: PersonMention[] = [{ personId: 'uuid-x', displayName: 'Clara' }]; + const doc = deserialize('@Clara und @Clara', sidecar); + const { mentionedPersons } = serialize(doc); + expect(mentionedPersons).toHaveLength(1); + expect(mentionedPersons[0].personId).toBe('uuid-x'); + }); +}); + +// ─── Round-trip invariant ───────────────────────────────────────────────────── + +describe('round-trip invariant', () => { + it('text is preserved exactly through deserialize → serialize', () => { + const cases = [ + ['', []], + ['Hallo Welt', []], + ['@Clara schreibt', [{ personId: 'uuid-x', displayName: 'Clara' }]], + ['Zeile 1\nZeile 2', []], + ['Sehr geehrte @Auguste Raddatz,', [{ personId: 'uuid-aug', displayName: 'Auguste Raddatz' }]] + ] as const; + + for (const [text, sidecar] of cases) { + const doc = deserialize(text, sidecar as PersonMention[]); + const { text: out } = serialize(doc); + expect(out, `round-trip failed for: "${text}"`).toBe(text); + } + }); +}); + +// ─── Backward compatibility (AC-6) ──────────────────────────────────────────── + +describe('backward compatibility', () => { + it('old-format full-name sidecar entry still round-trips correctly', () => { + // Before this issue, displayName stored the person's full DB name. + // renderTranscriptionBody already handles this — so does the serializer. + const oldSidecar: PersonMention[] = [{ personId: 'uuid-aug', displayName: 'Auguste Raddatz' }]; + const text = 'Brief von @Auguste Raddatz'; + const doc = deserialize(text, oldSidecar); + const { text: out, mentionedPersons } = serialize(doc); + expect(out).toBe(text); + expect(mentionedPersons).toEqual(oldSidecar); + }); +}); + +// ─── Security ───────────────────────────────────────────────────────────────── + +describe('security', () => { + it('displayName containing HTML special chars is preserved as a string, not injected', () => { + const sidecar: PersonMention[] = [ + { personId: 'uuid-x', displayName: '' } + ]; + const text = '@'; + const doc = deserialize(text, sidecar); + const { mentionedPersons } = serialize(doc); + // The displayName is stored verbatim — HTML escaping is the renderer's job + expect(mentionedPersons[0].displayName).toBe(''); + }); +}); diff --git a/frontend/src/lib/utils/mentionSerializer.ts b/frontend/src/lib/utils/mentionSerializer.ts new file mode 100644 index 00000000..b6213f25 --- /dev/null +++ b/frontend/src/lib/utils/mentionSerializer.ts @@ -0,0 +1,113 @@ +import type { JSONContent } from '@tiptap/core'; +import type { PersonMention } from '$lib/types'; + +/** + * Converts stored block text + sidecar into a Tiptap ProseMirror document. + * + * The text is split by "\n" into paragraphs. Within each paragraph, sidecar + * entries are matched against "@displayName" tokens (longest first) and + * converted to mention nodes. Unmatched text becomes plain text nodes. + * + * Round-trip invariant: serialize(deserialize(text, sidecar)).text === text + */ +export function deserialize(text: string, sidecar: PersonMention[]): JSONContent { + const lines = text === '' ? [''] : text.split('\n'); + + // Sort sidecar by displayName length descending so longer names shadow + // shorter prefix matches (same heuristic as renderTranscriptionBody). + const sorted = [...sidecar].sort((a, b) => b.displayName.length - a.displayName.length); + + return { + type: 'doc', + content: lines.map((line) => ({ + type: 'paragraph', + content: parseLine(line, sorted) + })) + }; +} + +function parseLine(text: string, sidecar: PersonMention[]): JSONContent[] { + if (text === '') return []; + + if (sidecar.length === 0) { + return [{ type: 'text', text }]; + } + + // Build a list of mention ranges: { start, end, mention } + const ranges: Array<{ start: number; end: number; mention: PersonMention }> = []; + + for (const mention of sidecar) { + const needle = `@${mention.displayName}`; + let idx = 0; + while (idx < text.length) { + const pos = text.indexOf(needle, idx); + if (pos === -1) break; + // Check that the range doesn't overlap an already-found range + const end = pos + needle.length; + const overlaps = ranges.some((r) => r.start < end && r.end > pos); + if (!overlaps) { + ranges.push({ start: pos, end, mention }); + } + idx = pos + 1; + } + } + + if (ranges.length === 0) { + return [{ type: 'text', text }]; + } + + // Sort by position + ranges.sort((a, b) => a.start - b.start); + + const nodes: JSONContent[] = []; + let cursor = 0; + + for (const { start, end, mention } of ranges) { + if (start > cursor) { + nodes.push({ type: 'text', text: text.slice(cursor, start) }); + } + nodes.push({ + type: 'mention', + attrs: { displayName: mention.displayName, personId: mention.personId } + }); + cursor = end; + } + + if (cursor < text.length) { + nodes.push({ type: 'text', text: text.slice(cursor) }); + } + + return nodes; +} + +/** + * Converts a Tiptap ProseMirror document back to stored block text + sidecar. + * + * Paragraphs are joined with "\n". Mention nodes are emitted as "@displayName" + * and collected into mentionedPersons (de-duplicated by personId). + */ +export function serialize(doc: JSONContent): { text: string; mentionedPersons: PersonMention[] } { + const paragraphs = doc.content ?? []; + const mentionedPersons: PersonMention[] = []; + const seenIds = new Set(); + const lines: string[] = []; + + for (const para of paragraphs) { + let line = ''; + for (const node of para.content ?? []) { + if (node.type === 'text') { + line += node.text ?? ''; + } else if (node.type === 'mention') { + const { displayName, personId } = node.attrs ?? {}; + line += `@${displayName}`; + if (!seenIds.has(personId)) { + seenIds.add(personId); + mentionedPersons.push({ personId, displayName }); + } + } + } + lines.push(line); + } + + return { text: lines.join('\n'), mentionedPersons }; +} -- 2.49.1 From e5634c301e34e921060f4a9e7d4c5ac8613c87d3 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 29 Apr 2026 15:08:44 +0200 Subject: [PATCH 05/17] =?UTF-8?q?feat(frontend):=20add=20MentionDropdown?= =?UTF-8?q?=20=E2=80=94=20Tiptap=20suggestion-compatible=20person=20dropdo?= =?UTF-8?q?wn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces PersonMentionEditor's inline popup for the Tiptap migration. Mounted imperatively to document.body by the suggestion plugin's render() lifecycle. Supports flip-upward strategy when viewport space is tight (Leonie #5602 mobile keyboard concern). 44px touch targets, WCAG accessible. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/components/MentionDropdown.svelte | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 frontend/src/lib/components/MentionDropdown.svelte diff --git a/frontend/src/lib/components/MentionDropdown.svelte b/frontend/src/lib/components/MentionDropdown.svelte new file mode 100644 index 00000000..7d01145a --- /dev/null +++ b/frontend/src/lib/components/MentionDropdown.svelte @@ -0,0 +1,143 @@ + + + +
+ {#if items.length === 0} +

+ {m.person_mention_popup_empty()} +

+ {:else} + {#each items as person, i (person.id)} +
{ + // Prevent blur on the editor before the selection fires. + e.preventDefault(); + selectItem(person); + }} + > + {person.displayName} + {#if formatLifeDateRange(person.birthYear, person.deathYear)} + + {formatLifeDateRange(person.birthYear, person.deathYear)} + + {/if} +
+ {/each} + {/if} +
-- 2.49.1 From 39ddf90725701d1ecfcd3013101d7392d586aa9e Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 29 Apr 2026 15:52:45 +0200 Subject: [PATCH 06/17] refactor(MentionDropdown): receive reactive state via single 'model' prop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Svelte 5's mount() does not return prop accessors — setting 'instance.items = newValue' is a no-op. Switching to a single $state proxy passed as 'model' lets the parent mutate fields and have the dropdown react. The prop is named 'model' (not 'state') because the $state rune name shadows a 'state' identifier in Svelte 5 templates. Position class also switches from absolute to fixed so viewport- relative DOMRect coordinates from clientRect() work when the dropdown is mounted on document.body. Co-Authored-By: Claude Opus 4.7 --- .../src/lib/components/MentionDropdown.svelte | 44 +++++++++++-------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/frontend/src/lib/components/MentionDropdown.svelte b/frontend/src/lib/components/MentionDropdown.svelte index 7d01145a..acfaae49 100644 --- a/frontend/src/lib/components/MentionDropdown.svelte +++ b/frontend/src/lib/components/MentionDropdown.svelte @@ -5,11 +5,18 @@ import { m } from '$lib/paraglide/messages.js'; type Person = components['schemas']['Person']; -// All reactive state is driven externally by the Tiptap suggestion plugin. -// The parent writes to these after mount() via the exported bindings. -let items = $state([]); -let command = $state<(item: Person) => void>(() => {}); -let clientRect = $state<(() => DOMRect | null) | null>(null); +// The dropdown receives a single reactive state object. PersonMentionEditor +// mutates fields on this object (model.items = ..., etc.) and Svelte's $state +// proxy reactivity propagates the change here. This is the supported way to +// update an imperatively-mounted Svelte 5 component — `mount` does not return +// settable prop accessors. +type DropdownState = { + items: Person[]; + command: (item: Person) => void; + clientRect: (() => DOMRect | null) | null; +}; + +let { model }: { model: DropdownState } = $props(); // highlightedIndex must be both writable (keyboard handler mutates it) and // reset when `items` changes (so it never points past the end of a new list). @@ -19,8 +26,8 @@ let clientRect = $state<(() => DOMRect | null) | null>(null); let highlightedIndex = $state(0); $effect(() => { - // Read items to subscribe; reset index whenever the list is replaced. - void items; + // Read model.items to subscribe; reset index whenever the list is replaced. + void model.items; highlightedIndex = 0; }); @@ -38,8 +45,9 @@ type Position = { const DROPDOWN_CLEARANCE_PX = 200; const position = $derived.by(() => { - if (!clientRect) return { top: '0px', bottom: null, left: '0px' }; - const rect = clientRect(); + const cr = model.clientRect; + if (!cr) return { top: '0px', bottom: null, left: '0px' }; + const rect = cr(); if (!rect) return { top: '0px', bottom: null, left: '0px' }; // Some editors report a caret DOMRect with zero width; fall back to rect.x. @@ -63,21 +71,21 @@ const position = $derived.by(() => { // --------------------------------------------------------------------------- export function onKeyDown(event: KeyboardEvent): boolean { + const len = model.items.length; if (event.key === 'ArrowDown') { - highlightedIndex = (highlightedIndex + 1) % Math.max(items.length, 1); + highlightedIndex = (highlightedIndex + 1) % Math.max(len, 1); return true; } if (event.key === 'ArrowUp') { - highlightedIndex = - (highlightedIndex - 1 + Math.max(items.length, 1)) % Math.max(items.length, 1); + highlightedIndex = (highlightedIndex - 1 + Math.max(len, 1)) % Math.max(len, 1); return true; } if (event.key === 'Enter') { - const selected = items[highlightedIndex]; + const selected = model.items[highlightedIndex]; if (selected) { - command(selected); + model.command(selected); } return true; } @@ -87,7 +95,7 @@ export function onKeyDown(event: KeyboardEvent): boolean { } function selectItem(item: Person) { - command(item); + model.command(item); } @@ -103,19 +111,19 @@ function selectItem(item: Person) { unauthenticated users. -->
- {#if items.length === 0} + {#if model.items.length === 0}

{m.person_mention_popup_empty()}

{:else} - {#each items as person, i (person.id)} + {#each model.items as person, i (person.id)}
Date: Wed, 29 Apr 2026 15:53:21 +0200 Subject: [PATCH 07/17] feat(PersonMentionEditor): rewrite as Tiptap editor with AC-1 typed-text displayName MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the textarea-based editor with a Tiptap v3 contenteditable. The custom Mention node uses personId/displayName attrs (instead of Tiptap's default id/label) so mentionSerializer round-trips cleanly. AC-1 fix (issue #372): when the user types '@Aug' and selects 'Auguste Raddatz', the mention node stores displayName: 'Aug' (the typed query) — not the person's DB display name. This preserves archival fidelity of the original transcription. The MentionDropdown is mounted imperatively on document.body via Svelte 5's mount(). Its three pieces of dynamic state (items, command, clientRect) are passed as a single $state proxy (model) because Svelte 5's mount() does not return prop accessors. Spec is fully rewritten — all old tests used document.querySelector ('textarea') which is dead after the migration. Co-Authored-By: Claude Opus 4.7 --- .../lib/components/PersonMentionEditor.svelte | 433 +++++++++--------- .../PersonMentionEditor.svelte.spec.ts | 361 ++++++--------- 2 files changed, 344 insertions(+), 450 deletions(-) diff --git a/frontend/src/lib/components/PersonMentionEditor.svelte b/frontend/src/lib/components/PersonMentionEditor.svelte index df686334..721831df 100644 --- a/frontend/src/lib/components/PersonMentionEditor.svelte +++ b/frontend/src/lib/components/PersonMentionEditor.svelte @@ -1,263 +1,242 @@ -
- - - {#if popupOpen} -
- {#if loading} -

{m.comp_typeahead_loading()}

- {:else if results.length === 0} -
-

{m.person_mention_popup_empty()}

- - {m.person_mention_create_new()} → - -
- {:else} - {#each results as person, i (person.id)} -
{ - e.preventDefault(); - selectPerson(person); - }} - > - {person.displayName} - {#if formatLifeDateRange(person.birthYear, person.deathYear)} - - {formatLifeDateRange(person.birthYear, person.deathYear)} - - {/if} -
- {/each} - {/if} -
- {/if} -
+
diff --git a/frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts b/frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts index ea8df7fe..39a1dc15 100644 --- a/frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts +++ b/frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts @@ -1,26 +1,19 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +/** + * PersonMentionEditor — Tiptap-based component tests. + * + * All old tests used document.querySelector('textarea') which is dead after + * the Tiptap migration. These tests drive the contenteditable via + * userEvent.type() and inspect the serialized output from the test host. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; -import { page } from 'vitest/browser'; +import { page, userEvent } from 'vitest/browser'; import PersonMentionEditorHost from './PersonMentionEditor.test-host.svelte'; import type { components } from '$lib/generated/api'; type Person = components['schemas']['Person']; type PersonMention = components['schemas']['PersonMention']; -// Editor's internal search debounce is 200ms — drive it via fake timers -// so tests are deterministic and fast (Tester #5506 §1). -const DEBOUNCE_MS = 200; - -async function flushDebounce() { - await vi.advanceTimersByTimeAsync(DEBOUNCE_MS); - // Let the awaited fetch resolve and the resulting state assignments flush. - await vi.runAllTimersAsync(); -} - -async function tick() { - await vi.advanceTimersByTimeAsync(0); -} - const AUGUSTE: Person = { id: 'p-aug', firstName: 'Auguste', @@ -39,34 +32,17 @@ const ANNA: Person = { } as unknown as Person; function mockFetchWithPersons(persons: Person[] = [AUGUSTE, ANNA]) { - const fetchMock = vi - .fn() - .mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue(persons) }); - vi.stubGlobal('fetch', fetchMock); - return fetchMock; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue(persons) }) + ); } function mockFetchEmpty() { - const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) }); - vi.stubGlobal('fetch', fetchMock); - return fetchMock; -} - -function mockFetchRejects() { - const fetchMock = vi.fn().mockRejectedValue(new TypeError('Failed to fetch')); - vi.stubGlobal('fetch', fetchMock); - return fetchMock; -} - -function getTextarea(): HTMLTextAreaElement { - return document.querySelector('textarea')!; -} - -function clickOption(personId: string) { - const opt = document.querySelector( - `[role="option"][data-test-person-id="${personId}"]` - ) as HTMLElement; - opt.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) }) + ); } type Snapshot = { value: string; mentionedPersons: PersonMention[] }; @@ -90,275 +66,216 @@ function renderHost(initial: { value?: string; mentionedPersons?: PersonMention[ }; } -beforeEach(() => { - vi.useFakeTimers({ shouldAdvanceTime: true }); -}); - afterEach(() => { cleanup(); vi.unstubAllGlobals(); - vi.useRealTimers(); }); // ─── Rendering ──────────────────────────────────────────────────────────────── describe('PersonMentionEditor — rendering', () => { - it('renders the textarea with placeholder', async () => { + it('renders the editor as a textbox (ARIA role from editorProps)', async () => { render(PersonMentionEditorHost, { initialValue: '', initialMentions: [], - placeholder: 'Transkription…', onChange: () => {} }); - await expect.element(page.getByPlaceholder('Transkription…')).toBeInTheDocument(); + await expect.element(page.getByRole('textbox')).toBeInTheDocument(); }); - it('reflects bound initial value', async () => { + it('reflects bound initial value as visible text', async () => { render(PersonMentionEditorHost, { initialValue: 'Hallo Welt', initialMentions: [], onChange: () => {} }); - await expect.element(page.getByRole('textbox')).toHaveValue('Hallo Welt'); + await expect.element(page.getByText('Hallo Welt')).toBeInTheDocument(); }); }); // ─── Typeahead opens on @ ───────────────────────────────────────────────────── describe('PersonMentionEditor — typeahead', () => { - it('opens the popup when typing @ + query and shows results', async () => { + it('opens the dropdown when typing @ + query and shows results', async () => { mockFetchWithPersons(); renderHost(); - const ta = getTextarea(); - ta.focus(); - ta.value = '@Aug'; - ta.selectionStart = 4; - ta.selectionEnd = 4; - ta.dispatchEvent(new Event('input', { bubbles: true })); + await userEvent.type(page.getByRole('textbox'), '@Aug'); - await flushDebounce(); - - await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument(); + await vi.waitFor(async () => { + await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument(); + }); }); it('hits /api/persons?q= with the typed query', async () => { - const fetchMock = mockFetchWithPersons(); + const fetchMock = vi + .fn() + .mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) }); + vi.stubGlobal('fetch', fetchMock); renderHost(); - const ta = getTextarea(); - ta.focus(); - ta.value = '@Aug'; - ta.selectionStart = 4; - ta.selectionEnd = 4; - ta.dispatchEvent(new Event('input', { bubbles: true })); + await userEvent.type(page.getByRole('textbox'), '@Aug'); - await flushDebounce(); - - expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Aug')); + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Aug')); + }); }); it('shows life dates next to the name in the dropdown', async () => { mockFetchWithPersons(); renderHost(); - const ta = getTextarea(); - ta.focus(); - ta.value = '@Aug'; - ta.selectionStart = 4; - ta.selectionEnd = 4; - ta.dispatchEvent(new Event('input', { bubbles: true })); - await flushDebounce(); + await userEvent.type(page.getByRole('textbox'), '@Aug'); - await expect.element(page.getByText('* 1882 – † 1944')).toBeInTheDocument(); + await vi.waitFor(async () => { + await expect.element(page.getByText('* 1882 – † 1944')).toBeInTheDocument(); + }); }); it('shows empty state when no persons match', async () => { mockFetchEmpty(); renderHost(); - const ta = getTextarea(); - ta.focus(); - ta.value = '@xyz'; - ta.selectionStart = 4; - ta.selectionEnd = 4; - ta.dispatchEvent(new Event('input', { bubbles: true })); - await flushDebounce(); + await userEvent.type(page.getByRole('textbox'), '@xyz'); - await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument(); - }); - - it('falls back to the empty state when the typeahead fetch rejects (network error)', async () => { - mockFetchRejects(); - renderHost(); - - const ta = getTextarea(); - ta.focus(); - ta.value = '@Aug'; - ta.selectionStart = 4; - ta.selectionEnd = 4; - ta.dispatchEvent(new Event('input', { bubbles: true })); - await flushDebounce(); - - await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument(); - }); - - it('keeps the popup open when the query has a trailing space (multi-word names)', async () => { - mockFetchWithPersons(); - renderHost(); - - const ta = getTextarea(); - ta.focus(); - ta.value = '@Auguste '; - ta.selectionStart = 9; - ta.selectionEnd = 9; - ta.dispatchEvent(new Event('input', { bubbles: true })); - await flushDebounce(); - - await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument(); + await vi.waitFor(async () => { + await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument(); + }); }); }); -// ─── Selection writes text + sidecar ───────────────────────────────────────── +// ─── AC-1: typed text becomes displayName, not DB name ─────────────────────── -describe('PersonMentionEditor — selecting a person', () => { - it('inserts @DisplayName followed by a trailing space into the textarea', async () => { +describe('PersonMentionEditor — AC-1: typed text as displayName', () => { + it('stores the typed query as displayName, not the person DB name', async () => { mockFetchWithPersons(); const host = renderHost(); - const ta = getTextarea(); - ta.focus(); - ta.value = '@Aug'; - ta.selectionStart = 4; - ta.selectionEnd = 4; - ta.dispatchEvent(new Event('input', { bubbles: true })); - await flushDebounce(); + // User types "@Aug" (not the full "Auguste Raddatz") and selects Auguste Raddatz + await userEvent.type(page.getByRole('textbox'), '@Aug'); + await vi.waitFor(async () => { + await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible(); + }); - clickOption('p-aug'); - await tick(); + await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ })); - expect(host.snapshot.value).toBe('@Auguste Raddatz '); + await vi.waitFor(() => { + expect(host.snapshot.mentionedPersons).toHaveLength(1); + expect(host.snapshot.mentionedPersons[0]).toEqual({ + personId: 'p-aug', + displayName: 'Aug' // typed text, not "Auguste Raddatz" + }); + }); }); - it('pushes {personId, displayName} into the bound mentionedPersons array', async () => { + it('regression: text value contains the typed query, not the full DB name', async () => { mockFetchWithPersons(); const host = renderHost(); - const ta = getTextarea(); - ta.focus(); - ta.value = '@Aug'; - ta.selectionStart = 4; - ta.selectionEnd = 4; - ta.dispatchEvent(new Event('input', { bubbles: true })); - await flushDebounce(); + await userEvent.type(page.getByRole('textbox'), '@Aug'); + await vi.waitFor(async () => { + await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible(); + }); - clickOption('p-aug'); - await tick(); + await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ })); - expect(host.snapshot.mentionedPersons).toEqual([ - { personId: 'p-aug', displayName: 'Auguste Raddatz' } - ]); + await vi.waitFor(() => { + // Text should contain "@Aug " (typed text + space), not "@Auguste Raddatz " + expect(host.snapshot.value).toContain('@Aug'); + expect(host.snapshot.value).not.toContain('@Auguste Raddatz'); + }); + }); + + it('pushes {personId, displayName} into mentionedPersons sidecar', async () => { + mockFetchWithPersons(); + const host = renderHost(); + + await userEvent.type(page.getByRole('textbox'), '@Aug'); + await vi.waitFor(async () => { + await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible(); + }); + + await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ })); + + await vi.waitFor(() => { + expect(host.snapshot.mentionedPersons).toEqual([{ personId: 'p-aug', displayName: 'Aug' }]); + }); }); it('does not duplicate the sidecar entry when the same person is selected twice', async () => { mockFetchWithPersons(); const host = renderHost({ - value: '@Auguste Raddatz ', - mentionedPersons: [{ personId: 'p-aug', displayName: 'Auguste Raddatz' }] + value: '@Aug ', + mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }] }); - const ta = getTextarea(); - ta.focus(); - ta.value = '@Auguste Raddatz @Aug'; - ta.selectionStart = ta.value.length; - ta.selectionEnd = ta.value.length; - ta.dispatchEvent(new Event('input', { bubbles: true })); - await flushDebounce(); + await userEvent.type(page.getByRole('textbox'), '@Aug'); + await vi.waitFor(async () => { + await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible(); + }); - clickOption('p-aug'); - await tick(); + await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ })); - expect(host.snapshot.mentionedPersons).toHaveLength(1); + await vi.waitFor(() => { + expect(host.snapshot.mentionedPersons).toHaveLength(1); + }); }); }); -// ─── Keyboard navigation (B11b) ────────────────────────────────────────────── +// ─── Keyboard navigation ────────────────────────────────────────────────────── -describe('PersonMentionEditor — keyboard navigation (B11b)', () => { - it('ArrowDown / ArrowUp cycle the highlighted result', async () => { +describe('PersonMentionEditor — keyboard navigation', () => { + it('Enter selects the highlighted result', async () => { + mockFetchWithPersons(); + const host = renderHost(); + + await userEvent.type(page.getByRole('textbox'), '@A'); + + await vi.waitFor(async () => { + await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible(); + }); + + await userEvent.keyboard('{Enter}'); + + await vi.waitFor(() => { + expect(host.snapshot.mentionedPersons).toHaveLength(1); + }); + }); + + it('ArrowDown moves the highlight to the next result', async () => { mockFetchWithPersons(); renderHost(); - const ta = getTextarea(); - ta.focus(); - ta.value = '@A'; - ta.selectionStart = 2; - ta.selectionEnd = 2; - ta.dispatchEvent(new Event('input', { bubbles: true })); - await flushDebounce(); + await userEvent.type(page.getByRole('textbox'), '@A'); - const optAuguste = document.querySelector( - '[role="option"][data-test-person-id="p-aug"]' - ) as HTMLElement; - const optAnna = document.querySelector( - '[role="option"][data-test-person-id="p-anna"]' - ) as HTMLElement; + await vi.waitFor(async () => { + await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible(); + }); - expect(optAuguste.getAttribute('aria-selected')).toBe('true'); - expect(optAnna.getAttribute('aria-selected')).toBe('false'); + await userEvent.keyboard('{ArrowDown}'); - ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); - await tick(); - - expect(optAuguste.getAttribute('aria-selected')).toBe('false'); - expect(optAnna.getAttribute('aria-selected')).toBe('true'); - - ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); - await tick(); - - expect(optAuguste.getAttribute('aria-selected')).toBe('true'); - expect(optAnna.getAttribute('aria-selected')).toBe('false'); + await vi.waitFor(async () => { + const annaOption = page.getByRole('option', { name: /Anna Schmidt/ }); + await expect.element(annaOption).toHaveAttribute('aria-selected', 'true'); + }); }); - it('Enter selects the currently highlighted result', async () => { + it('Escape closes the dropdown without inserting', async () => { mockFetchWithPersons(); const host = renderHost(); - const ta = getTextarea(); - ta.focus(); - ta.value = '@A'; - ta.selectionStart = 2; - ta.selectionEnd = 2; - ta.dispatchEvent(new Event('input', { bubbles: true })); - await flushDebounce(); + await userEvent.type(page.getByRole('textbox'), '@Aug'); - ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); - await tick(); - ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); - await tick(); + await vi.waitFor(async () => { + await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument(); + }); - expect(host.snapshot.mentionedPersons).toEqual([ - { personId: 'p-anna', displayName: 'Anna Schmidt' } - ]); - }); + await userEvent.keyboard('{Escape}'); - it('Escape closes the popup without inserting anything', async () => { - mockFetchWithPersons(); - const host = renderHost(); + await vi.waitFor(async () => { + await expect.element(page.getByRole('listbox')).not.toBeInTheDocument(); + }); - const ta = getTextarea(); - ta.focus(); - ta.value = '@Aug'; - ta.selectionStart = 4; - ta.selectionEnd = 4; - ta.dispatchEvent(new Event('input', { bubbles: true })); - await flushDebounce(); - - ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); - await tick(); - - await expect.element(page.getByText('Auguste Raddatz')).not.toBeInTheDocument(); - expect(host.snapshot.value).toBe('@Aug'); expect(host.snapshot.mentionedPersons).toEqual([]); }); }); @@ -370,13 +287,11 @@ describe('PersonMentionEditor — touch target', () => { mockFetchWithPersons(); renderHost(); - const ta = getTextarea(); - ta.focus(); - ta.value = '@Aug'; - ta.selectionStart = 4; - ta.selectionEnd = 4; - ta.dispatchEvent(new Event('input', { bubbles: true })); - await flushDebounce(); + await userEvent.type(page.getByRole('textbox'), '@Aug'); + + await vi.waitFor(async () => { + await expect.element(page.getByRole('option').first()).toBeVisible(); + }); const option = document.querySelector('[role="option"]') as HTMLElement; expect(option).not.toBeNull(); -- 2.49.1 From 7a25feb04ea56264eb81f4e297e744a61f7b96b6 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 29 Apr 2026 15:53:54 +0200 Subject: [PATCH 08/17] refactor(TranscriptionBlock): migrate quote selection to Tiptap selectionUpdate (AC-7) Replaces captureTextarea + handleTextareaMouseUp (which read selection bounds off a real