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) } } }