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