package main // 本文件钉住「面板文件的 `maxResults` 就是运行期的条数上限」这条路**通了** // (主人 2026-10-07 裁定;口径见 design/27 §3 与 `cmd/cyrene/wire_io_tools.go` 的 // `effectiveSearchConfig` —— 面板文件是可变源,`.env` 只播种)。 // // 夹具一律写进**本用例自己的** `$DATA_DIR`(`wsLoadConfig` 的 `t.TempDir()`): // 生产文件 `data/config/plugins/web-search.json` 不在测试的触碰范围内。 import ( "context" "fmt" "net/http" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search" ) // wsManyResultsBody 造一份 searxng 形状、n 条结果的假上游应答。 // link 逐条不同:`search.Finalize` 按 link 去重,重复的 link 会让「截断到 N」看不出效果。 func wsManyResultsBody(n int) string { items := make([]string, 0, n) for i := 0; i < n; i++ { items = append(items, fmt.Sprintf( `{"title":"T%d","url":"https://r%d.example/%d","content":"c%d"}`, i, i, i, i)) } return `{"results":[` + strings.Join(items, ",") + `]}` } // wsPanelWithMaxResults 把一份夹具面板配置写进 cfg 的数据目录(maxResults 给 0 = 未声明)。 func wsPanelWithMaxResults(t *testing.T, cfg *config.Config, maxResults int, upstream string) { t.Helper() if err := runtimeconfig.SaveWebSearch(wsPanelPath(cfg), runtimeconfig.WebSearchFile{ Version: runtimeconfig.WebSearchVersion, Provider: search.NameSearXNG, Fallback: []string{}, MaxResults: maxResults, Providers: []runtimeconfig.ProviderEntry{ {Provider: search.NameSearXNG, BaseURL: upstream, Enabled: runtimeconfig.EnableFlag(true)}, }, }); err != nil { t.Fatalf("写面板配置: %v", err) } } // wsSearchCount 走注册面里那把 web-search(组合根装配出来的那一把)打一次,返回条数。 func wsSearchCount(t *testing.T, a *app, args map[string]any) int { t.Helper() got, err := a.dispatcher.Invoke(context.Background(), webSearchToolName, args) if err != nil { t.Fatalf("Invoke: %v", err) } m, ok := got.(map[string]any) if !ok { t.Fatalf("工具返回值类型 = %T", got) } n, ok := m["count"].(int) if !ok { t.Fatalf("count 不是 int: %#v", m["count"]) } return n } // TestWebSearchPanelMaxResultsIsTheRuntimeLimit 是这条路的主判据:`.env` 写 5、 // 面板文件写 20 ⇒ 生效值必须是 **20**(面板优先),且它仍是**真上限** // (模型要 50 也只给 20、要 3 就给 3)。 func TestWebSearchPanelMaxResultsIsTheRuntimeLimit(t *testing.T) { t.Parallel() up := newWSFakeUpstream(t, http.StatusOK, wsManyResultsBody(30)) cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG, "WEB_SEARCH_MAX_RESULTS=5", ) wsPanelWithMaxResults(t, cfg, 20, up.url) a := wsSurfaceApp(t, cfg, nil) if got := wsSearchCount(t, a, map[string]any{"query": "cyrene", "count": 50}); got != 20 { t.Fatalf("count = %d, want 20(模型要 50,上限是面板文件里的 20)", got) } if got := wsSearchCount(t, a, map[string]any{"query": "cyrene", "count": 3}); got != 3 { t.Fatalf("count = %d, want 3(模型要得更少时尊重模型)", got) } if got := wsSearchCount(t, a, map[string]any{"query": "cyrene"}); got != 20 { t.Fatalf("count = %d, want 20(模型没给 count ⇒ 用面板文件的上限)", got) } } // TestWebSearchPanelMaxResultsUnsetFallsBackToEnv 钉住投影的另一半: // 面板文件里 `maxResults` **缺位**(0 = 未声明)⇒ 沿用 `.env` 那条既有键的值。 func TestWebSearchPanelMaxResultsUnsetFallsBackToEnv(t *testing.T) { t.Parallel() up := newWSFakeUpstream(t, http.StatusOK, wsManyResultsBody(30)) cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG, "WEB_SEARCH_MAX_RESULTS=7", ) wsPanelWithMaxResults(t, cfg, 0, up.url) a := wsSurfaceApp(t, cfg, nil) if got := wsSearchCount(t, a, map[string]any{"query": "cyrene", "count": 50}); got != 7 { t.Fatalf("count = %d, want 7(面板 maxResults 未声明 ⇒ 回落 .env 的 7)", got) } } // TestWebSearchDefaultsToTenWithoutPanelOrEnvGivesTen 钉住代码默认值这一档: // `.env` 与面板文件都没给条数 ⇒ io 包的共享上限(= 10),且它同样是真上限。 func TestWebSearchDefaultsToTenWithoutPanelOrEnvGivesTen(t *testing.T) { t.Parallel() up := newWSFakeUpstream(t, http.StatusOK, wsManyResultsBody(30)) cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG) wsPanelWithMaxResults(t, cfg, 0, up.url) // 0 = 未声明;.env 也没给 a := wsSurfaceApp(t, cfg, nil) if got := wsSearchCount(t, a, map[string]any{"query": "cyrene", "count": 50}); got != 10 { t.Fatalf("count = %d, want 10(代码默认值)", got) } }