Files
felhom-controller/controller/internal/web/i18n_parity_test.go
T
admin 43e99d160c
gates / gates (push) Successful in 26s
kept data: the choice at reinstall, the list, the read-only view, the load (09 decision 36); R-690 fixed
An install over an app's kept drive folder (appdata/<app> non-empty) asks the household:
"use my kept data" (a load from the newest copy of THIS drive's install, own unit or
second-drive mirror, then the template's after_load) or "start fresh" (the folder is
renamed into <drive>/kept/<app>/<date>/ with the removed app's unit; nothing deleted).
The install API answers 409 kept_data_choice until one is chosen; DeployStack refuses
too. New page Megorzott adatok / Kept data (/kept-data): Load / Look / Delete (typed
confirmation, the only deletion of kept data). FileBrowser gets a read-only source.
The drive-full warning names the kept folders. <drive>/kept is protected and outside
every backup leg.

R-690: the removed-app restore (R-487) never found a unit on a DATA drive — it asked
GetStackComposePath (true for every catalog app) and restored nextcloud with no env.
Now isStackDeployed; pinned with a production-shaped provider.

Red-proofs: audits/night-2026-09-26/E/redproofs/.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-25 13:32:30 +02:00

617 lines
26 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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_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.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,
}
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*</(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 *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)
}
}