612c417024
gates / gates (push) Successful in 19s
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
372 lines
15 KiB
Go
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))
|
|
}
|
|
}
|
|
}
|
|
}
|