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

@@ -0,0 +1,249 @@
package paneldecl
import (
"errors"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件锁定「非法声明逐条可分类」这条验收线:每一类非法声明一条用例,断言的是
// **分类哨兵 + 出错路径**,不是错误字符串(字符串匹配会在改文案时假绿)。
//
// 用例表按层次分在三个文件里:本文件(插件/面板层与聚合错误)、validate_section_test.go
// (区块层)、validate_field_test.go(字段与表格元素层)。
// rejectCase 是一条拒绝用例。
type rejectCase struct {
name string
// pluginID 为空表示用默认的 "web-search";blankPluginID 用于「插件 id 缺失」这条。
pluginID string
blankPluginID bool
panels func() []sdk.Panel
want error
wantPath string
}
// panelsOf 用 base 造一份面板并施加 edit(edit 为 nil 表示原样)。
func panelsOf(base sdk.Panel, edit func(*sdk.Panel)) []sdk.Panel {
p := base
if edit != nil {
edit(&p)
}
return []sdk.Panel{p}
}
// requireViolation 断言 err 中存在一条 Kind==want 且 Path==wantPath 的违规。
//
// 两条断言缺一不可:errors.Is 证明**可分类**,Path 证明指的是**那一处**(而不是碰巧
// 别的字段也不合法)。
func requireViolation(t *testing.T, err error, want error, wantPath string) {
t.Helper()
if err == nil {
t.Fatalf("期望校验失败(%v @ %s),实际通过", want, wantPath)
}
if !errors.Is(err, want) {
t.Fatalf("errors.Is(err, %v) 为假;err = %v", want, err)
}
var agg *Errors
if !errors.As(err, &agg) {
t.Fatalf("err 不是 *Errors 聚合,实际 %T:%v", err, err)
}
for _, v := range agg.Violations {
if errors.Is(v.Kind, want) && v.Path == wantPath {
return
}
}
t.Fatalf("聚合错误里没有 %v @ %s 这一条;err = %v", want, wantPath, err)
}
// allRejectCases 汇总三个层次的拒绝用例。
func allRejectCases() []rejectCase {
cases := rejectCasesPanelLevel()
cases = append(cases, rejectCasesSectionCommon()...)
cases = append(cases, rejectCasesFormShape()...)
cases = append(cases, rejectCasesTableShape()...)
cases = append(cases, rejectCasesReadOnlySections()...)
cases = append(cases, rejectCasesFieldCore()...)
cases = append(cases, rejectCasesFieldBoundsAndDefaults()...)
cases = append(cases, rejectCasesTableItems()...)
return cases
}
func TestValidate_Rejects(t *testing.T) {
t.Parallel()
for _, tc := range allRejectCases() {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
pluginID := tc.pluginID
if pluginID == "" && !tc.blankPluginID {
pluginID = "web-search"
}
requireViolation(t, Validate(pluginID, tc.panels()), tc.want, tc.wantPath)
})
}
}
// rejectCasesPanelLevel 是插件 id 与面板层的拒绝用例。
func rejectCasesPanelLevel() []rejectCase {
return []rejectCase{
{
name: "插件 id 含空格与大写(不能当路由段)", pluginID: "Web Search",
panels: func() []sdk.Panel { return panelsOf(samplePanel(), nil) },
want: ErrInvalidPluginID, wantPath: "pluginID",
},
{
name: "插件 id 缺失", blankPluginID: true,
panels: func() []sdk.Panel { return panelsOf(samplePanel(), nil) },
want: ErrMissingField, wantPath: "pluginID",
},
{
name: "面板 id 缺失",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.ID = "" })
},
want: ErrMissingField, wantPath: "adminPanels[0].id",
},
{
name: "面板 id 含大写与斜杠",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.ID = "Providers/List" })
},
want: ErrInvalidPanelID, wantPath: "adminPanels[0].id",
},
{
name: "面板标题缺失",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Title = " " })
},
want: ErrMissingField, wantPath: "adminPanels[0].title",
},
{
name: "分组不在闭集(本期只有 plugins)",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Group = "search" })
},
want: ErrUnknownGroup, wantPath: "adminPanels[0].group",
},
{
name: "order 为负",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Order = -1 })
},
want: ErrInvalidOrder, wantPath: "adminPanels[0].order",
},
{
name: "图标名塞了 SVG 片段",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Icon = "<svg onload=x>" })
},
want: ErrInvalidIcon, wantPath: "adminPanels[0].icon",
},
{
name: "面板 id 在本插件内重复",
panels: func() []sdk.Panel {
second := statusPanel()
second.ID = "providers"
return []sdk.Panel{samplePanel(), second}
},
want: ErrDuplicateID, wantPath: "adminPanels[1].id",
},
}
}
// TestValidate_AcceptsSampleDeclaration 锁定「合法声明照单通过」:夹具本身就是 §5.2 的示例,
// 它必须过 —— 否则上面的拒绝用例可能只是因为夹具本来就不合法(假绿)。
func TestValidate_AcceptsSampleDeclaration(t *testing.T) {
t.Parallel()
cases := map[string]sdk.Panel{
"§5.2 示例面板": samplePanel(),
"只读事实面": statusPanel(),
"只读日志卡": logPanel(),
}
for name, panel := range cases {
panel := panel
t.Run(name, func(t *testing.T) {
t.Parallel()
if err := Validate("web-search", []sdk.Panel{panel}); err != nil {
t.Fatalf("合法声明被拒:%v", err)
}
})
}
}
// TestValidate_AllowsDocumentedLeniency 锁定几处**刻意放宽**的口径,防止后续把「收紧」
// 当成默认动作无限加码(设计没禁止的就不该拒)。见 PLG1-report.md。
func TestValidate_AllowsDocumentedLeniency(t *testing.T) {
t.Parallel()
// 空分组 ⇒ 回落到唯一分组(§5.2「本期固定 plugins」),不算必填缺失。
noGroup := samplePanel()
noGroup.Group = ""
// 零区块面板 ⇒ §5.1「一个插件可声明零到多个管理页面」,面板本身可以是纯壳。
shell := samplePanel()
shell.Sections = nil
// when 缺省 ⇒ 无条件显示(条件显示是可选能力)。
noWhen := samplePanel()
noWhen.When = nil
// 列/字段的 label 缺省 ⇒ 宿主回落到 key。
noLabel := samplePanel()
noLabel.Sections[0].Fields[0].Label = ""
noLabel.Sections[1].Columns[0].Label = ""
for name, panel := range map[string]sdk.Panel{
"空分组回落": noGroup, "零区块面板": shell, "when 缺省": noWhen, "label 缺省": noLabel,
} {
panel := panel
t.Run(name, func(t *testing.T) {
t.Parallel()
if err := Validate("web-search", []sdk.Panel{panel}); err != nil {
t.Fatalf("刻意放宽的口径被拒:%v", err)
}
})
}
}
// TestValidatePanel_PathUsesIndex 锁定单面板校验的路径锚点与拒绝粒度(§5.2.1「该面板不注册」)。
func TestValidatePanel_PathUsesIndex(t *testing.T) {
t.Parallel()
if err := ValidatePanel(samplePanel(), 0); err != nil {
t.Fatalf("合法面板被拒:%v", err)
}
broken := statusPanel()
broken.Sections[0].Source = &sdk.Source{Action: "plugin.chain"}
requireViolation(t, ValidatePanel(broken, 7), ErrSectionShape, "adminPanels[7].sections[0].source")
}
// TestErrors_IsBoundedAndClassifiable 锁定聚合错误的两件事:Error() 不无界铺开(一条坏清单
// 可能有几百条违规),但结构化的完整列表一条不少。
func TestErrors_IsBoundedAndClassifiable(t *testing.T) {
t.Parallel()
var vs []Violation
for i := 0; i < maxReportedViolations+3; i++ {
vs = append(vs, violation(ErrInvalidPanelID, panelPath(i)+".id", "坏 id"))
}
err := violationsToError(vs)
if !errors.Is(err, ErrInvalidPanelID) {
t.Fatalf("errors.Is 未穿透聚合:%v", err)
}
var agg *Errors
if !errors.As(err, &agg) {
t.Fatalf("errors.As 未取到 *Errors:%T", err)
}
if len(agg.Violations) != maxReportedViolations+3 {
t.Fatalf("结构化列表条数 = %d,期望 %d", len(agg.Violations), maxReportedViolations+3)
}
if n := strings.Count(err.Error(), "坏 id"); n != maxReportedViolations {
t.Fatalf("Error() 铺设了 %d 条违规,期望最多 %d 条", n, maxReportedViolations)
}
if !strings.Contains(err.Error(), "more") {
t.Fatalf("Error() 应提示还有更多条违规:%s", err.Error())
}
if errors.Is(err, ErrMissingField) {
t.Fatal("无关哨兵不该被判为命中(分类必须精确)")
}
if got := violationsToError(nil); got != nil {
t.Fatalf("空违规应返回 nil,得到 %v", got)
}
}