纯库、零接线:不碰 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」打不红,正是因为全序与算法无关;这不是测试虚护。)
250 lines
8.4 KiB
Go
250 lines
8.4 KiB
Go
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)
|
||
}
|
||
}
|