diff --git a/CHANGELOG.md b/CHANGELOG.md index ab14b32..063f60c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,56 @@ +## v0.252.0 — The sentences the program writes follow the language (2026-09-18, R-557 slice 2 release A) + +**MinAgent: 0.131.0** (unchanged). **No hub release needed.** Nothing a Hungarian household reads +changes: every converted sentence is byte-identical to the literal it replaced, measured by a new +gate rather than read, and nothing on the wire moved. + +Slice 1 translated the dashboard's MARKUP. The sentences the program BUILDS — flash lines, page data, +the JSON the page's own script reads, the alert banners, the country names — were still Hungarian +literals in Go, so an English household clicked an English button and was answered in Hungarian. +This release moves 226 of them into the bundle and gives them a way to reach the reader. + +**The flash line was the hard part.** It travels to the page INSIDE THE REDIRECT URL, so it is +rendered by a different request from the one that wrote it — until now in the language of whoever +redirected. It now travels as a bundle key plus its parameters (`?flash=flash.share.enabled&fa=…`), +and the page renders it. A link minted by an older controller carries prose; that is shown verbatim, +never as a raw key and never dropped (`TestFlashKeyRoundTrip`, including the legacy and the +`", + }, + { + name: "no flash at all is empty, not a key", + query: "", lang: "en", + want: "", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := flashRequest(t, tc.query+"&lang="+tc.lang) + if got := s.flashFrom(r, "flash"); got != tc.want { + t.Errorf("flash\n got %q\n want %q", got, tc.want) + } + }) + } +} + +// TestFlashKeyCarriesParameters — a flash whose sentence names something (an app, a drive) carries +// that name as a separate `fa` parameter, so English may put it somewhere else in the sentence. +func TestFlashKeyCarriesParameters(t *testing.T) { + s := flashServer(t) + // The parameter-carrying flash in the bundle today. Asserted through the real bundle rather than a + // fixture, and self-arming: if no flash message takes a parameter any more the test says so out + // loud instead of passing vacuously. + const key = "flash.offbox.config_invalid" + if !s.i18n.Has(i18n.Default, key) { + t.Fatalf("%s left the bundle — this test no longer covers a parameter-carrying flash", key) + } + if !strings.Contains(s.i18n.Msg(i18n.Default, key), "%s") { + t.Fatalf("%s no longer takes a parameter — point this test at one that does", key) + } + r := flashRequest(t, flashQuery("flash_error", key, "a port nem szam")+"&lang=hu") + got := s.flashFrom(r, "flash_error") + if want := "Érvénytelen beállítás: a port nem szam"; got != want { + t.Errorf("parameter did not reach the message\n got %q\n want %q", got, want) + } + // And the same key, same parameter, in English: the parameter survives a different word order. + r = flashRequest(t, flashQuery("flash_error", key, "a port nem szam")+"&lang=en") + if got := s.flashFrom(r, "flash_error"); !strings.Contains(got, "a port nem szam") { + t.Errorf("the parameter was lost in English: %q", got) + } +} + +// TestFlashOnAServerWithNoBundleField — a Server built without going through loadTemplates still +// renders SENTENCES, never raw keys. s.bundle() falls back to the embedded i18n.Shared() for exactly +// this: a page that shows „flash.share.enabled" to a household is the failure mode §4 of the +// localisation design forbids, and it was reachable from any construction path but the real one. +// +// RED-PROOF (REPORT): make s.bundle() return s.i18n unchanged → this test reads back the key. +func TestFlashOnAServerWithNoBundleField(t *testing.T) { + s := &Server{} // i18n field never set + if got := s.flashFrom(flashRequest(t, "flash=flash.share.enabled"), "flash"); got != "A megosztás bekapcsolva." { + t.Errorf("a Server with no bundle field showed %q instead of the sentence", got) + } + // And a legacy sentence still passes through untouched. + if got := s.flashFrom(flashRequest(t, "flash=Sikeres+ment%C3%A9s"), "flash"); got != "Sikeres mentés" { + t.Errorf("got %q, want the legacy text verbatim", got) + } +} + +// flashSentence resolves the flash a redirect Location carries, in Hungarian. +// +// It exists because v0.252.0 moved the SENTENCE out of the URL and put a bundle KEY there instead. +// A test that searched the Location header for „elindult" or „letét" was never asserting the URL — +// it was asserting what the customer is told — and that is still exactly checkable, one lookup +// later. Tests assert through this rather than against the key, so a key renamed with its message +// intact stays green and a message REWORDED still fails, which is the right way round. +func flashSentence(t *testing.T, location string) string { + t.Helper() + u, err := url.Parse(location) + if err != nil { + t.Fatalf("Location %q: %v", location, err) + } + s := flashServer(t) + r := httptest.NewRequest(http.MethodGet, location, nil) + for _, p := range []string{"flash", "flash_error"} { + if v := u.Query().Get(p); v != "" { + return s.flashText(r, v) + } + } + return "" +} diff --git a/controller/internal/web/i18n_web.go b/controller/internal/web/i18n_web.go index 1b956c8..eb1a9d5 100644 --- a/controller/internal/web/i18n_web.go +++ b/controller/internal/web/i18n_web.go @@ -4,6 +4,7 @@ import ( "fmt" "html/template" "net/http" + "net/url" "strings" "time" @@ -18,6 +19,132 @@ import ( // was before this file existed — pinned by i18n_parity_test.go against fixtures captured from the // unconverted templates. +// ── Go-side copy (v0.252.0, slice 2 — R-557) ─────────────────────────────────────────────────── +// +// A handler builds sentences the template never sees: flash lines, page data, JSON answers. Those go +// through these three helpers, never through a Hungarian literal, so they follow the request's +// language exactly as the template does. +// +// The Hungarian text is the SAME BYTES the literal carried, which scripts/i18n_go_parity.py measures +// against a frozen capture of the base commit. So a handler that reads +// +// data["Msg"] = s.msg(r, "backup.window_updated") +// +// renders, for a Hungarian household, the literal that used to sit at that line and nothing else. + +// bundle returns the message bundle for these helpers. +// +// It falls back to the process-wide i18n.Shared() when the Server has none. That is not defensive +// clutter: `s.i18n` is set by loadTemplates, so any Server built WITHOUT going through it — a test +// fixture, a future construction path — would otherwise render raw KEYS onto a page, which is the one +// outcome §4 of the localisation design forbids ("never a key, never a blank"). The bundle is +// embedded in the binary, so the fallback cannot fail for any reason a running box can reach; if it +// somehow does, the caller still gets the key rather than a panic. +func (s *Server) bundle() *i18n.Bundle { + if s.i18n != nil { + return s.i18n + } + b, err := i18n.Shared() + if err != nil { + return nil + } + return b +} + +// msg returns a Go-side message in the request's language, with the message's own printf verbs +// filled in from a. +func (s *Server) msg(r *http.Request, key string, a ...interface{}) string { + return s.msgLang(s.langFor(r), key, a...) +} + +// msgLang is msg for a language that is already known — a background run reading the box's setting +// (internal/settings GetLanguage), or a handler that resolved the language once for several lines. +func (s *Server) msgLang(lang, key string, a ...interface{}) string { + b := s.bundle() + if b == nil { + return key + } + if len(a) == 0 { + return b.Msg(lang, key) + } + return b.Msgf(lang, key, a...) +} + +// msgN returns a count-dependent message in the request's language. Hungarian carries one form under +// the key itself; English carries key+".one" and key+".other" (i18n.Bundle.Plural). +func (s *Server) msgN(r *http.Request, key string, n int) string { + b := s.bundle() + if b == nil { + return key + } + return b.Plural(s.langFor(r), key, n) +} + +// ── Flash lines (v0.252.0) ───────────────────────────────────────────────────────────────────── +// +// A flash travels to the page INSIDE THE REDIRECT URL (`?flash=…`), so it is rendered by a DIFFERENT +// request from the one that wrote it — and until now it travelled as Hungarian text, which is the +// language of whoever redirected. It now travels as a bundle KEY plus its parameters, and the reader +// renders it in its own language. +// +// Backward compatibility is not a nicety here: a customer's open tab, a bookmark or a browser's +// back-forward cache can replay a URL minted by the previous version, and a mail client can carry +// one. So the rule is: a value the bundle knows as a key is a MESSAGE; anything else is TEXT and is +// shown verbatim, exactly as it was before. That also covers a hand-typed `?flash=