- 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
200 lines
6.7 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|
|
}
|