Collapse nested kbsearch/chats go.mod into the root module. Ranking stays cgo-free under internal/brain/rank so CI does not need ladybug. bin/kb/search is a deprecation wrapper that still sets CGO and builds the binary.
150 lines
4.1 KiB
Go
150 lines
4.1 KiB
Go
// Unit tests for ranking/filtering and CLI parsing (no db, no model, offline).
|
|
package rank
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func h(id, root, source string) Hit {
|
|
return Hit{ID: id, Text: id, Root: root, Source: source}
|
|
}
|
|
|
|
func ids(hits []Hit) []string {
|
|
out := make([]string, len(hits))
|
|
for i, hit := range hits {
|
|
out[i] = hit.ID
|
|
}
|
|
return out
|
|
}
|
|
|
|
func eq(t *testing.T, got []Hit, want ...string) {
|
|
t.Helper()
|
|
g := ids(got)
|
|
if len(g) != len(want) {
|
|
t.Fatalf("got %v, want %v", g, want)
|
|
}
|
|
for i := range want {
|
|
if g[i] != want[i] {
|
|
t.Fatalf("got %v, want %v", g, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A facts leaf that ranks below the limit in the unfiltered list must still
|
|
// be returned for --root facts. Filtering after truncation loses it.
|
|
func TestRankAndFilterFiltersBeforeLimit(t *testing.T) {
|
|
fts := []Hit{
|
|
h("i1", "info", "docs/a.md"),
|
|
h("i2", "info", "docs/b.md"),
|
|
h("i3", "info", "docs/c.md"),
|
|
h("f1", "facts", "docker ps x compose"),
|
|
}
|
|
eq(t, RankAndFilter(fts, nil, "facts", "", 2), "f1")
|
|
}
|
|
|
|
func TestRankAndFilterRepoFiltersBeforeLimit(t *testing.T) {
|
|
fts := []Hit{
|
|
h("a", "info", "eSlider/2dph:README.md"),
|
|
h("b", "info", "eSlider/2dph:PLAN.md"),
|
|
h("c", "info", "eSlider/ops:compose.yaml"),
|
|
}
|
|
eq(t, RankAndFilter(fts, nil, "", "ops", 2), "c")
|
|
}
|
|
|
|
func TestRankAndFilterTruncatesToLimit(t *testing.T) {
|
|
fts := []Hit{h("a", "info", "x"), h("b", "info", "x"), h("c", "info", "x")}
|
|
eq(t, RankAndFilter(fts, nil, "", "", 2), "a", "b")
|
|
}
|
|
|
|
func TestRankAndFilterLimitZeroKeepsAll(t *testing.T) {
|
|
fts := []Hit{h("a", "info", "x"), h("b", "info", "x")}
|
|
eq(t, RankAndFilter(fts, nil, "", "", 0), "a", "b")
|
|
}
|
|
|
|
func TestHybridFusesBothRetrievers(t *testing.T) {
|
|
fts := []Hit{h("only-fts", "info", "x"), h("both", "info", "x")}
|
|
vec := []Hit{h("only-vec", "info", "x"), h("both", "info", "x")}
|
|
eq(t, Hybrid(fts, vec, 0), "both", "only-fts", "only-vec")
|
|
}
|
|
|
|
func TestHybridTiesAreDeterministic(t *testing.T) {
|
|
fts := []Hit{h("b", "info", "x"), h("a", "info", "x")}
|
|
first := ids(Hybrid(fts, nil, 0))
|
|
for i := 0; i < 50; i++ {
|
|
got := ids(Hybrid(fts, nil, 0))
|
|
for j := range first {
|
|
if got[j] != first[j] {
|
|
t.Fatalf("unstable order: %v then %v", first, got)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHybridKeepsVectorScoreForSharedHit(t *testing.T) {
|
|
fts := []Hit{{ID: "x", Root: "info", Score: 0}}
|
|
vec := []Hit{{ID: "x", Root: "info", Score: 0.87}}
|
|
got := Hybrid(fts, vec, 0)
|
|
if len(got) != 1 || got[0].Score != 0.87 {
|
|
t.Fatalf("got %+v, want score 0.87", got)
|
|
}
|
|
}
|
|
|
|
// The old parser dropped unknown flags and appended their arguments to the
|
|
// query, so `search "q" --hop 1` searched for "q 1". --hop is not implemented
|
|
// here (needs File edges); it must still fail closed instead of changing q.
|
|
func TestParseHopIsNotSwallowedIntoTheQuery(t *testing.T) {
|
|
_, err := ParseArgs([]string{"what runs on arc-2", "--hop", "1"})
|
|
if err == nil {
|
|
t.Fatal("expected --hop to error (not implemented), not be swallowed")
|
|
}
|
|
if !strings.Contains(err.Error(), "--hop") {
|
|
t.Fatalf("error should name --hop, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestParseRejectsUnknownFlags(t *testing.T) {
|
|
if _, err := ParseArgs([]string{"query", "--nope"}); err == nil {
|
|
t.Fatal("unknown flag accepted")
|
|
}
|
|
}
|
|
|
|
func TestParseRejectsBadValues(t *testing.T) {
|
|
for _, args := range [][]string{
|
|
{"q", "-n", "zero"},
|
|
{"q", "-n", "0"},
|
|
{"q", "--root", "nonsense"},
|
|
{"q", "--hop"},
|
|
{"--json"},
|
|
} {
|
|
if _, err := ParseArgs(args); err == nil {
|
|
t.Errorf("accepted %v", args)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseDefaults(t *testing.T) {
|
|
opt, err := ParseArgs([]string{"two", "words", "--json"})
|
|
if err != nil || opt.Query != "two words" || opt.Limit != 20 || !opt.JSONOut {
|
|
t.Fatalf("got %+v err=%v", opt, err)
|
|
}
|
|
}
|
|
|
|
func TestListModelNeedsNoQuery(t *testing.T) {
|
|
if _, err := ParseArgs([]string{"--list-model"}); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestUsageNamesBrainSearch(t *testing.T) {
|
|
if !strings.Contains(Usage, "bin/brain/search.go") {
|
|
t.Fatalf("usage must name bin/brain/search.go, got:\n%s", Usage)
|
|
}
|
|
}
|
|
|
|
func TestFTSQueryOrdersByScoreDescending(t *testing.T) {
|
|
if !strings.Contains(FTSStmt, "ORDER BY score DESC") {
|
|
t.Fatalf("FTS query must order by score DESC, got:\n%s", FTSStmt)
|
|
}
|
|
}
|