304 lines
11 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|