package search import "testing" // TestFinalize_Pipeline 是「归一化之后**统一做一次**」的判据(design/27 §4): // 丢弃**全空壳行** → 域名过滤 → 去重 → 按分数稳定排序 → 截断。 // // 🔴 「没有链接」**不再**是被丢的理由(2026-10-07 真机缺陷的修复):智谱真机经常整批 // 不给 link 而 title/content 齐全,旧口径会把上游给的 10 条整批丢光、工具层谎报 // 「一家都没查到」。判据改成「四个可读字段全空才不算结果」。 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: "没有链接"}, // **保留**:上游给了标题,只是没给链接 {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) != 4 { t.Fatalf("条数 = %d, want 4: %+v", len(got), got) } // 去重保留**先出现的那一条**(A1,分 0.2);Score 降序;同分保持原序(稳定排序)。 if got[0].Title != "C4" || got[1].Title != "B2" || got[2].Title != "A1" || got[3].Title != "没有链接" { t.Fatalf("排序/去重结果 = %q,%q,%q,%q; want C4,B2,A1,没有链接(分数降序 + 同分稳定 + 重复地址保留首条)", got[0].Title, got[1].Title, got[2].Title, got[3].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) } if got[3].Link != "" { t.Fatalf("没有链接的条目不许被编出一个链接: %q", got[3].Link) } } // TestFinalize_LinklessResultsSurvive 是**真机缺陷的单元级判据**:一整批「有标题正文、 // 没链接」的结果(智谱真机形态)必须原样留下——它们**不是**空结果。 // // 同时钉住「不按 link 去重无链接条目」:没有链接就没有判同的身份,宁可重复展示, // 也不静默吞掉上游给的结果(吞掉会让上层把「查到了 10 条」谎报成「一家都没查到」)。 func TestFinalize_LinklessResultsSurvive(t *testing.T) { t.Parallel() in := make([]Result, 0, 10) for i := 0; i < 10; i++ { in = append(in, Result{Title: "热搜条目", Content: "正文摘要", Link: ""}) } got := Finalize(in, Options{Limit: 5}) if len(got) != 5 { t.Fatalf("条数 = %d, want 5(Limit 截断,而不是被空 link 判据丢光): %+v", len(got), got) } for i, r := range got { if r.Link != "" || r.Title == "" || r.Content == "" { t.Fatalf("got[%d] = %+v, want 原样保留(链接空、标题正文在)", i, r) } } } // 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) } }