Files
Cyrene_v2/pkg/plugins/sdk/panel_test.go
T
AskaEth c9144c1096
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(plugins): 插件「管理面板声明」库——声明模型 + 闭集校验 + catalog(design/09 §5.2/§5.2.1)
纯库、零接线:不碰 gateway/frontend/Docs/.env*(本片刻意不接线,避免与在途切片争用文件面)。

- pkg/plugins/sdk:面板声明类型(JSON tag 与 design/09 §5.2 示例逐字同形);
  字段类型与 section 类型都是**闭集**;When 三态 + **严格 UnmarshalJSON**(未知条件键报错,
  不静默丢)
- internal/capability/paneldecl:闭集校验(47 条拒绝用例,断言的是**分类哨兵 + JSON 路径**,
  不是错误字符串)· catalog(并发安全、返回拷贝、按 (group, order, 登记序号) 的**全序**稳定排序)·
  when 求值(上下文由 Resolver 注入,本包不读环境变量/文件)· 角色过滤(viewer 只留
  status/log/table 且摘掉 rowActions,纯写面板整体隐藏;未知角色 fail-closed)
- 拒绝粒度:插件 id 非法 ⇒ 整插件拒绝;单个面板非法 ⇒ 只拒该面板 + 聚合显式报错(理由见报告)

【我的验收】隔离 worktree(19 文件)build/vet/linux/darwin 全过、69 包全绿;
我亲手打红变异:把同组同序的回退比较  反成  ⇒ TestCatalog_StableOrder FAIL。
(记录一条观察:比较器已靠 seq 构成全序,故 sort.SliceStable 的稳定性是冗余保护 ——
我第一次用「SliceStable→Slice」打不红,正是因为全序与算法无关;这不是测试虚护。)
2026-10-06 08:06:20 +08:00

