diff --git a/backend/src/test/java/org/raddatz/familienarchiv/search/NlQueryParserServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/search/NlQueryParserServiceTest.java index 29eaf0f9..d1e9c970 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/search/NlQueryParserServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/search/NlQueryParserServiceTest.java @@ -21,6 +21,7 @@ import org.springframework.data.domain.Pageable; import java.time.LocalDate; import java.util.ArrayList; +import java.util.Collection; import java.util.List; import java.util.UUID; @@ -478,4 +479,201 @@ class NlQueryParserServiceTest { assertThat(resp.interpretation().tagsApplied()).isTrue(); assertThat(cap.getValue().text()).isNull(); } + + private static final UUID T2 = UUID.fromString("00000000-0000-0000-0001-000000000002"); + + // --- 25. Keyword matches multiple tags → all in resolvedTags, OR-union --- + + @Test + void search_keywordMatchesMultipleTags_allIncluded() { + Tag hochzeit1 = tag(T1, "Hochzeit Raddatz"); + Tag hochzeit2 = tag(T2, "Hochzeit Braun"); + when(ollamaClient.parse(anyString())) + .thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit"))); + when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit1, hochzeit2)); + + NlSearchResponse resp = service.search("Briefe über Hochzeit", PAGE); + + ArgumentCaptor cap = ArgumentCaptor.forClass(SearchFilters.class); + verify(documentService).searchDocuments(cap.capture(), any(), any(), any()); + assertThat(cap.getValue().tags()).containsExactlyInAnyOrder("Hochzeit Raddatz", "Hochzeit Braun"); + assertThat(cap.getValue().tagOperator()).isEqualTo(TagOperator.OR); + assertThat(resp.interpretation().resolvedTags()).hasSize(2); + } + + // --- 26. Keyword no tag match → stays as FTS text, resolvedTags empty --- + + @Test + void search_keywordNoTagMatch_staysAsFtsText() { + when(ollamaClient.parse(anyString())) + .thenReturn(extraction(List.of(), "any", null, null, List.of("Feldpost"))); + + NlSearchResponse resp = service.search("Feldpost Briefe", PAGE); + + ArgumentCaptor cap = ArgumentCaptor.forClass(SearchFilters.class); + verify(documentService).searchDocuments(cap.capture(), any(), any(), any()); + assertThat(cap.getValue().text()).contains("Feldpost"); + assertThat(cap.getValue().tags()).isEmpty(); + assertThat(resp.interpretation().resolvedTags()).isEmpty(); + assertThat(resp.interpretation().tagsApplied()).isFalse(); + } + + // --- 27. Mixed: one keyword resolves, one doesn't → tag filter + FTS text --- + + @Test + void search_mixedKeywords_oneResolves_oneStaysAsText() { + Tag hochzeit = tag(T1, "Hochzeit"); + when(ollamaClient.parse(anyString())) + .thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit", "Feldpost"))); + when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit)); + + NlSearchResponse resp = service.search("Hochzeit und Feldpost", PAGE); + + ArgumentCaptor cap = ArgumentCaptor.forClass(SearchFilters.class); + verify(documentService).searchDocuments(cap.capture(), any(), any(), any()); + assertThat(cap.getValue().tags()).containsExactly("Hochzeit"); + assertThat(cap.getValue().tagOperator()).isEqualTo(TagOperator.OR); + assertThat(cap.getValue().text()).contains("Feldpost"); + assertThat(resp.interpretation().resolvedTags()).hasSize(1); + assertThat(resp.interpretation().tagsApplied()).isTrue(); + } + + // --- 28. personRole=any + 1 person + resolvable keyword → personId search, tagsApplied=false --- + + @Test + void search_personRoleAny_singlePerson_resolvableKeyword_tagsAppliedFalse() { + Person walter = person(P1, "Walter", "Raddatz"); + Tag hochzeit = tag(T1, "Hochzeit"); + when(ollamaClient.parse(anyString())) + .thenReturn(extraction(List.of("Walter"), "any", null, null, List.of("Hochzeit"))); + when(personService.findByDisplayNameContaining("Walter")).thenReturn(List.of(walter)); + when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit)); + + NlSearchResponse resp = service.search("Briefe von Walter über Hochzeit", PAGE); + + verify(documentService).searchDocumentsByPersonId(eq(P1), isNull(), isNull(), eq(PAGE)); + verify(documentService, never()).searchDocuments(any(), any(), any(), any()); + assertThat(resp.interpretation().tagsApplied()).isFalse(); + assertThat(resp.interpretation().resolvedTags()).hasSize(1); + assertThat(resp.interpretation().resolvedTags().get(0).name()).isEqualTo("Hochzeit"); + } + + // --- 29. Cap: keyword matches > 10 tags → capped at 10 --- + + @Test + void search_keywordMatchesMoreThanMaxTags_cappedAtTen() { + List eleven = new ArrayList<>(); + for (int i = 0; i < 11; i++) { + eleven.add(tag(UUID.randomUUID(), "Thema " + i)); + } + when(ollamaClient.parse(anyString())) + .thenReturn(extraction(List.of(), "any", null, null, List.of("Thema"))); + when(tagService.findByNameContaining("Thema")).thenReturn(eleven); + + NlSearchResponse resp = service.search("Dokumente zum Thema", PAGE); + + assertThat(resp.interpretation().resolvedTags()).hasSize(10); + ArgumentCaptor cap = ArgumentCaptor.forClass(SearchFilters.class); + verify(documentService).searchDocuments(cap.capture(), any(), any(), any()); + assertThat(cap.getValue().tags()).hasSize(10); + } + + // --- 30. Short keyword (< 3 chars) → skipped, not passed to TagService --- + + @Test + void search_shortKeyword_skippedByTagResolution() { + when(ollamaClient.parse(anyString())) + .thenReturn(extraction(List.of(), "any", null, null, List.of("ab", "Krieg"))); + + service.search("ab Krieg", PAGE); + + verify(tagService, never()).findByNameContaining("ab"); + verify(tagService).findByNameContaining("Krieg"); + ArgumentCaptor cap = ArgumentCaptor.forClass(SearchFilters.class); + verify(documentService).searchDocuments(cap.capture(), any(), any(), any()); + assertThat(cap.getValue().text()).contains("ab"); + } + + // --- 31. Dedup: same tag matched by two keywords → appears once --- + + @Test + void search_sameTagMatchedByTwoKeywords_deduplicatedToOne() { + Tag hochzeit = tag(T1, "Hochzeit"); + when(ollamaClient.parse(anyString())) + .thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit", "hoch"))); + when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit)); + when(tagService.findByNameContaining("hoch")).thenReturn(List.of(hochzeit)); + + NlSearchResponse resp = service.search("Hochzeit hoch", PAGE); + + assertThat(resp.interpretation().resolvedTags()).hasSize(1); + ArgumentCaptor cap = ArgumentCaptor.forClass(SearchFilters.class); + verify(documentService).searchDocuments(cap.capture(), any(), any(), any()); + assertThat(cap.getValue().tags()).hasSize(1); + } + + // --- 32. All keywords resolve → rawQuery fallback suppressed, text=null --- + + @Test + void search_allKeywordsResolveToTags_rawQueryFallbackSuppressed() { + Tag hochzeit = tag(T1, "Hochzeit"); + when(ollamaClient.parse(anyString())) + .thenReturn(new OllamaExtraction(List.of(), "any", null, null, List.of("Hochzeit"), "raw query text")); + when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit)); + + NlSearchResponse resp = service.search("Hochzeit", PAGE); + + ArgumentCaptor cap = ArgumentCaptor.forClass(SearchFilters.class); + verify(documentService).searchDocuments(cap.capture(), any(), any(), any()); + assertThat(cap.getValue().text()).isNull(); + assertThat(cap.getValue().tags()).containsExactly("Hochzeit"); + } + + // --- 33. Flag independence: keywordsApplied=false AND tagsApplied=true --- + + @Test + void search_allKeywordsResolveToTags_keywordsAppliedFalse_tagsAppliedTrue() { + Tag hochzeit = tag(T1, "Hochzeit"); + when(ollamaClient.parse(anyString())) + .thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit"))); + when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit)); + + NlSearchResponse resp = service.search("Hochzeit Briefe", PAGE); + + assertThat(resp.interpretation().keywordsApplied()).isFalse(); + assertThat(resp.interpretation().tagsApplied()).isTrue(); + } + + // --- 34. Color carried through from resolveEffectiveColors --- + + @Test + void search_tagHint_carriesColorSetByResolveEffectiveColors() { + Tag hochzeit = tag(T1, "Hochzeit"); + doAnswer(invocation -> { + Collection tags = invocation.getArgument(0); + tags.forEach(t -> t.setColor("sage")); + return null; + }).when(tagService).resolveEffectiveColors(any()); + when(ollamaClient.parse(anyString())) + .thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit"))); + when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit)); + + NlSearchResponse resp = service.search("Hochzeit", PAGE); + + assertThat(resp.interpretation().resolvedTags().get(0).color()).isEqualTo("sage"); + } + + // --- 35. Color stays null when resolveEffectiveColors leaves it unset --- + + @Test + void search_tagHint_colorIsNull_whenNoColorResolved() { + Tag hochzeit = tag(T1, "Hochzeit"); + when(ollamaClient.parse(anyString())) + .thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit"))); + when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit)); + + NlSearchResponse resp = service.search("Hochzeit", PAGE); + + assertThat(resp.interpretation().resolvedTags().get(0).color()).isNull(); + } }