package search import ( "encoding/json" "sort" "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) } }) } } // 判据 ⑦:下拉选项 = 本期内置的全部已登记家(稳定排序),且**含尚未配置的家** // —— 表单的用途就是第一次配置,只列已配置的会让用户无从选起。 func TestProviderOptionsAreAllKnownNames(t *testing.T) { t.Parallel() got := ProviderOptions() want := append([]string(nil), KnownNames()...) sort.Strings(want) if strings.Join(got, ",") != strings.Join(want, ",") { t.Fatalf("ProviderOptions() = %v, want %v", got, want) } if len(got) == 0 { t.Fatal("选项为空:表单的下拉一上来就选不了东西") } } // 判据 ⑧:动作名是与插件管理器的**对外契约**(设计示例里的那四个字面量)。 // 改字号就是改契约:这里钉住字面量,免得一次「顺手重命名」让前端与设计文档对不上。 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 }