Files
felhom-controller/controller/internal/i18n/i18n_test.go
T
admin 11b790e314
gates / gates (push) Successful in 20s
v0.249.0: i18n slice 1 release B — backup pages in English, Hungarian byte-identical (R-556)
Seven backup pages + the restore-progress JS converted against fixtures captured unconverted
(f8ebc47); recovery renders through executeTemplateLang; English retrieval claims registered;
the Fut comparison left unconverted (R-563).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-17 18:01:09 +02:00

168 lines
5.5 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".
var verbRe = regexp.MustCompile(`%[-+#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, "") }