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 die Rückfallebene nur greift, wenn es // keinen exakten Treffer gibt. 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") } }