PrivateBin
+
+
+
+diff --git a/controller/internal/web/i18n_parity_test.go b/controller/internal/web/i18n_parity_test.go index 562dd6d..f181755 100644 --- a/controller/internal/web/i18n_parity_test.go +++ b/controller/internal/web/i18n_parity_test.go @@ -3,6 +3,11 @@ package web import ( "bytes" "flag" + "fmt" + "html/template" + "io/fs" + "path" + "net/http/httptest" "os" "path/filepath" "reflect" @@ -29,7 +34,8 @@ import ( // 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 updateI18nGolden = flag.Bool("update-i18n-golden", false, "write MISSING testdata/i18n_parity fixtures from the current templates (an existing fixture is never overwritten)") +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)`) @@ -41,12 +47,23 @@ type i18nCase struct { 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", +} + func i18nLayoutData(page, title string) map[string]interface{} { - return 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 { @@ -135,9 +152,9 @@ func i18nCases() []i18nCase { "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}, + {"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, } @@ -176,10 +193,35 @@ func i18nCases() []i18nCase { 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: "Eszközök", Description: "Titkosított jegyzetek.", + 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"}} @@ -189,7 +231,7 @@ func i18nCases() []i18nCase { 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["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 @@ -198,7 +240,7 @@ func i18nCases() []i18nCase { {"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.", + 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 @@ -207,9 +249,9 @@ func i18nCases() []i18nCase { return d }}, {"app_info_operator_creds", "app_info", func() map[string]interface{} { - d := i18nLayoutData("stacks", "Fájlkezelő") + 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ő", Slug: "filebrowser"} + st.Meta = stacks.Metadata{DisplayName: "Fájlkezelő app", Slug: "filebrowser"} d["Stack"] = st d["Meta"] = st.Meta d["AppInfo"] = st.Meta.AppInfo @@ -220,7 +262,7 @@ func i18nCases() []i18nCase { }}, {"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 := 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 @@ -233,7 +275,11 @@ func i18nCases() []i18nCase { 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 { + data := c.data() + // The production choke point's language step (executeTemplate → addLanguageData), with a request that + // carries no ?lang= — so the switch and the translated title behave exactly as on a real page. + s.addLanguageData(data, httptest.NewRequest("GET", "/i18n-fixture", nil), lang) + if err := s.templatesFor(lang).ExecuteTemplate(&buf, c.tmpl, data); err != nil { t.Fatalf("%s [%s]: render: %v", c.name, lang, err) } return relativeAgeRe.ReplaceAllString(buf.String(), "# $1") @@ -253,12 +299,20 @@ func TestI18nParity(t *testing.T) { 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) @@ -359,6 +413,13 @@ func TestI18nEnglishPages(t *testing.T) { } 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. @@ -369,3 +430,76 @@ func TestI18nEnglishPages(t *testing.T) { } } } + +// 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) + } +} diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index 6148a81..97da1a3 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -307,7 +307,9 @@ func (s *Server) loadTemplates() { for name, fn := range s.localeFuncs(lang) { funcs[name] = fn } + start := time.Now() t, st, err := parseTemplateSet(b, lang, funcs) + took := time.Since(start) if err != nil { panic(fmt.Errorf("templates (%s): %w", lang, err)) } @@ -316,7 +318,7 @@ func (s *Server) loadTemplates() { s.logger.Printf("[INFO] [web] i18n: %s template set shows Hungarian for %d markers (keys not yet translated)", lang, len(st.FellBack)) } if s.isDebug() { - s.logger.Printf("[DEBUG] [web] loadTemplates: lang=%s loaded %d templates, %d markers expanded", lang, len(t.Templates()), st.Markers) + s.logger.Printf("[DEBUG] [web] loadTemplates: lang=%s loaded %d templates, %d markers expanded in %s", lang, len(t.Templates()), st.Markers, took) } } s.tmpl = s.tmplByLang[i18n.Default] diff --git a/controller/internal/web/testdata/i18n_parity/app_info_available.html b/controller/internal/web/testdata/i18n_parity/app_info_available.html index c4222be..0f53cab 100644 --- a/controller/internal/web/testdata/i18n_parity/app_info_available.html +++ b/controller/internal/web/testdata/i18n_parity/app_info_available.html @@ -189,7 +189,7 @@