Files
summercms/phrasebook/phase10_test.go
Jakub Zych c87148a34f feat(10-02): backend strings, controller messages and declarative toolbar
- phrasebook ships the backend::lang admin strings (pl, en) with CLDR
  plural maps, loads them as namespace backend, applies
  pact.HasLangOverrides trees (lang/<locale>/<namespace>/<group>.yaml)
  after every namespace, and fails activation when a backend key cannot
  convert to plural forms
- Translator.Forms, Bundle, Resolved and Has serve keys as CLDR form maps
- Public GET /lang returns every backend::lang key for the request
  locale over the fallback locale, Cache-Control no-cache
- config_list, config_form and config_relation accept a strict messages
  block; omitted keys take framework defaults, schemas serve every message
  as CLDR forms, and activation fails on a missing phrase key
- toolbar.buttons is an ordered [create, delete] list; the Winter string
  form, duplicates, unknown actions and delete without showCheckboxes fail
  at boot, and create is dropped when the controller has no form
- Form schema serves the raw Winter redirects; scaffold emits the list
  syntax; form and relation schema routes are typed in the admin OpenAPI
2026-09-27 16:16:32 +02:00

200 lines
6.7 KiB
Go

package phrasebook
import (
"io/fs"
"os"
"path/filepath"
"regexp"
"runtime"
"sort"
"strings"
"testing"
"testing/fstest"
"git.golem15.com/golem15/summercms/backpack"
)
func TestPhase10Forms(t *testing.T) {
cat := NewCatalog()
if err := cat.Load("acme.demo", fstest.MapFS{
"lang/en/lang.yaml": {Data: []byte(`plain: Hello :name
plural:
one: ":count apple"
other: ":count apples"
pipe: ":count apple|:count apples"
exact: "{0} No apples|[1,*] :count apples"
`)},
"lang/pl/lang.yaml": {Data: []byte(`pipe: ":count jabłko|:count jabłka|:count jabłek|:count jabłka"
`)},
}); err != nil {
t.Fatal(err)
}
tr := NewTranslator(cat, Options{Locale: "en", Fallback: "en"})
check := func(locale, key string, want map[string]string) {
t.Helper()
got, ok := tr.Forms(locale, key)
if !ok {
t.Fatalf("%s %s: not convertible", locale, key)
}
if len(got) != len(want) {
t.Fatalf("%s %s = %v want %v", locale, key, got, want)
}
for cat, text := range want {
if got[cat] != text {
t.Fatalf("%s %s = %v want %v", locale, key, got, want)
}
}
}
check("en", "acme.demo::lang.plain", map[string]string{"other": "Hello :name"})
check("en", "acme.demo::lang.plural", map[string]string{"one": ":count apple", "other": ":count apples"})
check("en", "acme.demo::lang.pipe", map[string]string{"one": ":count apple", "other": ":count apples"})
check("pl", "acme.demo::lang.pipe", map[string]string{"one": ":count jabłko", "few": ":count jabłka", "many": ":count jabłek", "other": ":count jabłka"})
// pl falls back to en for a key it does not define.
check("pl", "acme.demo::lang.plain", map[string]string{"other": "Hello :name"})
if forms, ok := tr.Forms("en", "acme.demo::lang.exact"); ok {
t.Fatalf("exact/range pipe converted to %v", forms)
}
if forms, ok := tr.Forms("en", "acme.demo::lang.missing"); ok {
t.Fatalf("missing key converted to %v", forms)
}
got, ok := tr.Forms("en", "acme.demo::lang.plural")
if !ok {
t.Fatal("plural not convertible")
}
got["one"] = "mutated"
if again, _ := tr.Forms("en", "acme.demo::lang.plural"); again["one"] != ":count apple" {
t.Fatal("Forms returned the catalog's own map")
}
}
type overridePlugin struct {
id string
lang fs.FS
overrides fs.FS
}
func (p overridePlugin) ID() string { return p.id }
func (p overridePlugin) LangFS() fs.FS { return p.lang }
func (p overridePlugin) LangOverridesFS() fs.FS { return p.overrides }
func activateWith(t *testing.T, plugins ...overridePlugin) (*Translator, error) {
t.Helper()
app := backpack.New(nil)
if err := Activate(app, plugins); err != nil {
return nil, err
}
tr, ok := app.Lookup[*Translator]()
if !ok || tr == nil {
t.Fatal("translator was not published")
}
return tr, nil
}
func TestPhase10LangOverride(t *testing.T) {
demo := fstest.MapFS{"lang/en/lang.yaml": {Data: []byte("title: Demo\n")}}
tr, err := activateWith(t, overridePlugin{id: "acme.demo", lang: demo, overrides: fstest.MapFS{
"lang/pl/backend/lang.yaml": {Data: []byte("form:\n save: Zachowaj\n extra: Dodatkowy\n")},
"lang/de/backend/lang.yaml": {Data: []byte("form:\n save: Speichern\n")},
"lang/en/acme.demo/lang.yaml": {Data: []byte("title: Demo app\n")},
}})
if err != nil {
t.Fatalf("activate: %v", err)
}
for _, tc := range []struct{ locale, key, want string }{
{"pl", "backend::lang.form.save", "Zachowaj"},
{"pl", "backend::lang.form.extra", "Dodatkowy"},
{"pl", "backend::lang.form.cancel", "Anuluj"},
{"de", "backend::lang.form.save", "Speichern"},
{"en", "backend::lang.form.save", "Save"},
{"en", "acme.demo::lang.title", "Demo app"},
} {
if got := tr.GetIn(tc.locale, tc.key, nil); got != tc.want {
t.Fatalf("%s %s = %q want %q", tc.locale, tc.key, got, tc.want)
}
}
if bundle := tr.Bundle("de", "backend::lang."); bundle["backend::lang.form.save"]["other"] != "Speichern" || bundle["backend::lang.form.cancel"]["other"] != "Cancel" {
t.Fatalf("de bundle did not merge the fallback: save=%v cancel=%v", bundle["backend::lang.form.save"], bundle["backend::lang.form.cancel"])
}
for _, tc := range []struct {
name string
overrides fstest.MapFS
want []string
}{
{"missing namespace segment", fstest.MapFS{"lang/pl/lang.yaml": {Data: []byte("a: b\n")}}, []string{"acme.demo", "malformed override path"}},
{"not yaml", fstest.MapFS{"lang/pl/backend/lang.txt": {Data: []byte("a: b\n")}}, []string{"acme.demo", "malformed override path"}},
{"too deep", fstest.MapFS{"lang/pl/backend/sub/lang.yaml": {Data: []byte("a: b\n")}}, []string{"acme.demo", "malformed override path"}},
{"unknown namespace", fstest.MapFS{"lang/pl/acme.missing/lang.yaml": {Data: []byte("a: b\n")}}, []string{"acme.demo", "acme.missing"}},
{"unconvertible backend key", fstest.MapFS{"lang/en/backend/lang.yaml": {Data: []byte("list:\n results: \"{0} No results|[1,*] :count results\"\n")}}, []string{"backend::lang.list.results", "CLDR"}},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := activateWith(t, overridePlugin{id: "acme.demo", lang: demo, overrides: tc.overrides})
if err == nil {
t.Fatal("activation accepted a bad override")
}
for _, want := range tc.want {
if !strings.Contains(err.Error(), want) {
t.Fatalf("err=%v missing %q", err, want)
}
}
})
}
}
var spaKey = regexp.MustCompile(`backend::lang\.[A-Za-z0-9_.]*[A-Za-z0-9_]`)
// TestPhase10SPAKeysResolve walks the admin SPA sources and resolves every
// backend::lang key literal in pl and en, each without falling back to the
// other locale.
func TestPhase10SPAKeysResolve(t *testing.T) {
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("caller")
}
root := filepath.Join(filepath.Dir(file), "..", "admin", "src")
keys := map[string]bool{}
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !(strings.HasSuffix(path, ".ts") || strings.HasSuffix(path, ".vue")) {
return nil
}
raw, err := os.ReadFile(path)
if err != nil {
return err
}
for _, key := range spaKey.FindAllString(string(raw), -1) {
keys[key] = true
}
return nil
})
if err != nil {
t.Fatal(err)
}
if len(keys) < 10 {
t.Fatalf("found only %d backend::lang keys under %s", len(keys), root)
}
sorted := make([]string, 0, len(keys))
for key := range keys {
sorted = append(sorted, key)
}
sort.Strings(sorted)
cat := NewCatalog()
if err := cat.Load("backend", backendLang()); err != nil {
t.Fatal(err)
}
for _, locale := range []string{"pl", "en"} {
tr := NewTranslator(cat, Options{Locale: locale, Fallback: locale})
for _, key := range sorted {
forms, ok := tr.Forms(locale, key)
if !ok || forms["other"] == "" {
t.Errorf("%s: %s does not resolve", locale, key)
}
}
}
}