7c05b59708
gates / gates (push) Successful in 24s
179 Hungarian sentences were built deep inside a package with fmt.Errorf and printed by whoever caught them: too late to translate where they are shown, too early where they are made. Every one now carries its key across that gap. ZERO Hungarian error literals remain. util.MsgError does three things at once, each earned: - Error() is the Hungarian, byte for byte, so every un-converted printer is unchanged; - errors.Is answers for the kind AND for a wrapped cause (KindErrorf dropped the cause); - an error ARGUMENT renders recursively, so "formázás sikertelen: %w" translates whole. A foreign error — restic, docker, ssh, the stdlib — prints verbatim. It is not ours. 76 display sites go through errText, and TestNoErrErrorInPageOutput convicts any that do not. memoryVerdict returns an error rather than a sentence, so the deploy's 409 and the household's language come from one value; UpdateRefusal gained a Cause to carry it. Plurals, one rule, stated once: a key with .one/.other takes its COUNT first. Not a per-call-site flag — the producer somebody forgot would read "3 app is not running". The guard caught a real key collision (alert.deadapp.one) the day the rule landed. TWO DEFECTS FOUND IN MY OWN TOOLING, recorded rather than quietly fixed. The bulk converter silently dropped multi-line concatenations, damaging 7 producers — and the parity gate could not see it, because every surviving fragment WAS a real base literal while the CALL had lost text; two behaviour tests caught it. And the counting script was case-sensitive, so it said "0 left" while five remained. MinAgent: 0.131.0 (unchanged). No hub release needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
245 lines
8.7 KiB
Go
245 lines
8.7 KiB
Go
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", `<p>{{T "k"}}</p><p>{{ T "nope" }}</p>`)
|
|
if out != `<p>Szia {{.Name}}</p><p>{{ T "nope" }}</p>` {
|
|
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")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|