b53721db34
The state stays the containers' (probeSaysUnhealthy skips a not-checked record), so R-630 holds. Red-proof RP-D1 — felhom.eu audits/visitors-2026-10-01/D. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
708 lines
31 KiB
Go
708 lines
31 KiB
Go
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
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|