Files
Cyrene_v2/internal/config/websearch_providers_test.go
T
AskaEth c68387a69a
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(search): 面板上终于能改「结果条数上限」(原来改了会被静默丢弃)
真缺陷(上一片登记):`maxResults` 在面板上**根本改不了** —— ① 字段表里没有这个
字段(UI 不显示输入框);② `saveProvider` 只并 provider/apiKey/baseUrl/enabled;
③ `mergeSelectionPatch` 只处理 primary/fallback ⇒ **手工塞进 values 也会被静默丢弃**
(文件不变、也不报错)。「我改了它却没变」正是要消灭的那种假话。

- `panel.go`:表单加 `{Key: FieldMaxResults, Type: sdk.FieldNumber, Min: 1}`(**SDK 本就有
  数字类型**,未自造;前端本就渲染 number ⇒ **不需要改前端**)。刻意不设 `Default`
  (设了会让一次「只改 key」的保存把默认值固化进文件、还可能盖掉 `.env`)。
- `websearch_selection.go`:`mergeSelectionPatch` 里按**三态**落 `maxResults` ——
  缺位/null ⇒ **不改动**;正整数(含整数字符串)⇒ 采用;0/负数/小数/空串/非数/
  布尔/数组 ⇒ **400 `invalid_payload`** 且**盘上一字节不动**(绝不静默忽略)。
- `reauth` 回填该值(否则一保存就把上限清掉)。
- `.env.example`:该键的示例值与「默认 5」注释同步为 **10**(代码默认已改;⚠️ `.env` 未动)。

验收(主会话复跑 + 亲自变异):**整树 `go build ./...` 通过**;三包 vet/测试绿;
端到端「面板存 20 ⇒ 模型要 50 也只给 20、要 3 给 3」(走真 action hub → 落盘 →
热重载 → WithSearchMaxResults → 真 dispatcher,未重启后端);
我用 -overlay 把 `f.MaxResults = maxResults` 摘掉 ⇒ 两条用例变红
(「面板给的值必须真的落盘」/「回填的 maxResults = 5, want 20」)。

⚠️ 已知取舍(如实登记):**清空输入框 = 400**(后端按「非数 ⇒ 报错」拒),因此面板上
没有「去掉上限」的手势 —— 要回到 `.env` 兜底得改文件;要支持「清空 = 去掉覆盖」
需另定哨兵语义,未自行发挥。
⚠️ 契约面已变更(面板声明多一个 number 字段 + saveProvider 的 values 多一个可写键 +
响应多一个回显键)⇒ 主会话登记。
2026-10-07 11:10:56 +08:00

