Files
Cyrene_v2/plugins/tools/io/search/panel_test.go
T
AskaEth a30f4a49ff feat(search): provider 热重载 + primary/fallback(W3)
- 保存被包成事务:快照 → 落盘 → 按新配置重建工具 → 原子替换注册表条目(registry.ReplaceTool
  窄方法,保留运行期 Policy);下一轮即生效,不要求重启
- 若新配置会让后端下次起不来(主家未配齐 fail-fast)⇒ 文件按字节回滚 + 409 config_unstartable
- 声明面补 primary + fallback[](可配可生效);否决「另开 form」方案(会破坏既有 reauth 回填)
- 主 provider 解析口径核查结论:没被 PLG-4 改坏(顺手修了一处日志缺陷)
- 4 个变异自检(丢 Policy/不换条目/回滚 no-op/忽略 fallback)
2026-10-06 15:59:12 +08:00

296 lines
10 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"
"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
}