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 = "" }) }, 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) } }