Files
Cyrene_v2/cmd/cyrene/websearch_maxresults_test.go
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

287 lines
12 KiB
Go
Raw Permalink 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 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
}