- 新增 $DATA_DIR/config/plugins/web-search.json(0600 + temp+rename,文件名 = 插件 id)
- 组合根改「先读可变源、缺失回落 .env 播种」;**不新增任何 env 键**(.env 已冻结)
- 多实例:每家 provider 独立 key/baseURL/启用态 + 「测试连通」探针
- 用 PLG-1 的声明库给出该面板的 adminPanels 声明;动作名交给 PLG-2 的通用动作端点分发
(本片自己不开路由)
⚠️ 本片自认越界:改了 cmd/cyrene/wire_web_search_test.go(既有测试,为适配 DataDir);
另有 design/28 的相关注册留给主会话。
219 lines
7.7 KiB
Go
219 lines
7.7 KiB
Go
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])
|
|
}
|
|
}
|
|
}
|
|
|
|
// contains 是字符串切片成员判定(测试里只此一处,不值得抽包)。
|
|
func contains(list []string, want string) bool {
|
|
for _, s := range list {
|
|
if s == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|