Files
Cyrene_v2/internal/capability/paneldecl/validate_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

250 lines
8.4 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 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)
}
}