真缺陷(上一片登记):`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 多一个可写键 +
响应多一个回显键)⇒ 主会话登记。
172 lines
6.4 KiB
Go
172 lines
6.4 KiB
Go
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 ""
|
||
}
|