172 lines
6.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package config
import (
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
// 本文件钉住切片 WS1-B 的搜索配置键(design/27 §3):
// WEB_SEARCH_PROVIDER / _FALLBACK / _MAX_RESULTS 与各家独立一组键
// (WEB_SEARCH_<NAME>_API_KEY / _BASE_URL),以及 ResolveProvider 的关闭语义;
// 末尾还钉住 `.env.example` 的取值与代码默认值同口径(模板写错就是一句假话)。
// wsTestLoad 写一份只含必填键加 extra 行的 .env,加载出配置。
func wsTestLoad(t *testing.T, extra ...string) (*Config, error) {
t.Helper()
lines := append([]string{"JWT_SECRET=" + strings.Repeat("s", 40)}, extra...)
path := filepath.Join(t.TempDir(), ".env")
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil {
t.Fatalf("write .env: %v", err)
}
return Load(path)
}
// TestWebSearchProvider_Defaults 钉住三个新键的默认值:provider=searxng、
// maxResults=10、fallback 为空(= 只管手选那一家)。
func TestWebSearchProvider_Defaults(t *testing.T) {
cfg, err := wsTestLoad(t)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.WebSearch.Provider != "searxng" {
t.Fatalf("WEB_SEARCH_PROVIDER 默认值 = %q, want searxng", cfg.WebSearch.Provider)
}
if cfg.WebSearch.MaxResults != 10 {
t.Fatalf("WEB_SEARCH_MAX_RESULTS 默认值 = %d, want 10", cfg.WebSearch.MaxResults)
}
if len(cfg.WebSearch.Fallback) != 0 {
t.Fatalf("WEB_SEARCH_FALLBACK 未配置时应为空, got %v", cfg.WebSearch.Fallback)
}
// 云家的 base URL 有默认值、key 一律为空(未配置 ⇒ 该家不出现在可用清单里)。
if cfg.WebSearch.ZhipuBaseURL == "" || cfg.WebSearch.BochaBaseURL == "" ||
cfg.WebSearch.AliyunIQSBaseURL == "" || cfg.WebSearch.VolcengineBaseURL == "" ||
cfg.WebSearch.BaiduQianfanBaseURL == "" {
t.Fatalf("各家的 BASE_URL 必须有默认值: %+v", cfg.WebSearch)
}
if cfg.WebSearch.ZhipuAPIKey != "" || cfg.WebSearch.BochaAPIKey != "" ||
cfg.WebSearch.AliyunIQSAPIKey != "" || cfg.WebSearch.VolcengineAPIKey != "" ||
cfg.WebSearch.BaiduQianfanAPIKey != "" {
t.Fatalf("各家 API key 未配置时必须为空: %+v", cfg.WebSearch)
}
}
// TestWebSearchProvider_EnvOverrides 钉住新键都能被 .env 覆盖(含逗号分隔的降级链)。
func TestWebSearchProvider_EnvOverrides(t *testing.T) {
cfg, err := wsTestLoad(t,
"WEB_SEARCH_PROVIDER=Bocha",
"WEB_SEARCH_FALLBACK=zhipu,bocha,searxng",
"WEB_SEARCH_MAX_RESULTS=9",
"WEB_SEARCH_ZHIPU_API_KEY=zk",
"WEB_SEARCH_ZHIPU_BASE_URL=https://zhipu.example",
"WEB_SEARCH_ALIYUN_IQS_API_KEY=ak",
"WEB_SEARCH_ALIYUN_IQS_BASE_URL=https://iqs.example",
"WEB_SEARCH_BOCHA_API_KEY=bk",
"WEB_SEARCH_BOCHA_BASE_URL=https://bocha.example",
"WEB_SEARCH_VOLCENGINE_API_KEY=vk",
"WEB_SEARCH_VOLCENGINE_BASE_URL=https://volc.example",
"WEB_SEARCH_BAIDU_QIANFAN_API_KEY=qk",
"WEB_SEARCH_BAIDU_QIANFAN_BASE_URL=https://qianfan.example",
)
if err != nil {
t.Fatalf("Load: %v", err)
}
ws := cfg.WebSearch
if ws.Provider != "Bocha" {
t.Fatalf("Provider = %q(大小写应原样保留,比较时再归一化)", ws.Provider)
}
if got := ws.Fallback; len(got) != 3 || got[0] != "zhipu" || got[2] != "searxng" {
t.Fatalf("Fallback = %v, want [zhipu bocha searxng]", got)
}
if ws.MaxResults != 9 {
t.Fatalf("MaxResults = %d, want 9", ws.MaxResults)
}
for name, got := range map[string]string{
"Zhipu": ws.ZhipuAPIKey, "AliyunIQS": ws.AliyunIQSAPIKey, "Bocha": ws.BochaAPIKey,
"Volcengine": ws.VolcengineAPIKey, "BaiduQianfan": ws.BaiduQianfanAPIKey,
} {
if got == "" {
t.Errorf("%s 的 API key 没被读进来", name)
}
}
for name, got := range map[string]string{
"Zhipu": ws.ZhipuBaseURL, "AliyunIQS": ws.AliyunIQSBaseURL, "Bocha": ws.BochaBaseURL,
"Volcengine": ws.VolcengineBaseURL, "BaiduQianfan": ws.BaiduQianfanBaseURL,
} {
if !strings.HasSuffix(got, ".example") {
t.Errorf("%s 的 BASE_URL 没被覆盖: %q", name, got)
}
}
}
// TestWebSearchConfig_ResolveProvider 是关闭语义的表驱动:空 / off / none ⇒ 关闭
// (组合根据此不注册工具);其余 ⇒ 选定名。
func TestWebSearchConfig_ResolveProvider(t *testing.T) {
cases := []struct {
name string
value string
wantDisable bool
wantSel string
}{
{"默认 searxng", "searxng", false, "searxng"},
{"大写 Zhipu", " Zhipu ", false, "Zhipu"},
{"空串 = 关闭", "", true, ""},
{"off", "off", true, ""},
{"none", "NONE", true, ""},
{"带空白的 None", " none ", true, ""},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
got := WebSearchConfig{Provider: tc.value}.ResolveProvider()
if got.Disabled != tc.wantDisable || got.Selected != tc.wantSel {
t.Fatalf("ResolveProvider(%q) = %+v, want disabled=%v selected=%q",
tc.value, got, tc.wantDisable, tc.wantSel)
}
})
}
}
// TestWebSearchMaxResults_EnvExampleMatchesCodeDefault 是「配置事实 ↔ 模板」守卫:
// `.env.example` 是给人照抄的模板,它给出的取值必须等于代码默认值 —— 模板写 5 而代码默认
// 已改成 10(2026-10-07 的口径调整漏改了模板),照抄模板的人拿到的就不是默认行为。
//
// ⚠️ 只读模板;`.env` 已冻结(基础键不加/不改/不删),本用例不碰它。
func TestWebSearchMaxResults_EnvExampleMatchesCodeDefault(t *testing.T) {
cfg, err := wsTestLoad(t)
if err != nil {
t.Fatalf("Load: %v", err)
}
raw, err := os.ReadFile(filepath.Join("..", "..", ".env.example"))
if err != nil {
t.Fatalf("read .env.example: %v", err)
}
const key = "WEB_SEARCH_MAX_RESULTS"
value, err := strconv.Atoi(wsEnvExampleValue(t, string(raw), key))
if err != nil {
t.Fatalf(".env.example 的 %s 取值不是整数: %v", key, err)
}
if value != cfg.WebSearch.MaxResults {
t.Fatalf(".env.example 的 %s = %d,代码默认 = %d(模板与代码必须同口径)",
key, value, cfg.WebSearch.MaxResults)
}
}
// wsEnvExampleValue 取 `.env.example` 里某个键的**生效**取值(跳过注释行)。
func wsEnvExampleValue(t *testing.T, raw, key string) string {
t.Helper()
for _, line := range strings.Split(raw, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "#") {
continue
}
if rest, ok := strings.CutPrefix(line, key+"="); ok {
return strings.TrimSpace(rest)
}
}
t.Fatalf(".env.example 里没有生效的 %s=…(模板必须给出可照抄的取值)", key)
return ""
}