- type: money on fields.yaml with currency or currencyFrom, scale (2..4, default 4) and max; boot fails naming the field on two sources, none, a bad scale or max, a column that is not a string or a Scanner/Valuer, or a currencyFrom that is not a string column - a save answers 422 money.invalid, money.too_precise or money.too_large on the field and fills the accepted decimal string unchanged; the same check runs on controller, settings and relation child saves - backend::lang money.* phrases in en and pl - admin SPA: money.ts (parseMoneyInput, trimMoneyDisplay, formatMoney over Intl with the string itself), MoneyField.vue with the currency suffix, blur validation and the read-only box, preview through the control, the field scope on PreviewView; regenerated OpenAPI and TS types, rebuilt modules/boardwalk/dist - acme.payments fixture with TestMoneyFieldCompile and the Docker TestMoneyFieldSmoke; forms.md Money section and cabana README
428 lines
20 KiB
Go
428 lines
20 KiB
Go
package cabana
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
)
|
|
|
|
// Docker-free unit tests of the Phase 17 form features (plan 06): they
|
|
// compile the acme.unit links controller from an fstest tree and run the
|
|
// decode, recheck and sanitize steps of a save without a database. The
|
|
// Postgres tests through the HTTP routes are in companions_postgres_test.go,
|
|
// reorder_test.go and singleton_test.go.
|
|
|
|
// ucLink is the links model of the unit fixture.
|
|
type ucLink struct {
|
|
ID uint `gorm:"column:id;primaryKey"`
|
|
Title string `gorm:"column:title"`
|
|
Type *string `gorm:"column:type"`
|
|
Reference *string `gorm:"column:reference"`
|
|
Description *string `gorm:"column:description"`
|
|
Rows lagoon.Jsonable[[]map[string]any] `gorm:"column:rows"`
|
|
}
|
|
|
|
func (*ucLink) TableName() string { return "uc_links" }
|
|
func (*ucLink) Fillable() []string {
|
|
return []string{"title", "type", "reference", "description", "rows"}
|
|
}
|
|
func (*ucLink) Rules() map[string]string { return map[string]string{} }
|
|
|
|
// ucDependent is the fake dependent options binding: acme-page offers a
|
|
// two-level tree, acme-list and every other value take no reference.
|
|
func ucDependent(_ context.Context, _ string, values map[string]any) (pact.DependentOptionSet, error) {
|
|
for _, v := range values {
|
|
switch v {
|
|
case "acme-page":
|
|
return pact.DependentOptionSet{Applies: true, Options: []pact.Option{{Value: "1", Label: "Home"}, {Value: "2", Label: "About", Depth: 1}}}, nil
|
|
case "acme-broken":
|
|
return pact.DependentOptionSet{}, errors.New("references failed")
|
|
}
|
|
}
|
|
return pact.DependentOptionSet{Applies: false, Options: []pact.Option{}}, nil
|
|
}
|
|
|
|
// ucLinks is the links controller of the unit fixture.
|
|
type ucLinks struct {
|
|
calls *[]string
|
|
}
|
|
|
|
func (ucLinks) ID() string { return "acme.unit.links" }
|
|
func (ucLinks) ModelName() string { return "Link" }
|
|
func (ucLinks) ConfigDir() string { return "controllers/links" }
|
|
func (ucLinks) RequiredPermissions() []string { return []string{"acme.unit.access"} }
|
|
func (ucLinks) NewRecord() any { return &ucLink{} }
|
|
func (ucLinks) DropdownOptions(string) []pact.Option {
|
|
return []pact.Option{{Value: "url", Label: "URL"}, {Value: "acme-page", Label: "Page"}, {Value: "acme-list", Label: "All pages"}}
|
|
}
|
|
func (c ucLinks) DependentOptions(ctx context.Context, field string, values map[string]any) (pact.DependentOptionSet, error) {
|
|
if c.calls != nil {
|
|
*c.calls = append(*c.calls, fmt.Sprintf("%s=%v", field, values))
|
|
}
|
|
return ucDependent(ctx, field, values)
|
|
}
|
|
|
|
// ucPlainLinks is the same controller without a DependentOptionsProvider.
|
|
type ucPlainLinks struct{}
|
|
|
|
func (ucPlainLinks) ID() string { return "acme.unit.links" }
|
|
func (ucPlainLinks) ModelName() string { return "Link" }
|
|
func (ucPlainLinks) ConfigDir() string { return "controllers/links" }
|
|
func (ucPlainLinks) RequiredPermissions() []string { return []string{"acme.unit.access"} }
|
|
func (ucPlainLinks) NewRecord() any { return &ucLink{} }
|
|
func (ucPlainLinks) DropdownOptions(string) []pact.Option {
|
|
return []pact.Option{{Value: "url", Label: "URL"}}
|
|
}
|
|
|
|
// ucPlugin serves the unit fixture tree with one controller.
|
|
type ucPlugin struct {
|
|
fsys fs.FS
|
|
ctl pact.AdminController
|
|
}
|
|
|
|
func (ucPlugin) ID() string { return "acme.unit" }
|
|
func (ucPlugin) Requires() []string { return nil }
|
|
func (ucPlugin) Register(*backpack.App) error { return nil }
|
|
func (ucPlugin) Boot(*backpack.App) error { return nil }
|
|
func (p ucPlugin) AdminFS() fs.FS { return p.fsys }
|
|
func (p ucPlugin) AdminControllers() []pact.AdminController {
|
|
return []pact.AdminController{p.ctl}
|
|
}
|
|
|
|
// ucFields is the start of the unit fixture's fields.yaml: a title, a type
|
|
// dropdown and a reference dropdown that depends on it.
|
|
const ucFields = "fields:\n" +
|
|
" title:\n type: text\n label: Title\n" +
|
|
" type:\n type: dropdown\n label: Type\n options: typeOptions\n" +
|
|
" reference:\n type: dropdown\n label: Reference\n dependsOn: type\n"
|
|
|
|
// ucRepeater is a valid repeater named rows with every nested type it
|
|
// takes; extra is appended to the repeater's own keys.
|
|
func ucRepeater(extra string) string {
|
|
return " rows:\n type: repeater\n label: Rows\n" + extra +
|
|
" form:\n fields:\n" +
|
|
" title:\n type: text\n required: true\n" +
|
|
" body:\n type: richeditor\n" +
|
|
" count:\n type: number\n" +
|
|
" on:\n type: checkbox\n" +
|
|
" flag:\n type: switch\n" +
|
|
" note:\n type: textarea\n" +
|
|
" type:\n type: dropdown\n options: typeOptions\n" +
|
|
" reference:\n type: dropdown\n dependsOn: type\n"
|
|
}
|
|
|
|
// ucFS is the unit fixture tree with the given fields.yaml.
|
|
func ucFS(fields string) fstest.MapFS {
|
|
return fstest.MapFS{
|
|
"controllers/links/config_form.yaml": &fstest.MapFile{Data: []byte("name: Link\nform: ~/plugins/acme/unit/models/link/fields.yaml\nmodelClass: Link\n")},
|
|
"controllers/links/config_list.yaml": &fstest.MapFile{Data: []byte("list: ~/plugins/acme/unit/models/link/columns.yaml\nmodelClass: Link\nrecordsPerPage: 20\n")},
|
|
"models/link/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n title:\n label: Title\n")},
|
|
"models/link/fields.yaml": &fstest.MapFile{Data: []byte(fields)},
|
|
}
|
|
}
|
|
|
|
// ucCompile compiles the links controller of fields with ctl (ucLinks when
|
|
// nil).
|
|
func ucCompile(fields string, ctl pact.AdminController) (*CompiledController, error) {
|
|
if ctl == nil {
|
|
ctl = ucLinks{}
|
|
}
|
|
reg, err := ftCompile(ucPlugin{fsys: ucFS(fields), ctl: ctl})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cc, ok := reg.Get("acme.unit.links")
|
|
if !ok {
|
|
return nil, errors.New("no links controller")
|
|
}
|
|
return cc, nil
|
|
}
|
|
|
|
func ucMustCompile(t *testing.T, fields string, ctl pact.AdminController) *CompiledController {
|
|
t.Helper()
|
|
cc, err := ucCompile(fields, ctl)
|
|
if err != nil {
|
|
t.Fatalf("compile: %v", err)
|
|
}
|
|
return cc
|
|
}
|
|
|
|
// ucField returns the compiled form field name of cc.
|
|
func ucField(t *testing.T, cc *CompiledController, name string) FormField {
|
|
t.Helper()
|
|
for _, field := range cc.Form.Fields {
|
|
if field.Name == name {
|
|
return field
|
|
}
|
|
}
|
|
t.Fatalf("no field %s", name)
|
|
return FormField{}
|
|
}
|
|
|
|
// TestRepeaterCompile pins D-07's compile rules: every key a repeater
|
|
// refuses, every nested type it refuses, the nested tab/context/preset and
|
|
// dependsOn checks, the row bounds and titleFrom, and the repeater-only keys
|
|
// on other types.
|
|
func TestRepeaterCompile(t *testing.T) {
|
|
t.Run("a valid repeater compiles with its nested form", func(t *testing.T) {
|
|
cc := ucMustCompile(t, ucFields+ucRepeater(" prompt: Add row\n titleFrom: title\n minItems: 1\n maxItems: 3\n tab: Content\n span: left\n required: true\n comment: Rows\n"), nil)
|
|
rows := ucField(t, cc, "rows")
|
|
if rows.Span != "full" || rows.Prompt != "Add row" || rows.TitleFrom != "title" || rows.MinItems == nil || *rows.MinItems != 1 || rows.MaxItems == nil || *rows.MaxItems != 3 || !rows.Required || rows.Tab != "Content" {
|
|
t.Fatalf("rows = %+v", rows)
|
|
}
|
|
if rows.Form == nil || len(rows.Form.Fields) != 8 {
|
|
t.Fatalf("nested form = %+v", rows.Form)
|
|
}
|
|
var names []string
|
|
for _, inner := range rows.Form.Fields {
|
|
names = append(names, inner.Name+":"+inner.Type)
|
|
}
|
|
if strings.Join(names, ",") != "title:text,body:richeditor,count:number,on:checkbox,flag:switch,note:textarea,type:dropdown,reference:dropdown" {
|
|
t.Fatalf("nested fields = %v", names)
|
|
}
|
|
if !rows.Form.Fields[0].Required || rows.Form.Fields[7].DependsOn != "type" {
|
|
t.Fatalf("nested keys lost: %+v", rows.Form.Fields)
|
|
}
|
|
if names := repeaterNames(cc); !names["rows"] || len(names) != 1 {
|
|
t.Fatalf("repeaterNames = %v", names)
|
|
}
|
|
})
|
|
|
|
nested := func(field string) string {
|
|
return " rows:\n type: repeater\n form:\n fields:\n" + field
|
|
}
|
|
for _, tc := range []struct {
|
|
name, fields, want string
|
|
}{
|
|
{"groups", ucFields + ucRepeater(" groups: ~/groups.yaml\n"), "repeater groups are not supported"},
|
|
{"style", ucFields + ucRepeater(" style: accordion\n"), "repeater style is not supported"},
|
|
{"options key", ucFields + ucRepeater(" options: typeOptions\n"), "options is not valid on type: repeater"},
|
|
{"size key", ucFields + ucRepeater(" size: large\n"), "size is not valid on type: repeater"},
|
|
{"default key", ucFields + ucRepeater(" default: []\n"), "default is not valid on type: repeater"},
|
|
{"attributes key", ucFields + ucRepeater(" attributes: {}\n"), "attributes is not valid on type: repeater"},
|
|
{"no form", ucFields + " rows:\n type: repeater\n", "a repeater needs form: with fields"},
|
|
{"form without fields", ucFields + " rows:\n type: repeater\n form:\n label: x\n", "a repeater form takes fields only"},
|
|
{"form groups", ucFields + " rows:\n type: repeater\n form:\n groups: {}\n", "repeater groups are not supported"},
|
|
{"empty fields", ucFields + " rows:\n type: repeater\n form:\n fields: {}\n", "form.fields is empty"},
|
|
{"nested tab", ucFields + nested(" title:\n type: text\n tab: Main\n"), "tab, context and preset are not supported in a repeater row"},
|
|
{"nested context", ucFields + nested(" title:\n type: text\n context: update\n"), "tab, context and preset are not supported in a repeater row"},
|
|
{"nested dependsOn to a missing sibling", ucFields + nested(" reference:\n type: dropdown\n dependsOn: type\n"), "is not a field of the same repeater row"},
|
|
{"nested dependsOn to itself", ucFields + nested(" reference:\n type: dropdown\n dependsOn: reference\n"), "dependsOn names the field itself"},
|
|
{"negative maxItems", ucFields + ucRepeater(" maxItems: -1\n"), "maxItems: want a non-negative integer"},
|
|
{"text maxItems", ucFields + ucRepeater(" maxItems: many\n"), "maxItems: want a non-negative integer"},
|
|
{"negative minItems", ucFields + ucRepeater(" minItems: -2\n"), "minItems: want a non-negative integer"},
|
|
{"minItems above maxItems", ucFields + ucRepeater(" minItems: 4\n maxItems: 2\n"), "minItems 4 is greater than maxItems 2"},
|
|
{"titleFrom outside the form", ucFields + ucRepeater(" titleFrom: missing\n"), "titleFrom missing is not a field of the repeater form"},
|
|
{"maxItems on text", ucFields + " extra:\n type: text\n maxItems: 2\n", "maxItems is only valid on type: repeater"},
|
|
{"minItems on textarea", ucFields + " extra:\n type: textarea\n minItems: 2\n", "minItems is only valid on type: repeater"},
|
|
{"titleFrom on dropdown", ucFields + " extra:\n type: dropdown\n options: typeOptions\n titleFrom: x\n", "titleFrom is only valid on type: repeater"},
|
|
{"form on text", ucFields + " extra:\n type: text\n form:\n fields: {}\n", "form is only valid on type: repeater"},
|
|
{"groups on text", ucFields + " extra:\n type: text\n groups: x\n", "repeater groups are not supported"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, err := ucCompile(tc.fields, nil)
|
|
wantErr(t, err, tc.want)
|
|
})
|
|
}
|
|
|
|
// Every core type outside text, textarea, number, checkbox, switch,
|
|
// dropdown and richeditor is refused in a row.
|
|
// Each type gets the keys it needs on its own, so the refusal reached is
|
|
// the repeater's.
|
|
typeKeys := map[string]string{
|
|
"relation-manager": " relation: parts\n",
|
|
"widget": " widget: acme-x\n action: lookup\n",
|
|
"partial": " path: summary\n",
|
|
"permissioneditor": " mode: checkbox\n",
|
|
"repeater": " form:\n fields:\n a:\n type: text\n",
|
|
"money": " currency: PLN\n",
|
|
}
|
|
for typ := range formFieldTypes {
|
|
if repeaterNestedTypes[typ] {
|
|
continue
|
|
}
|
|
t.Run("nested "+typ, func(t *testing.T) {
|
|
_, err := ucCompile(ucFields+nested(" inner:\n type: "+typ+"\n"+typeKeys[typ]), nil)
|
|
wantErr(t, err, "field inner: type "+typ+" is not supported in a repeater")
|
|
})
|
|
}
|
|
}
|
|
|
|
// ucDecode runs liftRepeaterValues on a JSON body.
|
|
func ucDecode(t *testing.T, cc *CompiledController, raw, op string) (map[string]any, map[string]any) {
|
|
t.Helper()
|
|
body := utJSON(t, raw).(map[string]any)
|
|
err := liftRepeaterValues(context.Background(), cc, body, op, nil)
|
|
if err == nil {
|
|
return body, nil
|
|
}
|
|
var invalid *ValidationError
|
|
if !errors.As(err, &invalid) {
|
|
t.Fatalf("err = %v, want a ValidationError", err)
|
|
}
|
|
return body, invalid.Details
|
|
}
|
|
|
|
// TestRepeaterDecode pins the D-07 decode of a submitted repeater value:
|
|
// rows keep only the nested keys, scalars are coerced per nested type,
|
|
// required nested values fail under field.index.nested, the row bounds and
|
|
// the list shape are checked, and stored values hydrate back to rows.
|
|
func TestRepeaterDecode(t *testing.T) {
|
|
cc := ucMustCompile(t, ucFields+ucRepeater(" minItems: 1\n maxItems: 3\n"), nil)
|
|
|
|
t.Run("rows keep only nested keys, coerced per type", func(t *testing.T) {
|
|
body, details := ucDecode(t, cc, `{"rows":[{"title":"One","body":"<p>x</p>","count":"12","on":"yes","flag":1,"note":7,"type":"acme-page","reference":2,"extra":"dropped"},{"title":5,"count":3.5,"on":"off","flag":false,"note":true}]}`, "update")
|
|
if details != nil {
|
|
t.Fatalf("details = %v", details)
|
|
}
|
|
rows := body["rows"].([]any)
|
|
first, second := rows[0].(map[string]any), rows[1].(map[string]any)
|
|
if _, extra := first["extra"]; extra || len(first) != 8 || len(second) != 8 {
|
|
t.Fatalf("row keys = %v / %v", first, second)
|
|
}
|
|
want := map[string]any{"title": "One", "body": "<p>x</p>", "count": json.Number("12"), "on": true, "flag": true, "note": "7", "type": "acme-page", "reference": "2"}
|
|
if fmt.Sprint(first) != fmt.Sprint(want) {
|
|
t.Fatalf("first row = %v\nwant %v", first, want)
|
|
}
|
|
want = map[string]any{"title": "5", "body": nil, "count": json.Number("3.5"), "on": false, "flag": false, "note": "true", "type": nil, "reference": nil}
|
|
if fmt.Sprint(second) != fmt.Sprint(want) {
|
|
t.Fatalf("second row = %v\nwant %v", second, want)
|
|
}
|
|
})
|
|
|
|
t.Run("coercion refusals and required values are keyed per row", func(t *testing.T) {
|
|
_, details := ucDecode(t, cc, `{"rows":[{"title":"ok","count":"abc"},{"title":" ","on":"maybe"},{"title":{"x":1},"flag":[1]}]}`, "update")
|
|
for key, part := range map[string]string{
|
|
"rows.0.count": "must be a number",
|
|
"rows.1.title": "is required",
|
|
"rows.1.on": "must be true or false",
|
|
"rows.2.title": "must be text",
|
|
"rows.2.flag": "must be true or false",
|
|
} {
|
|
msgs, _ := details[key].([]string)
|
|
if len(msgs) != 1 || !strings.Contains(msgs[0], key) || !strings.Contains(msgs[0], part) {
|
|
t.Fatalf("%s = %v, want %q", key, details[key], part)
|
|
}
|
|
}
|
|
if len(details) != 5 {
|
|
t.Fatalf("details = %v", details)
|
|
}
|
|
})
|
|
|
|
t.Run("a missing required nested value fails", func(t *testing.T) {
|
|
_, details := ucDecode(t, cc, `{"rows":[{"title":"ok"},{"body":"<p>x</p>"}]}`, "update")
|
|
if msgs, _ := details["rows.1.title"].([]string); len(msgs) != 1 || len(details) != 1 {
|
|
t.Fatalf("details = %v", details)
|
|
}
|
|
})
|
|
|
|
t.Run("the list shape is checked", func(t *testing.T) {
|
|
for _, raw := range []string{`{"rows":"x"}`, `{"rows":{"a":1}}`, `{"rows":3}`, `{"rows":[{"title":"ok"},"x"]}`, `{"rows":[[1]]}`} {
|
|
_, details := ucDecode(t, cc, raw, "update")
|
|
msgs, _ := details["rows"].([]string)
|
|
if len(msgs) != 1 || !strings.Contains(msgs[0], "must be a list of rows") {
|
|
t.Fatalf("%s: details = %v", raw, details)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("row bounds", func(t *testing.T) {
|
|
_, details := ucDecode(t, cc, `{"rows":[{"title":"1"},{"title":"2"},{"title":"3"},{"title":"4"}]}`, "update")
|
|
if msgs, _ := details["rows"].([]string); len(msgs) != 1 || msgs[0] != msgRepeaterMaxItems {
|
|
t.Fatalf("max details = %v", details)
|
|
}
|
|
_, details = ucDecode(t, cc, `{"rows":[]}`, "update")
|
|
if msgs, _ := details["rows"].([]string); len(msgs) != 1 || msgs[0] != msgRepeaterMinItems {
|
|
t.Fatalf("min details = %v", details)
|
|
}
|
|
body, details := ucDecode(t, cc, `{"rows":[{"title":"1"},{"title":"2"},{"title":"3"}]}`, "update")
|
|
if details != nil || len(body["rows"].([]any)) != 3 {
|
|
t.Fatalf("three rows: %v %v", body, details)
|
|
}
|
|
})
|
|
|
|
t.Run("null is an empty list and an absent repeater is left alone", func(t *testing.T) {
|
|
free := ucMustCompile(t, ucFields+ucRepeater(""), nil)
|
|
body, details := ucDecode(t, free, `{"rows":null}`, "update")
|
|
if details != nil || fmt.Sprint(body["rows"]) != "[]" {
|
|
t.Fatalf("null rows: %v %v", body, details)
|
|
}
|
|
body, details = ucDecode(t, free, `{"title":"x"}`, "create")
|
|
if _, present := body["rows"]; present || details != nil {
|
|
t.Fatalf("absent rows: %v %v", body, details)
|
|
}
|
|
})
|
|
|
|
t.Run("a required repeater needs a row", func(t *testing.T) {
|
|
required := ucMustCompile(t, ucFields+ucRepeater(" required: true\n"), nil)
|
|
for _, tc := range []struct{ raw, op string }{{`{"rows":[]}`, "update"}, {`{"rows":null}`, "create"}, {`{"title":"x"}`, "create"}} {
|
|
_, details := ucDecode(t, required, tc.raw, tc.op)
|
|
if msgs, _ := details["rows"].([]string); len(msgs) != 1 || !strings.Contains(msgs[0], "is required") {
|
|
t.Fatalf("%s on %s: details = %v", tc.raw, tc.op, details)
|
|
}
|
|
}
|
|
if _, details := ucDecode(t, required, `{"title":"x"}`, "update"); details != nil {
|
|
t.Fatalf("absent on update: %v", details)
|
|
}
|
|
})
|
|
|
|
t.Run("nested richeditors are sanitized with row-path notices", func(t *testing.T) {
|
|
projected := map[string]any{"rows": []any{
|
|
map[string]any{"title": "a", "body": "<p>clean</p>"},
|
|
map[string]any{"title": "b", "body": `<p onclick="x()">dirty</p><script>1</script>`},
|
|
map[string]any{"title": "c", "body": nil},
|
|
}}
|
|
notices := sanitizeRepeaterRichEditors(cc, projected)
|
|
rows := projected["rows"].([]any)
|
|
if rows[0].(map[string]any)["body"] != "<p>clean</p>" || rows[1].(map[string]any)["body"] != "<p>dirty</p>" || rows[2].(map[string]any)["body"] != nil {
|
|
t.Fatalf("rows = %v", rows)
|
|
}
|
|
var fields []string
|
|
for _, n := range notices {
|
|
fields = append(fields, n.field+":"+n.notice.Message)
|
|
}
|
|
if strings.Join(fields, ",") != "rows.1.body:"+msgRichRemovedElements+",rows.1.body:"+msgRichRemovedAttributes {
|
|
t.Fatalf("notices = %v", fields)
|
|
}
|
|
})
|
|
|
|
t.Run("stored rows hydrate to a list", func(t *testing.T) {
|
|
stored := lagoon.Jsonable[[]map[string]any]{Data: []map[string]any{{"title": "a"}}, Valid: true}
|
|
text := `[{"title":"b"}]`
|
|
var nilText *string
|
|
for _, tc := range []struct {
|
|
value any
|
|
want string
|
|
}{
|
|
{stored, "[map[title:a]]"},
|
|
{text, "[map[title:b]]"},
|
|
{&text, "[map[title:b]]"},
|
|
{nilText, "[]"},
|
|
{nil, "[]"},
|
|
{"not json", "[]"},
|
|
{"null", "[]"},
|
|
{[]map[string]any{{"title": "c"}}, "[map[title:c]]"},
|
|
} {
|
|
data := map[string]any{"rows": tc.value, "title": "kept"}
|
|
hydrateRepeaterFields(cc, data)
|
|
if fmt.Sprint(data["rows"]) != tc.want || data["title"] != "kept" {
|
|
t.Fatalf("hydrate(%#v) = %v, want %s", tc.value, data["rows"], tc.want)
|
|
}
|
|
}
|
|
data := map[string]any{"title": "no rows key"}
|
|
hydrateRepeaterFields(cc, data)
|
|
if _, present := data["rows"]; present {
|
|
t.Fatal("hydrate added a missing rows key")
|
|
}
|
|
})
|
|
}
|