munin/internal/search/variants_test.go
2026-10-19 10:13:56 +02:00

304 lines
11 KiB
Go

package search
import (
"slices"
"strings"
"testing"
)
// contains prüft, ob ein Eintrag unter den Treffern ist.
func contains(hits []Result, docID string) bool {
return slices.Contains(resultIDs(hits), docID)
}
// ── Wortanfang ───────────────────────────────────────────────────────────────
// TestWordPrefixBesideExactHit prüft den Fehler, der zu diesem Umbau
// geführt hat: Früher griff die Suche nach dem Wortanfang nur, wenn es keinen
// exakten Treffer gab. Stand „deploy" in irgendeinem Eintrag, fand „deploy"
// den „Deployment-Prozess" nicht mehr — ein stiller Fehltreffer, der umso
// häufiger wird, je größer ein Projekt ist.
func TestWordPrefixBesideExactHit(t *testing.T) {
idx := NewIndex()
idx.Index("skript", "Skript", "Das Skript heißt deploy und liegt im Repository.", nil)
idx.Index("prozess", "Deployment-Prozess", "So kommt eine neue Fassung in Produktion.", nil)
hits := idx.Search("deploy", 10)
if !contains(hits, "prozess") {
t.Fatalf("Treffer = %v — der Wortanfang wird neben dem exakten Treffer nicht gefunden", resultIDs(hits))
}
if hits[0].DocID != "skript" {
t.Errorf("bester Treffer = %q, erwartet den exakten", hits[0].DocID)
}
}
// TestWordPrefixNotCountedTwice prüft, dass ein Eintrag mit vielen Wörtern
// desselben Anfangs nicht vor einem exakten Treffer landet.
func TestWordPrefixNotCountedTwice(t *testing.T) {
idx := NewIndex()
idx.Index("exakt", "Hinweis", "Hier steht config.", nil)
idx.Index("viele", "Hinweis", "configuration configure configured configs configurable", nil)
hits := idx.Search("config", 10)
if len(hits) != 2 || hits[0].DocID != "exakt" {
t.Errorf("Treffer = %v, erwartet exakt vor viele", resultIDs(hits))
}
}
// ── Zusammengesetzte Wörter ──────────────────────────────────────────────────
// TestSubwordFindsCompounds prüft zusammengeschriebene deutsche Wörter. Mit
// Bindestrich („Release-Prozess") genügte schon immer die Zerlegung in Wörter;
// zusammengeschrieben ist „prozess" aber weder ein eigenes Wort noch ein
// Wortanfang.
func TestSubwordFindsCompounds(t *testing.T) {
idx := NewIndex()
idx.Index("release", "Releaseprozess", "Ablauf für neue Fassungen.", nil)
idx.Index("zertifikat", "Wartung", "Der jährliche SSL-Zertifikatswechsel steht im Kalender.", nil)
idx.Index("datenbank", "Datenbankzugang", "Zugriff nur über das VPN.", nil)
cases := map[string]string{
"prozess": "release",
"wechsel": "zertifikat",
"zugang": "datenbank",
"datenbank": "datenbank", // Wortanfang, kein Teilwort — darf trotzdem nicht verloren gehen
}
for query, want := range cases {
if hits := idx.Search(query, 10); !contains(hits, want) {
t.Errorf("Search(%q) = %v, erwartet %s", query, resultIDs(hits), want)
}
}
}
// TestSubwordNeedsFiveChars prüft die Untergrenze: Kurze Wörter stecken
// zufällig in zu vielen anderen.
func TestSubwordNeedsFiveChars(t *testing.T) {
idx := NewIndex()
idx.Index("bericht", "Reporting", "Monatliche Auswertung.", nil)
if hits := idx.Search("port", 10); len(hits) > 0 {
t.Errorf("„port\" fand %v — vierbuchstabige Teilwörter sollten nicht greifen", resultIDs(hits))
}
}
// TestExactBeforeSubword prüft die Gewichtung: Das eigenständige Wort steht vor
// dem Wort, in dem es nur steckt.
func TestExactBeforeSubword(t *testing.T) {
idx := NewIndex()
idx.Index("kompositum", "Releaseprozess", "Ablauf.", nil)
idx.Index("eigenstaendig", "Prozess", "Ablauf.", nil)
hits := idx.Search("prozess", 10)
if len(hits) != 2 || hits[0].DocID != "eigenstaendig" {
t.Errorf("Treffer = %v, erwartet eigenstaendig vor kompositum", resultIDs(hits))
}
}
// ── Endungen ─────────────────────────────────────────────────────────────────
// TestSuffixesAreNormalized prüft Suchwörter, die länger sind als das
// Wort im Eintrag. Die Suche nach dem Wortanfang hilft hier nicht: „deployen"
// ist kein Anfang von „deployment".
func TestSuffixesAreNormalized(t *testing.T) {
idx := NewIndex()
idx.Index("deployment", "Deployment", "Ablauf.", nil)
idx.Index("zertifikat", "Zertifikat", "Erneuerung.", nil)
idx.Index("datenbank", "Datenbank", "Sicherung.", nil)
cases := map[string]string{
"deployen": "deployment",
"zertifikate": "zertifikat",
"datenbanken": "datenbank",
}
for query, want := range cases {
if hits := idx.Search(query, 10); !contains(hits, want) {
t.Errorf("Search(%q) = %v, erwartet %s", query, resultIDs(hits), want)
}
}
}
func TestTrimSuffix(t *testing.T) {
cases := map[string]string{
"deployen": "deploy",
"zertifikate": "zertifikat",
"datenbanken": "datenbank",
"prozesses": "prozess", // längere Endung zuerst
"deployments": "deployment",
"logs": "", // Rest „log" wäre zu kurz
"nginx": "", // keine Endung
"server": "", // „-er" wird bewusst nicht gekürzt
}
for input, want := range cases {
if got := trimSuffix(input); got != want {
t.Errorf("kuerzeEndung(%q) = %q, erwartet %q", input, got, want)
}
}
}
// ── Stoppwörter ──────────────────────────────────────────────────────────────
// TestStopwordsCreateNoNoise prüft eine typische Agenten-Frage.
// Ohne Stoppwörter fand „wir" über den Wortanfang den Eintrag zur „Wirkung".
func TestStopwordsCreateNoNoise(t *testing.T) {
idx := NewIndex()
idx.Index("deployment", "Deployment-Prozess", "So kommt eine Fassung in Produktion.", nil)
idx.Index("kuehlung", "Kühlung", "Die Wirkung der Lüfter im Serverraum.", nil)
hits := idx.Search("wie deployen wir", 10)
if !contains(hits, "deployment") {
t.Errorf("Treffer = %v, erwartet deployment", resultIDs(hits))
}
if contains(hits, "kuehlung") {
t.Errorf("Treffer = %v — ein Stoppwort hat einen unpassenden Eintrag gefunden", resultIDs(hits))
}
}
// TestOnlyStopwordsStaysSearchable prüft die Ausnahme: Besteht die Anfrage nur
// aus Stoppwörtern, wird trotzdem gesucht.
func TestOnlyStopwordsStaysSearchable(t *testing.T) {
idx := NewIndex()
idx.Index("anleitung", "Wie man testet", "Schritt für Schritt.", nil)
if hits := idx.Search("wie", 10); len(hits) == 0 {
t.Error("eine Anfrage nur aus Stoppwörtern fand nichts")
}
}
// TestStopwordsAreFolded stellt sicher, dass jedes Stoppwort in der Form
// in der Liste steht, die tokenize erzeugt. Stünde dort „für" statt „fur",
// würde es nie greifen.
func TestStopwordsAreFolded(t *testing.T) {
for word := range stopwords {
if got := tokenize(word); len(got) != 1 || got[0] != word {
t.Errorf("Stoppwort %q wird zu %v — es muss in gefalteter Form in der Liste stehen", word, got)
}
}
}
// ── Tippfehler ───────────────────────────────────────────────────────────────
// TestTypoOnlyInSearchFuzzy prüft, dass Tippfehler nur dort ausgeglichen
// werden, wo es gewollt ist: in der Weboberfläche, nicht für Agenten.
func TestTypoOnlyInSearchFuzzy(t *testing.T) {
idx := NewIndex()
idx.Index("deployment", "Deployment", "Ablauf.", nil)
idx.Index("server", "Server", "Wartung.", nil)
cases := map[string]string{
"deploymnet": "deployment", // zwei vertauschte Buchstaben
"sever": "server", // ein Buchstabe fehlt
}
for query, want := range cases {
if hits := idx.SearchFuzzy(query, 10); !contains(hits, want) {
t.Errorf("SearchFuzzy(%q) = %v, erwartet %s", query, resultIDs(hits), want)
}
if hits := idx.Search(query, 10); len(hits) > 0 {
t.Errorf("Search(%q) = %v — Search soll Tippfehler nicht ausgleichen", query, resultIDs(hits))
}
}
}
// TestTypoOnlyAsFallback prüft, dass die Tippfehler-Suche nicht greift,
// wenn das Suchwort schon etwas findet — sonst fände „host" auch „post".
func TestTypoOnlyAsFallback(t *testing.T) {
idx := NewIndex()
idx.Index("host", "Host", "Rechner im Netz.", nil)
idx.Index("cost", "Cost", "Kostenstelle.", nil)
hits := idx.SearchFuzzy("host", 10)
if len(hits) != 1 || hits[0].DocID != "host" {
t.Errorf("Treffer = %v, erwartet nur host", resultIDs(hits))
}
}
func TestLevenshtein(t *testing.T) {
cases := []struct {
a, b string
distance int
}{
{"server", "server", 0},
{"sever", "server", 1}, // einfügen
{"servers", "server", 1}, // löschen
{"sarver", "server", 1}, // ersetzen
{"deploymnet", "deployment", 2},
{"", "abc", 3},
{"prüfung", "prufung", 1}, // Runen, nicht Bytes
}
for _, f := range cases {
if got := levenshtein([]rune(f.a), []rune(f.b)); got != f.distance {
t.Errorf("levenshtein(%q, %q) = %d, erwartet %d", f.a, f.b, got, f.distance)
}
}
}
func TestAllowedTypos(t *testing.T) {
cases := map[int]int{3: 0, 4: 1, 7: 1, 8: 2, 15: 2}
for length, want := range cases {
if got := allowedTypos(length); got != want {
t.Errorf("erlaubteTippfehler(%d) = %d, erwartet %d", length, got, want)
}
}
}
// ── Überdeckung (Dubletten) ──────────────────────────────────────────────────
// TestCoverageWithSearchTerms: Im Titel zählen alle Varianten, ein
// Suchbegriff nur wörtlich. Dazu kommen die gefundenen Wörter zurück.
func TestCoverageWithSearchTerms(t *testing.T) {
cases := []struct {
name string
query, title, searchTerms string
share float64
shared string
}{
{"Suchbegriff wörtlich", "Release-Ablauf", "Deployment-Prozess", "release, rollout", 0.5, "release"},
{"Suchbegriff nur als Wortanfang zählt nicht", "Betrieb mit PostgreSQL", "Ausbaustand", "roadmap, postgres-migration", 0, ""},
{"im Titel zählt der Wortanfang weiter", "Betrieb mit PostgreSQL", "Postgres-Einrichtung", "", 0.5, "postgresql"},
{"Endung im Titel", "Zertifikate erneuern", "Zertifikat erneuern", "", 1, "zertifikate erneuern"},
{"Umlaute im Suchbegriff", "Übergabe", "Anderes", "uebergabe, übergabe", 1, "ubergabe"},
{"nichts", "VPN-Zugang", "Drucker", "", 0, ""},
}
for _, f := range cases {
t.Run(f.name, func(t *testing.T) {
share, shared := CoverageWithSearchTerms(f.query, f.title, f.searchTerms)
if share != f.share || strings.Join(shared, " ") != f.shared {
t.Errorf("= %v %q, erwartet %v %q", share, shared, f.share, f.shared)
}
})
}
}
func TestCoverage(t *testing.T) {
cases := []struct {
query, text string
want float64
}{
{"Deployment-Ablauf", "Deployment-Prozess", 0.5},
{"Releaseprozess", "Release-Prozess", 1},
{"Release-Prozess", "Releaseprozess", 1},
{"Deployments", "Deployment", 1},
{"Zertifikate erneuern", "Zertifikat erneuern", 1},
{"Wie wir deployen", "Deployment", 1}, // Stoppwörter zählen nicht
{"Server-Server-Setup", "Server", 0.5}, // doppelte Wörter einmal
{"VPN-Zugang", "Drucker einrichten", 0},
{"", "Deployment", 0},
{"Port", "Reporting", 0}, // zu kurz für ein Teilwort
}
for _, f := range cases {
if got := Coverage(f.query, f.text); got != f.want {
t.Errorf("Ueberdeckung(%q, %q) = %v, erwartet %v", f.query, f.text, got, f.want)
}
}
}