Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

214 lines
6.6 KiB
Go

package rerank
import (
"math"
"testing"
"github.com/zzet/gortex/internal/graph"
)
// --- Test helpers -----------------------------------------------------
// newTestGraph returns an empty graph; tests populate it via AddNode /
// AddEdge directly. Avoiding the indexer means tests run in
// microseconds and don't need a tree-sitter binary.
func newTestGraph() *graph.Graph { return graph.New() }
func mustNode(g *graph.Graph, id, name string, kind graph.NodeKind) *graph.Node {
n := &graph.Node{ID: id, Name: name, Kind: kind, FilePath: "file.go"}
g.AddNode(n)
return n
}
func candidateFor(n *graph.Node, textRank, vecRank int) *Candidate {
return &Candidate{Node: n, TextRank: textRank, VectorRank: vecRank}
}
// --- Pipeline tests ---------------------------------------------------
func TestPipelineEmptyInputReturnsEmpty(t *testing.T) {
p := NewDefault()
got := p.Rerank("q", nil, nil)
if len(got) != 0 {
t.Fatalf("expected 0, got %d", len(got))
}
}
func TestPipelineBM25OrderPreservedWhenOnlyTextSignalActive(t *testing.T) {
g := newTestGraph()
a := mustNode(g, "f.go::A", "A", graph.KindFunction)
b := mustNode(g, "f.go::B", "B", graph.KindFunction)
c := mustNode(g, "f.go::C", "C", graph.KindFunction)
weights := map[string]float64{SignalBM25: 1.0}
p := New(DefaultSignals(), weights)
cands := []*Candidate{candidateFor(a, 0, -1), candidateFor(b, 1, -1), candidateFor(c, 2, -1)}
out := p.Rerank("anything", cands, &Context{Graph: g})
if out[0].Node.ID != a.ID || out[1].Node.ID != b.ID || out[2].Node.ID != c.ID {
t.Fatalf("expected A,B,C order, got %v", []string{out[0].Node.ID, out[1].Node.ID, out[2].Node.ID})
}
if out[0].Score <= out[1].Score || out[1].Score <= out[2].Score {
t.Fatalf("expected monotonically decreasing scores, got %v %v %v",
out[0].Score, out[1].Score, out[2].Score)
}
}
func TestPipelineSemanticPromotesTopVectorHit(t *testing.T) {
g := newTestGraph()
a := mustNode(g, "f.go::A", "A", graph.KindFunction)
b := mustNode(g, "f.go::B", "B", graph.KindFunction)
// A is BM25 #2, B is BM25 #1 but semantic #1 with high weight.
weights := map[string]float64{SignalBM25: 1.0, SignalSemantic: 5.0}
p := New(DefaultSignals(), weights)
cands := []*Candidate{candidateFor(a, 1, 5), candidateFor(b, 0, 0)}
out := p.Rerank("q", cands, &Context{Graph: g})
if out[0].Node.ID != b.ID {
t.Fatalf("expected B first (semantic-driven), got %s", out[0].Node.ID)
}
}
func TestPipelinePerSignalContributionsRecorded(t *testing.T) {
g := newTestGraph()
a := mustNode(g, "f.go::A", "A", graph.KindFunction)
p := NewDefault()
cands := []*Candidate{candidateFor(a, 0, -1)}
p.Rerank("q", cands, &Context{Graph: g})
if cands[0].Signals == nil {
t.Fatalf("expected Signals to be populated")
}
if _, ok := cands[0].Signals[SignalBM25]; !ok {
t.Fatalf("expected bm25 signal in contributions")
}
}
func TestPipelineZeroWeightSkipsSignal(t *testing.T) {
g := newTestGraph()
a := mustNode(g, "f.go::A", "A", graph.KindFunction)
weights := DefaultWeights()
weights[SignalBM25] = 0
p := New(DefaultSignals(), weights)
cands := []*Candidate{candidateFor(a, 0, -1)}
p.Rerank("q", cands, &Context{Graph: g})
if _, ok := cands[0].Signals[SignalBM25]; ok {
t.Fatalf("expected bm25 to be skipped under weight=0")
}
}
func TestPipelineSetWeight(t *testing.T) {
p := NewDefault()
p.SetWeight(SignalBM25, 7.5)
if got := p.Weights()[SignalBM25]; got != 7.5 {
t.Fatalf("expected weight 7.5, got %v", got)
}
}
func TestPipelineWeightsReturnsCopy(t *testing.T) {
p := NewDefault()
w := p.Weights()
w[SignalBM25] = 999
if got := p.Weights()[SignalBM25]; got == 999 {
t.Fatalf("expected Weights() to return a defensive copy")
}
}
func TestPipelineStableSortDeterministic(t *testing.T) {
g := newTestGraph()
a := mustNode(g, "f.go::A", "A", graph.KindFunction)
b := mustNode(g, "f.go::B", "B", graph.KindFunction)
c := mustNode(g, "f.go::C", "C", graph.KindFunction)
// Three candidates with identical scores — secondary key is
// TextRank, then node ID. Repeated runs must produce same order.
weights := map[string]float64{} // no signals active → all scores 0
p := New(DefaultSignals(), weights)
for range 5 {
cands := []*Candidate{candidateFor(c, 2, -1), candidateFor(a, 0, -1), candidateFor(b, 1, -1)}
out := p.Rerank("q", cands, &Context{Graph: g})
// All scores are 0 → secondary key (TextRank ascending) → A,B,C.
if out[0].Node.ID != a.ID || out[1].Node.ID != b.ID || out[2].Node.ID != c.ID {
t.Fatalf("tie-break order not stable: %s %s %s",
out[0].Node.ID, out[1].Node.ID, out[2].Node.ID)
}
}
}
func TestAllSignalNamesCoversDefaultSignals(t *testing.T) {
want := AllSignalNames()
got := map[string]bool{}
for _, sig := range DefaultSignals() {
got[sig.Name()] = true
}
if len(want) != len(got) {
t.Fatalf("AllSignalNames count %d != DefaultSignals %d", len(want), len(got))
}
for _, n := range want {
if !got[n] {
t.Errorf("AllSignalNames lists %q but DefaultSignals has no such signal", n)
}
}
}
// --- Helper / tokens tests -------------------------------------------
func TestTokenizeSplitsCamelAndSnake(t *testing.T) {
cases := map[string][]string{
"ParseHTTPHeader": {"parse", "http", "header"},
"validate_user_token": {"validate", "user", "token"},
"user2Token": {"user", "2", "token"},
"hashPasswordSha256": {"hash", "password", "sha", "256"},
"": nil,
"ABC": {"abc"},
"already-clean tokens": {"already", "clean", "tokens"},
}
for in, want := range cases {
got := tokenize(in)
if !sliceEq(got, want) {
t.Errorf("tokenize(%q) = %v, want %v", in, got, want)
}
}
}
func TestJaccardAndOverlap(t *testing.T) {
if got := Jaccard(nil, []string{"a"}); got != 0 {
t.Errorf("Jaccard(nil, …) = %v, want 0", got)
}
if got := Jaccard([]string{"a", "b"}, []string{"b", "c"}); !floatEq(got, 1.0/3.0) {
t.Errorf("Jaccard expected 1/3, got %v", got)
}
if got := overlap([]string{"a", "b"}, []string{"a", "b", "c"}); got != 1.0 {
t.Errorf("overlap expected 1.0 (full subset), got %v", got)
}
if got := overlap([]string{"a"}, []string{"b", "c"}); got != 0 {
t.Errorf("overlap expected 0 (no intersection), got %v", got)
}
}
func TestNormLogClamps(t *testing.T) {
if got := normLog(0, 10); got != 0 {
t.Errorf("normLog(0,10) = %v, want 0", got)
}
if got := normLog(5, 0); got != 0 {
t.Errorf("normLog(5,0) = %v, want 0", got)
}
if got := normLog(10, 10); !floatEq(got, 1.0) {
t.Errorf("normLog(10,10) = %v, want 1.0", got)
}
}
func sliceEq(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func floatEq(a, b float64) bool { return math.Abs(a-b) < 1e-9 }