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) } } }