feat(plugins): 插件「管理面板声明」库——声明模型 + 闭集校验 + catalog(design/09 §5.2/§5.2.1)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

纯库、零接线:不碰 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」打不红,正是因为全序与算法无关;这不是测试虚护。)
This commit is contained in:
AskaEth committed 2026-10-06 08:06:20 +08:00
1 parent 6e28b38b08
commit c9144c1096
19 files changed
+3053

No files matched your search

+229
View File
@@ -0,0 +1,229 @@
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)
}
}
}