309 lines
9.8 KiB
Go
309 lines
9.8 KiB
Go
package search
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// buildIndex legt einen Index mit ein paar Dokumenten an.
|
|
func buildIndex() *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
|
|
}
|
|
|
|
// resultIDs gibt die Kennungen der Treffer in Reihenfolge zurück.
|
|
func resultIDs(hits []Result) []string {
|
|
ids := make([]string, len(hits))
|
|
for i, t := range hits {
|
|
ids[i] = t.DocID
|
|
}
|
|
return ids
|
|
}
|
|
|
|
func TestSearchFindsExactTerm(t *testing.T) {
|
|
hits := buildIndex().Search("bastion", 10)
|
|
|
|
if len(hits) != 1 || hits[0].DocID != "server-zugang" {
|
|
t.Errorf("Treffer = %v, erwartet nur server-zugang", resultIDs(hits))
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
|
|
hits := idx.Search("deployment", 10)
|
|
|
|
if len(hits) != 2 {
|
|
t.Fatalf("%d Treffer, erwartet 2", len(hits))
|
|
}
|
|
if hits[0].DocID != "im-titel" {
|
|
t.Errorf("bester Treffer = %q, erwartet den Titel-Treffer", hits[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) {
|
|
hits := buildIndex().Search("deploy", 10)
|
|
|
|
if len(hits) == 0 {
|
|
t.Fatal("die Präfix-Suche fand nichts")
|
|
}
|
|
if hits[0].DocID != "deployment-prozess" {
|
|
t.Errorf("bester Treffer = %q, erwartet deployment-prozess", hits[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 hits := buildIndex().Search("de", 10); len(hits) > 0 {
|
|
t.Errorf("zweibuchstabiges Präfix lieferte %d Treffer", len(hits))
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
|
|
hits := idx.Search("ssh", 10)
|
|
|
|
if len(hits) == 0 {
|
|
t.Fatal("keine Treffer")
|
|
}
|
|
if hits[0].DocID != "exakt" {
|
|
t.Errorf("bester Treffer = %q, erwartet den exakten", hits[0].DocID)
|
|
}
|
|
}
|
|
|
|
func TestSearchTagsAreIndexed(t *testing.T) {
|
|
hits := buildIndex().Search("sicherheit", 10)
|
|
|
|
if len(hits) != 1 || hits[0].DocID != "server-zugang" {
|
|
t.Errorf("Treffer = %v — Schlagworte fließen nicht in den Index ein", resultIDs(hits))
|
|
}
|
|
}
|
|
|
|
func TestSearchIsCaseInsensitive(t *testing.T) {
|
|
idx := buildIndex()
|
|
|
|
lower := idx.Search("bastion", 10)
|
|
upper := idx.Search("BASTION", 10)
|
|
|
|
if len(lower) != len(upper) || len(lower) == 0 {
|
|
t.Errorf("Groß-/Kleinschreibung macht einen Unterschied: %d vs. %d", len(lower), len(upper))
|
|
}
|
|
}
|
|
|
|
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 hits := idx.Search("gemeinsames", 5); len(hits) != 5 {
|
|
t.Errorf("%d Treffer, erwartet 5", len(hits))
|
|
}
|
|
}
|
|
|
|
func TestSearchEmptyQuery(t *testing.T) {
|
|
if hits := buildIndex().Search("", 10); len(hits) != 0 {
|
|
t.Errorf("eine leere Anfrage lieferte %d Treffer", len(hits))
|
|
}
|
|
}
|
|
|
|
// 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 hits := idx.Search("apfelbaum", 10); len(hits) != 0 {
|
|
t.Error("der alte Inhalt ist noch im Index")
|
|
}
|
|
if hits := idx.Search("birnbaum", 10); len(hits) != 1 {
|
|
t.Error("der neue Inhalt fehlt im Index")
|
|
}
|
|
}
|
|
|
|
func TestIndexRemove(t *testing.T) {
|
|
idx := buildIndex()
|
|
idx.Remove("server-zugang")
|
|
|
|
if hits := idx.Search("bastion", 10); len(hits) != 0 {
|
|
t.Error("der entfernte Eintrag ist noch auffindbar")
|
|
}
|
|
// Die übrigen Einträge müssen unberührt bleiben.
|
|
if hits := idx.Search("onboarding", 10); len(hits) == 0 {
|
|
t.Error("beim Entfernen wurde zu viel gelöscht")
|
|
}
|
|
}
|
|
|
|
func TestRemoveUnknownIsHarmless(t *testing.T) {
|
|
idx := buildIndex()
|
|
idx.Remove("gibtesnicht")
|
|
|
|
if hits := idx.Search("bastion", 10); len(hits) != 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.")
|
|
|
|
want := map[string]bool{"github": true, "actions": true, "ssh": true, "scp": true, "done": true}
|
|
for _, token := range got {
|
|
if !want[token] {
|
|
t.Errorf("unerwartetes Token %q in %v", token, got)
|
|
}
|
|
}
|
|
if len(got) != len(want) {
|
|
t.Errorf("tokenize = %v, erwartet %d Token", got, len(want))
|
|
}
|
|
}
|
|
|
|
// ── Umlaute ──────────────────────────────────────────────────────────────────
|
|
|
|
// TestUmlautFolding 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 TestUmlautFolding(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.
|
|
pairs := [][2]string{
|
|
{"prüfung", "prufung"},
|
|
{"größe", "grosse"},
|
|
{"störung", "storung"},
|
|
{"maßnahme", "massnahme"},
|
|
{"läuft", "lauft"},
|
|
}
|
|
|
|
for _, pair := range pairs {
|
|
for _, query := range pair {
|
|
if hits := idx.Search(query, 10); len(hits) == 0 {
|
|
t.Errorf("Suche nach %q fand nichts", query)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestUmlautFoldingInTag prüft, dass auch Schlagworte gefaltet werden.
|
|
func TestUmlautFoldingInTag(t *testing.T) {
|
|
idx := NewIndex()
|
|
idx.Index("eintrag", "Titel", "Inhalt", []string{"prüfung"})
|
|
|
|
if hits := idx.Search("prufung", 10); len(hits) == 0 {
|
|
t.Error("Schlagwort mit Umlaut ist ohne Umlaut nicht auffindbar")
|
|
}
|
|
}
|
|
|
|
// TestFoldingSparesNormalWords stellt sicher, dass die Faltung nicht mehr
|
|
// anfasst als nötig — „neue" darf nicht zu „nu" werden.
|
|
func TestFoldingSparesNormalWords(t *testing.T) {
|
|
cases := map[string]string{
|
|
"neue": "neue",
|
|
"steuer": "steuer",
|
|
"deployment": "deployment",
|
|
"grün": "grun",
|
|
"straße": "strasse",
|
|
"tür": "tur",
|
|
}
|
|
|
|
for input, want := range cases {
|
|
got := tokenize(input)
|
|
if len(got) != 1 || got[0] != want {
|
|
t.Errorf("tokenize(%q) = %v, erwartet [%s]", input, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPrefixWithUmlaut prüft das Zusammenspiel von Faltung und Präfix-Suche.
|
|
func TestPrefixWithUmlaut(t *testing.T) {
|
|
idx := NewIndex()
|
|
idx.Index("eintrag", "Überwachung", "Die Überwachung meldet Störungen.", nil)
|
|
|
|
// "uberwach" ist Präfix von "uberwachung" — beides gefaltet.
|
|
if hits := idx.Search("uberwach", 10); len(hits) == 0 {
|
|
t.Error("Präfix ohne Umlaut fand den Eintrag nicht")
|
|
}
|
|
}
|
|
|
|
// ── Bevorzugung (Boost) ──────────────────────────────────────────────────────
|
|
|
|
// TestBoostPrefersOnEqualRelevance prüft, dass der Faktor bei sonst
|
|
// gleichen Dokumenten den Ausschlag gibt.
|
|
func TestBoostPrefersOnEqualRelevance(t *testing.T) {
|
|
idx := NewIndex()
|
|
idx.Index("normal", "Deployment", "Ablauf.", nil)
|
|
idx.IndexWithBoost("bevorzugt", "Deployment", "Ablauf.", nil, 1.2)
|
|
|
|
hits := idx.Search("deployment", 10)
|
|
|
|
if len(hits) != 2 || hits[0].DocID != "bevorzugt" {
|
|
t.Errorf("Treffer = %v, erwartet bevorzugt zuerst", resultIDs(hits))
|
|
}
|
|
}
|
|
|
|
// TestBoostDoesNotDisplaceExactHit prüft, dass der kleine Faktor einen
|
|
// klar besser passenden Eintrag nicht überholt.
|
|
func TestBoostDoesNotDisplaceExactHit(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)
|
|
|
|
hits := idx.Search("zertifikatswechsel", 10)
|
|
|
|
if len(hits) != 2 || hits[0].DocID != "genau" {
|
|
t.Errorf("Treffer = %v, erwartet genau zuerst", resultIDs(hits))
|
|
}
|
|
}
|
|
|
|
// TestBoostWithoutValueIsNeutral prüft, dass ein Faktor ≤ 0 kein Dokument
|
|
// unauffindbar macht.
|
|
func TestBoostWithoutValueIsNeutral(t *testing.T) {
|
|
idx := NewIndex()
|
|
idx.IndexWithBoost("null", "Deployment", "Ablauf.", nil, 0)
|
|
|
|
hits := idx.Search("deployment", 10)
|
|
|
|
if len(hits) != 1 || hits[0].Score <= 0 {
|
|
t.Errorf("Treffer = %+v — ein Faktor 0 hat das Dokument entwertet", hits)
|
|
}
|
|
}
|