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