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
124 lines
4.5 KiB
Go
124 lines
4.5 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"testing"
|
|
|
|
mcplib "github.com/mark3labs/mcp-go/mcp"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/zzet/gortex/internal/search/rerank"
|
|
)
|
|
|
|
// searchResp runs search_symbols and returns the decoded JSON response
|
|
// map so tests can inspect both the result rows and top-level flags
|
|
// (decomposed, filters_relaxed, ...).
|
|
func searchResp(t *testing.T, srv *Server, args map[string]any) map[string]any {
|
|
t.Helper()
|
|
req := mcplib.CallToolRequest{}
|
|
req.Params.Name = "search_symbols"
|
|
req.Params.Arguments = args
|
|
res, err := srv.handleSearchSymbols(context.Background(), req)
|
|
require.NoError(t, err)
|
|
require.Falsef(t, res.IsError, "search errored: %v", res.Content)
|
|
var resp map[string]any
|
|
require.NoError(t, json.Unmarshal([]byte(res.Content[0].(mcplib.TextContent).Text), &resp))
|
|
return resp
|
|
}
|
|
|
|
func respIDs(resp map[string]any) map[string]bool {
|
|
ids := map[string]bool{}
|
|
if results, ok := resp["results"].([]any); ok {
|
|
for _, r := range results {
|
|
ids[r.(map[string]any)["id"].(string)] = true
|
|
}
|
|
}
|
|
return ids
|
|
}
|
|
|
|
// --- Unit tests for the decomposition helpers. ---
|
|
|
|
func TestQueryHasDecomposableSeparator(t *testing.T) {
|
|
cases := []struct {
|
|
q string
|
|
want bool
|
|
}{
|
|
{"UserService.FindUser", true},
|
|
{"internal/auth/token", true},
|
|
{"pkg::Symbol", true},
|
|
{"validate_user_token", true},
|
|
{"FindUser", true}, // PascalCase internal boundary
|
|
{"httpHandler", true}, // camelCase boundary
|
|
{"flush the cache", false},
|
|
{"validateuser", false}, // single lowercase word, no boundary
|
|
{"auth", false},
|
|
{"", false},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := queryHasDecomposableSeparator(tc.q); got != tc.want {
|
|
t.Fatalf("queryHasDecomposableSeparator(%q) = %v, want %v", tc.q, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDecomposeQueryToLeaves(t *testing.T) {
|
|
// "UserService.FindUser" -> user, service, find (the second "user"
|
|
// is deduped by the tokenizer).
|
|
got := decomposeQueryToLeaves("UserService.FindUser")
|
|
require.Equal(t, []string{"user", "service", "find"}, got)
|
|
|
|
// Tiny tokens are dropped.
|
|
got = decomposeQueryToLeaves("a.b.parse")
|
|
require.Equal(t, []string{"parse"}, got)
|
|
|
|
// A single short token decomposes to nothing (it would just
|
|
// repeat the original miss).
|
|
require.Nil(t, decomposeQueryToLeaves("ab"))
|
|
|
|
// Sanity: the tokenizer is the same one the rest of search uses.
|
|
require.Equal(t, rerank.Tokenize("FindUser"), []string{"find", "user"})
|
|
}
|
|
|
|
// --- Handler integration: the zero-result decomposition fallback. ---
|
|
|
|
// TestSearchSymbols_DecomposeRescuesCompoundMiss is the core feature
|
|
// test: a compound query whose full form finds nothing is rescued by
|
|
// decomposing it into leaf terms, one of which hits a real symbol.
|
|
func TestSearchSymbols_DecomposeRescuesCompoundMiss(t *testing.T) {
|
|
srv := equivalenceTestServer(t, []string{
|
|
"FetchProfile", "RenderTemplate", "UnrelatedThing",
|
|
}, nil)
|
|
|
|
// A single CamelCase token whose FULL form the backend finds
|
|
// nothing for (the query tokenizer does not camelCase-split, so
|
|
// "ZzqwxnomatchProfile" is one unknown posting), but which the
|
|
// camelCase-aware leaf decomposition splits into
|
|
// [zzqwxnomatch, profile] — and "profile" hits FetchProfile.
|
|
resp := searchResp(t, srv, map[string]any{"query": "ZzqwxnomatchProfile"})
|
|
ids := respIDs(resp)
|
|
require.Truef(t, ids["pkg/FetchProfile.go::FetchProfile"],
|
|
"camelCase decomposition should rescue Profile -> FetchProfile; got %v flags=%v", ids, resp["decomposed"])
|
|
require.Equal(t, true, resp["decomposed"], "the rescued response must carry decomposed:true")
|
|
}
|
|
|
|
// TestSearchSymbols_DecomposeSkipsProseMiss confirms a prose-shaped
|
|
// miss (no separator, no camelCase boundary) does NOT trigger
|
|
// decomposition — the flag stays absent.
|
|
func TestSearchSymbols_DecomposeSkipsProseMiss(t *testing.T) {
|
|
srv := equivalenceTestServer(t, []string{"FetchProfile"}, nil)
|
|
resp := searchResp(t, srv, map[string]any{"query": "zzqwxnomatch"})
|
|
require.Empty(t, respIDs(resp))
|
|
require.Nil(t, resp["decomposed"], "a bare prose miss must not set decomposed")
|
|
}
|
|
|
|
// TestSearchSymbols_DecomposeNotSetOnHit confirms the flag is absent
|
|
// when the primary query already found results — decomposition is a
|
|
// zero-result-only fallback.
|
|
func TestSearchSymbols_DecomposeNotSetOnHit(t *testing.T) {
|
|
srv := equivalenceTestServer(t, []string{"FetchProfile"}, nil)
|
|
resp := searchResp(t, srv, map[string]any{"query": "FetchProfile"})
|
|
require.NotEmpty(t, respIDs(resp))
|
|
require.Nil(t, resp["decomposed"], "decomposed must be absent when the primary query hit")
|
|
}
|