Files
zzet--gortex/internal/mcp/tools_search_decompose_test.go
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

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