package web import ( "bytes" "flag" "fmt" "html/template" "io/fs" "net/http/httptest" "os" "path" "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, "write MISSING testdata/i18n_parity fixtures from the current templates (an existing fixture is never overwritten)") var i18nDumpDir = flag.String("i18n-dump-dir", "", "TestI18nEnglishPages: also write every case's hu and en render to this directory (evidence, per-page review)") var i18nGoldenOnly = flag.String("i18n-golden-only", "", "with -update-i18n-golden: only cases whose name matches this regexp") 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) } // i18nTitleKeys mirrors the handlers' data["TitleKey"]: a page whose Go-side title has a key renders it // translated in English, exactly as addLanguageData does in production. var i18nTitleKeys = map[string]string{ "Indítópult": "page.title.launcher", "Biztonsági mentés": "page.title.backups", "Vezérlőpult": "page.title.dashboard", "Alkalmazások": "page.title.stacks", "Rendszermonitor": "page.title.monitoring", "Beállítások": "page.title.settings", "Értesítések": "page.title.settings_notifications", "Biztonság és hozzáférés": "page.title.settings_security", "Tárhely": "page.title.storage", "Hálózati tárhely": "page.title.storage_network", "Megőrzött adatok": "page.title.kept_data", "Új meghajtó inicializálása": "page.title.storage_init", "Meglévő meghajtó csatolása": "page.title.storage_attach", "Hálózati megosztás": "page.title.sharing", } func i18nLayoutData(page, title string) map[string]interface{} { d := 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", } if k, ok := i18nTitleKeys[title]; ok { d["TitleKey"] = k } return d } func i18nCases() []i18nCase { base := []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 tároló (local)", "NotSetUp": false, "HasSuccess": true, "SuccessAt": at, "SizeKnown": true, "SizeBytes": int64(3 << 30), "Current": true, "FailedAfter": true, "FailedAt": at}, {"Label": "Távoli szerver (PBS)", "NotSetUp": false, "HasSuccess": false, "Current": false}, {"Label": "Harmadik szint", "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 }}, {"backups_tier_due", "backups", func() map[string]interface{} { at := i18nFixtureTime() d := i18nLayoutData("backups", "Biztonsági mentés") d["Backup"] = map[string]interface{}{"DumpFiles": []string{"a.sql"}} d["GuestBackup"] = map[string]interface{}{ "Available": true, "HasBackup": true, "Success": false, "StartedAt": at, "SizeUnknown": true, "Target": "PBS (helyi)", "Due": true, "HasRestoreTest": true, "RestorePass": false, "RestoreTestedAt": at, "Tiers": []map[string]interface{}{ {"Label": "Helyi tároló (local)", "NotSetUp": false, "HasSuccess": true, "SuccessAt": at, "SizeKnown": false, "Current": false}, }, "Running": false, "CanTrigger": true, "Offsite": false, } d["DBSectionState"] = "dumps" return d }}, {"app_info_installable", "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: "Hasznos eszközök", Resources: stacks.ResourceHints{MemRequest: "64M"}} d["Stack"] = st d["Meta"] = st.Meta d["AppInfo"] = st.Meta.AppInfo return d }}, {"app_info_not_checked", "app_info", func() map[string]interface{} { // R-772: the declared health check could not run — the page says so. d := i18nLayoutData("stacks", "Karakeep") st := stacks.Stack{Name: "karakeep", Deployed: true, State: stacks.StateDegraded, HealthProbe: &stacks.HealthProbeResult{NotChecked: true}} st.Meta = stacks.Metadata{DisplayName: "Karakeep", Slug: "karakeep", Category: "Hasznos eszközök", Resources: stacks.ResourceHints{MemRequest: "64M"}} d["Stack"] = st d["Meta"] = st.Meta d["AppInfo"] = st.Meta.AppInfo 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: "Hasznos 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ött fájlok", 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: "Hasznos 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ő app") 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ő app", 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 folyamatban…"} st.Meta = stacks.Metadata{DisplayName: "PrivateBin", Slug: "privatebin"} d["Stack"] = st d["Meta"] = st.Meta d["AppInfo"] = st.Meta.AppInfo return d }}, } // Slice 1 (R-556): one file of cases per release, each captured from its unconverted templates. base = append(base, i18nCasesA()...) base = append(base, i18nCasesB()...) base = append(base, i18nCasesC()...) // v0.269.0 (`09` §3 decision 28): an app the box stopped for crashing — its Start button, captured when born. base = append(base, i18nCase{"stacks_unhealthy_stop", "stacks", func() map[string]interface{} { d := i18nLayoutData("stacks", "Alkalmazások") st := i18nStack("gokapi", "Go Kapi", stacks.StateStopped, true) st.HoldReason, st.HoldKind = "Az alkalmazást leállítottuk.", "unhealthy_stop" d["Stacks"] = []stacks.Stack{st} d["Subdomains"] = map[string]string{} d["MissingStorage"] = map[string]string{} d["NetworkStubs"] = map[string]string{} d["NetworkWarnings"] = map[string]string{} d["StorageLabels"] = map[string]string{} return d }}) // v0.270.0 (R-681): an app whose install a restart cut off — its sentence beside Install, captured when born. base = append(base, i18nCase{"stacks_install_interrupted", "stacks", func() map[string]interface{} { d := i18nLayoutData("stacks", "Alkalmazások") st := i18nStack("n8n", "n8n", stacks.StateNotDeployed, false) st.InstallInterrupted = true d["Stacks"] = []stacks.Stack{st} d["Subdomains"] = map[string]string{} d["MissingStorage"] = map[string]string{} d["NetworkStubs"] = map[string]string{} d["NetworkWarnings"] = map[string]string{} d["StorageLabels"] = map[string]string{} return d }}) // v0.280.0 (`09` §3 decision 46, R-710): the setup gate's card (button / probe) and "I changed it" — captured // when born. gateCase := func(name string, probe bool) i18nCase { return i18nCase{name, "app_info", func() map[string]interface{} { d := i18nLayoutData("stacks", "Immich") st := stacks.Stack{Name: "immich", Deployed: true, State: stacks.StateRunning} st.Meta = stacks.Metadata{DisplayName: "Immich", Slug: "immich"} d["Stack"] = st d["Meta"] = st.Meta d["AppInfo"] = st.Meta.AppInfo d["SetupGateClosed"] = true d["SetupGateHasProbe"] = probe return d }} } base = append(base, gateCase("app_info_setup_gate_button", false), gateCase("app_info_setup_gate_probe", true)) // R-741: the install hold's card, while an after_install app waits for its first-login change. base = append(base, i18nCase{"app_info_install_hold", "app_info", func() map[string]interface{} { d := i18nLayoutData("stacks", "Calibre") st := stacks.Stack{Name: "calibre-web", Deployed: true, State: stacks.StateRunning} st.Meta = stacks.Metadata{DisplayName: "Calibre", Slug: "calibre-web"} d["Stack"] = st d["Meta"] = st.Meta d["AppInfo"] = st.Meta.AppInfo d["InstallHoldClosed"] = true return d }}) // v0.281.0 (decision 47): the sign-up card, closed (with the app's how-to) and open for the household's window. signupCase := func(name string, closed bool, until string) i18nCase { return i18nCase{name, "app_info", func() map[string]interface{} { d := i18nLayoutData("stacks", "Opengist") st := stacks.Stack{Name: "opengist", Deployed: true, State: stacks.StateRunning} st.Meta = stacks.Metadata{DisplayName: "Opengist", Slug: "opengist", AppInfo: stacks.AppInfo{AddPeople: "Admin panel → Users."}} d["Stack"] = st d["Meta"] = st.Meta d["AppInfo"] = st.Meta.AppInfo d["SignupClosed"] = closed if until != "" { d["SignupOpenUntil"] = until } return d }} } base = append(base, signupCase("app_info_signup_closed", true, ""), signupCase("app_info_signup_window", false, "14:05")) // v0.282.0 (decision 49): "close sign-up now"; the app's own switch (restart line, failed line). base = append(base, i18nCase{"app_info_close_signup", "app_info", func() map[string]interface{} { d := signupCase("x", false, "").data() d["CloseSignupOffered"] = true return d }}, i18nCase{"app_info_signup_native", "app_info", func() map[string]interface{} { d := signupCase("x", true, "").data() d["SignupNative"], d["SignupNativeFailed"] = true, true return d }}) // v0.283.0 (decision 50): the one-press off-site offer on both backup pages, and the size card. base = append(base, i18nCase{"backups_remote_offsite_offer_fit", "backups_remote", func() map[string]interface{} { return i18nRemoteData(func(d, o m) { d["EscrowAgentOK"] = true d["OffboxOfferCount"] = 2 d["OffsiteFitTotal"], d["OffsiteFitQuota"], d["OffsiteFitTop"] = "130.0 GB", 100, "Immich (95.0 GB), Nextcloud (34.0 GB)" }) }}, i18nCase{"backups_apps_offsite_offer", "backups_apps", func() map[string]interface{} { d := i18nAppsData("ok", false, true, "dumps") d["OffboxOfferCount"] = 2 return d }}) base = append(base, i18nCase{"app_info_known_login_changed", "app_info", func() map[string]interface{} { d := i18nLayoutData("stacks", "Calibre-Web") st := stacks.Stack{Name: "calibre-web", Deployed: true, State: stacks.StateRunning} st.Meta = stacks.Metadata{DisplayName: "Calibre-Web", Slug: "calibre-web", AppInfo: stacks.AppInfo{DefaultCreds: "admin / admin123"}} d["Stack"] = st d["Meta"] = st.Meta d["AppInfo"] = st.Meta.AppInfo d["HasAppInfo"] = true d["KnownLoginLine"] = "Ez az alkalmazás egy ismert, közös jelszóval indul: admin / admin123. Telepítés után azonnal változtasd meg." return d }}) // v0.268.0 (`09` §3 decision 14): the ladder's "steps remaining" line, captured when it was born. base = append(base, i18nCase{"app_info_ladder_steps", "app_info", func() map[string]interface{} { d := i18nLayoutData("stacks", "RomM") st := stacks.Stack{Name: "romm", Deployed: true, State: stacks.StateRunning, LadderStepsLeft: 2} st.Meta = stacks.Metadata{DisplayName: "RomM", Slug: "romm"} d["Stack"] = st d["Meta"] = st.Meta d["AppInfo"] = st.Meta.AppInfo return d }}) return base } func renderI18nCase(t *testing.T, s *Server, lang string, c i18nCase) string { t.Helper() var buf bytes.Buffer data := c.data() r := httptest.NewRequest("GET", "/i18n-fixture", nil) // TWO production paths, and the fixture has to be rendered through the RIGHT one or it is a // picture of something no visitor sees (v0.254.0, R-557 release C). // // * a dashboard page goes through executeTemplate → addLanguageData: session CSRF, and its // language globe posts to the HOUSEHOLD switch; // * the pages outside the dashboard chrome go through executeTemplateLang: no CSRF, and their // globe posts to /lang, the visitor's own display cookie. // // Rendering a shell through addLanguageData would bake a form the real page never serves — which // is exactly what the first attempt at this release did, and what the diff caught. if i18nDirectTemplates[c.tmpl] { data["Lang"] = lang // The same asset data executeTemplateLang fills — one function, so the fixture cannot be a // picture of a page nobody serves (v0.255.0). s.addShellAssetData(data) // And WITHIN the direct-render set, two shapes again: /recovery is an AUTHENTICATED route, so // its globe writes the household's setting; /login and /claim are met with no session, so // theirs writes the visitor's cookie. The fixture has to be the one the real page serves. if i18nSessionTemplates[c.tmpl] { addLangOptions(data, lang, "/settings/language", "/i18n-fixture", s.csrfField(r)) } else { addLangOptions(data, lang, langCookiePath, "/i18n-fixture", "") } } else { s.addLanguageData(data, r, lang) } if err := s.templatesFor(lang).ExecuteTemplate(&buf, c.tmpl, data); err != nil { t.Fatalf("%s [%s]: render: %v", c.name, lang, err) } return normaliseI18nRender(buf.String()) } // csrfValueRe matches a CSRF field's value. A session CSRF token is random per session, so it can // never be a fixture value; it is blanked on BOTH sides of every parity comparison, exactly as // relative ages are. What the fixture still pins is that the field is THERE and which form it is in — // which is the part that says whether the page posts to the household switch or to /lang. var csrfValueRe = regexp.MustCompile(`(name="_csrf" value=")[^"]*"`) // normaliseI18nRender removes the two things that legitimately differ between two renders of the same // page: a relative age off the wall clock, and a per-session CSRF token. func normaliseI18nRender(s string) string { s = relativeAgeRe.ReplaceAllString(s, "# $1") return csrfValueRe.ReplaceAllString(s, "${1}CSRF\"") } // i18nDirectTemplates are the templates rendered by executeTemplateLang — the pages a request with no // household session meets. Kept beside the harness that has to branch on it; i18nDirectPages (in // i18n_wiring_test.go) names the same set with one case and an English probe each. // i18nSessionTemplates are the direct-render templates whose reader is SIGNED IN. /recovery is in the // authenticated route table; the rest of the direct set is met with no session. var i18nSessionTemplates = map[string]bool{"recovery": true} var i18nDirectTemplates = map[string]bool{ "login": true, "claim": true, "recovery": true, "launcher_shared": true, "launcher_share_password": true, "catchall": true, "setupgate": true, // v0.280.0 (decision 46): the gate page a stranger meets "signupclosed": true, // v0.281.0 (decision 47): an app's sign-up address once closed "familygate": true, // v0.287.0 (decisions 63/64): the family sign-in page } 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 *i18nGoldenOnly != "" && !regexp.MustCompile(*i18nGoldenOnly).MatchString(c.name) { continue } if _, err := os.Stat(p); err == nil { t.Logf("%s: fixture exists — NOT rewritten (fixtures are captured once, from the unconverted template)", c.name) continue } if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(p, []byte(got), 0o644); err != nil { t.Fatal(err) } t.Logf("%s: fixture WRITTEN", c.name) 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[a-z][a-z_]*\.[a-z0-9_.]+\b`) var emptyElemRe = regexp.MustCompile(`<(h[1-6]|button|label|li|strong|option|p)(\s[^>]*)?>\s*`) // 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 *i18nDumpDir != "" { _ = os.MkdirAll(*i18nDumpDir, 0o755) _ = os.WriteFile(filepath.Join(*i18nDumpDir, c.name+".hu.html"), []byte(hu), 0o644) _ = os.WriteFile(filepath.Join(*i18nDumpDir, c.name+".en.html"), []byte(en), 0o644) } 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) } for _, m := range i18nKeyLikeRe.FindAllString(en, -1) { if s.i18n.Has(i18n.Default, m) { t.Errorf("%s: a raw bundle key reached the English page: %q", c.name, m) break } } 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 { // NARROWED (slice 1): only a data string long enough to be unmistakable is masked — two or // more words, or 12+ characters. A one-word Hungarian value („Indítópult") equal to template // copy is no longer able to hide that copy; fixture data therefore carries no one-word // Hungarian values. if len(strings.Fields(k)) < 2 && len([]rune(k)) < 12 { continue } 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)) } } } } // TestI18nParityCoversEveryMarker — every {{T}} marker OCCURRENCE in every template is rendered by at // least one parity case. A branch no case reaches carries copy the parity and English tests never saw, // so a page is not done while one exists. // // How: a probe template set is parsed in which each occurrence is expanded to an invisible, unique // sentinel (U+2063 … U+2063) followed by its Hungarian text; every case renders through it; any sentinel // absent from every output names the file and the key. The sentinel is literal template text, so it // survives html/template untouched in every context. // // RED-PROOF (REPORT.md): a marker placed in a launcher.html branch no case reaches → this test fails // naming launcher.html and the key. func TestI18nParityCoversEveryMarker(t *testing.T) { s := i18nTestServer(t) b, err := i18n.Shared() if err != nil { t.Fatal(err) } names, err := fs.Glob(templateFS, "templates/*.html") if err != nil { t.Fatal(err) } probe := template.New("").Funcs(s.templateFuncMap()) occ := map[string]string{} for _, n := range names { raw, err := templateFS.ReadFile(n) if err != nil { t.Fatal(err) } base := path.Base(n) idx := 0 src := i18nMarkerRe.ReplaceAllStringFunc(string(raw), func(m string) string { key := i18nMarkerRe.FindStringSubmatch(m)[1] id := fmt.Sprintf("⁣%s#%d⁣", base, idx) idx++ occ[id] = base + " " + key text, _, ok := b.Text(i18n.Default, key) if !ok { t.Fatalf("%s: key %q not in hu.json", base, key) } return id + text }) tmpl := probe if base != probe.Name() { tmpl = probe.New(base) } if _, err := tmpl.Parse(src); err != nil { t.Fatalf("probe parse %s: %v", base, err) } } if len(occ) < 250 { t.Fatalf("only %d marker occurrences found — the probe is not reading the converted templates", len(occ)) } var all strings.Builder for _, c := range i18nCases() { data := c.data() s.addLanguageData(data, httptest.NewRequest("GET", "/i18n-fixture", nil), i18n.Default) if err := probe.ExecuteTemplate(&all, c.tmpl, data); err != nil { t.Fatalf("%s: probe render: %v", c.name, err) } } out := all.String() var missing []string for id, where := range occ { if !strings.Contains(out, id) { missing = append(missing, where) } } sort.Strings(missing) for _, m := range missing { t.Errorf("marker never rendered by any parity case: %s", m) } }