package i18n import ( "regexp" "sort" "strings" "testing" "testing/fstest" ) func testBundle(t *testing.T, hu, en string) *Bundle { t.Helper() b, err := loadFrom(fstest.MapFS{ "locales/hu.json": {Data: []byte(hu)}, "locales/en.json": {Data: []byte(en)}, }) if err != nil { t.Fatal(err) } return b } func TestNormalize(t *testing.T) { for in, want := range map[string]string{"en": "en", " EN ": "en", "hu": "hu", "": "hu", "de": "hu", "en-GB": "hu"} { if got := Normalize(in); got != want { t.Errorf("Normalize(%q) = %q, want %q", in, got, want) } } if IsSupported("EN") || IsSupported("") || !IsSupported("en") { t.Error("IsSupported must be exact") } } // Rule 2 of the package: a missing English line shows the Hungarian one, and says it fell back; a key // absent everywhere is reported as not ok — it never becomes an empty string silently. func TestTextFallback(t *testing.T) { b := testBundle(t, `{"a":"alma","b":"banán"}`, `{"a":"apple"}`) if v, fb, ok := b.Text("en", "a"); v != "apple" || fb || !ok { t.Errorf("translated key: %q fellBack=%v ok=%v", v, fb, ok) } if v, fb, ok := b.Text("en", "b"); v != "banán" || !fb || !ok { t.Errorf("missing English must show Hungarian and flag the fallback: %q fellBack=%v ok=%v", v, fb, ok) } if v, fb, ok := b.Text("hu", "b"); v != "banán" || fb || !ok { t.Errorf("Hungarian is never a fallback of itself: %q fellBack=%v", v, fb) } if _, _, ok := b.Text("en", "zzz"); ok { t.Error("a key absent from Hungarian must not be ok") } if got := b.Msg("en", "zzz"); got != "zzz" { t.Errorf("Msg of an undefined key must be visible, never blank: %q", got) } if got := b.MissingKeys("en"); len(got) != 1 || got[0] != "b" { t.Errorf("MissingKeys(en) = %v, want [b]", got) } } func TestPlural(t *testing.T) { b := testBundle(t, `{"m":"%d perce"}`, `{"m.one":"%d minute ago","m.other":"%d minutes ago"}`) for n, want := range map[int]string{0: "0 minutes ago", 1: "1 minute ago", 5: "5 minutes ago"} { if got := b.Plural("en", "m", n); got != want { t.Errorf("en Plural(%d) = %q, want %q", n, got, want) } } if got := b.Plural("hu", "m", 1); got != "1 perce" { t.Errorf("hu has one form: %q", got) } if !b.Has("en", "m") || len(b.MissingKeys("en")) != 0 { t.Error("a key carried as .one/.other is present, not missing") } } // A marker whose key exists nowhere is LEFT IN PLACE, so html/template refuses the file ("function T // not defined") — loud at startup, never a silently shown marker or an empty string. func TestExpandLeavesUndefinedMarker(t *testing.T) { b := testBundle(t, `{"k":"Szia {{.Name}}"}`, `{}`) out, st := b.Expand("en", `

{{T "k"}}

{{ T "nope" }}

`) if out != `

Szia {{.Name}}

{{ T "nope" }}

