From 5270bad76e9ad002faa86e52b8958c83a301e09b Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Fri, 18 Sep 2026 10:15:56 +0200 Subject: [PATCH] =?UTF-8?q?v0.252.0=20=E2=80=94=20the=20sentences=20the=20?= =?UTF-8?q?program=20builds=20follow=20the=20language=20(R-557=20slice=202?= =?UTF-8?q?=20release=20A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 1 translated the dashboard's markup. The sentences the program BUILDS were still Hungarian literals in Go, so an English household clicked an English button and was answered in Hungarian. 226 of them move into the bundle here. A flash was the hard part: it travels inside the redirect URL and is rendered by a DIFFERENT request, so it now carries a bundle key plus its parameters. A link minted by an older controller carries prose and is shown verbatim — never a raw key, never dropped. Also converted: page data and view-model text, the internal/api JSON answers, the alert banners (Alert.MessageKey, rendered on the way out of GetAlerts), 237 country names at display, and the four page titles built around an app name (R-566 closed). Hungarian is byte-identical, and that is measured rather than read: scripts/i18n_go_parity.py freezes every Go literal at the base commit (7 467) and refuses a key whose Hungarian is not that text, byte for byte. Three decoys, each seen to convict. Its own first version filtered the capture through an ASCII-Hungarian word list and missed seven real literals — the R-565 class. The filter is gone. Nothing on the wire moved, and wire goldens now hold it there: the report's health warnings and every notify event message stay Hungarian, because the hub MAILS the controller's sentence when it has no entry of its own. Slice 3 (R-558) owns those. MinAgent: 0.131.0 (unchanged). No hub release needed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- CHANGELOG.md | 53 + CONTEXT.md | 4 +- REPORT.md | 172 +- controller/README.md | 33 +- controller/internal/api/deploygate_test.go | 8 +- controller/internal/api/geo.go | 31 +- controller/internal/api/i18n_api.go | 84 + controller/internal/api/i18n_api_test.go | 125 + .../api/lifecycle_gate_wiring_test.go | 22 +- controller/internal/api/router.go | 63 +- controller/internal/api/slice4_update_test.go | 2 +- controller/internal/i18n/i18n.go | 14 + controller/internal/i18n/i18n_test.go | 7 +- controller/internal/i18n/locales/en.json | 428 +- controller/internal/i18n/locales/hu.json | 428 +- controller/internal/monitor/healthcheck.go | 23 +- .../internal/monitor/wire_golden_test.go | 100 + controller/internal/notify/notifier.go | 10 + .../internal/notify/wire_golden_test.go | 122 + controller/internal/web/alerts.go | 193 +- controller/internal/web/async_restore_test.go | 4 +- controller/internal/web/auth.go | 2 +- controller/internal/web/backup_handlers.go | 11 +- .../internal/web/backup_page_state_test.go | 6 +- controller/internal/web/deadapp_alert_test.go | 6 +- controller/internal/web/handler_debug.go | 2 +- controller/internal/web/handlers.go | 217 +- controller/internal/web/i18n_flash_test.go | 165 + controller/internal/web/i18n_web.go | 146 +- controller/internal/web/i18n_wiring_test.go | 53 + controller/internal/web/offbox_escrow_test.go | 14 +- controller/internal/web/offbox_handlers.go | 116 +- .../internal/web/offbox_run_inflight_test.go | 4 +- .../web/offbox_warning_display_test.go | 20 +- .../internal/web/r103_tier2_action_test.go | 2 +- .../internal/web/r487_removed_row_test.go | 2 +- .../internal/web/r537_tier_contents_test.go | 2 +- .../internal/web/r553_alert_placement_test.go | 4 +- .../internal/web/r553_stale_note_test.go | 18 +- controller/internal/web/server.go | 2 +- controller/internal/web/share_handlers.go | 45 +- controller/internal/web/sharing_handlers.go | 34 +- controller/internal/web/slice4_update_test.go | 2 +- controller/internal/web/storage_handlers.go | 52 +- .../internal/web/tier2_config_handler.go | 16 +- .../web/tier2_restore_handler_test.go | 2 +- controller/scripts/controller_gates.py | 4 + controller/scripts/i18n_go_base.json | 7474 +++++++++++++++++ controller/scripts/i18n_go_keys.json | 507 ++ controller/scripts/i18n_go_parity.py | 351 + controller/scripts/i18n_missing_gate.py | 7 +- controller/scripts/test_gate_decoys.py | 21 + 52 files changed, 10755 insertions(+), 478 deletions(-) create mode 100644 controller/internal/api/i18n_api.go create mode 100644 controller/internal/api/i18n_api_test.go create mode 100644 controller/internal/monitor/wire_golden_test.go create mode 100644 controller/internal/notify/wire_golden_test.go create mode 100644 controller/internal/web/i18n_flash_test.go create mode 100644 controller/scripts/i18n_go_base.json create mode 100644 controller/scripts/i18n_go_keys.json create mode 100644 controller/scripts/i18n_go_parity.py 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=