munin/internal/search/search_test.go

309 lines
10 KiB
Go

package search
import (
"strings"
"testing"
)
// baueIndex legt einen Index mit ein paar Dokumenten an.
func baueIndex() *Index {
idx := NewIndex()
idx.Index("deployment-prozess", "Deployment-Prozess",
"Wir deployen über GitHub Actions auf den Produktivserver.", []string{"ops", "deployment"})
idx.Index("server-zugang", "Server-Zugang",
"Der Zugang läuft über den Bastion-Host per SSH.", []string{"ops", "sicherheit"})
idx.Index("onboarding", "Onboarding neuer Kolleginnen",
"Am ersten Tag: Rechner einrichten, Zugänge beantragen, Team kennenlernen.", []string{"personal"})
return idx
}
// ergebnisIDs gibt die Kennungen der Treffer in Reihenfolge zurück.
func ergebnisIDs(treffer []Result) []string {
ids := make([]string, len(treffer))
for i, t := range treffer {
ids[i] = t.DocID
}
return ids
}
func TestSearchFindsExactTerm(t *testing.T) {
treffer := baueIndex().Search("bastion", 10)
if len(treffer) != 1 || treffer[0].DocID != "server-zugang" {
t.Errorf("Treffer = %v, erwartet nur server-zugang", ergebnisIDs(treffer))
}
}
// TestSearchRanksTitleHigher prüft die Gewichtung: Ein Wort im Titel wiegt
// schwerer als dasselbe Wort im Fließtext.
func TestSearchRanksTitleHigher(t *testing.T) {
idx := NewIndex()
idx.Index("im-titel", "Deployment", "Ein Text über etwas ganz anderes.", nil)
idx.Index("im-text", "Irgendwas", "Hier kommt das Wort Deployment einmal vor.", nil)
treffer := idx.Search("deployment", 10)
if len(treffer) != 2 {
t.Fatalf("%d Treffer, erwartet 2", len(treffer))
}
if treffer[0].DocID != "im-titel" {
t.Errorf("bester Treffer = %q, erwartet den Titel-Treffer", treffer[0].DocID)
}
}
// TestSearchFindsByPrefix prüft die Rückfallebene.
//
// Ohne sie liefert "deploy" kein Ergebnis, obwohl "Deployment-Prozess" existiert —
// bei einer KI-Anfrage ein stiller Fehltreffer: Das Modell erfährt schlicht
// nicht, dass es den Eintrag gäbe.
func TestSearchFindsByPrefix(t *testing.T) {
treffer := baueIndex().Search("deploy", 10)
if len(treffer) == 0 {
t.Fatal("die Präfix-Suche fand nichts")
}
if treffer[0].DocID != "deployment-prozess" {
t.Errorf("bester Treffer = %q, erwartet deployment-prozess", treffer[0].DocID)
}
}
// TestSearchPrefixNeedsThreeChars prüft, dass sehr kurze Bruchstücke nicht
// greifen — "de" würde sonst auf halbe Wörterbücher passen.
func TestSearchPrefixNeedsThreeChars(t *testing.T) {
if treffer := baueIndex().Search("de", 10); len(treffer) > 0 {
t.Errorf("zweibuchstabiges Präfix lieferte %d Treffer", len(treffer))
}
}
// TestSearchExactBeatsPrefix prüft, dass ein exakter Treffer vor einem Treffer
// über den Wortanfang steht.
func TestSearchExactBeatsPrefix(t *testing.T) {
idx := NewIndex()
idx.Index("exakt", "Test", "Das Wort ssh kommt hier vor.", nil)
idx.Index("praefix", "Test", "Hier steht sshd, also nur ein Wortanfang.", nil)
treffer := idx.Search("ssh", 10)
if len(treffer) == 0 {
t.Fatal("keine Treffer")
}
if treffer[0].DocID != "exakt" {
t.Errorf("bester Treffer = %q, erwartet den exakten", treffer[0].DocID)
}
}
func TestSearchTagsAreIndexed(t *testing.T) {
treffer := baueIndex().Search("sicherheit", 10)
if len(treffer) != 1 || treffer[0].DocID != "server-zugang" {
t.Errorf("Treffer = %v — Schlagworte fließen nicht in den Index ein", ergebnisIDs(treffer))
}
}
func TestSearchIsCaseInsensitive(t *testing.T) {
idx := baueIndex()
klein := idx.Search("bastion", 10)
gross := idx.Search("BASTION", 10)
if len(klein) != len(gross) || len(klein) == 0 {
t.Errorf("Groß-/Kleinschreibung macht einen Unterschied: %d vs. %d", len(klein), len(gross))
}
}
func TestSearchRespectsLimit(t *testing.T) {
idx := NewIndex()
for i := 0; i < 20; i++ {
idx.Index(strings.Repeat("x", i+1), "Titel", "gemeinsames Stichwort", nil)
}
if treffer := idx.Search("gemeinsames", 5); len(treffer) != 5 {
t.Errorf("%d Treffer, erwartet 5", len(treffer))
}
}
func TestSearchEmptyQuery(t *testing.T) {
if treffer := baueIndex().Search("", 10); len(treffer) != 0 {
t.Errorf("eine leere Anfrage lieferte %d Treffer", len(treffer))
}
}
// TestIndexUpdateReplaces prüft, dass ein erneutes Indizieren den alten Stand
// ersetzt und keine Reste zurücklässt.
func TestIndexUpdateReplaces(t *testing.T) {
idx := NewIndex()
idx.Index("eintrag", "Alt", "Das alte Stichwort lautet Apfelbaum.", nil)
idx.Index("eintrag", "Neu", "Das neue Stichwort lautet Birnbaum.", nil)
if treffer := idx.Search("apfelbaum", 10); len(treffer) != 0 {
t.Error("der alte Inhalt ist noch im Index")
}
if treffer := idx.Search("birnbaum", 10); len(treffer) != 1 {
t.Error("der neue Inhalt fehlt im Index")
}
}
func TestIndexRemove(t *testing.T) {
idx := baueIndex()
idx.Remove("server-zugang")
if treffer := idx.Search("bastion", 10); len(treffer) != 0 {
t.Error("der entfernte Eintrag ist noch auffindbar")
}
// Die übrigen Einträge müssen unberührt bleiben.
if treffer := idx.Search("onboarding", 10); len(treffer) == 0 {
t.Error("beim Entfernen wurde zu viel gelöscht")
}
}
func TestRemoveUnknownIsHarmless(t *testing.T) {
idx := baueIndex()
idx.Remove("gibtesnicht")
if treffer := idx.Search("bastion", 10); len(treffer) != 1 {
t.Error("das Entfernen eines unbekannten Eintrags hat den Index beschädigt")
}
}
// TestTokenizeIgnoresSingleChars prüft, dass Einzelzeichen nicht indiziert
// werden — sie tragen keine Bedeutung und blähen den Index auf.
func TestTokenizeIgnoresSingleChars(t *testing.T) {
got := tokenize("a bc def g")
for _, token := range got {
if len(token) < 2 {
t.Errorf("Einzelzeichen %q wurde indiziert", token)
}
}
if len(got) != 2 {
t.Errorf("tokenize = %v, erwartet [bc def]", got)
}
}
func TestTokenizeSplitsOnPunctuation(t *testing.T) {
got := tokenize("GitHub-Actions, SSH/SCP; done.")
erwartet := map[string]bool{"github": true, "actions": true, "ssh": true, "scp": true, "done": true}
for _, token := range got {
if !erwartet[token] {
t.Errorf("unerwartetes Token %q in %v", token, got)
}
}
if len(got) != len(erwartet) {
t.Errorf("tokenize = %v, erwartet %d Token", got, len(erwartet))
}
}
// ── Umlaute ──────────────────────────────────────────────────────────────────
// TestUmlautfaltung prüft, dass Umlaute und ß beim Suchen keine Rolle spielen.
//
// Ohne das findet „prufung" den Eintrag „Prüfung" nicht — für den Index sind es
// zwei verschiedene Wörter. Genau so tippt aber, wer auf einer fremden Tastatur
// sitzt.
func TestUmlautfaltung(t *testing.T) {
idx := NewIndex()
idx.Index("bericht", "Prüfung der Größe",
"Bei der Prüfung ergab sich eine Störung. Die Maßnahme läuft.", []string{"prüfung"})
// Jeweils: mit Umlaut geschrieben und ohne — beide müssen treffen.
paare := [][2]string{
{"prüfung", "prufung"},
{"größe", "grosse"},
{"störung", "storung"},
{"maßnahme", "massnahme"},
{"läuft", "lauft"},
}
for _, paar := range paare {
for _, anfrage := range paar {
if treffer := idx.Search(anfrage, 10); len(treffer) == 0 {
t.Errorf("Suche nach %q fand nichts", anfrage)
}
}
}
}
// TestUmlautfaltungImSchlagwort prüft, dass auch Schlagworte gefaltet werden.
func TestUmlautfaltungImSchlagwort(t *testing.T) {
idx := NewIndex()
idx.Index("eintrag", "Titel", "Inhalt", []string{"prüfung"})
if treffer := idx.Search("prufung", 10); len(treffer) == 0 {
t.Error("Schlagwort mit Umlaut ist ohne Umlaut nicht auffindbar")
}
}
// TestFaltungVerschontNormaleWoerter stellt sicher, dass die Faltung nicht mehr
// anfasst als nötig — „neue" darf nicht zu „nu" werden.
func TestFaltungVerschontNormaleWoerter(t *testing.T) {
cases := map[string]string{
"neue": "neue",
"steuer": "steuer",
"deployment": "deployment",
"grün": "grun",
"straße": "strasse",
"tür": "tur",
}
for eingabe, erwartet := range cases {
got := tokenize(eingabe)
if len(got) != 1 || got[0] != erwartet {
t.Errorf("tokenize(%q) = %v, erwartet [%s]", eingabe, got, erwartet)
}
}
}
// TestPraefixMitUmlaut prüft das Zusammenspiel von Faltung und Präfix-Suche.
func TestPraefixMitUmlaut(t *testing.T) {
idx := NewIndex()
idx.Index("eintrag", "Überwachung", "Die Überwachung meldet Störungen.", nil)
// "uberwach" ist Präfix von "uberwachung" — beides gefaltet.
if treffer := idx.Search("uberwach", 10); len(treffer) == 0 {
t.Error("Präfix ohne Umlaut fand den Eintrag nicht")
}
}
// ── Bevorzugung (Boost) ──────────────────────────────────────────────────────
// TestBoostBevorzugtBeiGleicherRelevanz prüft, dass der Faktor bei sonst
// gleichen Dokumenten den Ausschlag gibt.
func TestBoostBevorzugtBeiGleicherRelevanz(t *testing.T) {
idx := NewIndex()
idx.Index("normal", "Deployment", "Ablauf.", nil)
idx.IndexWithBoost("bevorzugt", "Deployment", "Ablauf.", nil, 1.2)
treffer := idx.Search("deployment", 10)
if len(treffer) != 2 || treffer[0].DocID != "bevorzugt" {
t.Errorf("Treffer = %v, erwartet bevorzugt zuerst", ergebnisIDs(treffer))
}
}
// TestBoostVerdraengtKeinenGenauenTreffer prüft, dass der kleine Faktor einen
// klar besser passenden Eintrag nicht überholt.
func TestBoostVerdraengtKeinenGenauenTreffer(t *testing.T) {
idx := NewIndex()
idx.Index("genau", "Zertifikatswechsel", "Der Zertifikatswechsel im März.", nil)
idx.IndexWithBoost("rand", "Wartung", "Allgemeines, unter anderem ein Zertifikatswechsel.", nil, 1.2)
treffer := idx.Search("zertifikatswechsel", 10)
if len(treffer) != 2 || treffer[0].DocID != "genau" {
t.Errorf("Treffer = %v, erwartet genau zuerst", ergebnisIDs(treffer))
}
}
// TestBoostOhneWertIstNeutral prüft, dass ein Faktor ≤ 0 kein Dokument
// unauffindbar macht.
func TestBoostOhneWertIstNeutral(t *testing.T) {
idx := NewIndex()
idx.IndexWithBoost("null", "Deployment", "Ablauf.", nil, 0)
treffer := idx.Search("deployment", 10)
if len(treffer) != 1 || treffer[0].Score <= 0 {
t.Errorf("Treffer = %+v — ein Faktor 0 hat das Dokument entwertet", treffer)
}
}