f30a80841e
gates / gates (push) Successful in 50s
FileBrowser (and traefik, cloudflared, samba) has no app.yaml, so Deployed is false. Since v0.315.0 the app page read Deployed alone and showed FileBrowser "not installed", with Install and without its login. A protected infra stack is now installed by definition on the app page (Deployed || Protected, as every other surface already counted): settings/guide/log tabs, Restart only, no install page, no data tab. The Install press would have deployed the infra stack as an app: DeployStack and the deploy API now refuse a protected stack. In the operator state (the box does not know FileBrowser's password - both demo boxes) the login panel offers "Új jelszó beállítása": the file browser's own CLI with the server stopped (spiked on demo-hp: new 200, old 401, users/shares/settings unchanged), verified, stored encrypted, state generated. Every installed app's settings tab has the login panel with exactly one answer: the box's fields, the login the app wrote (InitialCreds), or a sentence chosen by the catalog's new app_info.login_kind (first_visit default, email, none; none behind the family gate says the family sign-in is the login). "Fájlok ↗" opens the library folder, else the first declared folder, else a media app's first present drive's media folder. The data tab lists each drive's media folders; a folder whose drive is not bound into FileBrowser is text, never a link. Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
1036 lines
48 KiB
Go
1036 lines
48 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
|
||
}},
|
||
// R-365 follow-up: the countdown's due moment has passed, the daily sweep has not run yet.
|
||
{"launcher_abandon_overdue", "launcher", func() map[string]interface{} {
|
||
d := i18nLayoutData("launcher", "Indítópult")
|
||
d["RecoveryBanner"] = true
|
||
d["RecoveryBannerBack"] = "/launcher"
|
||
d["RecoveryAbandonDays"] = 0
|
||
d["RecoveryAbandonOverdue"] = true
|
||
d["RecoveryAbandonDate"] = "2026-09-29"
|
||
return d
|
||
}},
|
||
{"launcher_abandon_overdue_offered", "launcher", func() map[string]interface{} {
|
||
d := i18nLayoutData("launcher", "Indítópult")
|
||
d["RecoveryBanner"] = true
|
||
d["RecoveryBannerBack"] = "/launcher"
|
||
d["RecoveryAbandonDays"] = 0
|
||
d["RecoveryAbandonOverdue"] = true
|
||
d["RecoveryAbandonDate"] = "2026-09-29"
|
||
d["RecoveryAbandonRetrievalOffered"] = true
|
||
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
|
||
}},
|
||
// R-871 (v0.295.0, decision 110): the missed-backup banner. Its two sentences are computed in Go per reader
|
||
// language (missed_backup_banner.go); here the Hungarian ones, as the hu render receives them.
|
||
{"launcher_missed_backup", "launcher", func() map[string]interface{} {
|
||
d := i18nLayoutData("launcher", "Indítópult")
|
||
d["MissedBackupBanner"] = true
|
||
d["MissedBackupMessage"] = "A legutóbbi mentés 2 nappal ezelőtt készült. A doboz ki volt kapcsolva a mentés idején (02:30)."
|
||
d["MissedBackupHint"] = "Válassz egy olyan időpontot, amikor a doboz általában be van kapcsolva — például 21:00."
|
||
d["MissedBackupBack"] = "/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_guide_installable", "app_guide", 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_settings_not_checked", "app_settings", 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_settings_deployed", "app_settings", 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_guide_available", "app_guide", 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_settings_operator_creds", "app_settings", 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_settings_updating", "app_settings", 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_settings", 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_settings_setup_gate_button", false), gateCase("app_settings_setup_gate_probe", true))
|
||
// v0.313.0 (R-1028): the Plex claim box inside a closed Plex gate — captured when born.
|
||
base = append(base, i18nCase{"app_settings_after_start", "app_settings", func() map[string]interface{} {
|
||
d := i18nLayoutData("stacks", "Plex")
|
||
st := stacks.Stack{Name: "plex", Deployed: true, State: stacks.StateRunning}
|
||
st.Meta = stacks.Metadata{DisplayName: "Plex", Slug: "plex"}
|
||
d["Stack"] = st
|
||
d["Meta"] = st.Meta
|
||
d["AppInfo"] = st.Meta.AppInfo
|
||
d["SetupGateClosed"] = true
|
||
d["SetupGateHasProbe"] = true
|
||
// v0.315.0: the claim box is the GENERIC after-start card. What makes it render is the field
|
||
// being in the page data at all — afterStartFields puts it there only when the app has a
|
||
// registered Go action for it and that action says the value is still wanted.
|
||
d["AfterStartFields"] = []stacks.DeployField{{EnvVar: "PLEX_CLAIM", Label: "Plex igénylő kód",
|
||
Description: "Illeszd be a plex.tv-n kapott kódot.", Placeholder: "claim-xxxxxxxxxxxxxxxxxxxx", AfterStart: true}}
|
||
return d
|
||
}})
|
||
// R-741: the install hold's card, while an after_install app waits for its first-login change.
|
||
base = append(base, i18nCase{"app_settings_install_hold", "app_settings", 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_settings", 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_settings_signup_closed", true, ""), signupCase("app_settings_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_settings_close_signup", "app_settings", func() map[string]interface{} {
|
||
d := signupCase("x", false, "").data()
|
||
d["CloseSignupOffered"] = true
|
||
return d
|
||
}}, i18nCase{"app_settings_signup_native", "app_settings", func() map[string]interface{} {
|
||
d := signupCase("x", true, "").data()
|
||
d["SignupNative"], d["SignupNativeFailed"] = true, true
|
||
return d
|
||
}}, i18nCase{"app_settings_signup_native_open_failed", "app_settings", func() map[string]interface{} {
|
||
d := signupCase("x", true, "").data()
|
||
d["SignupNativeOpenFailed"] = true // R-717: the window could not open the app's own switch
|
||
return d
|
||
}})
|
||
// R-1007: the lost-files note after an unclean boot (name, date taken, the boot time, the dismiss button).
|
||
base = append(base, i18nCase{"app_settings_lost_files", "app_settings", func() map[string]interface{} {
|
||
d := signupCase("x", false, "").data()
|
||
d["LostFiles"] = []stacks.LostFile{{Name: "IMG_0001.jpg", Taken: "2026-10-14 10:41"}}
|
||
d["LostBootAt"] = "2026-10-14 14:07"
|
||
return d
|
||
}})
|
||
// R-718: the close card and the gate card say the app restarts once where the app has its own switch.
|
||
base = append(base, i18nCase{"app_settings_close_signup_native", "app_settings", func() map[string]interface{} {
|
||
d := signupCase("x", false, "").data()
|
||
d["CloseSignupOffered"], d["SignupNative"] = true, true
|
||
return d
|
||
}}, i18nCase{"app_settings_setup_gate_native", "app_settings", func() map[string]interface{} {
|
||
d := gateCase("x", false).data()
|
||
d["SignupNative"] = 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
|
||
}})
|
||
// v0.296.0 (R-519): the interrupted-run line, captured when it was born — on both backup pages.
|
||
base = append(base, i18nCase{"backups_interrupted_run", "backups", func() map[string]interface{} {
|
||
d := i18nLayoutData("backups", "Biztonsági mentés")
|
||
d["Backup"] = map[string]interface{}{"DumpFiles": []string{"a.sql"}, "Interrupted": true, "InterruptedRunAt": i18nFixtureTime(),
|
||
"LastDBDump": map[string]interface{}{"Success": false, "Results": []int{1}}}
|
||
d["DBSectionState"] = "dumps"
|
||
return d
|
||
}}, i18nCase{"backups_apps_interrupted_run", "backups_apps", func() map[string]interface{} {
|
||
d := i18nAppsData("ok", false, true, "dumps")
|
||
if b, ok := d["Backup"].(map[string]interface{}); ok {
|
||
b["Interrupted"], b["InterruptedRunAt"] = true, i18nFixtureTime()
|
||
} else {
|
||
d["Backup"] = map[string]interface{}{"Interrupted": true, "InterruptedRunAt": i18nFixtureTime()}
|
||
}
|
||
return d
|
||
}})
|
||
base = append(base, i18nCase{"app_settings_known_login_changed", "app_settings", 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_settings_ladder_steps", "app_settings", 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
|
||
}})
|
||
// R-621: the hold panel's "the app's own log was kept" line and its Napló link, captured when born.
|
||
base = append(base, i18nCase{"app_settings_hold_log", "app_settings", func() map[string]interface{} {
|
||
d := i18nLayoutData("stacks", "AdventureLog")
|
||
st := stacks.Stack{Name: "adventurelog", Deployed: true, State: stacks.StateStopped,
|
||
HoldReason: "Az AdventureLog frissítése nem sikerült, az alkalmazás leállítva marad."}
|
||
st.Meta = stacks.Metadata{DisplayName: "AdventureLog", Slug: "adventurelog"}
|
||
d["Stack"] = st
|
||
d["Meta"] = st.Meta
|
||
d["AppInfo"] = st.Meta.AppInfo
|
||
d["HoldLogKept"] = true
|
||
return d
|
||
}})
|
||
// v0.320.0 — logins and folders: the infra app page (FileBrowser), the two login sentences a catalog key
|
||
// chooses, and the „Fajlok" button with a media folder and a folder whose drive is away. Captured when born.
|
||
infraFB := func() map[string]interface{} {
|
||
d := i18nLayoutData("stacks", "FileBrowser")
|
||
st := stacks.Stack{Name: "filebrowser", Deployed: false, Protected: true, State: stacks.StateRunning}
|
||
st.Meta = stacks.Metadata{DisplayName: "FileBrowser", Slug: "filebrowser"}
|
||
d["Stack"], d["Meta"], d["AppInfo"] = st, st.Meta, st.Meta.AppInfo
|
||
d["Infra"] = true
|
||
d["InfraInfo"] = &InfraMeta{DisplayName: "FileBrowser", Description: "Fájlkezelő — a tárhely fájljainak böngészése a böngészőből.", Linked: true}
|
||
d["EffectiveSubdomain"] = "files"
|
||
d["Deployed"], d["AppOperational"] = true, true
|
||
return d
|
||
}
|
||
base = append(base, i18nCase{"app_settings_infra_filebrowser", "app_settings", func() map[string]interface{} {
|
||
d := infraFB()
|
||
d["AppTabs"] = []AppTabLink{{Key: appTabSettings, Href: "/apps/filebrowser/settings", Label: "Beállítások", Active: true},
|
||
{Key: appTabGuide, Href: "/apps/filebrowser/guide", Label: "Útmutató"}, {Key: appTabLogs, Href: "/apps/filebrowser/logs", Label: "Napló"}}
|
||
d["InitialCreds"] = &stacks.ExtractedCreds{Available: true, Username: "admin", Note: "A Fájlkezelő belépése."}
|
||
d["InitialCredsHasPassword"] = true
|
||
return d
|
||
}}, i18nCase{"app_guide_infra_filebrowser", "app_guide", func() map[string]interface{} {
|
||
d := infraFB()
|
||
d["AppTabs"] = []AppTabLink{{Key: appTabSettings, Href: "/apps/filebrowser/settings", Label: "Beállítások"},
|
||
{Key: appTabGuide, Href: "/apps/filebrowser/guide", Label: "Útmutató", Active: true}, {Key: appTabLogs, Href: "/apps/filebrowser/logs", Label: "Napló"}}
|
||
return d
|
||
}}, i18nCase{"app_settings_login_email", "app_settings", func() map[string]interface{} {
|
||
d := i18nLayoutData("stacks", "Rallly")
|
||
st := i18nStack("rallly", "Rallly", stacks.StateRunning, true)
|
||
st.Meta.AppInfo.LoginKind = stacks.LoginKindEmail
|
||
d["Stack"], d["Meta"], d["AppInfo"] = st, st.Meta, st.Meta.AppInfo
|
||
return d
|
||
}}, i18nCase{"app_settings_login_none", "app_settings", func() map[string]interface{} {
|
||
d := i18nLayoutData("stacks", "BentoPDF")
|
||
st := i18nStack("bentopdf", "BentoPDF", stacks.StateRunning, true)
|
||
st.Meta.AppInfo.LoginKind = stacks.LoginKindNone
|
||
d["Stack"], d["Meta"], d["AppInfo"] = st, st.Meta, st.Meta.AppInfo
|
||
return d
|
||
}}, i18nCase{"app_settings_login_family", "app_settings", func() map[string]interface{} {
|
||
d := i18nLayoutData("stacks", "MeTube")
|
||
st := i18nStack("metube", "MeTube", stacks.StateRunning, true)
|
||
st.Meta.AppInfo.LoginKind = stacks.LoginKindNone
|
||
st.Meta.FamilyGate = true
|
||
d["Stack"], d["Meta"], d["AppInfo"] = st, st.Meta, st.Meta.AppInfo
|
||
return d
|
||
}}, i18nCase{"app_data_files_media", "app_data", func() map[string]interface{} {
|
||
d := i18nLayoutData("stacks", "Plex")
|
||
st := i18nStack("plex", "Plex", stacks.StateRunning, true)
|
||
d["Stack"], d["Meta"], d["AppInfo"] = st, st.Meta, st.Meta.AppInfo
|
||
d["DisplayName"], d["StackName"] = "Plex", "plex"
|
||
d["FilesLink"] = "https://files.example.hu/files/HDD%201/media"
|
||
d["DataPathCards"] = []DataPathCard{
|
||
{Label: "Filmek — HDD 1", Link: "https://files.example.hu/files/HDD%201/media/movies", IsMedia: true, Role: stacks.RoleLibrary},
|
||
{Label: "Filmek — HDD 2", IsMedia: true, Role: stacks.RoleLibrary}}
|
||
return d
|
||
}})
|
||
|
||
// v0.315.0 — THE APP PAGE'S OWN TABS. Three cases, each for a tab whose content no earlier case
|
||
// covered: the data tab's folder cards and move offer, the guide tab with a full guide, and the
|
||
// guide tab's „no guide yet" line. Captured when born.
|
||
base = append(base, i18nCase{"app_data_deployed", "app_data", func() map[string]interface{} {
|
||
d := i18nLayoutData("stacks", "PrivateBin")
|
||
st := i18nStack("privatebin", "PrivateBin", stacks.StateRunning, true)
|
||
d["Stack"], d["Meta"], d["AppInfo"] = st, st.Meta, st.Meta.AppInfo
|
||
d["DisplayName"], d["StackName"] = "PrivateBin", "privatebin"
|
||
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 kettő"}}
|
||
d["MigrateCurrent"] = "/mnt/hdd1"
|
||
d["Tier2"] = map[string]interface{}{"IsHDDApp": true, "Disabled": false, "NoTarget": false,
|
||
"EffectiveLabel": "HDD kettő", "EffectiveIsSSD": false, "Preferred": "", "EffectiveDesc": "a legnagyobb szabad hely"}
|
||
d["ExportOffered"] = true
|
||
d["Drives"] = []map[string]interface{}{{"Path": "/mnt/hdd1", "Label": "HDD egy"}}
|
||
return d
|
||
}}, i18nCase{"app_guide_full", "app_guide", func() map[string]interface{} {
|
||
d := i18nLayoutData("stacks", "PrivateBin")
|
||
st := i18nStack("privatebin", "PrivateBin", stacks.StateRunning, true)
|
||
st.Meta.AppInfo = stacks.AppInfo{
|
||
Tagline: "Titkosított szövegmegosztás.",
|
||
UseCases: []string{"Jelszó átadása"},
|
||
FirstSteps: []string{"Nyisd meg a címet"},
|
||
Prerequisites: []string{"Semmi különös"},
|
||
DocsURL: "https://privatebin.info",
|
||
Guide: stacks.AppGuide{
|
||
CheckedVersion: "2.0.6",
|
||
Sections: []stacks.GuideSection{
|
||
{Title: "Első bekapcsolás", Intro: "Nyisd meg a böngészőben.", Steps: []string{"Kattints a Fiók menüre", "Add meg a nevedet"}},
|
||
{Title: "Megosztás másokkal", Steps: []string{"Válaszd ki a mappát"}},
|
||
},
|
||
FAQ: []stacks.GuideFAQ{
|
||
{Q: "Hol vannak a fájlok?", A: "A megadott mappában."},
|
||
{Q: "Lehet több felhasználó?", A: "Igen, meghívóval."},
|
||
},
|
||
},
|
||
}
|
||
d["Stack"], d["Meta"], d["AppInfo"] = st, st.Meta, st.Meta.AppInfo
|
||
d["HasAppInfo"] = true
|
||
return d
|
||
}}, i18nCase{"app_guide_no_guide", "app_guide", func() map[string]interface{} {
|
||
d := i18nLayoutData("stacks", "Grocy")
|
||
st := i18nStack("grocy", "Grocy", stacks.StateNotDeployed, false)
|
||
st.Meta.AppInfo = stacks.AppInfo{Tagline: "Házi készletnyilvántartó."}
|
||
d["Stack"], d["Meta"], d["AppInfo"] = st, st.Meta, st.Meta.AppInfo
|
||
return d
|
||
}})
|
||
return base
|
||
}
|
||
|
||
// i18nAppTabTemplates maps each of the app page's four tab templates to the tab it renders. A case
|
||
// on one of them gets the four shell keys appPageHandler sets, derived here from the SAME fields the
|
||
// handler derives them from — so a fixture shows the tab bar the household actually sees, in the
|
||
// language being rendered, instead of a page with no tabs that nobody is served.
|
||
var i18nAppTabTemplates = map[string]string{
|
||
"app_settings": appTabSettings,
|
||
"app_data": appTabData,
|
||
"app_guide": appTabGuide,
|
||
"app_logs": appTabLogs,
|
||
}
|
||
|
||
// fillI18nAppShell is appPageHandler's shell block, for the harness. A case may set any of these
|
||
// four itself (a not-deployed app on the guide tab does); what it sets is left alone.
|
||
func fillI18nAppShell(s *Server, lang, tmpl string, d map[string]interface{}) {
|
||
tab, ok := i18nAppTabTemplates[tmpl]
|
||
if !ok {
|
||
return
|
||
}
|
||
deployed, slug := false, ""
|
||
var state stacks.ContainerState
|
||
switch st := d["Stack"].(type) {
|
||
case stacks.Stack:
|
||
deployed, state, slug = st.Deployed, st.State, st.Meta.Slug
|
||
case *stacks.Stack:
|
||
if st != nil {
|
||
deployed, state, slug = st.Deployed, st.State, st.Meta.Slug
|
||
}
|
||
}
|
||
if v, set := d["Deployed"]; set {
|
||
deployed, _ = v.(bool)
|
||
} else {
|
||
d["Deployed"] = deployed
|
||
}
|
||
if _, set := d["AppOperational"]; !set {
|
||
d["AppOperational"] = deployed && isOperationalState(state)
|
||
}
|
||
if _, set := d["ActiveTab"]; !set {
|
||
d["ActiveTab"] = tab
|
||
}
|
||
if _, set := d["AppTabs"]; !set {
|
||
d["AppTabs"] = s.appTabLinksLang(lang, slug, deployed, tab)
|
||
}
|
||
// v0.320.0: the settings tab's login panel answer, chosen by the handler's own function from the same data.
|
||
if _, set := d["LoginAnswer"]; !set && tab == appTabSettings && deployed {
|
||
var meta *stacks.Metadata
|
||
switch m := d["Meta"].(type) {
|
||
case stacks.Metadata:
|
||
meta = &m
|
||
case *stacks.Metadata:
|
||
meta = m
|
||
}
|
||
d["LoginAnswer"] = loginAnswerFor(d, meta)
|
||
}
|
||
}
|
||
|
||
func renderI18nCase(t *testing.T, s *Server, lang string, c i18nCase) string {
|
||
t.Helper()
|
||
var buf bytes.Buffer
|
||
data := c.data()
|
||
fillI18nAppShell(s, lang, c.tmpl, 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())
|
||
}
|
||
|
||
// assetTagRe matches the stylesheet's cache-buster. Since v0.316.0 that is a PER-BUILD tag, so it
|
||
// differs between two builds of the same source — which is the whole point of it (a cache must not
|
||
// be able to serve a previous build's CSS under an address the new build still asks for). A parity
|
||
// fixture pins COPY, not which build rendered it, so the tag is blanked on both sides of every
|
||
// comparison, exactly as a per-session CSRF token is. Without this, every fixture in the directory
|
||
// would churn on every build and the whole set would stop meaning anything.
|
||
var assetTagRe = regexp.MustCompile(`(/static/style\.css\?v=)[^"']*`)
|
||
|
||
// 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 three things that legitimately differ between two renders of the
|
||
// same page: a relative age off the wall clock, a per-session CSRF token, and the per-build asset
|
||
// tag. None of them is copy; all three would otherwise make the fixtures unmaintainable.
|
||
func normaliseI18nRender(s string) string {
|
||
s = relativeAgeRe.ReplaceAllString(s, "# $1")
|
||
s = assetTagRe.ReplaceAllString(s, "${1}ASSET")
|
||
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
|
||
}
|
||
raw, 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)
|
||
}
|
||
// BOTH sides are normalised, and the function is idempotent (a normalised render passes
|
||
// through it unchanged). It has to be both: v0.316.0 moved every shell's stylesheet from
|
||
// `?v=<version>` to a per-build tag, and normalising only the fresh render would have
|
||
// convicted all 148 fixtures for a difference that is not copy. Normalising both leaves the
|
||
// stored bytes untouched and keeps the fixtures meaningful across builds.
|
||
want := normaliseI18nRender(string(raw))
|
||
if got != 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, "áéíóöőúüűÁÉÍÓÖŐÚÜŰ")
|
||
}
|
||
|
||
// asciiHuRe (R-565) is the extractor's ASCII-only Hungarian word list (scripts/i18n_extract.py
|
||
// ASCII_HU), plus the words releases B and C found by eye after every test was green (mp, db, FIGYELEM,
|
||
// jelenlegi, majd a(z), Konfig, Megtartva, …). An unaccented Hungarian word is invisible to huLetter,
|
||
// and those six fragments reached English pages with no test failing. Case-insensitive, whole words.
|
||
// Words that are also ordinary English or markup („Nem", „pl", „nap", „ora", „Csak" never clash, but
|
||
// „Fut"/„Perc" would match nothing English either) are kept; a word that DOES collide with English
|
||
// copy or markup must be removed here with a reason, never by weakening the mask.
|
||
var asciiHuRe = regexp.MustCompile(`(?i)\b(Fut|Nincs|Igen|Nem|Hiba|Mentve|Rendben|Adatok|Napi|Heti|Havi|Mindig|Soha|Tegnap|` +
|
||
`Perc|Letiltva|Bekapcsolva|Kikapcsolva|Folyamatban|Sikertelen|Sikeres|Tartalom|` +
|
||
`Kapcsolat|Vissza|Megosztva|Kezdeti|Rendszer|Csatlakozva|Kulcs|Megnyit|Elrejt|` +
|
||
`Ismeretlen|Szabad|Foglalt|Kijelentkezes|Napok|nap|perc|ora|` +
|
||
`Megszakadt|Elavult|Csak|kompatibilis|Rendszermonitor|Most nem|` +
|
||
`Folyamat|Titkos|Processzor|Hamarosan|aldomain|adatai|Figyelem|pelda|` +
|
||
`Konfig|Megtartva|helyi|pl|Befejezve|automatikus|jelenleg|kedd|szerda|szombat|szint|` +
|
||
// release C (R-556): found by eye in JS strings, no test failed on any of them
|
||
`jelenlegi|mp|\d+ db)\b|\bmajd a\(z\)`)
|
||
|
||
// „db" (darab) only after a number: bare, it is markup (class="db-table", data-has-db) and the English
|
||
// abbreviation „DB". The count word the review found was always „<n> db".
|
||
|
||
var htmlTagRe = regexp.MustCompile(`<[^>]*>`)
|
||
var urlLikeRe = regexp.MustCompile(`\b[\w-]+(?:\.[\w-]+)+(?:/[\w./-]*)?`)
|
||
|
||
// asciiHuWord returns the first ASCII-only Hungarian word in s's COPY, or "". Tags (attribute values
|
||
// such as value="helyi" are code, not copy) and host/path tokens (felhom.eu/kapcsolat) are removed
|
||
// first; JavaScript string literals stay in, because inline JS copy is page copy.
|
||
func asciiHuWord(s string) string {
|
||
s = htmlTagRe.ReplaceAllString(s, " ")
|
||
s = urlLikeRe.ReplaceAllString(s, " ")
|
||
return asciiHuRe.FindString(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")
|
||
}
|
||
// R-565 controls for the ASCII word detector: a planted „mp" in an English value is caught, and a
|
||
// plain English sentence is not.
|
||
for _, planted := range []string{"label += ' · ' + p.elapsed_sec + ' mp';", "FIGYELEM: the drive", "then majd a(z) app", "3 db"} {
|
||
if asciiHuWord(planted) == "" {
|
||
t.Fatalf("control: the ASCII detector does not flag ASCII-only Hungarian in %q", planted)
|
||
}
|
||
}
|
||
if w := asciiHuWord(`Backup completed at 03:00, next run tomorrow; 3 items, 12 seconds left. <table class="db-table"> Config + DB + Data, see felhom.eu/kapcsolat`); w != "" {
|
||
t.Fatalf("control: the ASCII detector flags plain English (%q)", w)
|
||
}
|
||
|
||
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, "")
|
||
}
|
||
// R-565: a ONE-word data value is not masked above (it could hide template copy), but the ASCII
|
||
// detector would read it as copy. Mask it only where it is an element's WHOLE text — exactly
|
||
// how data renders (catalog default credentials „nincs"), never inside a sentence.
|
||
for _, k := range keys {
|
||
if len(strings.Fields(k)) == 1 {
|
||
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))
|
||
} else if w := asciiHuWord(line); w != "" {
|
||
t.Errorf("%s: ASCII-only Hungarian word %q on the English page (R-565), line %d: %q", c.name, w, 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()
|
||
// The app page's shell keys, exactly as renderI18nCase fills them: without them the tab bar
|
||
// and the Megnyitas link are absent from every render and their markers read as dead.
|
||
fillI18nAppShell(s, i18n.Default, c.tmpl, 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)
|
||
}
|
||
}
|