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) } } } }