v0.252.0 — the sentences the program builds follow the language (R-557 slice 2 release A)
gates / gates (push) Successful in 23s

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
This commit is contained in:
2026-09-18 10:15:56 +02:00
parent 736f54b496
commit 5270bad76e
52 changed files with 10755 additions and 478 deletions
+125
View File
@@ -0,0 +1,125 @@
package api
import (
"io"
"log"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// Localisation slice 2 (R-557), scenario S2 — the JSON the page's own script reads follows the
// household's language, and the Hungarian answer is byte-identical to what it was.
//
// This is the half of the dashboard a template test cannot see. Since slice 1 the PAGE has been
// English, but every sentence that arrives by fetch — a refused deploy, a start blocked for memory,
// "the backup has started" — was a Hungarian literal in internal/api. An English household clicked an
// English button and was answered in Hungarian.
//
// The Hungarian side is a parity measurement, not a translation check: the `want` strings below are
// the literals that stood at the call sites at 736f54b49610.
func langRouter(t *testing.T, lang string) *Router {
t.Helper()
lg := log.New(io.Discard, "", 0)
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), lg)
if err != nil {
t.Fatal(err)
}
if lang != "" {
if err := sett.SetLanguage(lang); err != nil {
t.Fatal(err)
}
}
return &Router{sett: sett, logger: lg}
}
func TestAPIMessagesFollowTheHouseholdLanguage(t *testing.T) {
cases := []struct {
key, hu, en string
args []interface{}
}{
{key: "api.deploy.started",
hu: "Telepítés elindítva – az állapot a kártyán követhető",
en: "The install has started – the card shows how it is going"},
{key: "api.backup.already_running",
hu: "Mentés már folyamatban", en: "A backup is already running"},
{key: "api.start.drive_missing", args: []interface{}{"Kulso HDD"},
hu: "A(z) Kulso HDD tárhely jelenleg nem elérhető — az alkalmazás nem indítható, amíg a meghajtó vissza nem csatlakozik.",
en: "Storage Kulso HDD cannot be reached right now — the app cannot start until the drive is back."},
{key: "api.start.not_enough_memory", args: []interface{}{512, 128, 1400, 1528},
hu: "Nincs elég memória az indításhoz. Szükséges: 512 MB, elérhető: 128 MB (használt: 1400 MB / használható: 1528 MB)",
en: "There is not enough memory to start. Needs 512 MB, 128 MB is free (1400 MB used of 1528 MB usable)"},
}
for _, c := range cases {
t.Run(c.key, func(t *testing.T) {
// hu: the saved setting, no query override — the ordinary household.
r := langRouter(t, "hu")
req := httptest.NewRequest("POST", "/api/stacks/x/start", nil)
if got := r.msg(req, c.key, c.args...); got != c.hu {
t.Errorf("Hungarian answer moved (parity)\n got %q\n want %q", got, c.hu)
}
// en: the same request shape on a box switched to English.
r = langRouter(t, "en")
if got := r.msg(req, c.key, c.args...); got != c.en {
t.Errorf("English answer\n got %q\n want %q", got, c.en)
}
})
}
}
// TestAPILangForPrecedence — `?lang=` overrides the saved setting (the testing door slice 0 built),
// an unsupported value is ignored rather than honoured, and a box with no setting answers Hungarian.
func TestAPILangForPrecedence(t *testing.T) {
cases := []struct{ saved, query, want string }{
{"hu", "", "hu"},
{"en", "", "en"},
{"hu", "en", "en"},
{"en", "hu", "hu"},
{"en", "de", "en"}, // unsupported: ignored, the saved setting stands
{"", "", i18n.Default},
}
for _, c := range cases {
r := langRouter(t, c.saved)
url := "/api/stacks"
if c.query != "" {
url += "?lang=" + c.query
}
if got := r.langFor(httptest.NewRequest("GET", url, nil)); got != c.want {
t.Errorf("saved=%q query=%q: got %q, want %q", c.saved, c.query, got, c.want)
}
}
}
// TestGeoCountryNamesFollowTheLanguage — the picker's 237 country names. The cloudflare TABLE is not
// translated (it validates codes, and internal/report puts codes on the wire); only the displayed
// name is, and the list is re-sorted afterwards because alphabetical order is per language.
func TestGeoCountryNamesFollowTheLanguage(t *testing.T) {
r := langRouter(t, "hu")
for _, c := range []struct{ lang, code, want string }{
{"hu", "DE", "Németország"},
{"en", "DE", "Germany"},
{"hu", "US", "Egyesült Államok"},
{"en", "US", "United States"},
{"hu", "AO", "Angola"}, // identical in both languages — must still resolve, not fall through
{"en", "AO", "Angola"},
} {
if got := r.countryName(c.lang, c.code, "FALLBACK"); got != c.want {
t.Errorf("%s/%s: got %q, want %q", c.lang, c.code, got, c.want)
}
}
// A code the bundle does not carry keeps the table's own name rather than showing a key.
if got := r.countryName("en", "ZZ", "Ismeretlen"); got != "Ismeretlen" {
t.Errorf("an unknown code must keep the table name, got %q", got)
}
// And the English list really is sorted by the English names: „Németország" sorts under N in
// Hungarian and Germany under G, so the two orders must differ.
if strings.EqualFold(r.countryName("hu", "DE", ""), r.countryName("en", "DE", "")) {
t.Error("the country names did not change with the language at all")
}
}