test(search): add 11 tag-resolution test cases to NlQueryParserServiceTest
Covers multi-tag match, no-match FTS fallback, mixed resolution, personRole bypass, cap at 10, short-keyword skip, dedup, rawQuery suppression when all keywords resolve, flag independence, colour propagation via resolveEffectiveColors, and colour=null when depth constraint prevents resolution. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@ import org.springframework.data.domain.Pageable;
|
|||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -478,4 +479,201 @@ class NlQueryParserServiceTest {
|
|||||||
assertThat(resp.interpretation().tagsApplied()).isTrue();
|
assertThat(resp.interpretation().tagsApplied()).isTrue();
|
||||||
assertThat(cap.getValue().text()).isNull();
|
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<SearchFilters> 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<SearchFilters> 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<SearchFilters> 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<Tag> 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<SearchFilters> 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<SearchFilters> 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<SearchFilters> 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<SearchFilters> 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<Tag> 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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user