package main // 本文件钉住「面板文件的 `maxResults` 就是运行期的条数上限」这条路**通了** // (主人 2026-10-07 裁定;口径见 design/27 §3 与 `cmd/cyrene/wire_io_tools.go` 的 // `effectiveSearchConfig` —— 面板文件是可变源,`.env` 只播种)。 // // 上半部分直接写盘(测投影:文件里的 `maxResults` 就是真上限);下半部分走**面板动作** // (`plugin.saveProvider` 带上 `maxResults` ⇒ 热重载 ⇒ 下一次搜索真的最多那么多条), // 并钉住三态:缺位 = 不改动、正整数 = 采用、其余 = 400 invalid_payload(绝不静默丢弃)。 // // 夹具一律写进**本用例自己的** `$DATA_DIR`(`wsLoadConfig` 的 `t.TempDir()`): // 生产文件 `data/config/plugins/web-search.json` 不在测试的触碰范围内。 import ( "bytes" "context" "fmt" "net/http" "strings" "testing" pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin" "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) } } // --- 面板动作这一侧:存得进去、真的生效、非法值如实报错 ------------------------- // wsMaxResultsEnv 是一份「`.env` 给 envMaxResults、假上游答 30 条」的面板动作夹具 // (热重载面 + 分派器都在:`newReloadTestEnv`)。`.env` 那个值是留给「面板压过 `.env`」 // 这条判据当对照用的。 func wsMaxResultsEnv(t *testing.T, envMaxResults int) (*reloadTestEnv, *wsFakeUpstream) { t.Helper() up := newWSFakeUpstream(t, http.StatusOK, wsManyResultsBody(30)) env := newReloadTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG, "WEB_SEARCH_ENDPOINT="+up.url, fmt.Sprintf("WEB_SEARCH_MAX_RESULTS=%d", envMaxResults), ) return env, up } // TestWebSearchPanelSaveAppliesMaxResults 是本片的主判据(端到端):面板提交里带上 // `maxResults: 20` ⇒ 落盘 20 ⇒ **热重载**后下一次搜索真的最多 20 条 —— `.env` 的 5 不作数, // 也没有重启后端。 func TestWebSearchPanelSaveAppliesMaxResults(t *testing.T) { env, up := wsMaxResultsEnv(t, 5) outcome := env.mustSave(t, map[string]any{ "provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true, "maxResults": 20, }) if !outcome.Applied || !outcome.Registered { t.Fatalf("hotReload = %+v, want applied 且 registered", outcome) } if got := env.loadFile(t).MaxResults; got != 20 { t.Fatalf("盘上 maxResults = %d, want 20(面板给的值必须真的落盘)", got) } if got := wsSearchCount(t, env.app, map[string]any{"query": "cyrene", "count": 50}); got != 20 { t.Fatalf("count = %d, want 20(模型要 50,上限是面板刚存下的 20 —— 且没重启)", got) } if got := wsSearchCount(t, env.app, map[string]any{"query": "cyrene", "count": 3}); got != 3 { t.Fatalf("count = %d, want 3(模型要得更少时尊重模型)", got) } } // TestWebSearchPanelSaveWithoutMaxResultsKeepsStoredValue 钉住三态的第一态:提交里**缺位** // ⇒ 盘上那个上限一个字节都不动(「我没改它」必须真的是没改),运行中的生效值也照旧。 // // 前置的 20 是**直接写盘**的(不经过写侧):这条判据只关心「缺位的那次保存有没有动它」, // 不该依赖另一条用例的能力。 func TestWebSearchPanelSaveWithoutMaxResultsKeepsStoredValue(t *testing.T) { env, up := wsMaxResultsEnv(t, 5) if err := runtimeconfig.SaveWebSearch(env.path, runtimeconfig.WebSearchFile{ Version: runtimeconfig.WebSearchVersion, Provider: search.NameSearXNG, MaxResults: 20, Providers: []runtimeconfig.ProviderEntry{ {Provider: search.NameSearXNG, BaseURL: up.url, Enabled: runtimeconfig.EnableFlag(true)}, }, }); err != nil { t.Fatalf("写面板配置: %v", err) } env.mustSave(t, map[string]any{ "provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true, }) if got := env.loadFile(t).MaxResults; got != 20 { t.Fatalf("缺位的一次保存把上限改成了 %d, want 20(缺位 = 不改动)", got) } if got := wsSearchCount(t, env.app, map[string]any{"query": "cyrene", "count": 50}); got != 20 { t.Fatalf("count = %d, want 20(上限没被动过,热重载后的生效值也该照旧)", got) } } // TestWebSearchPanelSaveRejectsInvalidMaxResults 钉住三态的第三态:给了值却用不了 ⇒ // **400 invalid_payload**,盘上一个字节都不动 —— 绝不静默忽略。静默忽略正是这一片要消灭的 // 那条「我改了它却没变」(旧行为就是:手塞进去的值被写侧无声丢掉,文件不变、也不报错)。 func TestWebSearchPanelSaveRejectsInvalidMaxResults(t *testing.T) { cases := []struct { name string value any }{ {"零", 0}, {"负数", -3}, {"小数", 2.5}, {"空串(前端清空输入框送的就是它)", ""}, {"非数字字符串", "twenty"}, {"布尔", true}, {"数组", []any{20}}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { env, up := wsMaxResultsEnv(t, 5) before := env.rawFile(t) _, err := env.save(map[string]any{ "provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true, "maxResults": tc.value, }) ae, ok := pluginmgr.AsActionError(err) if !ok { t.Fatalf("err = %v, want 结构化 ActionError(非法上限绝不能被静默忽略)", err) } if ae.Code != pluginmgr.ActionCodeInvalidPayload || ae.Status != http.StatusBadRequest { t.Fatalf("失败 = %d/%s, want 400/%s", ae.Status, ae.Code, pluginmgr.ActionCodeInvalidPayload) } if got := env.rawFile(t); !bytes.Equal(got, before) { t.Fatalf("被拒的保存动了盘上的文件:\nbefore=%s\nafter =%s", before, got) } }) } } // TestWebSearchReauthEchoesMaxResults:回填必须带上盘上的上限 —— 不回填的话表单里那个框是空的, // 用户既看不见当前上限,也无从改起。 func TestWebSearchReauthEchoesMaxResults(t *testing.T) { env, up := wsMaxResultsEnv(t, 5) env.mustSave(t, map[string]any{ "provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true, "maxResults": 20, }) values := wsReauthValues(t, env, search.NameSearXNG) if got := values[search.FieldMaxResults]; got != 20 { t.Fatalf("回填的 %s = %#v, want 20", search.FieldMaxResults, got) } } // TestWebSearchReauthOmitsUnsetMaxResults:盘上**没给**上限(`maxResults` 缺位 ⇒ Go 侧 0 = // 未声明,由 `.env` 兜底)时不得回填一个 0 —— 0 会被前端当成一个值送回来,而写侧拒 ≤0, // 那会让「只改 key」的一次保存整笔进不去。 func TestWebSearchReauthOmitsUnsetMaxResults(t *testing.T) { env, up := wsMaxResultsEnv(t, 5) if err := runtimeconfig.SaveWebSearch(env.path, runtimeconfig.WebSearchFile{ Version: runtimeconfig.WebSearchVersion, Provider: search.NameSearXNG, // 刻意不写 maxResults(= 未声明)。 Providers: []runtimeconfig.ProviderEntry{ {Provider: search.NameSearXNG, BaseURL: up.url, Enabled: runtimeconfig.EnableFlag(true)}, }, }); err != nil { t.Fatalf("写面板配置: %v", err) } if got := env.loadFile(t).MaxResults; got != 0 { t.Fatalf("前置不成立:盘上 maxResults = %d, want 0(未声明)", got) } values := wsReauthValues(t, env, search.NameSearXNG) if raw, ok := values[search.FieldMaxResults]; ok { t.Fatalf("未声明时回填了 %#v(前端会把它当值送回,写侧又拒 ≤0)", raw) } } // wsReauthValues 走 reauth 动作,取出回填的 values。 func wsReauthValues(t *testing.T, env *reloadTestEnv, provider string) map[string]any { t.Helper() out, err := env.hub.Dispatch(context.Background(), pluginmgr.ActionRequest{ PluginID: search.PanelPluginID, Action: search.ActionReauthProvider, Payload: map[string]any{"row": map[string]any{"provider": provider}}, }) if err != nil { t.Fatalf("reauth: %v", err) } values, _ := out.(map[string]any)["values"].(map[string]any) if values == nil { t.Fatalf("reauth 结果缺 values: %#v", out) } return values }