- form extensions: placement across three plugins, required plugins' tabs and fields, Hide with neighbours and whole tabs, spans, grouping only of extended forms; every remaining boot error with plugin, controller and offending name - record events: the pure snapshot/diff (create, update, pointers, times as instants, timestamps and key omitted, per-locale translations) and the value helpers - descriptors: MatchPattern edge cases, MatchPath ordering, types without Content, duplicate Model/Controller across plugins
339 lines
17 KiB
Go
339 lines
17 KiB
Go
package cabana
|
|
|
|
import (
|
|
"io/fs"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/party"
|
|
)
|
|
|
|
// The D-91 unit matrix (no database): placement across several plugins,
|
|
// tabs and Hide, and every form extension boot error with the plugin, the
|
|
// controller and the offending name. The smoke tests in
|
|
// form_extensions_smoke_test.go cover the first cases of each table.
|
|
|
|
// feText is an extension field document line set: a text field name with
|
|
// optional placement keys ("after: a", "tab: T1").
|
|
func feText(name string, keys ...string) string {
|
|
out := " " + name + ":\n type: text\n"
|
|
for _, key := range keys {
|
|
out += " " + key + "\n"
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestFormExtensionPlacementMatrix(t *testing.T) {
|
|
type layout struct{ names, tabs string }
|
|
cases := []struct {
|
|
name string
|
|
plugins func() []party.Plugin
|
|
want layout
|
|
}{
|
|
// party activates plugins in Requires() order; cabana places their
|
|
// fields in that order.
|
|
{"three plugins on one anchor keep their Requires order", func() []party.Plugin {
|
|
return []party.Plugin{
|
|
feBlog(),
|
|
feExt("acme.one", nil, "acme.blog.posts", "fields:\n"+feText("c1", "after: a")),
|
|
feExt("acme.two", []string{"acme.one"}, "acme.blog.posts", "fields:\n"+feText("c2", "after: a")),
|
|
feExt("acme.three", []string{"acme.two"}, "acme.blog.posts", "fields:\n"+feText("c3", "after: a")),
|
|
}
|
|
}, layout{"a c1 c2 c3 b", "default"}},
|
|
{"three plugins before one anchor keep their Requires order", func() []party.Plugin {
|
|
return []party.Plugin{
|
|
feBlog(),
|
|
feExt("acme.one", nil, "acme.blog.posts", "fields:\n"+feText("c1", "before: b")),
|
|
feExt("acme.two", []string{"acme.one"}, "acme.blog.posts", "fields:\n"+feText("c2", "before: b")),
|
|
feExt("acme.three", []string{"acme.two"}, "acme.blog.posts", "fields:\n"+feText("c3", "before: b")),
|
|
}
|
|
}, layout{"a c1 c2 c3 b", "default"}},
|
|
{"an anchor on a required plugin's field", func() []party.Plugin {
|
|
return []party.Plugin{
|
|
feBlog(),
|
|
feExt("acme.one", nil, "acme.blog.posts", "fields:\n"+feText("c1", "after: a")),
|
|
feExt("acme.two", []string{"acme.one"}, "acme.blog.posts", "fields:\n"+feText("c2", "before: c1")),
|
|
}
|
|
}, layout{"a c2 c1 b", "default"}},
|
|
{"a field in a required plugin's tab", func() []party.Plugin {
|
|
return []party.Plugin{
|
|
feBlog(),
|
|
feExt("acme.one", nil, "acme.blog.posts", "tabs:\n - name: N\nfields:\n"+feText("n1", "tab: N")),
|
|
feExt("acme.two", []string{"acme.one"}, "acme.blog.posts", "fields:\n"+feText("n2", "tab: N")),
|
|
}
|
|
}, layout{"a b n1 n2", "default N"}},
|
|
{"a tab after a required plugin's tab", func() []party.Plugin {
|
|
return []party.Plugin{
|
|
feBlog("a", "x:T1"),
|
|
feExt("acme.one", nil, "acme.blog.posts", "tabs:\n - name: N\n after: default\nfields:\n"+feText("n", "tab: N")),
|
|
feExt("acme.two", []string{"acme.one"}, "acme.blog.posts", "tabs:\n - name: M\n after: N\nfields:\n"+feText("m", "tab: M")),
|
|
}
|
|
}, layout{"a n m x", "default N M T1"}},
|
|
{"a new tab right after the default tab of a tabbed form", func() []party.Plugin {
|
|
return []party.Plugin{
|
|
feBlog("a", "x:T1", "y:T2"),
|
|
feExt("acme.extra", nil, "acme.blog.posts", "tabs:\n - name: N\n after: default\nfields:\n"+feText("n", "tab: N")),
|
|
}
|
|
}, layout{"a n x y", "default N T1 T2"}},
|
|
{"a field before a tab's first field joins that tab", func() []party.Plugin {
|
|
return []party.Plugin{
|
|
feBlog("a", "x:T1", "y:T1"),
|
|
feExt("acme.extra", nil, "acme.blog.posts", "fields:\n"+feText("c", "before: x")),
|
|
}
|
|
}, layout{"a c x y", "default T1"}},
|
|
{"a field anchored in its own tab with tab: named", func() []party.Plugin {
|
|
return []party.Plugin{
|
|
feBlog("a", "x:T1", "y:T1"),
|
|
feExt("acme.extra", nil, "acme.blog.posts", "fields:\n"+feText("c", "after: x", "tab: T1")),
|
|
}
|
|
}, layout{"a x c y", "default T1"}},
|
|
{"fields of one extension go to several tabs", func() []party.Plugin {
|
|
return []party.Plugin{
|
|
feBlog("a", "x:T1", "z:T2"),
|
|
feExt("acme.extra", nil, "acme.blog.posts", "fields:\n"+feText("c", "after: a")+feText("d", "tab: T2")+feText("e", "before: x")),
|
|
}
|
|
}, layout{"a c e x z d", "default T1 T2"}},
|
|
{"hide two owner fields of the default tab", func() []party.Plugin {
|
|
return []party.Plugin{
|
|
feBlog("a", "b", "x:T1"),
|
|
feExt("acme.extra", nil, "acme.blog.posts", "", func(e *pact.AdminFormExtension) { e.Hide = []string{"a", "b"} }),
|
|
}
|
|
}, layout{"x", "T1"}},
|
|
{"hide a field and anchor on its neighbour", func() []party.Plugin {
|
|
return []party.Plugin{
|
|
feBlog("a", "b", "y"),
|
|
feExt("acme.extra", nil, "acme.blog.posts", "fields:\n"+feText("c", "after: a"), func(e *pact.AdminFormExtension) { e.Hide = []string{"b"} }),
|
|
}
|
|
}, layout{"a c y", "default"}},
|
|
{"hide every field of a tab and add a tab in its place", func() []party.Plugin {
|
|
return []party.Plugin{
|
|
feBlog("a", "x:T1", "z:T3"),
|
|
feExt("acme.extra", nil, "acme.blog.posts", "tabs:\n - name: T2\n after: T1\nfields:\n"+feText("n", "tab: T2"), func(e *pact.AdminFormExtension) { e.Hide = []string{"x"} }),
|
|
}
|
|
}, layout{"a n z", "default T2 T3"}},
|
|
{"an interleaved owner list is grouped when extended", func() []party.Plugin {
|
|
return []party.Plugin{
|
|
feBlog("x:T1", "a", "y:T2", "x2:T1", "b"),
|
|
feExt("acme.extra", nil, "acme.blog.posts", "fields:\n"+feText("c", "after: b")),
|
|
}
|
|
}, layout{"a b c x x2 y", "default T1 T2"}},
|
|
{"an interleaved owner list stays as written when not extended", func() []party.Plugin {
|
|
return []party.Plugin{
|
|
feBlog("x:T1", "a", "y:T2", "x2:T1", "b"),
|
|
feExt("acme.extra", nil, "acme.news.posts", "fields:\n"+feText("c")),
|
|
feOwnerPlugin{id: "acme.news", fields: feFields("y")},
|
|
}
|
|
}, layout{"x a y x2 b", "T1 default T2"}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
for range 3 {
|
|
names, tabs := feLayout(t, "acme.blog.posts", tc.plugins()...)
|
|
got := layout{feJoin(names), feJoin(tabs)}
|
|
if got != tc.want {
|
|
t.Fatalf("fields = %s, tabs = %s; want fields %s, tabs %s", got.names, got.tabs, tc.want.names, tc.want.tabs)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("span left after a span left keeps both values", func(t *testing.T) {
|
|
// cabana keeps each field's span; the SPA starts a new row when a
|
|
// left field follows a left field, so an inserted field never
|
|
// changes its neighbours' spans.
|
|
owner := feOwnerPlugin{id: "acme.blog", fields: "fields:\n a:\n type: text\n span: left\n b:\n type: text\n span: right\n"}
|
|
ext := feExt("acme.extra", nil, "acme.blog.posts", "fields:\n c:\n type: text\n span: left\n after: a\n")
|
|
reg, err := feCompile(owner, ext)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cc, _ := reg.Get("acme.blog.posts")
|
|
var got []string
|
|
for _, field := range cc.Form.Fields {
|
|
got = append(got, field.Name+"="+field.Span)
|
|
}
|
|
if strings.Join(got, " ") != "a=left c=left b=right" {
|
|
t.Fatalf("spans = %v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("an extension field is registered as the contributor's", func(t *testing.T) {
|
|
reg, err := feCompile(feBlog(), feExt("acme.extra", nil, "acme.blog.posts", "fields:\n"+feText("c", "after: a")))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cc, _ := reg.Get("acme.blog.posts")
|
|
if ext := cc.extensionOf("c"); ext == nil || ext.pluginID != "acme.extra" {
|
|
t.Fatalf("extensionOf(c) = %+v", ext)
|
|
}
|
|
if cc.extensionOf("a") != nil {
|
|
t.Fatal("an owner field is an extension's")
|
|
}
|
|
for _, field := range cc.Writable {
|
|
if field.Name == "c" {
|
|
t.Fatalf("the extension field c is in the owner's writable fields: %+v", cc.Writable)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// feNoForm owns a controller without config_form.yaml.
|
|
type feNoForm struct{ feOwnerPlugin }
|
|
|
|
func (p feNoForm) AdminFS() fs.FS {
|
|
fsys := p.feOwnerPlugin.AdminFS().(fstest.MapFS)
|
|
out := fstest.MapFS{}
|
|
for name, file := range fsys {
|
|
if name != "controllers/posts/config_form.yaml" {
|
|
out[name] = file
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestFormExtensionBootErrorsMatrix(t *testing.T) {
|
|
const prefix = `cabana: plugin acme.extra: controller "acme.blog.posts": `
|
|
widget := func(doc string, actions ...pact.AdminAction) feContributor {
|
|
ext := feExtWidgetPlugin(doc)
|
|
if actions != nil {
|
|
ext.extensions[0].Form.Actions = actions
|
|
}
|
|
return ext
|
|
}
|
|
widgetDoc := "fields:\n title:\n type: text\n w:\n type: widget\n widget: acme-extra-preview\n action: preview\n"
|
|
cases := []struct {
|
|
name string
|
|
plugins []party.Plugin
|
|
want []string
|
|
}{
|
|
{"a controller without a form", []party.Plugin{feNoForm{feBlog()}, feExt("acme.extra", nil, "acme.blog.posts", "")},
|
|
[]string{"cabana: plugin acme.extra", "acme.blog.posts", "has no form"}},
|
|
{"an anchor hidden by an earlier plugin", []party.Plugin{feBlog(),
|
|
feExt("acme.one", nil, "acme.blog.posts", "", func(e *pact.AdminFormExtension) { e.Hide = []string{"b"} }),
|
|
feExt("acme.extra", []string{"acme.one"}, "acme.blog.posts", "fields:\n"+feText("c", "after: b"))},
|
|
[]string{prefix, "field c", `anchor "b"`, "hidden by plugin acme.one"}},
|
|
{"hide of another plugin's field", []party.Plugin{feBlog(),
|
|
feExt("acme.one", nil, "acme.blog.posts", "fields:\n"+feText("c1")),
|
|
feExt("acme.extra", []string{"acme.one"}, "acme.blog.posts", "", func(e *pact.AdminFormExtension) { e.Hide = []string{"c1"} })},
|
|
[]string{prefix, `Hide "c1"`, "belongs to plugin acme.one"}},
|
|
{"a tab named default", []party.Plugin{feBlog(), feExt("acme.extra", nil, "acme.blog.posts", "tabs:\n - name: default\n")},
|
|
[]string{prefix, "tab default", "reserved"}},
|
|
{"a tab after one tab and before an earlier one", []party.Plugin{feBlog("a", "x:T1", "z:T3"), feExt("acme.extra", nil, "acme.blog.posts", "tabs:\n - name: N\n after: T3\n before: T1\n")},
|
|
[]string{prefix, "tab N", `after "T3"`, `before "T1"`}},
|
|
{"a field after one field and before an earlier one", []party.Plugin{feBlog(), feExt("acme.extra", nil, "acme.blog.posts", "fields:\n"+feText("c", "after: b", "before: a"))},
|
|
[]string{prefix, "field c", `after "b"`, `before "a"`}},
|
|
{"a field whose anchor is in another tab", []party.Plugin{feBlog("a", "x:T1", "z:T2"), feExt("acme.extra", nil, "acme.blog.posts", "fields:\n"+feText("c", "after: x", "tab: T2"))},
|
|
[]string{prefix, "field c", `its anchor is in tab "T1", not "T2"`}},
|
|
{"a field in another plugin's tab outside Requires()", []party.Plugin{feBlog(),
|
|
feExt("acme.one", nil, "acme.blog.posts", "tabs:\n - name: N\nfields:\n"+feText("n1", "tab: N")),
|
|
feExt("acme.extra", nil, "acme.blog.posts", "fields:\n"+feText("n2", "tab: N"))},
|
|
[]string{prefix, "field n2", `tab "N"`, "acme.one", "Requires()"}},
|
|
{"a tab anchored on another plugin's tab outside Requires()", []party.Plugin{feBlog(),
|
|
feExt("acme.one", nil, "acme.blog.posts", "tabs:\n - name: N\nfields:\n"+feText("n1", "tab: N")),
|
|
feExt("acme.extra", nil, "acme.blog.posts", "tabs:\n - name: M\n after: N\n")},
|
|
[]string{prefix, "tab M", `anchor "N"`, "acme.one", "Requires()"}},
|
|
{"an unsupported field type", []party.Plugin{feBlog(), feExt("acme.extra", nil, "acme.blog.posts", "fields:\n r:\n type: repeater\n form:\n fields:\n q:\n type: text\n")},
|
|
[]string{prefix, "field r", "type repeater is not supported in a form extension"}},
|
|
{"a dropdown with an options method", []party.Plugin{feBlog(), feExt("acme.extra", nil, "acme.blog.posts", "fields:\n d:\n type: dropdown\n options: listThings\n")},
|
|
[]string{prefix, "field d", "options"}},
|
|
{"dependsOn", []party.Plugin{feBlog(), feExt("acme.extra", nil, "acme.blog.posts", "fields:\n d:\n type: dropdown\n dependsOn: a\n")},
|
|
[]string{prefix, "field d", "dependsOn is not supported"}},
|
|
{"a widget action that is not one of the extension's Actions", []party.Plugin{feBlog(), widget(strings.Replace(widgetDoc, "action: preview", "action: nope", 1))},
|
|
[]string{prefix, "field w", "action nope is not one of the extension's Actions"}},
|
|
{"a widget fill that is not a value field of the extension", []party.Plugin{feBlog(), widget(widgetDoc + " fill: [a]\n")},
|
|
[]string{prefix, "field w", "fill a is not a value field of this extension"}},
|
|
{"a widget without an element script", []party.Plugin{feBlog(), feExt("acme.extra", nil, "acme.blog.posts", widgetDoc, func(e *pact.AdminFormExtension) {
|
|
e.Actions = []pact.AdminAction{{Name: "preview", Label: "Preview", Run: feActionRun}}
|
|
})}, []string{prefix, "field w", "element script"}},
|
|
{"a widget action whose name is not an identifier", []party.Plugin{feBlog(), widget("", pact.AdminAction{Name: "pre view", Run: feActionRun})},
|
|
[]string{prefix, `widget action "pre view"`, "not an identifier"}},
|
|
{"a widget action with a reserved name", []party.Plugin{feBlog(), widget("", pact.AdminAction{Name: "create", Run: feActionRun})},
|
|
[]string{prefix, "widget action create", "reserved"}},
|
|
{"a widget action without Run", []party.Plugin{feBlog(), widget("", pact.AdminAction{Name: "preview"})},
|
|
[]string{prefix, "widget action preview", "no Run function"}},
|
|
{"a widget action declared twice", []party.Plugin{feBlog(), widget("", pact.AdminAction{Name: "preview", Run: feActionRun}, pact.AdminAction{Name: "preview", Run: feActionRun})},
|
|
[]string{prefix, "widget action preview", "declared twice"}},
|
|
{"a partial without PartialData", []party.Plugin{feBlog(), func() feContributor {
|
|
ext := feExtPartialPlugin("fields:\n p:\n type: partial\n path: status\n")
|
|
ext.extensions[0].Form.PartialData = nil
|
|
return ext
|
|
}()}, []string{prefix, "partial status", "PartialData"}},
|
|
{"a partial without Dir", []party.Plugin{feBlog(), func() feContributor {
|
|
ext := feExtPartialPlugin("fields:\n p:\n type: partial\n path: status\n")
|
|
ext.extensions[0].Form.Dir = ""
|
|
return ext
|
|
}()}, []string{prefix, "partial status", `Dir ""`, "must name a directory"}},
|
|
{"a partial Dir outside the AdminFS", []party.Plugin{feBlog(), func() feContributor {
|
|
ext := feExtPartialPlugin("fields:\n p:\n type: partial\n path: status\n")
|
|
ext.extensions[0].Form.Dir = "../partials"
|
|
return ext
|
|
}()}, []string{prefix, "partial status", `Dir "../partials"`, "must name a directory"}},
|
|
{"a partial template missing from Dir", []party.Plugin{feBlog(), func() feContributor {
|
|
ext := feExtPartialPlugin("fields:\n p:\n type: partial\n path: status\n")
|
|
ext.extensions[0].Form.Dir = "nowhere"
|
|
return ext
|
|
}()}, []string{prefix, "partial status", "template nowhere/_status.htm is not in the plugin's embedded files"}},
|
|
{"an unknown zone", []party.Plugin{feBlog(), feExt("acme.extra", nil, "acme.blog.posts", "fields:\n"+feText("c", "zone: sidebar"))},
|
|
[]string{prefix, "field c", `unknown zone "sidebar"`}},
|
|
{"tabs that are not a list", []party.Plugin{feBlog(), feExt("acme.extra", nil, "acme.blog.posts", "tabs:\n N: {}\n")},
|
|
[]string{prefix, "tabs must be a list"}},
|
|
{"a tab without a name", []party.Plugin{feBlog(), feExt("acme.extra", nil, "acme.blog.posts", "tabs:\n - after: default\n")},
|
|
[]string{prefix, "tabs[0]", "name is required"}},
|
|
{"an unknown tab key", []party.Plugin{feBlog(), feExt("acme.extra", nil, "acme.blog.posts", "tabs:\n - name: N\n color: red\n")},
|
|
[]string{prefix, "tabs[0]", "unknown key color"}},
|
|
{"two YAML documents", []party.Plugin{feBlog(), feExt("acme.extra", nil, "acme.blog.posts", "fields: {}\n---\nfields: {}\n")},
|
|
[]string{prefix, "one YAML document"}},
|
|
{"a document that is not a mapping", []party.Plugin{feBlog(), feExt("acme.extra", nil, "acme.blog.posts", "- fields\n")},
|
|
[]string{prefix, "must be a mapping"}},
|
|
{"a Fields file that does not exist", []party.Plugin{feBlog(), feExt("acme.extra", nil, "acme.blog.posts", "", func(e *pact.AdminFormExtension) {
|
|
e.FieldsYAML = nil
|
|
e.Fields = "extensions/missing.yaml"
|
|
})}, []string{prefix, "form extension fields", "missing.yaml"}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, err := feCompile(tc.plugins...)
|
|
if err == nil {
|
|
t.Fatalf("boot succeeded, want an error naming %v", tc.want)
|
|
}
|
|
for _, want := range tc.want {
|
|
if !strings.Contains(err.Error(), want) {
|
|
t.Fatalf("err = %v\nwant it to contain %q", err, want)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("a Fields file is read from the plugin's AdminFS", func(t *testing.T) {
|
|
ext := feExt("acme.extra", nil, "acme.blog.posts", "", func(e *pact.AdminFormExtension) {
|
|
e.FieldsYAML = nil
|
|
e.Fields = "extensions/posts.yaml"
|
|
})
|
|
ext.fsys = fstest.MapFS{"extensions/posts.yaml": &fstest.MapFile{Data: []byte("fields:\n" + feText("c", "after: a"))}}
|
|
names, _ := feLayout(t, "acme.blog.posts", feBlog(), ext)
|
|
if feJoin(names) != "a c b" {
|
|
t.Fatalf("fields = %s", feJoin(names))
|
|
}
|
|
})
|
|
|
|
t.Run("no error without extensions", func(t *testing.T) {
|
|
if _, err := feCompile(feBlog()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := compileFormExtensions(nil, nil); err == nil {
|
|
t.Fatal("a nil registry compiled")
|
|
}
|
|
})
|
|
|
|
t.Run("the Save of an extension with only a widget and a partial is optional", func(t *testing.T) {
|
|
ext := feExtWidgetPlugin("fields:\n w:\n type: widget\n widget: acme-extra-preview\n action: preview\n")
|
|
ext.extensions[0].Form.Save = nil
|
|
if _, err := feCompile(feBlog(), ext); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
})
|
|
|
|
}
|