Files
Cyrene_v2/plugins/tools/io/search/panel_test.go
T
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

396 lines
15 KiB
Go
Raw 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 search
import (
"encoding/json"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/paneldecl"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件钉住 web-search 插件向面板声明的管理页面(design/09 §5.2 / §5.2.1):
// 声明必须能过宿主的**闭集校验**(否则 §5.2.1 的「非法声明 ⇒ 该面板不注册」会真的
// 把它挡在门外),动作名必须与 ActionNames() 同源,表格状态的语义必须可断言。
// 判据 ①:声明必须过宿主校验(这是「面板真的会出现」的唯一凭据)。
func TestAdminPanelsPassHostValidation(t *testing.T) {
t.Parallel()
if err := paneldecl.Validate(PanelPluginID, AdminPanels()); err != nil {
t.Fatalf("宿主校验不通过(面板会被整体拒掉): %v", err)
}
}
// 判据 ②:面板里的每一个动作名都必须是 ActionNames() 里的成员(声明与动作表同源),
// 且 ActionNames() 本身无重复 —— 否则「声明了却没实现」或「白名单漏一个」都会变成
// 前端点了没反应的半渲染。
func TestAdminPanelsActionsAreDeclaredOnce(t *testing.T) {
t.Parallel()
declared := make(map[string]bool)
for _, n := range ActionNames() {
if declared[n] {
t.Fatalf("ActionNames() 里 %q 重复", n)
}
declared[n] = true
}
used := make([]string, 0, len(declared))
for _, p := range AdminPanels() {
for _, s := range p.Sections {
if s.Submit != nil {
used = append(used, s.Submit.Action)
}
if s.Source != nil {
used = append(used, s.Source.Action)
}
for _, a := range s.RowActions {
used = append(used, a.Action)
}
}
}
if len(used) == 0 {
t.Fatal("声明里一个动作都没有:这张表点不动")
}
for _, a := range used {
if !declared[a] {
t.Errorf("面板声明了动作 %q,但它不在 ActionNames() 里(动作白名单会把它拒掉)", a)
}
}
// 反向:声明的动作都必须真的出现在面板里(否则白名单里挂着一个点不到的名字)。
seen := make(map[string]bool, len(used))
for _, a := range used {
seen[a] = true
}
for _, n := range ActionNames() {
if !seen[n] {
t.Errorf("ActionNames() 里的 %q 在面板声明里没有用到", n)
}
}
}
// 判据 ③:面板 id 与可变源的文件名同源(design/13 §2.1:文件名 = 插件 id)。
// 这两个字面量分处两个包,只有这条断言能把「改了插件 id 却忘了改文件落点」拦下来。
func TestPanelPluginIDMatchesConfigFileName(t *testing.T) {
t.Parallel()
got := runtimeconfig.WebSearchConfigPath("/data")
if !strings.HasSuffix(got, PanelPluginID+".json") {
t.Fatalf("可变源落点 %q 的文件名必须等于插件 id %q", got, PanelPluginID)
}
}
// 判据 ④:表格区块必须声明 source + columns + 行内动作(§5.2.1 的形状矩阵由宿主校验
// 兜底,这里再钉一层语义:这张表就是用来看状态与点动作的)。
func TestAdminPanelsTableShapeIsUsable(t *testing.T) {
t.Parallel()
var table *sdk.Section
for _, p := range AdminPanels() {
for i := range p.Sections {
if p.Sections[i].Type == sdk.SectionTable {
table = &p.Sections[i]
}
}
}
if table == nil {
t.Fatal("声明里没有 table 区块:多实例列表无处可看")
}
if table.Source == nil || table.Source.Action != ActionListProviders {
t.Fatalf("表格数据源 = %+v, want %s", table.Source, ActionListProviders)
}
keys := make([]string, 0, len(table.Columns))
for _, c := range table.Columns {
keys = append(keys, c.Key)
}
for _, want := range []string{"provider", "state"} {
if !contains(keys, want) {
t.Errorf("表格列里缺 %q(行数据必须能显示出来): %v", want, keys)
}
}
// 行内动作至少要有「测试连通」(本片任务书点名的动作)。
found := false
for _, a := range table.RowActions {
if a.Action == ActionTestProvider {
found = true
}
}
if !found {
t.Fatalf("表格行内动作里没有 %s: %+v", ActionTestProvider, table.RowActions)
}
}
// 判据 ⑤:表格行与列同源(列 key 都能在行数据里取到)。
func TestBuildRowsFieldsCoverDeclaredColumns(t *testing.T) {
t.Parallel()
rows := BuildRows([]ProviderInput{
{Provider: NameBocha, APIKey: "sk-x", BaseURL: "https://api.bocha.cn", Enabled: true},
})
if len(rows) != 1 {
t.Fatalf("rows = %d, want 1", len(rows))
}
raw, err := json.Marshal(rows[0])
if err != nil {
t.Fatalf("marshal: %v", err)
}
var obj map[string]any
if err := json.Unmarshal(raw, &obj); err != nil {
t.Fatalf("unmarshal: %v", err)
}
for _, want := range []string{"provider", "state"} {
if _, ok := obj[want]; !ok {
t.Errorf("行数据缺列 key %q: %s", want, raw)
}
}
if strings.Contains(string(raw), "sk-x") {
t.Fatalf("行数据泄漏了凭据: %s", raw)
}
}
// 判据 ⑥:state 的判定顺序(根因在前):未登记 > 停用 > 未配齐 > 可用。
func TestBuildRowsStates(t *testing.T) {
t.Parallel()
cases := []struct {
name string
in ProviderInput
state string
}{
{"已登记已配齐", ProviderInput{Provider: NameBocha, APIKey: "k", BaseURL: "https://b", Enabled: true}, StateOK},
{"名字没登记", ProviderInput{Provider: "tavily", APIKey: "k", BaseURL: "https://t", Enabled: true}, StateUnknown},
{"名字没登记且停用(名字错了才是根因)", ProviderInput{Provider: "tavily", Enabled: false}, StateUnknown},
{"显式停用", ProviderInput{Provider: NameBocha, APIKey: "k", BaseURL: "https://b", Enabled: false}, StateDisabled},
{"云家缺 key", ProviderInput{Provider: NameBocha, BaseURL: "https://b", Enabled: true}, StateNotConfigured},
{"searxng 缺地址", ProviderInput{Provider: NameSearXNG, Enabled: true}, StateNotConfigured},
{"searxng 有地址", ProviderInput{Provider: NameSearXNG, BaseURL: "http://127.0.0.1:38471", Enabled: true}, StateOK},
{"名字大小写与空白不敏感", ProviderInput{Provider: " BOCHA ", APIKey: "k", BaseURL: "https://b", Enabled: true}, StateOK},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := BuildRows([]ProviderInput{tc.in})[0]
if got.State != tc.state {
t.Fatalf("state = %q, want %q", got.State, tc.state)
}
})
}
}
// 判据 ⑦:下拉候选 = 本期内置的全部已登记家(稳定排序),且**含尚未配置的家**
// —— 表单的用途就是第一次配置,只列已配置的会让用户无从选起。
//
// 本判据同时钉住「值 / 显示名分离」(主人 2026-10-06 报障:下拉里全是英文名):
// - `value` 必须**逐字**等于 KnownNames()(它会被写进 web-search.json、被注册表按名取用);
// - `label` 必须非空(否则下拉里是一条空白项),且**括注了 id**(表格/下拉里同时看得见
// 中文名与值,人工核对才对得上)。
func TestProviderOptionsCarryValuesAndLabels(t *testing.T) {
t.Parallel()
got := ProviderOptions()
want := KnownNames()
if strings.Join(providerOptionValues(got), ",") != strings.Join(want, ",") {
t.Fatalf("候选的 value = %v, want %v(值必须逐字等于 KnownNames())", providerOptionValues(got), want)
}
if len(got) == 0 {
t.Fatal("选项为空:表单的下拉一上来就选不了东西")
}
for _, o := range got {
if strings.TrimSpace(o.Label) == "" {
t.Errorf("%q 没有显示名:下拉里会是一条空白项", o.Value)
}
if !strings.Contains(o.Label, o.Value) {
t.Errorf("%q 的显示名 %q 里没有括注 id(面板上就看不到真正落盘的值了)", o.Value, o.Label)
}
}
}
// 判据 ⑦b:标签表与接入位表**必须逐家对齐**两件事 —— 表里的键都是已登记的家(写错的名字
// 会被静默忽略,标签就白加了),且每家都给了**非空**标签(判据⑦只对 ProviderOptions 的
// 现存成员成立;这里直接查表,防的是「标签写了个不存在的 id」)。
func TestProviderLabelsCoverEveryKnownName(t *testing.T) {
t.Parallel()
labels := providerLabels()
for name, label := range labels {
if !Known(name) {
t.Errorf("标签表里的键 %q 不是本期内置的接入位(这个标签永远不会被用到)", name)
}
if strings.TrimSpace(label) == "" {
t.Errorf("%q 的标签是空白", name)
}
}
for _, name := range KnownNames() {
if _, ok := labels[name]; !ok {
t.Errorf("已登记的接入位 %q 没有标签:下拉与表格里只会显示英文 id", name)
}
}
}
// 判据 ⑦c:**未知 id 原样透传** —— 新增接入位而忘加标签(或以后有人手写一个名字)时,
// 面板显示成 id,不崩、不丢、也不编一个假中文名。
func TestProviderLabelFallsBackToID(t *testing.T) {
t.Parallel()
for _, name := range []string{"tavily", "zhipu_x", "", " ", "ZHIPU_X"} {
if got := ProviderLabel(name); got != name {
t.Errorf("ProviderLabel(%q) = %q, want 原样回", name, got)
}
}
// 大小写与首尾空白不敏感(与 Known / Configured 同口径):配置里写 `Zhipu` 也认。
if got, want := ProviderLabel(" Zhipu "), providerLabels()[NameZhipu]; got != want {
t.Errorf("ProviderLabel(的大小写/空白容忍口径) = %q, want %q", got, want)
}
}
// providerOptionValues 取出候选的**值**(落盘真源),丢掉显示名。
func providerOptionValues(opts []ProviderOption) []string {
out := make([]string, 0, len(opts))
for _, o := range opts {
out = append(out, o.Value)
}
return out
}
// 判据 ⑧:动作名是与插件管理器的**对外契约**(设计示例里的那四个字面量)。
// 改字号就是改契约:这里钉住字面量,免得一次「顺手重命名」让前端与设计文档对不上。
func TestDeclaredActionNamesMatchDesignExample(t *testing.T) {
t.Parallel()
want := []string{
"plugin.listProviders", "plugin.saveProvider", "plugin.testProvider", "plugin.reauthProvider",
}
got := ActionNames()
if len(got) != len(want) {
t.Fatalf("ActionNames() = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("ActionNames()[%d] = %q, want %q", i, got[i], want[i])
}
}
}
// 判据 ⑨(切片 W3;裁决 2026-10-06 #2):表单必须能配「谁先用」——`primary` 与 `fallback`
// 两个**文件级**字段就位,且都从 `plugin:providers` 拉候选(否则控件降级成文本框,
// 用户得自己记住 provider 名)。
func TestAdminPanelsDeclareSelectionFields(t *testing.T) {
t.Parallel()
form := adminPanelsFormSection(t)
byKey := make(map[string]sdk.Field, len(form.Fields))
for _, f := range form.Fields {
byKey[f.Key] = f
}
cases := []struct {
key string
wantType sdk.FieldType
wantOptions string
}{
{FieldPrimary, sdk.FieldSelect, "plugin:providers"},
{FieldFallback, sdk.FieldMultiSelect, "plugin:providers"},
}
for _, tc := range cases {
f, ok := byKey[tc.key]
if !ok {
t.Errorf("表单里缺 %q 字段(主 provider / 降级链就没法配了): %+v", tc.key, byKey)
continue
}
if f.Type != tc.wantType {
t.Errorf("字段 %q 类型 = %q, want %q", tc.key, f.Type, tc.wantType)
}
if f.OptionsFrom != tc.wantOptions {
t.Errorf("字段 %q 的 optionsFrom = %q, want %q(下拉必须拉得到候选)", tc.key, f.OptionsFrom, tc.wantOptions)
}
}
// 两个 key 不能互相撞,也不能撞上条目级字段:撞了就是「我在改谁」要猜。
if FieldPrimary == FieldFallback {
t.Fatalf("两个文件级字段的 key 相同(%q)", FieldPrimary)
}
for _, taken := range []string{"provider", "apiKey", "baseUrl", "enabled"} {
if FieldPrimary == taken || FieldFallback == taken {
t.Fatalf("文件级字段的 key 撞上了条目级字段 %q", taken)
}
}
}
// 判据 ⑩(切片 W3):面板**恰好一个 form 区块**这条约束是有原因的,钉在这里免得后人
// 顺手拆成两个表单:
//
// 前端的「带回表单」(reauth)只在恰好一个 form 时转交(`PluginPanel.tsx` 的 singleForm:
// 两个表单没有归属,宁可不填也不猜)。第二处增补因此必须挂在既有表单上 —— 拆成两个 form
// 会让「重配凭据」的回填整条失灵(那是客户端行为,改它不在本片文件面内)。
func TestAdminPanelsKeepASingleFormSection(t *testing.T) {
t.Parallel()
forms := 0
for _, p := range AdminPanels() {
for _, s := range p.Sections {
if s.Type == sdk.SectionForm {
forms++
}
}
}
if forms != 1 {
t.Fatalf("面板里有 %d 个 form 区块,want 1(多一个会让前端的 reauth 回填失效)", forms)
}
}
// adminPanelsFormSection 取唯一那个 form 区块(没有就失败)。
func adminPanelsFormSection(t *testing.T) sdk.Section {
t.Helper()
for _, p := range AdminPanels() {
for _, s := range p.Sections {
if s.Type == sdk.SectionForm {
return s
}
}
}
t.Fatal("面板里没有 form 区块")
return sdk.Section{}
}
// contains 是字符串切片成员判定(测试里只此一处,不值得抽包)。
func contains(list []string, want string) bool {
for _, s := range list {
if s == want {
return true
}
}
return false
}
// 判据 ⑪(切片:结果条数上限真能改):表单里必须有承载「结果条数上限」的 **number** 字段。
//
// 来由是一次真机缺陷:可变源与 `.env` 都认 `maxResults`,但声明里没有这个字段 ⇒ 面板上
// 根本没有输入框(「改不了」),而手工把它塞进提交体又会被写侧静默丢弃。声明字段是这条链
// 的头一环,因此在这里钉住它的类型、边界与 key 同源。
func TestAdminPanelsDeclareMaxResultsField(t *testing.T) {
t.Parallel()
form := adminPanelsFormSection(t)
var got *sdk.Field
for i := range form.Fields {
if form.Fields[i].Key == FieldMaxResults {
got = &form.Fields[i]
}
}
if got == nil {
t.Fatalf("表单里缺 %q 字段(上限在面板上就改不了): %+v", FieldMaxResults, form.Fields)
}
if got.Type != sdk.FieldNumber {
t.Errorf("字段 %q 类型 = %q, want %q(闭集里只有 number 是数字控件)", got.Key, got.Type, sdk.FieldNumber)
}
if got.Min == nil || *got.Min < 1 {
t.Errorf("字段 %q 的 min = %v, want ≥1(0 / 负数在文件里是「未声明」,面板不该把它当成一个合法上限)",
got.Key, got.Min)
}
if strings.TrimSpace(got.Label) == "" || strings.TrimSpace(got.Placeholder) == "" {
t.Errorf("字段 %q 缺标签或说明(label=%q placeholder=%q):「它会压过 .env、是运行期上限」"+
"这件事必须在界面上说清", got.Key, got.Label, got.Placeholder)
}
// key 必须与**落盘真源**同源:声明写一个 key、文件里叫另一个 = 「我改了上限但它没变」。
raw, err := json.Marshal(runtimeconfig.WebSearchFile{MaxResults: 3})
if err != nil {
t.Fatalf("marshal: %v", err)
}
if !strings.Contains(string(raw), `"`+FieldMaxResults+`"`) {
t.Errorf("字段 key %q 与 web-search.json 的键名对不上(落盘真源 = %s)", FieldMaxResults, raw)
}
// 不得撞上条目级字段:撞了就是「我在改谁」要猜。
for _, taken := range []string{"provider", "apiKey", "baseUrl", "enabled"} {
if FieldMaxResults == taken {
t.Fatalf("文件级字段的 key 撞上了条目级字段 %q", taken)
}
}
}