Files
felhom-controller/controller/internal/i18n/i18n_test.go
T
admin 5270bad76e
gates / gates (push) Successful in 23s
v0.252.0 — the sentences the program builds follow the language (R-557 slice 2 release A)
Slice 1 translated the dashboard's markup. The sentences the program BUILDS were still
Hungarian literals in Go, so an English household clicked an English button and was
answered in Hungarian. 226 of them move into the bundle here.

A flash was the hard part: it travels inside the redirect URL and is rendered by a
DIFFERENT request, so it now carries a bundle key plus its parameters. A link minted by
an older controller carries prose and is shown verbatim — never a raw key, never dropped.

Also converted: page data and view-model text, the internal/api JSON answers, the alert
banners (Alert.MessageKey, rendered on the way out of GetAlerts), 237 country names at
display, and the four page titles built around an app name (R-566 closed).

Hungarian is byte-identical, and that is measured rather than read:
scripts/i18n_go_parity.py freezes every Go literal at the base commit (7 467) and refuses
a key whose Hungarian is not that text, byte for byte. Three decoys, each seen to convict.
Its own first version filtered the capture through an ASCII-Hungarian word list and missed
seven real literals — the R-565 class. The filter is gone.

Nothing on the wire moved, and wire goldens now hold it there: the report's health
warnings and every notify event message stay Hungarian, because the hub MAILS the
controller's sentence when it has no entry of its own. Slice 3 (R-558) owns those.

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
2026-09-18 10:15:56 +02:00

173 lines
5.8 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, "") }