230 lines
7.8 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 sdk
import (
"encoding/json"
"errors"
"reflect"
"testing"
)
// 本文件锁定两件事:
// 1. 声明的 JSON tag 与 design/09 §5.2 的示例**逐字同形**(示例 JSON 直接解码进来);
// 2. 闭集(分组 / section 类型 / 字段类型)与 when 的严格解码确实按闭集工作。
// designExample 是 design/09-插件生态 §5.2 示例里 adminPanels 段的**逐字拷贝**
// (只去掉 JSONC 注释)。它在这里是权威输入:tag 一旦改坏,本用例必红。
const designExample = `[
{
"id": "providers",
"title": "搜索提供商",
"group": "plugins",
"order": 20,
"icon": "search",
"when": { "enabled": true },
"sections": [
{ "type": "form", "id": "add", "title": "添加提供商",
"fields": [
{ "key": "provider", "label": "提供商", "type": "select", "optionsFrom": "plugin:providers", "required": true },
{ "key": "apiKey", "label": "API Key", "type": "secret", "required": true },
{ "key": "endpoint", "label": "接口地址", "type": "text", "placeholder": "https://..." }
],
"submit": { "action": "plugin.saveProvider" } },
{ "type": "table", "id": "list", "title": "已配置的提供商",
"source": { "action": "plugin.listProviders" },
"columns": [
{ "key": "provider", "label": "提供商" },
{ "key": "state", "label": "状态" }
],
"rowActions": [
{ "id": "test", "label": "测试连通", "action": "plugin.testProvider" },
{ "id": "reauth","label": "重配凭据", "action": "plugin.reauthProvider" }
] }
]
}]`
// TestPanel_DecodesDesignExample 证明 §5.2 的示例能原样解码进我们的类型。
func TestPanel_DecodesDesignExample(t *testing.T) {
t.Parallel()
var panels []Panel
if err := json.Unmarshal([]byte(designExample), &panels); err != nil {
t.Fatalf("§5.2 示例解码失败(说明 JSON tag 与文档不同形):%v", err)
}
if len(panels) != 1 {
t.Fatalf("面板数 = %d,期望 1", len(panels))
}
p := panels[0]
if p.ID != "providers" || p.Title != "搜索提供商" || p.Group != GroupPlugins {
t.Fatalf("面板头字段解码错误:%+v", p)
}
if p.Order != 20 || p.Icon != "search" {
t.Fatalf("order/icon 解码错误:%+v", p)
}
if p.When == nil || p.When.Enabled == nil || !*p.When.Enabled {
t.Fatalf("when 解码错误:%+v", p.When)
}
if len(p.Sections) != 2 {
t.Fatalf("区块数 = %d,期望 2", len(p.Sections))
}
form := p.Sections[0]
if form.Type != SectionForm || form.ID != "add" || form.Submit == nil ||
form.Submit.Action != "plugin.saveProvider" {
t.Fatalf("form 区块解码错误:%+v", form)
}
if len(form.Fields) != 3 || form.Fields[0].OptionsFrom != "plugin:providers" ||
!form.Fields[0].Required || form.Fields[1].Type != FieldSecret ||
form.Fields[2].Placeholder != "https://..." {
t.Fatalf("字段解码错误:%+v", form.Fields)
}
table := p.Sections[1]
if table.Type != SectionTable || table.Source == nil ||
table.Source.Action != "plugin.listProviders" {
t.Fatalf("table 区块解码错误:%+v", table)
}
if len(table.Columns) != 2 || table.Columns[1].Key != "state" {
t.Fatalf("列解码错误:%+v", table.Columns)
}
if len(table.RowActions) != 2 || table.RowActions[1].ID != "reauth" ||
table.RowActions[1].Action != "plugin.reauthProvider" {
t.Fatalf("行内动作解码错误:%+v", table.RowActions)
}
}
// TestPanel_RoundTrips 锁反向:编码再解码必须一模一样(tag 只对了一半就会在这里露出来)。
func TestPanel_RoundTrips(t *testing.T) {
t.Parallel()
var first []Panel
if err := json.Unmarshal([]byte(designExample), &first); err != nil {
t.Fatalf("解码: %v", err)
}
raw, err := json.Marshal(first)
if err != nil {
t.Fatalf("编码: %v", err)
}
var second []Panel
if err := json.Unmarshal(raw, &second); err != nil {
t.Fatalf("再解码: %v (%s)", err, raw)
}
if !reflect.DeepEqual(first, second) {
t.Fatalf("编码往返不一致:\n%+v\n%+v", first, second)
}
}
// TestWhen_StrictDecode 锁定 when 是**闭集**:未知条件键必须显式报错,不能被静默丢掉。
func TestWhen_StrictDecode(t *testing.T) {
t.Parallel()
cases := []struct {
name string
raw string
want error
}{
{"只有 enabled", `{"enabled":true}`, nil},
{"空对象(无条件)", `{}`, nil},
{"未知条件键", `{"configured":true}`, ErrUnknownWhenKey},
{"未知条件键(多个,报错必须确定)", `{"zed":1,"alpha":2}`, ErrUnknownWhenKey},
{"enabled 不是布尔", `{"enabled":"yes"}`, ErrInvalidWhen},
{"不是对象", `true`, ErrInvalidWhen},
{"是 null", `null`, ErrInvalidWhen},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var w When
err := json.Unmarshal([]byte(tc.raw), &w)
if tc.want == nil {
if err != nil {
t.Fatalf("解码 %s 失败: %v", tc.raw, err)
}
return
}
if !errors.Is(err, tc.want) {
t.Fatalf("解码 %s err = %v,期望 %v", tc.raw, err, tc.want)
}
})
}
}
// TestWhen_UnknownKeyIsDeterministic 锁定多未知键时报的是同一个键(错误信息不能随 map 迭代变)。
func TestWhen_UnknownKeyIsDeterministic(t *testing.T) {
t.Parallel()
var first error
for i := 0; i < 20; i++ {
var w When
err := json.Unmarshal([]byte(`{"zeta":1,"alpha":2,"mu":3}`), &w)
if first == nil {
first = err
continue
}
if err == nil || err.Error() != first.Error() {
t.Fatalf("第 %d 次报错 %v,与首次 %v 不一致", i, err, first)
}
}
}
// TestWhen_PropagatesThroughPanel 锁定严格性会穿透面板解码 —— 插件管理器拿到的就是这一层。
func TestWhen_PropagatesThroughPanel(t *testing.T) {
t.Parallel()
raw := `[{"id":"p","title":"t","group":"plugins","order":1,` +
`"when":{"enabled":true,"configured":true},"sections":[]}]`
var panels []Panel
err := json.Unmarshal([]byte(raw), &panels)
if !errors.Is(err, ErrUnknownWhenKey) {
t.Fatalf("err = %v,期望 ErrUnknownWhenKey 穿透到面板解码", err)
}
}
// TestClosedSets 锁定三个闭集与它们的判定方法。新增成员必须同时改这里 —— 这正是闭集的意义。
func TestClosedSets(t *testing.T) {
t.Parallel()
for _, tt := range []FieldType{FieldText, FieldSecret, FieldNumber, FieldSwitch,
FieldSelect, FieldMultiSelect, FieldDate, FieldKV} {
if !tt.Valid() {
t.Errorf("字段类型 %q 应当合法", tt)
}
}
if FieldType("chart").Valid() {
t.Error("字段类型 chart 不该合法(闭集外)")
}
if FieldType("").Valid() {
t.Error("空字段类型不该合法")
}
for _, st := range []SectionType{SectionForm, SectionTable, SectionStatus, SectionLog} {
if !st.Valid() {
t.Errorf("区块类型 %q 应当合法", st)
}
}
if SectionType("chart").Valid() {
t.Error("区块类型 chart 不该合法(闭集外)")
}
if !GroupPlugins.Valid() || GroupID("search").Valid() {
t.Error("分组闭集判定错误(本期只有 plugins)")
}
}
// TestTypeHelpers 锁定三个语义判定:敏感、数值、要拉选项。
func TestTypeHelpers(t *testing.T) {
t.Parallel()
if !FieldSecret.Sensitive() || FieldText.Sensitive() {
t.Error("Sensitive 判定错误")
}
if !FieldNumber.Numeric() || FieldDate.Numeric() {
t.Error("Numeric 判定错误(只有 number 接受 min/max)")
}
if !FieldSelect.PullsOptions() || !FieldMultiSelect.PullsOptions() || FieldText.PullsOptions() {
t.Error("PullsOptions 判定错误")
}
}
// TestSectionType_ReadOnly 锁定角色过滤的第一道判据:只有 form 自带写面。
func TestSectionType_ReadOnly(t *testing.T) {
t.Parallel()
if SectionForm.ReadOnly() {
t.Error("form 不是只读区块(它有 submit)")
}
for _, st := range []SectionType{SectionTable, SectionStatus, SectionLog} {
if !st.ReadOnly() {
t.Errorf("%q 应当是只读区块", st)
}
}
}