package search import ( "slices" "testing" ) // enthaelt prüft, ob ein Eintrag unter den Treffern ist. func enthaelt(treffer []Result, docID string) bool { return slices.Contains(ergebnisIDs(treffer), docID) } // ── Wortanfang ─────────────────────────────────────────────────────────────── // TestWortanfangNebenExaktemTreffer 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 TestWortanfangNebenExaktemTreffer(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) treffer := idx.Search("deploy", 10) if !enthaelt(treffer, "prozess") { t.Fatalf("Treffer = %v — der Wortanfang wird neben dem exakten Treffer nicht gefunden", ergebnisIDs(treffer)) } if treffer[0].DocID != "skript" { t.Errorf("bester Treffer = %q, erwartet den exakten", treffer[0].DocID) } } // TestWortanfangZaehltNichtDoppelt prüft, dass ein Eintrag mit vielen Wörtern // desselben Anfangs nicht vor einem exakten Treffer landet. func TestWortanfangZaehltNichtDoppelt(t *testing.T) { idx := NewIndex() idx.Index("exakt", "Hinweis", "Hier steht config.", nil) idx.Index("viele", "Hinweis", "configuration configure configured configs configurable", nil) treffer := idx.Search("config", 10) if len(treffer) != 2 || treffer[0].DocID != "exakt" { t.Errorf("Treffer = %v, erwartet exakt vor viele", ergebnisIDs(treffer)) } } // ── Zusammengesetzte Wörter ────────────────────────────────────────────────── // TestTeilwortFindetKomposita 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 TestTeilwortFindetKomposita(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) faelle := map[string]string{ "prozess": "release", "wechsel": "zertifikat", "zugang": "datenbank", "datenbank": "datenbank", // Wortanfang, kein Teilwort — darf trotzdem nicht verloren gehen } for anfrage, erwartet := range faelle { if treffer := idx.Search(anfrage, 10); !enthaelt(treffer, erwartet) { t.Errorf("Search(%q) = %v, erwartet %s", anfrage, ergebnisIDs(treffer), erwartet) } } } // TestTeilwortBrauchtFuenfZeichen prüft die Untergrenze: Kurze Wörter stecken // zufällig in zu vielen anderen. func TestTeilwortBrauchtFuenfZeichen(t *testing.T) { idx := NewIndex() idx.Index("bericht", "Reporting", "Monatliche Auswertung.", nil) if treffer := idx.Search("port", 10); len(treffer) > 0 { t.Errorf("„port\" fand %v — vierbuchstabige Teilwörter sollten nicht greifen", ergebnisIDs(treffer)) } } // TestExaktVorTeilwort prüft die Gewichtung: Das eigenständige Wort steht vor // dem Wort, in dem es nur steckt. func TestExaktVorTeilwort(t *testing.T) { idx := NewIndex() idx.Index("kompositum", "Releaseprozess", "Ablauf.", nil) idx.Index("eigenstaendig", "Prozess", "Ablauf.", nil) treffer := idx.Search("prozess", 10) if len(treffer) != 2 || treffer[0].DocID != "eigenstaendig" { t.Errorf("Treffer = %v, erwartet eigenstaendig vor kompositum", ergebnisIDs(treffer)) } } // ── Endungen ───────────────────────────────────────────────────────────────── // TestEndungenWerdenAusgeglichen 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 TestEndungenWerdenAusgeglichen(t *testing.T) { idx := NewIndex() idx.Index("deployment", "Deployment", "Ablauf.", nil) idx.Index("zertifikat", "Zertifikat", "Erneuerung.", nil) idx.Index("datenbank", "Datenbank", "Sicherung.", nil) faelle := map[string]string{ "deployen": "deployment", "zertifikate": "zertifikat", "datenbanken": "datenbank", } for anfrage, erwartet := range faelle { if treffer := idx.Search(anfrage, 10); !enthaelt(treffer, erwartet) { t.Errorf("Search(%q) = %v, erwartet %s", anfrage, ergebnisIDs(treffer), erwartet) } } } func TestKuerzeEndung(t *testing.T) { faelle := 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 eingabe, erwartet := range faelle { if got := kuerzeEndung(eingabe); got != erwartet { t.Errorf("kuerzeEndung(%q) = %q, erwartet %q", eingabe, got, erwartet) } } } // ── Stoppwörter ────────────────────────────────────────────────────────────── // TestStoppwoerterErzeugenKeinRauschen prüft eine typische Agenten-Frage. // Ohne Stoppwörter fand „wir" über den Wortanfang den Eintrag zur „Wirkung". func TestStoppwoerterErzeugenKeinRauschen(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) treffer := idx.Search("wie deployen wir", 10) if !enthaelt(treffer, "deployment") { t.Errorf("Treffer = %v, erwartet deployment", ergebnisIDs(treffer)) } if enthaelt(treffer, "kuehlung") { t.Errorf("Treffer = %v — ein Stoppwort hat einen unpassenden Eintrag gefunden", ergebnisIDs(treffer)) } } // TestNurStoppwoerterBleibtSuchbar prüft die Ausnahme: Besteht die Anfrage nur // aus Stoppwörtern, wird trotzdem gesucht. func TestNurStoppwoerterBleibtSuchbar(t *testing.T) { idx := NewIndex() idx.Index("anleitung", "Wie man testet", "Schritt für Schritt.", nil) if treffer := idx.Search("wie", 10); len(treffer) == 0 { t.Error("eine Anfrage nur aus Stoppwörtern fand nichts") } } // TestStoppwoerterSindGefaltet 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 TestStoppwoerterSindGefaltet(t *testing.T) { for wort := range stoppwoerter { if got := tokenize(wort); len(got) != 1 || got[0] != wort { t.Errorf("Stoppwort %q wird zu %v — es muss in gefalteter Form in der Liste stehen", wort, got) } } } // ── Tippfehler ─────────────────────────────────────────────────────────────── // TestTippfehlerNurInSearchFuzzy prüft, dass Tippfehler nur dort ausgeglichen // werden, wo es gewollt ist: in der Weboberfläche, nicht für Agenten. func TestTippfehlerNurInSearchFuzzy(t *testing.T) { idx := NewIndex() idx.Index("deployment", "Deployment", "Ablauf.", nil) idx.Index("server", "Server", "Wartung.", nil) faelle := map[string]string{ "deploymnet": "deployment", // zwei vertauschte Buchstaben "sever": "server", // ein Buchstabe fehlt } for anfrage, erwartet := range faelle { if treffer := idx.SearchFuzzy(anfrage, 10); !enthaelt(treffer, erwartet) { t.Errorf("SearchFuzzy(%q) = %v, erwartet %s", anfrage, ergebnisIDs(treffer), erwartet) } if treffer := idx.Search(anfrage, 10); len(treffer) > 0 { t.Errorf("Search(%q) = %v — Search soll Tippfehler nicht ausgleichen", anfrage, ergebnisIDs(treffer)) } } } // TestTippfehlerNurAlsRueckfall prüft, dass die Tippfehler-Suche nicht greift, // wenn das Suchwort schon etwas findet — sonst fände „host" auch „post". func TestTippfehlerNurAlsRueckfall(t *testing.T) { idx := NewIndex() idx.Index("host", "Host", "Rechner im Netz.", nil) idx.Index("cost", "Cost", "Kostenstelle.", nil) treffer := idx.SearchFuzzy("host", 10) if len(treffer) != 1 || treffer[0].DocID != "host" { t.Errorf("Treffer = %v, erwartet nur host", ergebnisIDs(treffer)) } } func TestLevenshtein(t *testing.T) { faelle := []struct { a, b string abstand 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 faelle { if got := levenshtein([]rune(f.a), []rune(f.b)); got != f.abstand { t.Errorf("levenshtein(%q, %q) = %d, erwartet %d", f.a, f.b, got, f.abstand) } } } func TestErlaubteTippfehler(t *testing.T) { faelle := map[int]int{3: 0, 4: 1, 7: 1, 8: 2, 15: 2} for laenge, erwartet := range faelle { if got := erlaubteTippfehler(laenge); got != erwartet { t.Errorf("erlaubteTippfehler(%d) = %d, erwartet %d", laenge, got, erwartet) } } } // ── Überdeckung (Dubletten) ────────────────────────────────────────────────── func TestUeberdeckung(t *testing.T) { faelle := []struct { anfrage, text string erwartet 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 faelle { if got := Ueberdeckung(f.anfrage, f.text); got != f.erwartet { t.Errorf("Ueberdeckung(%q, %q) = %v, erwartet %v", f.anfrage, f.text, got, f.erwartet) } } }