munin/internal/search/varianten_test.go

304 lines
12 KiB
Go

package search
import (
"slices"
"strings"
"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) ──────────────────────────────────────────────────
// TestUeberdeckungMitSuchbegriffen: Im Titel zählen alle Varianten, ein
// Suchbegriff nur wörtlich. Dazu kommen die gefundenen Wörter zurück.
func TestUeberdeckungMitSuchbegriffen(t *testing.T) {
faelle := []struct {
name string
anfrage, titel, suchbegriffe string
anteil float64
gemeinsam string
}{
{"Suchbegriff wörtlich", "Release-Ablauf", "Deployment-Prozess", "release, rollout", 0.5, "release"},
{"Suchbegriff nur als Wortanfang zählt nicht", "Betrieb mit PostgreSQL", "Ausbaustand", "roadmap, postgres-migration", 0, ""},
{"im Titel zählt der Wortanfang weiter", "Betrieb mit PostgreSQL", "Postgres-Einrichtung", "", 0.5, "postgresql"},
{"Endung im Titel", "Zertifikate erneuern", "Zertifikat erneuern", "", 1, "zertifikate erneuern"},
{"Umlaute im Suchbegriff", "Übergabe", "Anderes", "uebergabe, übergabe", 1, "ubergabe"},
{"nichts", "VPN-Zugang", "Drucker", "", 0, ""},
}
for _, f := range faelle {
t.Run(f.name, func(t *testing.T) {
anteil, gemeinsam := UeberdeckungMitSuchbegriffen(f.anfrage, f.titel, f.suchbegriffe)
if anteil != f.anteil || strings.Join(gemeinsam, " ") != f.gemeinsam {
t.Errorf("= %v %q, erwartet %v %q", anteil, gemeinsam, f.anteil, f.gemeinsam)
}
})
}
}
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)
}
}
}