` { t.Errorf("Expand = %q", out) } if st.Markers != 1 || len(st.FellBack) != 1 || len(st.Undefined) != 1 || st.Undefined[0] != "nope" { t.Errorf("stats = %+v", st) } } // The embedded bundles themselves. func TestEmbeddedBundles(t *testing.T) { b, err := Load() if err != nil { t.Fatal(err) } if len(b.Keys(Default)) < 200 { t.Fatalf("hu bundle has %d keys — the embedded file is not the converted one", len(b.Keys(Default))) } // No orphan: every English key translates a Hungarian key (plural forms count for their base). for _, k := range b.Keys("en") { base := strings.TrimSuffix(strings.TrimSuffix(k, ".one"), ".other") if _, ok := b.msgs[Default][k]; !ok { if _, ok := b.msgs[Default][base]; !ok { t.Errorf("en key %q has no Hungarian source", k) } } } for lang, m := range b.msgs { for k, v := range m { if strings.TrimSpace(v) == "" { t.Errorf("%s %q is empty — a blank box", lang, k) } } } } var fieldRefRe = regexp.MustCompile(`\.[A-Z][A-Za-z0-9_]*(\.[A-Z][A-Za-z0-9_]*)*`) // verbRe counts printf verbs OUTSIDE template actions ({{printf "%.0f" .X}} is a parameter, not a verb // of the message), and without the space flag — „50% szabad" is prose, not „% s". // // `%[2]s` — an EXPLICIT ARGUMENT INDEX — counts as one verb (slice 2, R-557). It is how a translation // reorders a Go-side message while the Hungarian keeps the plain positional verbs the code always had, // which is what keeps hu.json byte-identical to the base-commit format string. The index is part of // the verb, not an extra one, so the counts on both sides stay comparable. var verbRe = regexp.MustCompile(`%(?:\[\d+\])?[-+#0-9.]*[sdvq]`) func refs(s string) []string { var out []string for _, a := range regexp.MustCompile(`\{\{.*?\}\}`).FindAllString(s, -1) { out = append(out, fieldRefRe.FindAllString(a, -1)...) } seen := map[string]bool{} var uniq []string for _, r := range out { if !seen[r] { seen[r] = true uniq = append(uniq, r) } } sort.Strings(uniq) return uniq } // A translation carries the same parameters as the Hungarian: the same page fields inside its template // actions, and the same printf verbs. A dropped {{.RecoveryAbandonDate}} would render an English banner // with no date; a dropped %d would print "%!(EXTRA int=5)". func TestBundleParametersMatchAcrossLanguages(t *testing.T) { b, err := Load() if err != nil { t.Fatal(err) } for _, lang := range Supported[1:] { for _, k := range b.Keys(Default) { hu := b.msgs[Default][k] forms := []string{k} if _, ok := b.msgs[lang][k]; !ok { forms = []string{k + ".one", k + ".other"} } for _, f := range forms { v, ok := b.msgs[lang][f] if !ok { continue } if a, c := strings.Join(refs(hu), ","), strings.Join(refs(v), ","); a != c { t.Errorf("%s %q: template fields %q, Hungarian has %q", lang, f, c, a) } if a, c := len(verbRe.FindAllString(stripActions(hu), -1)), len(verbRe.FindAllString(stripActions(v), -1)); a != c { t.Errorf("%s %q: %d printf verbs, Hungarian has %d", lang, f, c, a) } } } } } func stripActions(s string) string { return regexp.MustCompile(`\{\{.*?\}\}`).ReplaceAllString(s, "") } // TestPluralFirstArgIsNumeric — the other half of the plural rule Bundle.form states. // // The rule is "a key with .one/.other forms takes its count first". That is only safe if the VALUES // are written that way, so this reads every plural form in every language and requires its first // printf verb to be a numeric one. A translator who writes "%s apps are running" would otherwise // produce `%!d(string=…)` at run time, on a page, in front of a household. func TestPluralFirstArgIsNumeric(t *testing.T) { b, err := Load() if err != nil { t.Fatal(err) } firstVerb := regexp.MustCompile(`%(?:\[\d+\])?[-+#0-9.]*[a-zA-Z]`) seen := 0 for _, lang := range Supported { for _, k := range b.Keys(lang) { if !strings.HasSuffix(k, ".one") && !strings.HasSuffix(k, ".other") { continue } seen++ v := b.msgs[lang][k] m := firstVerb.FindString(stripActions(v)) if m == "" { t.Errorf("%s %q is a plural form with no parameter at all: %q", lang, k, v) continue } if !strings.HasSuffix(m, "d") && !strings.HasSuffix(m, "f") { t.Errorf("%s %q takes %q first, but a plural form's first parameter is the COUNT: %q", lang, k, m, v) } // And the base key must exist in Hungarian: the fallback for every language without the // split, and the sentence a Hungarian household reads. base := strings.TrimSuffix(strings.TrimSuffix(k, ".one"), ".other") if _, ok := b.msgs[Default][base]; !ok { t.Errorf("%s %q has no Hungarian base key %q", lang, k, base) } } } if seen == 0 { t.Fatal("no plural forms were examined — the suffix test no longer matches the bundle") } t.Logf("examined %d plural forms", seen) } // TestNoOrdinaryKeyEndsInAPluralSuffix — `.one` and `.other` are RESERVED by the plural rule // (Bundle.form). A key that merely ends in one of them would be read as the singular or plural form // of a key that does not exist, and the sentence would silently change with a count. // // It was not a hypothetical: `alert.deadapp.one` (the ONE dead app, as against the grouped banner) // was exactly that collision, caught by TestPluralFirstArgIsNumeric on the day the rule landed and // renamed to `alert.deadapp.single`. This is the guard that keeps the next one from shipping. func TestNoOrdinaryKeyEndsInAPluralSuffix(t *testing.T) { b, err := Load() if err != nil { t.Fatal(err) } for _, lang := range Supported { for _, k := range b.Keys(lang) { for _, suffix := range []string{".one", ".other"} { if !strings.HasSuffix(k, suffix) { continue } base := strings.TrimSuffix(k, suffix) // A REAL plural form's base is a Hungarian key. A collision's base is nothing. if _, ok := b.msgs[Default][base]; !ok { t.Errorf("%s %q ends in %q but %q is no key — `.one`/`.other` are reserved for "+ "plural forms; rename it (for example to %q)", lang, k, suffix, base, base+".single") } } } } }