主人定调「先把原生的做进插件,MCP 路线等后端 MCP 完善后再接」。 - 新包 plugins/tools/io/search/:一 provider 一文件 + 共享 http/提取器 + 归一化 Result;域名过滤/去重/排序**归一化后统一做一次** - 配置 15 键:WEB_SEARCH_PROVIDER(默认 searxng)/_FALLBACK/_MAX_RESULTS + 五家 ×(API_KEY/BASE_URL);WEB_SEARCH_TIMEOUT 语义改为「**每次上游尝试**」(降级链才有意义) - 硬语义:未注册/未配置的主 provider ⇒ **启动期报错**(不许静默回落);off ⇒ 不注册工具;缺 key 的家不进可用清单;降级链**如实标实际应答的 provider**(变异:拿主力冒充 ⇒ 测试红「provider = zhipu, want bocha」) - 密钥绝不进日志/响应(有断言);错误分类(鉴权/限流/额度/网络)+ **空结果 ≠ 成功**(变异暴露过一个死分支⇒下移到 searchWith 修掉) - searxng 真机 e2e 通过(provider=searxng 5 条);**其余五家无 key ⇒ 如实标未实测**;契约无改动(无新端点、工具 schema 不变) - 注册表留好 B 档接入位(tavily/exa/firecrawl/serper/serpapi),并有「未登记⇒启动报错」的防回归断言
138 lines
5.1 KiB
Go
138 lines
5.1 KiB
Go
package search
|
|
|
|
import "testing"
|
|
|
|
// TestFinalize_Pipeline 是「归一化之后**统一做一次**」的判据(design/27 §4):
|
|
// 空 link 丢弃 → 域名过滤 → 去重 → 按分数稳定排序 → 截断。
|
|
func TestFinalize_Pipeline(t *testing.T) {
|
|
t.Parallel()
|
|
in := []Result{
|
|
{Link: "https://a.example/1#frag", Title: "A1", Score: 0.2},
|
|
{Link: "https://a.example/1", Title: "A1 副本", Score: 0.9}, // 去重被丢(同一地址)
|
|
{Link: "https://b.example/2", Title: "B2", Score: 0.9},
|
|
{Link: " ", Title: "没有链接"},
|
|
{Link: "https://blocked.example/3", Title: "被排除的域名"},
|
|
{Link: "https://c.example/4/", Title: "C4", Score: 1.5},
|
|
}
|
|
got := Finalize(in, Options{BlockDomains: []string{"blocked.example"}})
|
|
if len(got) != 3 {
|
|
t.Fatalf("条数 = %d, want 3: %+v", len(got), got)
|
|
}
|
|
// 去重保留**先出现的那一条**(A1,分 0.2);Score 降序;同分保持原序(稳定排序)。
|
|
if got[0].Title != "C4" || got[1].Title != "B2" || got[2].Title != "A1" {
|
|
t.Fatalf("排序/去重结果 = %q,%q,%q; want C4,B2,A1(分数降序 + 同分稳定 + 重复地址保留首条)",
|
|
got[0].Title, got[1].Title, got[2].Title)
|
|
}
|
|
if got[2].Link != "https://a.example/1#frag" {
|
|
t.Fatalf("去重应保留先出现的那一条: %+v", got[2])
|
|
}
|
|
if got[1].Link != "https://b.example/2" {
|
|
t.Fatalf("B2 的链接被改动: %q", got[1].Link)
|
|
}
|
|
}
|
|
|
|
// TestFinalize_LimitAndAllowDomains 覆盖截断与域名白名单(域名过滤只在归一化后做一次)。
|
|
func TestFinalize_LimitAndAllowDomains(t *testing.T) {
|
|
t.Parallel()
|
|
in := []Result{
|
|
{Link: "https://a.example/1", Title: "A"},
|
|
{Link: "https://sub.a.example/2", Title: "A 子域"},
|
|
{Link: "https://other.example/3", Title: "O"},
|
|
}
|
|
allow := Finalize(in, Options{AllowDomains: []string{".a.example"}})
|
|
if len(allow) != 2 {
|
|
t.Fatalf("白名单结果 = %+v, want 2 条(含子域)", allow)
|
|
}
|
|
limited := Finalize(in, Options{Limit: 1})
|
|
if len(limited) != 1 {
|
|
t.Fatalf("Limit=1 应截断成 1 条, got %d", len(limited))
|
|
}
|
|
none := Finalize(in, Options{AllowDomains: []string{"nope.example"}})
|
|
if len(none) != 0 {
|
|
t.Fatalf("白名单不命中时应为空, got %+v", none)
|
|
}
|
|
}
|
|
|
|
// TestFinalize_ToleratesUnparsableLink 解析不出主机的链接**保留**(域名过滤不该误杀)。
|
|
func TestFinalize_ToleratesUnparsableLink(t *testing.T) {
|
|
t.Parallel()
|
|
got := Finalize([]Result{{Link: "not a url", Title: "X"}}, Options{AllowDomains: []string{"a.example"}})
|
|
if len(got) != 1 {
|
|
t.Fatalf("解析不出主机的链接应被保留(不是过滤失败): %+v", got)
|
|
}
|
|
}
|
|
|
|
// TestCanonicalLink 覆盖去重键的口径:去 fragment、scheme/host 小写、path 去尾斜杠,
|
|
// **保留 query**(查询串不同通常是不同页面,过度归并会吞掉真结果)。
|
|
func TestCanonicalLink(t *testing.T) {
|
|
t.Parallel()
|
|
same := [][2]string{
|
|
{"https://A.example/p/", "https://a.example/p"},
|
|
{"https://a.example/p#sec", "https://a.example/p"},
|
|
}
|
|
for _, pair := range same {
|
|
if canonicalLink(pair[0]) != canonicalLink(pair[1]) {
|
|
t.Errorf("应视为同一条: %q vs %q", pair[0], pair[1])
|
|
}
|
|
}
|
|
if canonicalLink("https://a.example/p?x=1") == canonicalLink("https://a.example/p?x=2") {
|
|
t.Error("query 不同不该被去重合并")
|
|
}
|
|
}
|
|
|
|
// TestScalarStringAndPick 覆盖容错提取的取值口径(字符串/数字/布尔;空串不算命中)。
|
|
func TestScalarStringAndPick(t *testing.T) {
|
|
t.Parallel()
|
|
obj := map[string]any{
|
|
"empty": "",
|
|
"title": "标题",
|
|
"num": float64(1700000000000),
|
|
"flag": true,
|
|
"nested": map[string]any{"x": 1},
|
|
"score": "0.75",
|
|
"badnum": "abc",
|
|
}
|
|
if got := pickString(obj, []string{"empty", "title"}); got != "标题" {
|
|
t.Errorf("空串不该算命中, got %q", got)
|
|
}
|
|
if got := pickString(obj, []string{"num"}); got != "1700000000000" {
|
|
t.Errorf("数字应原样字符串化, got %q", got)
|
|
}
|
|
if got := pickString(obj, []string{"flag"}); got != "true" {
|
|
t.Errorf("布尔应字符串化, got %q", got)
|
|
}
|
|
if got := pickString(obj, []string{"nested"}); got != "" {
|
|
t.Errorf("非标量应返回空串, got %q", got)
|
|
}
|
|
if got := pickFloat(obj, []string{"score"}); got != 0.75 {
|
|
t.Errorf("数字字符串应能当分数用, got %v", got)
|
|
}
|
|
if got := pickFloat(obj, []string{"badnum"}); got != 0 {
|
|
t.Errorf("非数字字符串应返回 0, got %v", got)
|
|
}
|
|
if got := pickFloat(obj, []string{"missing"}); got != 0 {
|
|
t.Errorf("缺键应返回 0, got %v", got)
|
|
}
|
|
}
|
|
|
|
// TestResolvePath 覆盖点分路径解析(含中间不是对象的情况)。
|
|
func TestResolvePath(t *testing.T) {
|
|
t.Parallel()
|
|
var root any
|
|
mustUnmarshal(t, `{"a":{"b":[1,2]}}`, &root)
|
|
if v, ok := resolvePath(root, "a.b"); !ok {
|
|
t.Fatal("a.b 应能解析")
|
|
} else if arr, ok := v.([]any); !ok || len(arr) != 2 {
|
|
t.Fatalf("a.b = %v", v)
|
|
}
|
|
if _, ok := resolvePath(root, "a.c"); ok {
|
|
t.Error("不存在的键不该命中")
|
|
}
|
|
if _, ok := resolvePath(root, "a.b.c"); ok {
|
|
t.Error("路径穿过数组时不该命中")
|
|
}
|
|
if arr, ok := findArray(root, []string{"nope", "a.b"}); !ok || len(arr) != 2 {
|
|
t.Errorf("findArray 应回退到后面的候选路径: %v %v", arr, ok)
|
|
}
|
|
}
|