Files
felhom-controller/controller/internal/web/i18n_parity_test.go
T
admin 612c417024
gates / gates (push) Successful in 19s
v0.247.0: i18n spike — the dashboard can speak English, Hungarian byte-identical
Message bundles (internal/i18n) expanded into templates before parsing, one
template set per language. Launcher, /backups, /apps/<slug> and the layout
converted; household language setting, POST /settings/language, ?lang= override,
report field. Parity test against fixtures captured from unconverted templates;
copy gates read templates expanded; new i18n_missing_gate.

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

372 lines
15 KiB
Go

package web
import (
"bytes"
"flag"
"os"
"path/filepath"
"reflect"
"regexp"
"sort"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// ── i18n (v0.247.0) — THE RELEASE GATE: Hungarian renders byte-for-byte as it did ──────────────────
//
// The fixtures under testdata/i18n_parity/ were captured from the templates BEFORE any of them carried
// a {{T}} marker (`go test -run TestI18nParity -update-i18n-golden` on the unconverted tree, commit
// recorded in felhom-controller/REPORT.md). Every conversion since must reproduce them exactly.
//
// Normalisation, and it is the ONLY one: relative ages („3 napja") are rewritten to „# napja", because
// they are computed from the wall clock and the fixtures carry fixed dates. Nothing else is touched.
//
// RED BEFORE THE FIXTURE EXISTS: a missing fixture file fails the test; it is never silently written.
// RED-PROOF (recorded in REPORT.md): change one byte of one launcher value in locales/hu.json and
// TestI18nParity fails naming the page and the first differing line.
var updateI18nGolden = flag.Bool("update-i18n-golden", false, "rewrite testdata/i18n_parity fixtures from the current templates")
var relativeAgeRe = regexp.MustCompile(`\d+ (napja|órája|perce)`)
type i18nCase struct {
name string
tmpl string
data func() map[string]interface{}
}
func i18nFixtureTime() time.Time { return time.Date(2026, 9, 14, 3, 12, 0, 0, time.UTC) }
func i18nLayoutData(page, title string) map[string]interface{} {
return map[string]interface{}{
"Page": page, "Title": title, "CustomerName": "Teszt Ügyfél", "Domain": "example.hu",
"Version": "0.247.0", "AuthEnabled": true, "DebugMode": false,
"CSRFField": "", "CSRFToken": "tok",
}
}
func i18nCases() []i18nCase {
return []i18nCase{
{"launcher_full", "launcher", func() map[string]interface{} {
d := i18nLayoutData("launcher", "Indítópult")
d["ClaimLegacyOpen"] = true
d["RecoveryBanner"] = true
d["RecoveryBannerBack"] = "/launcher"
d["RecoveryAbandonDays"] = 12
d["RecoveryAbandonDate"] = "2026-09-29"
d["RecoveryAbandonRetrievalOffered"] = true
d["Apps"] = []LauncherApp{
{Name: "immich", DisplayName: "Immich", Slug: "immich", State: stacks.StateRunning, Subdomain: "fotok"},
{Name: "privatebin", DisplayName: "PrivateBin", Slug: "privatebin", State: stacks.StateUnhealthy, Subdomain: "paste", OpenPath: "/"},
{Name: "kimai", DisplayName: "Kimai", Slug: "kimai", State: stacks.StateStopped, Subdomain: "ido"},
}
d["ShareEnabled"] = true
d["ShareURL"] = "https://felhom.example.hu/s/abc"
d["SharePasswordSet"] = true
d["ShareFlash"] = "A megosztás bekapcsolva."
return d
}},
{"launcher_empty", "launcher", func() map[string]interface{} {
d := i18nLayoutData("launcher", "Indítópult")
d["RecoveryBanner"] = true
d["RecoveryBannerBack"] = "/launcher"
d["RecoveryReminderTier"] = 14
d["EscrowBanner"] = true
d["EscrowBannerBack"] = "/launcher"
return d
}},
{"launcher_nopassword", "launcher", func() map[string]interface{} {
d := i18nLayoutData("launcher", "Indítópult")
d["RecoveryBanner"] = true
d["RecoveryAbandonDays"] = 3
d["RecoveryAbandonDate"] = "2026-09-20"
d["Alerts"] = []Alert{
{ID: "a", Level: "warning", Message: "A rendszer meghajtója 91%-on áll.", Link: "/monitoring", LinkText: "Rendszermonitor"},
{ID: "b", Level: "info", Message: "Frissítés érhető el."},
{ID: "c", Level: "error", Message: "Csak a műszerfalon.", PageOnly: []string{"dashboard"}},
}
d["Apps"] = []LauncherApp{{Name: "gokapi", DisplayName: "Gokapi", Slug: "gokapi", State: stacks.StateStarting, Subdomain: "fajl"}}
d["ShareEnabled"] = true
d["ShareURL"] = "https://felhom.example.hu/s/xyz"
return d
}},
{"launcher_reminder_tiers", "launcher", func() map[string]interface{} {
d := i18nLayoutData("launcher", "Indítópult")
d["RecoveryBanner"] = true
d["RecoveryReminderTier"] = 7
return d
}},
{"launcher_reminder_tier3", "launcher", func() map[string]interface{} {
d := i18nLayoutData("launcher", "Indítópult")
d["RecoveryBanner"] = true
d["RecoveryReminderTier"] = 3
d["AuthEnabled"] = false
return d
}},
{"launcher_reminder_tier0", "launcher", func() map[string]interface{} {
d := i18nLayoutData("launcher", "Indítópult")
d["RecoveryBanner"] = true
d["RecoveryReminderTier"] = 0 // the handler always sets an int; a missing key cannot reach `ge`
return d
}},
{"backups_full", "backups", func() map[string]interface{} {
at := i18nFixtureTime()
d := i18nLayoutData("backups", "Biztonsági mentés")
d["Backup"] = map[string]interface{}{
"FlashSuccess": "A mentés elindult.", "FlashError": "", "SingleCopyWarning": "Csak egy másolat létezik.",
"DumpFiles": []string{"a.sql", "b.sql"},
"LastDBDump": map[string]interface{}{"Success": true, "Results": []int{1, 2, 3}},
}
d["BackupTarget"] = map[string]interface{}{
"Message": "A rendszermentésnek nincs helye.", "OfferPath": "/mnt/hdd2",
"OfferText": "Kijelölheted a második meghajtót.", "OfferLabel": "HDD 2 (1 TB)",
}
d["SystemInfo"] = map[string]interface{}{"DiskPercent": 42.0, "DiskUsedGB": 42.5, "DiskTotalGB": 100.0}
d["StorageBars"] = []map[string]interface{}{
{"Label": "Adatok", "Disconnected": false, "Percent": 71.3, "UsedGB": 713.0, "TotalGB": 1000.0},
{"Label": "Régi lemez", "Disconnected": true},
}
d["GuestBackup"] = map[string]interface{}{
"Available": true, "HasBackup": true, "Success": true, "StartedAt": at, "SizeUnknown": false,
"SizeBytes": int64(5 << 30), "Target": "PBS (helyi)", "Due": false, "AgeHours": 7,
"HasRestoreTest": true, "RestorePass": true, "RestoreTestedAt": at,
"Tiers": []map[string]interface{}{
{"Label": "Helyi", "NotSetUp": false, "HasSuccess": true, "SuccessAt": at, "SizeKnown": true, "SizeBytes": int64(3 << 30), "Current": true, "FailedAfter": true, "FailedAt": at},
{"Label": "Távoli", "NotSetUp": false, "HasSuccess": false, "Current": false},
{"Label": "Harmadik", "NotSetUp": true},
},
"Running": true, "Phase": "vzdump", "CanTrigger": true, "Offsite": true,
}
d["BackupWindow"] = "01:30"
d["BackupLegDB"] = "01:30"
d["BackupLegTier2"] = "02:00"
d["BackupLegOffbox"] = "02:30"
d["BackupGateFrom"] = "03:00"
d["BackupGateTo"] = "05:00"
d["DBSectionState"] = "dumps"
d["EscrowBanner"] = true
return d
}},
{"backups_empty", "backups", func() map[string]interface{} {
return i18nLayoutData("backups", "Biztonsági mentés")
}},
{"backups_degraded", "backups", func() map[string]interface{} {
at := i18nFixtureTime()
d := i18nLayoutData("backups", "Biztonsági mentés")
d["Backup"] = map[string]interface{}{
"FlashSuccess": "", "FlashError": "A mentés nem sikerült.",
"LastDBDump": map[string]interface{}{"Success": false, "Results": []int{}},
}
d["BackupTarget"] = map[string]interface{}{"Message": "A rendszermentés helye nem elérhető."}
d["GuestBackup"] = map[string]interface{}{"Available": false, "Note": "Az ügynök nem elérhető."}
d["DBSectionState"] = "embedded"
_ = at
return d
}},
{"backups_nobackup_yet", "backups", func() map[string]interface{} {
d := i18nLayoutData("backups", "Biztonsági mentés")
d["Backup"] = map[string]interface{}{"DumpFiles": []string{}}
d["GuestBackup"] = map[string]interface{}{
"Available": true, "HasBackup": false, "Due": true, "HasRestoreTest": false, "CanTrigger": false,
}
d["DBSectionState"] = "pending"
return d
}},
{"app_info_deployed", "app_info", func() map[string]interface{} {
d := i18nLayoutData("stacks", "PrivateBin")
st := stacks.Stack{Name: "privatebin", Deployed: true, State: stacks.StateRunning, HoldReason: "A frissítés megállt: nincs friss mentés."}
st.Meta = stacks.Metadata{DisplayName: "PrivateBin", Slug: "privatebin", Category: "Eszközök", Description: "Titkosított jegyzetek.",
Resources: stacks.ResourceHints{MemRequest: "64M", NeedsHDD: true, PiCompatible: false, HungarianUI: true},
AppInfo: stacks.AppInfo{Tagline: "Titkosított szövegmegosztás.", UseCases: []string{"Jelszó átadása"}, FirstSteps: []string{"Nyisd meg"},
Prerequisites: []string{"Semmi"}, DefaultCreds: "nincs", DocsURL: "https://privatebin.info"}}
d["Stack"] = st
d["Meta"] = st.Meta
d["AppInfo"] = st.Meta.AppInfo
d["HasAppInfo"] = true
d["EffectiveSubdomain"] = "paste"
d["MissingStorageLabel"] = "HDD 1"
d["DataPathCards"] = []DataPathCard{{Label: "Feltöltések", Link: "/files/x", Consequence: "Mentve minden éjjel.", IsImport: true, FreeSpace: "12 GB szabad"}}
d["MigrateTargets"] = []map[string]interface{}{{"Path": "/mnt/hdd2", "Label": "HDD 2"}}
d["InitialCreds"] = &stacks.ExtractedCreds{Available: true, Username: "admin", Note: "A jelszót a Felhom állította be."}
d["InitialCredsHasPassword"] = true
return d
}},
{"app_info_available", "app_info", func() map[string]interface{} {
d := i18nLayoutData("stacks", "PrivateBin")
st := stacks.Stack{Name: "privatebin", Deployed: false, State: stacks.StateNotDeployed}
st.Meta = stacks.Metadata{DisplayName: "PrivateBin", Slug: "privatebin", Category: "Eszközök", Description: "Titkosított jegyzetek.",
Lifecycle: "abandoned", Resources: stacks.ResourceHints{MemRequest: "64M", PiCompatible: true}}
d["Stack"] = st
d["Meta"] = st.Meta
d["AppInfo"] = st.Meta.AppInfo
d["HasAppInfo"] = false
return d
}},
{"app_info_operator_creds", "app_info", func() map[string]interface{} {
d := i18nLayoutData("stacks", "Fájlkezelő")
st := stacks.Stack{Name: "filebrowser", Deployed: true, State: stacks.StateStopped, Orphaned: true, Updating: false, UpdateError: "A frissítés sikertelen."}
st.Meta = stacks.Metadata{DisplayName: "Fájlkezelő", Slug: "filebrowser"}
d["Stack"] = st
d["Meta"] = st.Meta
d["AppInfo"] = st.Meta.AppInfo
d["HasAppInfo"] = true
d["InitialCreds"] = &stacks.ExtractedCreds{Available: true, Username: "admin"}
d["InitialCredsOperatorSet"] = true
return d
}},
{"app_info_updating", "app_info", func() map[string]interface{} {
d := i18nLayoutData("stacks", "PrivateBin")
st := stacks.Stack{Name: "privatebin", Deployed: true, State: stacks.StateDeploying, Updating: true, UpdatePhase: "pull", UpdatePhaseLabel: "Letöltés…"}
st.Meta = stacks.Metadata{DisplayName: "PrivateBin", Slug: "privatebin"}
d["Stack"] = st
d["Meta"] = st.Meta
d["AppInfo"] = st.Meta.AppInfo
return d
}},
}
}
func renderI18nCase(t *testing.T, s *Server, lang string, c i18nCase) string {
t.Helper()
var buf bytes.Buffer
if err := s.templatesFor(lang).ExecuteTemplate(&buf, c.tmpl, c.data()); err != nil {
t.Fatalf("%s [%s]: render: %v", c.name, lang, err)
}
return relativeAgeRe.ReplaceAllString(buf.String(), "# $1")
}
func i18nTestServer(t *testing.T) *Server {
s := testServer(t)
s.loadTemplates()
return s
}
// TestI18nParity — the Hungarian pages are byte-identical to the fixtures captured before conversion.
func TestI18nParity(t *testing.T) {
s := i18nTestServer(t)
dir := filepath.Join("testdata", "i18n_parity")
for _, c := range i18nCases() {
got := renderI18nCase(t, s, "hu", c)
p := filepath.Join(dir, c.name+".html")
if *updateI18nGolden {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(got), 0o644); err != nil {
t.Fatal(err)
}
continue
}
want, err := os.ReadFile(p)
if err != nil {
t.Fatalf("%s: no parity fixture (%v) — fixtures are captured from UNCONVERTED templates, never regenerated to make a conversion pass", c.name, err)
}
if got != string(want) {
gl, wl := strings.Split(got, "\n"), strings.Split(string(want), "\n")
for i := 0; i < len(gl) || i < len(wl); i++ {
var g, w string
if i < len(gl) {
g = gl[i]
}
if i < len(wl) {
w = wl[i]
}
if g != w {
t.Errorf("%s: Hungarian page CHANGED at line %d\n fixture: %q\n now: %q", c.name, i+1, w, g)
break
}
}
}
}
}
// huLetter reports a Hungarian accented letter. ASCII source: the letters are escapes.
func huLetter(s string) bool {
return strings.ContainsAny(s, "áéíóöőúüűÁÉÍÓÖŐÚÜŰ")
}
// fixtureStrings collects every string the fixture DATA carries (catalog copy, Go-side messages the
// handler would build). Those are not template copy: they are slices 2 and 5 of the plan, and the
// English page check subtracts them rather than pretending the template could translate them.
func fixtureStrings(v reflect.Value, out map[string]bool) {
switch v.Kind() {
case reflect.Interface, reflect.Ptr:
if !v.IsNil() {
fixtureStrings(v.Elem(), out)
}
case reflect.String:
if v.String() != "" {
out[v.String()] = true
}
case reflect.Map:
for _, k := range v.MapKeys() {
fixtureStrings(v.MapIndex(k), out)
}
case reflect.Slice, reflect.Array:
for i := 0; i < v.Len(); i++ {
fixtureStrings(v.Index(i), out)
}
case reflect.Struct:
for i := 0; i < v.NumField(); i++ {
if v.Type().Field(i).IsExported() {
fixtureStrings(v.Field(i), out)
}
}
}
}
var i18nKeyLikeRe = regexp.MustCompile(`\b(launcher|backups|backups_shared|app_info|layout|func)\.[a-z0-9_]+\b`)
var emptyElemRe = regexp.MustCompile(`<(h[1-6]|button|label|li|strong|option|p)(\s[^>]*)?>\s*</(h[1-6]|button|label|li|strong|option|p)>`)
// TestI18nEnglishPages — the three pages in English: no Hungarian letter from the TEMPLATE copy, no
// raw key, no marker, and no element that is empty in English but not in Hungarian.
func TestI18nEnglishPages(t *testing.T) {
s := i18nTestServer(t)
// Controls for the letter detector, so a zero below means something.
if !huLetter("Indítópult") {
t.Fatal("control: the detector does not flag a Hungarian word")
}
if huLetter("Backup completed at 03:00, next run tomorrow.") {
t.Fatal("control: the detector flags plain English")
}
for _, c := range i18nCases() {
hu := renderI18nCase(t, s, "hu", c)
en := renderI18nCase(t, s, "en", c)
if !huLetter(hu) {
t.Fatalf("%s: positive control failed — the Hungarian render carries no Hungarian letter", c.name)
}
if strings.Contains(en, `{{T `) {
t.Errorf("%s: an unexpanded marker reached the English page", c.name)
}
if m := i18nKeyLikeRe.FindString(en); m != "" && s.i18n.Has(i18n.Default, m) {
t.Errorf("%s: a raw bundle key reached the English page: %q", c.name, m)
}
if a, b := len(emptyElemRe.FindAllString(hu, -1)), len(emptyElemRe.FindAllString(en, -1)); b > a {
t.Errorf("%s: English has %d empty elements, Hungarian %d — a translation rendered blank", c.name, b, a)
}
data := map[string]bool{}
fixtureStrings(reflect.ValueOf(c.data()), data)
stripped := en
keys := make([]string, 0, len(data))
for k := range data {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool { return len(keys[i]) > len(keys[j]) })
for _, k := range keys {
stripped = strings.ReplaceAll(stripped, k, "")
}
// Script/style bodies are included on purpose: inline JS copy is page copy.
for i, line := range strings.Split(stripped, "\n") {
if huLetter(line) {
t.Errorf("%s: Hungarian template copy on the English page, line %d: %q", c.name, i+1, strings.TrimSpace(line))
}
}
}
}