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) } }