feat(search): 面板上终于能改「结果条数上限」(原来改了会被静默丢弃)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

真缺陷(上一片登记):`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 多一个可写键 +
响应多一个回显键)⇒ 主会话登记。
This commit is contained in:
AskaEth committed 2026-10-07 11:10:56 +08:00
1 parent 8325d1bb8c
commit c68387a69a
8 files changed
+387 -31

No files matched your search

+166
View File
@@ -4,16 +4,22 @@ package main
// (主人 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"
@@ -118,3 +124,163 @@ func TestWebSearchDefaultsToTenWithoutPanelOrEnvGivesTen(t *testing.T) {
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
}