Files
felhom-controller/controller/internal/web/i18n_parity_test.go
T
admin e66d4669d6
gates / gates (push) Failing after 47s
v0.315.0: one app page with four tabs; the guide the catalog can carry; no Raspberry Pi badge
An app was six pages — details, install/settings, logs, export, the per-app backup panel and a
details card for moving data — and every "Vissza" on them went to the app LIST, so there was no way
back to the app you were looking at. It is now ONE page with four tab URLs under /apps/{slug}/,
one shared header and one set of banners.

  settings  Mukodes (Start/Restart/Stop, Stop confirms) + the cards that want something from the
            household + the install-time and optional settings. The default for an installed app,
            RENDERED at the bare URL and never redirected to.
  data      the data folders, move to another drive, the nightly copy, the 2nd-copy setting, export
  guide     tagline, screenshots, use cases, first steps, the catalog's guide, the FAQ, the docs
            link — and the WHOLE page for an app that is not installed
  logs      the log, and the only tab whose poller runs

The tabs are links, not panels: a link in an alert has to land ON a tab, and the log poller must not
run on the other three. Start/Restart/Stop left the page header, where they read as menu buttons.

The old /stacks/{name}/{deploy,logs,export,backup} GETs answer 302 — never 301, which a browser
caches beyond anyone's reach — to the tab that now holds them, query string carried through, with
name->slug resolved through the stack list rather than assumed equal. Every POST on those paths is
untouched: the backup form, the app-email toggle and the reveal endpoints keep their URLs.

A value asked AFTER the app starts is now generic (D2). The catalog marks a field `after_start: true`
and supplies its label; the ACTION is Go, in a registry keyed by slug + env var. The catalog can
never declare what runs — a declared field with no registered action renders nothing, POSTs 404 and
logs one WARN with the env var KEY, never the value. A red-proof found the second half of that guard
unpinned: deleting the "the catalog must have DECLARED this field" check left the suite green, and a
POST for an undeclared field then reached Plex and opened the setup gate.
TestAfterStart_RegistryAloneIsNotAnInvitation pins it.

New catalog block app_info.guide (sections + faq + checked_version), parsed, localised field by
field and rendered with the household's own address filled in. It renders only when checked_version
names the image tag its author clicked through. No template carries one yet.

The Pi and "csak x86" badges are gone; PiCompatible and the catalog key stay this release, or a box
on 0.314.0 would read "csak x86" on every app (R-1067).

template_id_gate.py now follows {{template}} includes, with a scope decoy proving the walk did not
become vacuous.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
2026-10-15 18:37:50 +02:00

953 lines
44 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
import (
"bytes"
"flag"
"fmt"
"html/template"
"io/fs"
"net/http/httptest"
"os"
"path"
"path/filepath"
"reflect"
"regexp"
"sort"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// ── i18n (v0.247.0) — THE RELEASE GATE: Hungarian renders byte-for-byte as it did ──────────────────
//
// The fixtures under testdata/i18n_parity/ were captured from the templates BEFORE any of them carried
// a {{T}} marker (`go test -run TestI18nParity -update-i18n-golden` on the unconverted tree, commit
// recorded in felhom-controller/REPORT.md). Every conversion since must reproduce them exactly.
//
// Normalisation, and it is the ONLY one: relative ages („3 napja") are rewritten to „# napja", because
// they are computed from the wall clock and the fixtures carry fixed dates. Nothing else is touched.
//
// RED BEFORE THE FIXTURE EXISTS: a missing fixture file fails the test; it is never silently written.
// RED-PROOF (recorded in REPORT.md): change one byte of one launcher value in locales/hu.json and
// TestI18nParity fails naming the page and the first differing line.
var updateI18nGolden = flag.Bool("update-i18n-golden", false, "write MISSING testdata/i18n_parity fixtures from the current templates (an existing fixture is never overwritten)")
var i18nDumpDir = flag.String("i18n-dump-dir", "", "TestI18nEnglishPages: also write every case's hu and en render to this directory (evidence, per-page review)")
var i18nGoldenOnly = flag.String("i18n-golden-only", "", "with -update-i18n-golden: only cases whose name matches this regexp")
var relativeAgeRe = regexp.MustCompile(`\d+ (napja|órája|perce)`)
type i18nCase struct {
name string
tmpl string
data func() map[string]interface{}
}
func i18nFixtureTime() time.Time { return time.Date(2026, 9, 14, 3, 12, 0, 0, time.UTC) }
// i18nTitleKeys mirrors the handlers' data["TitleKey"]: a page whose Go-side title has a key renders it
// translated in English, exactly as addLanguageData does in production.
var i18nTitleKeys = map[string]string{
"Indítópult": "page.title.launcher",
"Biztonsági mentés": "page.title.backups",
"Vezérlőpult": "page.title.dashboard",
"Alkalmazások": "page.title.stacks",
"Rendszermonitor": "page.title.monitoring",
"Beállítások": "page.title.settings",
"Értesítések": "page.title.settings_notifications",
"Biztonság és hozzáférés": "page.title.settings_security",
"Tárhely": "page.title.storage",
"Hálózati tárhely": "page.title.storage_network",
"Megőrzött adatok": "page.title.kept_data",
"Új meghajtó inicializálása": "page.title.storage_init",
"Meglévő meghajtó csatolása": "page.title.storage_attach",
"Hálózati megosztás": "page.title.sharing",
}
func i18nLayoutData(page, title string) map[string]interface{} {
d := map[string]interface{}{
"Page": page, "Title": title, "CustomerName": "Teszt Ügyfél", "Domain": "example.hu",
"Version": "0.247.0", "AuthEnabled": true, "DebugMode": false,
"CSRFField": "", "CSRFToken": "tok",
}
if k, ok := i18nTitleKeys[title]; ok {
d["TitleKey"] = k
}
return d
}
func i18nCases() []i18nCase {
base := []i18nCase{
{"launcher_full", "launcher", func() map[string]interface{} {
d := i18nLayoutData("launcher", "Indítópult")
d["ClaimLegacyOpen"] = true
d["RecoveryBanner"] = true
d["RecoveryBannerBack"] = "/launcher"
d["RecoveryAbandonDays"] = 12
d["RecoveryAbandonDate"] = "2026-09-29"
d["RecoveryAbandonRetrievalOffered"] = true
d["Apps"] = []LauncherApp{
{Name: "immich", DisplayName: "Immich", Slug: "immich", State: stacks.StateRunning, Subdomain: "fotok"},
{Name: "privatebin", DisplayName: "PrivateBin", Slug: "privatebin", State: stacks.StateUnhealthy, Subdomain: "paste", OpenPath: "/"},
{Name: "kimai", DisplayName: "Kimai", Slug: "kimai", State: stacks.StateStopped, Subdomain: "ido"},
}
d["ShareEnabled"] = true
d["ShareURL"] = "https://felhom.example.hu/s/abc"
d["SharePasswordSet"] = true
d["ShareFlash"] = "A megosztás bekapcsolva."
return d
}},
// 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.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)
}
}
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())
}
// 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
"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
}
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, "áéíóöőúüűÁÉÍÓÖŐÚÜŰ")
}
// 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)
}
}