9cc7ffcf3b
gates / gates (push) Failing after 44s
MEASURED FIRST (audits/app-page-2026-10-15b/P0/VERDICT.md). The tab bar rendered unstyled through
the public address for one reason, and it was not the box: exactly one guest runs 0.315.0 and it
runs the registry's current digest (8887343011d6, identical to the registry's). The floor is still
0.314.0, so nothing else could have taken it.
What it was, in four measured facts:
1. the stylesheet URL is /static/style.css?v={{.Version}} -> ?v=0.315.0 for all THREE different
0.315.0 builds (e66d466, fe0228c, db52f5a), the first of which had no .app-tabs rules at all;
2. the origin sends NO validator at all (ETag present = 0, Last-Modified present = 0), so a
cached copy cannot be revalidated - it can only expire;
3. Cloudflare caches that URL (cf-cache-status HIT, age climbing) and rewrites the browser TTL
from the origin's 3600 to 14400 - four hours;
4. the session's first request returned cf-cache-status EXPIRED, which only happens when the edge
holds an entry that has aged out.
Origin, edge and box now agree byte-for-byte. The stale object itself is gone - that first probe
forced its revalidation - and the REPORT says so rather than claiming a HIT nobody can reproduce.
THE FIX IS IN TWO PARTS, NEITHER OF WHICH IS PURGING A CACHE:
build.sh refuses to push a version that already exists, before building, for --push and
--multiarch. A registry that cannot be asked is a refusal too: proving the tag is new is the point,
and "I could not ask" is not that proof. Local builds are untouched - reusing a version while
iterating is normal. scripts/test_build_push_once.sh drives the real build.sh with a fake docker on
PATH over six cases; red-proved twice (check deleted -> a present tag builds and pushes; an
unreachable registry treated as absent -> two cases fail).
assetTag() now hashes version + GitCommit + BuildTime, and EVERY shell's stylesheet link uses it
(seven of the nine asked for ?v={{.Version}}; only the two stranger-facing share pages already had
it). It stays opaque and 8 hex, because the share pages are reached by a capability URL and naming
the build to whoever holds one is a gift to somebody hunting a known fault. Cache-Control is
unchanged: the new address is the fix, not a shorter cache.
The parity harness normalises the asset tag on BOTH sides of the comparison, as it already does for
the per-session CSRF token, so all 148 fixtures stay byte-identical on disk and stop depending on
which build rendered them. The same normalisation is applied at the second comparison site in
i18n_wiring_test.go, which had the same assumption.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
969 lines
45 KiB
Go
969 lines
45 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.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())
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|