Why: The Generative UI proposal U2 needs a versioned, host-validated envelope for agent-authored pages before any backend surface exists: identity/compat/version envelopes, stable per-node ids with source annotations, catalog-driven spec validation, and the surface.updated projection event. Evidence: go vet + go test in this repo (table tests cover a realistic valid spec from the U1 trial shape, violations, limits, and old-payload backward compatibility). Impact: Additive only - UINode gains optional id/source (omitempty), old card/form paths unaffected; no existing symbol changes. Refs: Documents proposal 2026-10-10-generative-ui-plugin.md (r2, U2/U1 sections); Dev task feature/generative-ui-u2
138 lines
4.8 KiB
Go
138 lines
4.8 KiB
Go
package core
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func validSpec() SurfaceSpec {
|
|
return SurfaceSpec{
|
|
Title: "三家候选工具选型对比",
|
|
Nodes: []UINode{
|
|
{Type: UISection, ID: "n1", Title: "一、概览", Children: []UINode{
|
|
{Type: UIMarkdown, ID: "n1a", Source: []string{"doc:eval/rc-01/vendor1"}, Markdown: "本期评估三家候选。"},
|
|
}},
|
|
{Type: UITable, ID: "n2", Source: []string{"doc:eval/rc-01/vendor1", "doc:eval/rc-01/vendor2"}, Table: &UITableSpec{
|
|
Columns: []UITableColumn{{Key: "k", Label: "维度"}, {Key: "v", Label: "值"}},
|
|
Rows: []UITableRow{{ID: "r1", Cells: [][2]string{{"k", "价格"}, {"v", "¥8/人/天"}}}},
|
|
}},
|
|
{Type: UISteps, ID: "n3", Steps: &UIStepsSpec{Current: 1, Steps: []UIStep{
|
|
{Label: "需求冻结", Status: "done"}, {Label: "契约定义", Status: "active"},
|
|
}}},
|
|
{Type: UIForm, ID: "n4", Fields: []UIField{
|
|
{Key: "years", Label: "归档年限", Type: "select", Options: []string{"3", "5"}, Required: true},
|
|
}},
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestValidateSurfaceSpecValid(t *testing.T) {
|
|
if errs := ValidateSurfaceSpec(validSpec(), nil); len(errs) != 0 {
|
|
t.Fatalf("合法规格不应有违规: %v", errs)
|
|
}
|
|
// JSON 往返后仍合法(信封序列化面)。
|
|
b, err := json.Marshal(validSpec())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var back SurfaceSpec
|
|
if err := json.Unmarshal(b, &back); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if errs := ValidateSurfaceSpec(back, nil); len(errs) != 0 {
|
|
t.Fatalf("JSON 往返后不应有违规: %v", errs)
|
|
}
|
|
}
|
|
|
|
func TestValidateSurfaceSpecViolations(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
mut func(*SurfaceSpec)
|
|
want string
|
|
}{
|
|
{"未知类型", func(s *SurfaceSpec) { s.Nodes[0].Type = " hologram" }, "未知节点类型"},
|
|
{"重复节点 id", func(s *SurfaceSpec) { s.Nodes[1].ID = "n1" }, "重复节点 id"},
|
|
{"表格行列不齐", func(s *SurfaceSpec) {
|
|
s.Nodes[1].Table.Rows = []UITableRow{{Cells: [][2]string{{"k", "只有一列"}}}}
|
|
}, "不等长"},
|
|
{"select 缺 options", func(s *SurfaceSpec) {
|
|
s.Nodes[3].Fields[0].Options = nil
|
|
}, "缺 options"},
|
|
{"steps 越界", func(s *SurfaceSpec) { s.Nodes[2].Steps.Current = 5 }, "current 在范围内"},
|
|
{"空 title", func(s *SurfaceSpec) { s.Title = "" }, "title 必填"},
|
|
{"空 nodes", func(s *SurfaceSpec) { s.Nodes = nil }, "nodes 需非空"},
|
|
{"markdown 空", func(s *SurfaceSpec) {
|
|
s.Nodes[0].Children[0].Markdown = ""
|
|
}, "markdown 需要正文"},
|
|
{"progress 越界", func(s *SurfaceSpec) {
|
|
s.Nodes = append(s.Nodes, UINode{Type: UIProgress, Progress: &UIProgressSpec{Value: 1.5}})
|
|
}, "progress.value"},
|
|
{"未知来源", func(s *SurfaceSpec) {}, ""},
|
|
}
|
|
for _, c := range cases {
|
|
s := validSpec()
|
|
c.mut(&s)
|
|
errs := ValidateSurfaceSpec(s, nil)
|
|
if c.want == "" {
|
|
continue
|
|
}
|
|
found := false
|
|
for _, e := range errs {
|
|
if strings.Contains(e, c.want) {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("%s: 期望包含 %q,得到 %v", c.name, c.want, errs)
|
|
}
|
|
}
|
|
// knownSources 非空时:未知来源拒绝、已知来源放行。
|
|
s := validSpec()
|
|
errs := ValidateSurfaceSpec(s, map[string]bool{"doc:eval/rc-01/vendor1": true, "doc:eval/rc-01/vendor2": true})
|
|
if len(errs) != 0 {
|
|
t.Fatalf("已知来源全引用不应违规: %v", errs)
|
|
}
|
|
s.Nodes[0].Children[0].Source = append(s.Nodes[0].Children[0].Source, "doc:eval/rc-99/ghost")
|
|
errs = ValidateSurfaceSpec(s, map[string]bool{"doc:eval/rc-01/vendor1": true})
|
|
if len(errs) == 0 {
|
|
t.Fatal("引用未知来源应拒绝")
|
|
}
|
|
}
|
|
|
|
func TestValidateSurfaceSpecLimits(t *testing.T) {
|
|
deep := UINode{Type: UISection, ID: "d0"}
|
|
cur := &deep
|
|
for i := 1; i <= SurfaceMaxDepth+1; i++ {
|
|
cur.Children = []UINode{{Type: UISection, ID: "d" + string(rune('0'+i%10))}}
|
|
cur = &cur.Children[0]
|
|
}
|
|
if errs := ValidateSurfaceSpec(SurfaceSpec{Title: "t", Nodes: []UINode{deep}}, nil); len(errs) == 0 {
|
|
t.Fatal("超过嵌套深度应拒绝")
|
|
}
|
|
many := make([]UINode, SurfaceMaxNodes+1)
|
|
for i := range many {
|
|
many[i] = UINode{Type: UIMarkdown, Markdown: "x"}
|
|
}
|
|
if errs := ValidateSurfaceSpec(SurfaceSpec{Title: "t", Nodes: many}, nil); len(errs) == 0 {
|
|
t.Fatal("超过节点数应拒绝")
|
|
}
|
|
}
|
|
|
|
func TestUINodeBackwardCompat(t *testing.T) {
|
|
// 旧卡片路径的 UINode JSON(无 id/source)解出后字段为零值,不受影响。
|
|
var n UINode
|
|
if err := json.Unmarshal([]byte(`{"type":"list","items":["a"]}`), &n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n.ID != "" || n.Source != nil || n.Type != UIList || len(n.Items) != 1 {
|
|
t.Fatalf("旧负载解引用异常: %+v", n)
|
|
}
|
|
// 新字段双向序列化。
|
|
b, _ := json.Marshal(UINode{Type: UIMarkdown, ID: "m1", Source: []string{"s1"}, Markdown: "x"})
|
|
if !strings.Contains(string(b), `"id":"m1"`) || !strings.Contains(string(b), `"source":["s1"]`) {
|
|
t.Fatalf("新字段序列化缺失: %s", b)
|
|
}
|
|
}
|