Files
Cyrene_v2/plugins/tools/io/search/panel_test.go
T
AskaEth c46053e276
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(admin): 搜索提供商下拉/多选/表格显示中文名(值仍是 ASCII id)
主人报障:「为什么中文搜索接口……下拉菜单里全给的英文名?你甚至不愿意带个括号
把中文名写上去」。根因不是漏写文案,而是整条链路没有「显示名」的位置:字段
声明只有 optionsFrom,候选动作回的就是 KnownNames() 的纯 id 数组。

- 后端:候选改为对象数组 {value,label};label 表与接入位表同源(键用
  NameSearXNG 等常量),未知 id 原样回。value 逐字等于 KnownNames()。
- 前端:下拉/多选「显示 label、写 value」;表格单元格按 value→label 索引显示,
  查不到回落显示原值。多选从裸输入框改成「候选开关 + 落盘值输入框」。
  解码器本就兼容裸字符串/{value,label}/{id} 三形状 ⇒ 其他插件零影响。

中文名逐字:SearXNG 本地兜底(searxng)/ 智谱 BigModel(zhipu)/
阿里云 IQS 信息查询(aliyun_iqs)/ 博查 Bocha(bocha)/
火山引擎·豆包搜索(volcengine)/ 百度千帆 AI 搜索(baidu_qianfan)。

验收(主会话复跑 + 亲自变异):go build ./... + vet 绿;search 包测试绿;
tsc --noEmit 0 错;新渲染测试 26 checks + 既有 plugin panel 55 / data 90 /
schema values 40 / channel config 70 全绿。我用 sed 把 values.ts 的
option.value 全换成 option.label ⇒ 渲染测试红(「点选把候选项的 id 追加到
降级链末尾」got="bocha, 智谱 BigModel(zhipu)")—— 这正是会把配置文件写坏的
错法;还原后 md5 一致且绿。

⚠️ 契约面变更(我在 Docs/api-client.md 登记):plugin.providers 候选响应形状
字符串数组 → 对象数组;label 仅供显示、value 才是落盘值。
⚠️ 已知边界:viewer 角色看不到 form 区块 ⇒ 表格对 viewer 仍显示裸 id(未做)。
⚠️ 真机浏览器点击未跑(项目无 jsdom)⇒ 建议刷新管理页点一次确认观感。
2026-10-07 10:08:19 +08:00

352 lines
13 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
}