diff --git a/cabana/form_schema.go b/cabana/form_schema.go new file mode 100644 index 0000000..013d387 --- /dev/null +++ b/cabana/form_schema.go @@ -0,0 +1,28 @@ +package cabana + +import ( + "fmt" + "io/fs" + + "git.golem15.com/golem15/summercms/pact" +) + +// FormSchema is the boot-compiled form contract. The RED stub rejects every +// asset so the ADMIN-01 tests fail on the contract, not on a missing symbol. +type FormSchema struct{} + +// CompileForm will compile config_form.yaml and fields.yaml. It does not yet. +func CompileForm(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSchema, error) { + if pluginID == "" || ctl == nil || fsys == nil { + return nil, fmt.Errorf("cabana: form schema is not implemented") + } + return nil, fmt.Errorf("cabana: form schema is not implemented") +} + +// CanonicalJSON will emit the stable form contract. It does not yet. +func (s *FormSchema) CanonicalJSON() ([]byte, error) { + if s == nil { + return nil, fmt.Errorf("cabana: form schema is not implemented") + } + return nil, fmt.Errorf("cabana: form schema is not implemented") +} diff --git a/cabana/form_schema_test.go b/cabana/form_schema_test.go new file mode 100644 index 0000000..f5ca978 --- /dev/null +++ b/cabana/form_schema_test.go @@ -0,0 +1,334 @@ +package cabana + +import ( + "bytes" + "encoding/json" + "io/fs" + "strings" + "testing" + "testing/fstest" + + "git.golem15.com/golem15/summercms/backpack" + "git.golem15.com/golem15/summercms/party" +) + +const formConfig = `name: demo.form +form: ~/plugins/acme/demo/models/widget/fields.yaml +modelClass: Widget +defaultRedirect: acme/demo/widgets +create: + redirect: acme/demo/widgets/update/:id + redirectClose: acme/demo/widgets +update: + redirect: acme/demo/widgets + redirectClose: acme/demo/widgets +` + +const allFieldsYAML = `fields: + title: + label: demo.lang.title + comment: demo.lang.title_comment + span: left + type: text + required: true + tab: demo.lang.main + context: create + default: Untitled + body: + label: demo.lang.body + span: full + type: textarea + size: small + count: + label: demo.lang.count + span: right + type: number + default: 3 + attributes: + min: 0 + readonly: false + step: 1 + active: + label: demo.lang.active + span: left + type: checkbox + default: false + enabled: + label: demo.lang.enabled + type: switch + default: 0 + format: + label: demo.lang.format + type: dropdown + required: true + emptyOption: demo.lang.none + options: + vinyl: Vinyl + 2: Two + true: "Yes" + artist: + label: demo.lang.artist + span: right + type: relation + nameFrom: name + emptyOption: demo.lang.pick + editors: + label: demo.lang.editors + type: relation-manager + relation: editors + tab: demo.lang.editors_tab + span: full + context: + - update + - create +` + +const allFieldsJSON = `{"name":"demo.form","modelClass":"Widget","fields":[` + + `{"name":"title","type":"text","label":"demo.lang.title","comment":"demo.lang.title_comment","span":"left","tab":"demo.lang.main","context":"create","required":true,"default":"Untitled"},` + + `{"name":"body","type":"textarea","label":"demo.lang.body","span":"full","size":"small"},` + + `{"name":"count","type":"number","label":"demo.lang.count","span":"right","default":3,"attributes":{"min":0,"readonly":false,"step":1}},` + + `{"name":"active","type":"checkbox","label":"demo.lang.active","span":"left","default":false},` + + `{"name":"enabled","type":"switch","label":"demo.lang.enabled","default":0},` + + `{"name":"format","type":"dropdown","label":"demo.lang.format","emptyOption":"demo.lang.none","required":true,"options":[{"value":"vinyl","label":"Vinyl"},{"value":2,"label":"Two"},{"value":true,"label":"Yes"}]},` + + `{"name":"artist","type":"relation","label":"demo.lang.artist","span":"right","nameFrom":"name","emptyOption":"demo.lang.pick"},` + + `{"name":"editors","type":"relation-manager","label":"demo.lang.editors","span":"full","tab":"demo.lang.editors_tab","context":["update","create"],"relation":"editors"}]}` + +func TestFormSchemaCompile(t *testing.T) { + got := compileFormJSON(t, schemaController{model: "Widget"}, formConfig, allFieldsYAML) + if got != allFieldsJSON { + t.Fatalf("compiled form =\n%s\nwant\n%s", got, allFieldsJSON) + } + again := compileFormJSON(t, schemaController{model: "Widget"}, formConfig, allFieldsYAML) + if again != got { + t.Fatalf("recompilation changed JSON:\n%s\n%s", got, again) + } + for _, want := range []string{`"default":false`, `"default":0`, `"value":2`, `"value":true`, `"value":"vinyl"`} { + if !strings.Contains(got, want) { + t.Fatalf("compiled form missing scalar %s:\n%s", want, got) + } + } + if strings.Contains(got, `"value":"2"`) || strings.Contains(got, `"value":"true"`) || strings.Contains(got, `"fields":null`) { + t.Fatalf("scalar type or null fields drifted:\n%s", got) + } + + t.Run("list-only controller still activates", func(t *testing.T) { + _, err := compileRegistry([]controllerRef{{ + plugin: formPlugin{fsys: fstest.MapFS{ + "controllers/widgets/config_list.yaml": &fstest.MapFile{Data: []byte("list: ~/plugins/acme/demo/models/widget/columns.yaml\nmodelClass: Widget\nrecordsPerPage: 20\nshowSearch: true\n")}, + "models/widget/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n name:\n label: Name\n searchable: true\n")}, + }}, + ctl: schemaController{model: "Widget"}, + }}) + if err != nil { + t.Fatalf("list-only registry: %v", err) + } + }) + t.Run("field name case is exact", func(t *testing.T) { + raw := compileFormJSON(t, schemaController{model: "Widget"}, formConfig, "fields:\n Name:\n label: Name\n type: text\n") + if !strings.Contains(raw, `"name":"Name"`) || strings.Contains(raw, `"name":"name"`) { + t.Fatalf("field name was folded: %s", raw) + } + }) +} + +func TestFormSchemaEmpty(t *testing.T) { + cfg := strings.Replace(formConfig, "name: demo.form", "name: empty", 1) + got := compileFormJSON(t, schemaController{model: "Widget"}, cfg, "fields: {}\n") + if got != `{"name":"empty","modelClass":"Widget","fields":[]}` { + t.Fatalf("empty form = %s", got) + } + if strings.Contains(got, `"fields":null`) { + t.Fatal("empty fields serialized as null") + } +} + +func TestFormSchemaSingle(t *testing.T) { + cfg := strings.Replace(formConfig, "name: demo.form", "name: one", 1) + got := compileFormJSON(t, schemaController{model: "Widget"}, cfg, "fields:\n title:\n label: Title\n type: text\n") + want := `{"name":"one","modelClass":"Widget","fields":[{"name":"title","type":"text","label":"Title"}]}` + if got != want { + t.Fatalf("single form =\n%s\nwant\n%s", got, want) + } +} + +func TestFormSchemaOrdering(t *testing.T) { + fields := `fields: + zeta: + label: Z + type: text + alpha: + label: A + type: text + mid: + label: M + type: number +` + first := compileFormJSON(t, schemaController{model: "Widget"}, formConfig, fields) + second := compileFormJSON(t, schemaController{model: "Widget"}, formConfig, fields) + if first != second { + t.Fatalf("order changed between compiles:\n%s\n%s", first, second) + } + zeta := strings.Index(first, `"name":"zeta"`) + alpha := strings.Index(first, `"name":"alpha"`) + mid := strings.Index(first, `"name":"mid"`) + if zeta < 0 || alpha < 0 || mid < 0 || !(zeta < alpha && alpha < mid) { + t.Fatalf("source order lost: %s", first) + } +} + +func TestFormSchemaRejects(t *testing.T) { + cases := []struct { + name string + model string + config string + fields string + want []string + }{ + { + name: "unknown key", + config: formConfig, + fields: "fields:\n title:\n type: text\n cssClass: bad\n", + want: []string{"acme.demo", "acme.demo.widgets", "models/widget/fields.yaml", "cssClass"}, + }, + { + name: "unknown type", + config: formConfig, + fields: "fields:\n title:\n type: widget\n label: Title\n", + want: []string{"acme.demo", "acme.demo.widgets", "models/widget/fields.yaml", "widget"}, + }, + { + name: "duplicate name", + config: formConfig, + fields: "fields:\n title:\n type: text\n label: A\n title:\n type: text\n label: B\n", + want: []string{"acme.demo", "acme.demo.widgets", "models/widget/fields.yaml", "duplicate"}, + }, + { + name: "path escape", + config: strings.Replace(formConfig, "form: ~/plugins/acme/demo/models/widget/fields.yaml", "form: ../../etc/passwd", 1), + fields: "fields: {}\n", + want: []string{"acme.demo", "acme.demo.widgets", "config_form.yaml", "escape"}, + }, + { + name: "mismatched modelClass", + model: "Widget", + config: strings.Replace(formConfig, "modelClass: Widget", "modelClass: widget", 1), + fields: "fields: {}\n", + want: []string{"acme.demo", "acme.demo.widgets", "config_form.yaml", "modelClass"}, + }, + { + name: "partial", + config: formConfig, + fields: "fields:\n editors:\n type: partial\n tab: Editors\n span: full\n", + want: []string{"acme.demo", "acme.demo.widgets", "models/widget/fields.yaml", "partial"}, + }, + { + name: "partial path", + config: formConfig, + fields: "fields:\n editors:\n type: partial\n path: $/golem15/fonoteka/controllers/collections/_editors.htm\n", + want: []string{"acme.demo", "acme.demo.widgets", "models/widget/fields.yaml", "path"}, + }, + { + name: "missing fields", + config: strings.Replace(formConfig, "models/widget/fields.yaml", "models/widget/missing.yaml", 1), + fields: "fields: {}\n", + want: []string{"acme.demo", "acme.demo.widgets", "missing.yaml"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + model := tc.model + if model == "" { + model = "Widget" + } + _, err := CompileForm("acme.demo", schemaController{model: model}, formFS(tc.config, tc.fields)) + if err == nil { + t.Fatal("expected boot error") + } + msg := err.Error() + for _, want := range tc.want { + if want == "duplicate" { + if strings.Contains(msg, "duplicate") || strings.Contains(msg, "already defined") { + continue + } + t.Fatalf("error %q missing duplicate", msg) + } + if want == "escape" { + if strings.Contains(msg, "escape") || strings.Contains(msg, "..") { + continue + } + t.Fatalf("error %q missing escape", msg) + } + if !strings.Contains(msg, want) { + t.Fatalf("error %q missing %q", msg, want) + } + } + }) + } + + t.Run("bad form fails activation", func(t *testing.T) { + fsys := formFS(formConfig, "fields:\n editors:\n type: partial\n tab: Editors\n") + fsys = withList(fsys) + _, err := compileRegistry([]controllerRef{{ + plugin: &formPlugin{fsys: fsys}, + ctl: schemaController{model: "Widget"}, + }}) + if err == nil || !strings.Contains(err.Error(), "partial") { + t.Fatalf("activation err = %v", err) + } + }) +} + +type schemaController struct{ model string } + +func (c schemaController) ID() string { return "acme.demo.widgets" } +func (c schemaController) ModelName() string { + if c.model == "" { + return "Widget" + } + return c.model +} +func (c schemaController) ConfigDir() string { return "controllers/widgets" } + +type formPlugin struct{ fsys fs.FS } + +func (p formPlugin) ID() string { return "acme.demo" } +func (p formPlugin) Requires() []string { return nil } +func (p formPlugin) Register(*backpack.App) error { return nil } +func (p formPlugin) Boot(*backpack.App) error { return nil } +func (p formPlugin) AdminFS() fs.FS { return p.fsys } + +func formFS(config, fields string) fs.FS { + return fstest.MapFS{ + "controllers/widgets/config_form.yaml": &fstest.MapFile{Data: []byte(config)}, + "models/widget/fields.yaml": &fstest.MapFile{Data: []byte(fields)}, + } +} + +func withList(fsys fs.FS) fs.FS { + base, ok := fsys.(fstest.MapFS) + if !ok { + return fsys + } + base["controllers/widgets/config_list.yaml"] = &fstest.MapFile{Data: []byte("list: ~/plugins/acme/demo/models/widget/columns.yaml\nmodelClass: Widget\nrecordsPerPage: 20\nshowSearch: true\n")} + base["models/widget/columns.yaml"] = &fstest.MapFile{Data: []byte("columns:\n name:\n label: Name\n searchable: true\n")} + return base +} + +func compileFormJSON(t *testing.T, ctl schemaController, config, fields string) string { + t.Helper() + schema, err := CompileForm("acme.demo", ctl, formFS(config, fields)) + if err != nil { + t.Fatalf("compile: %v", err) + } + raw, err := schema.CanonicalJSON() + if err != nil { + t.Fatalf("json: %v", err) + } + var buf bytes.Buffer + if err := json.Compact(&buf, raw); err != nil { + t.Fatalf("compact: %v\n%s", err, raw) + } + return buf.String() +} + +var _ party.Plugin = formPlugin{}