refactor(10.2-01): nest framework packages under modules
- Move remaining beach packages and embedded admin assets\n- Rewrite framework, example, build, and gate paths
This commit is contained in:
354
modules/cabana/messages_test.go
Normal file
354
modules/cabana/messages_test.go
Normal file
@@ -0,0 +1,354 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
"git.golem15.com/golem15/summercms/modules/towel"
|
||||
)
|
||||
|
||||
type langPlugin struct {
|
||||
id string
|
||||
lang fs.FS
|
||||
}
|
||||
|
||||
func (p langPlugin) ID() string { return p.id }
|
||||
func (p langPlugin) LangFS() fs.FS { return p.lang }
|
||||
|
||||
// messagesTranslator activates phrasebook with the framework backend strings
|
||||
// and an acme.demo catalog.
|
||||
func messagesTranslator(t *testing.T) *phrasebook.Translator {
|
||||
t.Helper()
|
||||
app := backpack.New(nil)
|
||||
demo := fstest.MapFS{
|
||||
"lang/pl/lang.yaml": {Data: []byte(`widgets:
|
||||
count:
|
||||
one: ":count widżet"
|
||||
few: ":count widżety"
|
||||
many: ":count widżetów"
|
||||
other: ":count widżetu"
|
||||
create: Nowy widżet
|
||||
saved: Widżet „:name” zapisany
|
||||
link: Dodaj edytora
|
||||
`)},
|
||||
"lang/en/lang.yaml": {Data: []byte(`widgets:
|
||||
count:
|
||||
one: ":count widget"
|
||||
other: ":count widgets"
|
||||
create: New widget
|
||||
saved: Widget “:name” saved
|
||||
link: Add editor
|
||||
`)},
|
||||
}
|
||||
if err := phrasebook.Activate(app, []langPlugin{{id: "acme.demo", lang: demo}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tr, ok := app.Lookup[*phrasebook.Translator]()
|
||||
if !ok || tr == nil {
|
||||
t.Fatal("translator missing")
|
||||
}
|
||||
return tr
|
||||
}
|
||||
|
||||
const messagesListConfig = `modelClass: Widget
|
||||
list: ~/plugins/acme/demo/models/widget/columns.yaml
|
||||
recordsPerPage: 20
|
||||
showCheckboxes: true
|
||||
toolbar:
|
||||
buttons: [create, delete]
|
||||
search:
|
||||
prompt: backend::lang.list.search_prompt
|
||||
messages:
|
||||
recordCount: acme.demo::lang.widgets.count
|
||||
create: acme.demo::lang.widgets.create
|
||||
`
|
||||
|
||||
func localeCtx(locale string) context.Context {
|
||||
return towel.WithLocale(context.Background(), locale)
|
||||
}
|
||||
|
||||
func TestPhase10Messages(t *testing.T) {
|
||||
tr := messagesTranslator(t)
|
||||
schema, err := CompileList("acme.demo", schemaController{}, listFS(messagesListConfig, "columns: {}\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pl, err := schema.Localize(localeCtx("pl"), tr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := pl.Messages
|
||||
if m == nil {
|
||||
t.Fatal("localized list has no messages")
|
||||
}
|
||||
if len(m.RecordCount) != 4 || m.RecordCount["few"] != ":count widżety" || m.RecordCount["many"] != ":count widżetów" {
|
||||
t.Fatalf("plural recordCount=%v", m.RecordCount)
|
||||
}
|
||||
if m.Create["other"] != "Nowy widżet" {
|
||||
t.Fatalf("plugin create=%v", m.Create)
|
||||
}
|
||||
for name, got := range map[string]MessageForms{
|
||||
"deleteConfirm": m.DeleteConfirm,
|
||||
"searchPrompt": m.SearchPrompt,
|
||||
"empty": m.Empty,
|
||||
"deleteSelected": m.DeleteSelected,
|
||||
"selected": m.Selected,
|
||||
} {
|
||||
if got["other"] == "" || strings.Contains(got["other"], "::") {
|
||||
t.Fatalf("default %s did not resolve: %v", name, got)
|
||||
}
|
||||
}
|
||||
if m.DeleteConfirm["other"] != "Usunąć zaznaczone (:count)?" || m.SearchPrompt["other"] != "Szukaj…" {
|
||||
t.Fatalf("framework defaults: deleteConfirm=%v searchPrompt=%v", m.DeleteConfirm, m.SearchPrompt)
|
||||
}
|
||||
if len(m.Deleted) != 4 || m.Deleted["one"] != "Usunięto :count rekord" {
|
||||
t.Fatalf("default plural deleted=%v", m.Deleted)
|
||||
}
|
||||
en, err := schema.Localize(localeCtx("en"), tr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if en.Messages.RecordCount["one"] != ":count widget" || len(en.Messages.RecordCount) != 2 || en.Messages.Create["other"] != "New widget" {
|
||||
t.Fatalf("en messages=%+v", en.Messages)
|
||||
}
|
||||
raw, err := json.Marshal(pl)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"messages":{"recordCount":{`) {
|
||||
t.Fatalf("served list JSON has no messages: %s", raw)
|
||||
}
|
||||
if schema.Messages.RecordCount["other"] != "acme.demo::lang.widgets.count" {
|
||||
t.Fatalf("cached schema lost its keys: %v", schema.Messages.RecordCount)
|
||||
}
|
||||
|
||||
t.Run("form", func(t *testing.T) {
|
||||
config := `name: acme.demo::lang.widgets.create
|
||||
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
|
||||
messages:
|
||||
saved: acme.demo::lang.widgets.saved
|
||||
`
|
||||
form, err := CompileForm("acme.demo", schemaController{}, formFS(config, "fields:\n name:\n type: text\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
view, err := form.Localize(localeCtx("pl"), tr, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if view.Messages.Saved["other"] != "Widżet „:name” zapisany" || view.Messages.Create["other"] != "Nowy widżet" || view.Messages.Update["other"] != "Edycja rekordu" {
|
||||
t.Fatalf("form messages=%+v", view.Messages)
|
||||
}
|
||||
want := FormRedirects{Default: "acme/demo/widgets",
|
||||
Create: FormRedirect{Redirect: "acme/demo/widgets/update/:id", RedirectClose: "acme/demo/widgets"},
|
||||
Update: FormRedirect{Redirect: "acme/demo/widgets", RedirectClose: "acme/demo/widgets"}}
|
||||
if view.Redirects != want {
|
||||
t.Fatalf("redirects=%+v", view.Redirects)
|
||||
}
|
||||
raw, err := view.CanonicalJSON()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"redirects":{"default":"acme/demo/widgets"`) || !strings.Contains(string(raw), `"saved":{"other":`) {
|
||||
t.Fatalf("form view JSON=%s", raw)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("relation", func(t *testing.T) {
|
||||
yaml := strings.Replace(validRelationYAML, "editors:\n label: demo.editors\n", "editors:\n label: demo.editors\n messages:\n link: acme.demo::lang.widgets.link\n", 1)
|
||||
ctl := relationTestController{}
|
||||
fsys := relationTestFS(yaml)
|
||||
form, err := CompileForm("acme.demo", ctl, fsys)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
relations, err := compileRelations("acme.demo", ctl, fsys, form)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
view := relations["editors"].Schema.Localize(localeCtx("pl"), tr)
|
||||
if view.Messages.Link["other"] != "Dodaj edytora" || len(view.Messages.Linked) != 4 || view.Messages.Linked["few"] != "Dołączono :count rekordy" {
|
||||
t.Fatalf("relation messages=%+v", view.Messages)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown key fails at boot", func(t *testing.T) {
|
||||
list := strings.Replace(messagesListConfig, " create: acme.demo::lang.widgets.create\n", " create: acme.demo::lang.widgets.create\n bogusKey: x\n", 1)
|
||||
if _, err := CompileList("acme.demo", schemaController{}, listFS(list, "columns: {}\n")); err == nil || !strings.Contains(err.Error(), "bogusKey") || !strings.Contains(err.Error(), "config_list.yaml") {
|
||||
t.Fatalf("list err=%v", err)
|
||||
}
|
||||
form := "form: ~/plugins/acme/demo/models/widget/fields.yaml\nmodelClass: Widget\nmessages:\n toast: x\n"
|
||||
if _, err := CompileForm("acme.demo", schemaController{}, formFS(form, "fields: {}\n")); err == nil || !strings.Contains(err.Error(), "toast") {
|
||||
t.Fatalf("form err=%v", err)
|
||||
}
|
||||
yaml := strings.Replace(validRelationYAML, " label: demo.editors\n", " label: demo.editors\n messages:\n bogusKey: x\n", 1)
|
||||
ctl := relationTestController{}
|
||||
fsys := relationTestFS(yaml)
|
||||
compiled, _ := CompileForm("acme.demo", ctl, fsys)
|
||||
if _, err := compileRelations("acme.demo", ctl, fsys, compiled); err == nil || !strings.Contains(err.Error(), "bogusKey") {
|
||||
t.Fatalf("relation err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing phrase key fails at boot", func(t *testing.T) {
|
||||
list := strings.Replace(messagesListConfig, "acme.demo::lang.widgets.count", "acme.demo::lang.widgets.missing", 1)
|
||||
reg, err := compileRegistry([]controllerRef{{plugin: formPlugin{fsys: listFS(list, "columns: {}\n")}, ctl: schemaController{}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = validateMessageKeys(reg, tr)
|
||||
if err == nil || !strings.Contains(err.Error(), "messages.recordCount") || !strings.Contains(err.Error(), "acme.demo::lang.widgets.missing") || !strings.Contains(err.Error(), "acme.demo.widgets") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
ok, err := compileRegistry([]controllerRef{{plugin: formPlugin{fsys: listFS(messagesListConfig, "columns: {}\n")}, ctl: schemaController{}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateMessageKeys(ok, tr); err != nil {
|
||||
t.Fatalf("declared keys failed: %v", err)
|
||||
}
|
||||
if err := validateMessageKeys(reg, nil); err != nil {
|
||||
t.Fatalf("no translator must skip the check: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPhase10Toolbar(t *testing.T) {
|
||||
base := "modelClass: Widget\nlist: ~/plugins/acme/demo/models/widget/columns.yaml\nshowCheckboxes: true\ntoolbar:\n buttons: BUTTONS\n"
|
||||
compile := func(buttons string, checkboxes bool) (*ListSchema, error) {
|
||||
config := strings.Replace(base, "BUTTONS", buttons, 1)
|
||||
if !checkboxes {
|
||||
config = strings.Replace(config, "showCheckboxes: true\n", "", 1)
|
||||
}
|
||||
return CompileList("acme.demo", schemaController{}, listFS(config, "columns: {}\n"))
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
buttons string
|
||||
want []string
|
||||
}{
|
||||
{"[create, delete]", []string{"create", "delete"}},
|
||||
{"[delete, create]", []string{"delete", "create"}},
|
||||
{"[create]", []string{"create"}},
|
||||
{"[]", []string{}},
|
||||
{"\n - delete\n - create", []string{"delete", "create"}},
|
||||
} {
|
||||
schema, err := compile(tc.buttons, true)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", tc.buttons, err)
|
||||
}
|
||||
if strings.Join(schema.ToolbarButtons, ",") != strings.Join(tc.want, ",") {
|
||||
t.Fatalf("%s compiled %v want %v", tc.buttons, schema.ToolbarButtons, tc.want)
|
||||
}
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
buttons string
|
||||
checkboxes bool
|
||||
want string
|
||||
}{
|
||||
{"list_toolbar", true, "toolbar.buttons must be a list"},
|
||||
{"list_toolbar", true, `"list_toolbar" is not supported`},
|
||||
{"[create, delete]", false, "showCheckboxes"},
|
||||
{"[create, create]", true, "duplicate action create"},
|
||||
{"[create, export]", true, "unsupported action export"},
|
||||
{"[{create: true}]", true, "toolbar.buttons"},
|
||||
} {
|
||||
_, err := compile(tc.buttons, tc.checkboxes)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) || !strings.Contains(err.Error(), "acme.demo.widgets") {
|
||||
t.Fatalf("%s err=%v want %q", tc.buttons, err, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
// A controller without a compiled form cannot create: create is dropped.
|
||||
reg, err := compileRegistry([]controllerRef{{plugin: formPlugin{fsys: listFS(strings.Replace(base, "BUTTONS", "[create, delete]", 1), "columns: {}\n")}, ctl: schemaController{}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cc, _ := reg.Get("acme.demo.widgets")
|
||||
if strings.Join(cc.List.ToolbarButtons, ",") != "delete" {
|
||||
t.Fatalf("form-less toolbar=%v", cc.List.ToolbarButtons)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhase10Bundle(t *testing.T) {
|
||||
tr := messagesTranslator(t)
|
||||
app := backpack.New(nil)
|
||||
if err := app.Publish(tr); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := &service{app: app}
|
||||
call := func(locale string) (map[string]map[string]string, string, *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, adminAPI("/lang"), nil)
|
||||
if locale != "" {
|
||||
req = req.WithContext(towel.WithLocale(req.Context(), locale))
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
svc.langBundle(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Data map[string]map[string]string `json:"data"`
|
||||
Meta struct {
|
||||
Locale string `json:"locale"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(rec.Body.String()))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return body.Data, body.Meta.Locale, rec
|
||||
}
|
||||
pl, locale, rec := call("pl")
|
||||
if locale != "pl" || rec.Header().Get("Cache-Control") != "no-cache" {
|
||||
t.Fatalf("locale=%q cache=%q", locale, rec.Header().Get("Cache-Control"))
|
||||
}
|
||||
if pl["backend::lang.form.save"]["other"] != "Zapisz" || len(pl["backend::lang.list.results"]) != 4 {
|
||||
t.Fatalf("pl bundle save=%v results=%v", pl["backend::lang.form.save"], pl["backend::lang.list.results"])
|
||||
}
|
||||
if len(pl) < 50 {
|
||||
t.Fatalf("pl bundle has only %d keys", len(pl))
|
||||
}
|
||||
for key := range pl {
|
||||
if !strings.HasPrefix(key, "backend::lang.") {
|
||||
t.Fatalf("bundle leaked a key outside backend::lang: %s", key)
|
||||
}
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "acme.demo::") || strings.Contains(rec.Body.String(), "lagoon::") {
|
||||
t.Fatalf("bundle leaked another namespace: %s", rec.Body.String())
|
||||
}
|
||||
en, locale, _ := call("en-GB")
|
||||
if locale != "en" || en["backend::lang.form.save"]["other"] != "Save" {
|
||||
t.Fatalf("en-GB locale=%q save=%v", locale, en["backend::lang.form.save"])
|
||||
}
|
||||
fallback, locale, _ := call("de")
|
||||
if locale != "en" || fallback["backend::lang.form.save"]["other"] != "Save" {
|
||||
t.Fatalf("de falls back to en: locale=%q save=%v", locale, fallback["backend::lang.form.save"])
|
||||
}
|
||||
// No translator: an empty bundle, still an envelope.
|
||||
empty := &service{app: backpack.New(nil)}
|
||||
req := httptest.NewRequest(http.MethodGet, adminAPI("/lang"), nil)
|
||||
out := httptest.NewRecorder()
|
||||
empty.langBundle(out, req)
|
||||
if out.Code != http.StatusOK || !strings.Contains(out.Body.String(), `"data":{}`) {
|
||||
t.Fatalf("no translator status=%d body=%s", out.Code, out.Body.String())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user