From 6490dbc368d5b9c687e5815c07288c20cfe15494 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sun, 23 Aug 2026 17:38:55 +0000 Subject: [PATCH] localization: three shipped locales were never loaded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit es-ES, de-DE and fr-FR shipped dead. Every one of their 1,129 keys was translated and in parity, all three were in SHIPPED_LOCALES so the picker offered them, and choosing one rendered the whole UI in raw keys. All three catalogue files still imported only en-GB and pt-PT, so `catalogues[code]` resolved to `{}` in every namespace — present, and useless. Adding a locale is four edits across four files and nothing knew whether all four had happened. The parity tests could not have caught this and never will. They compare the JSON files against each other, and a file nobody imports is exactly as parity-correct as one that is loaded. The gap was between "the translation exists" and "the app can reach it". Registers all three in appCatalogs, panelCatalogs and uiCatalogs, moves the merge out of I18nBridge into lib/catalogues.ts so it is testable without mounting React, and adds catalogues.test.ts as the place that knows: every shipped locale must resolve to a populated catalogue in all three namespaces, and be the same size as the fallback. Verified the test fails against the shipped code — 6 failures, panel.* for each of the three — and passes after. 26 pass. Co-Authored-By: Claude Opus 5 Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 --- src/apps/officer-web/lib/I18nBridge.tsx | 24 +-------- src/apps/officer-web/lib/catalogues.test.ts | 51 +++++++++++++++++++ src/apps/officer-web/lib/catalogues.ts | 47 +++++++++++++++++ src/workspaces/components/locales/index.ts | 11 +++- .../officerdev/src/locales/index.ts | 11 +++- 5 files changed, 120 insertions(+), 24 deletions(-) create mode 100644 src/apps/officer-web/lib/catalogues.test.ts create mode 100644 src/apps/officer-web/lib/catalogues.ts diff --git a/src/apps/officer-web/lib/I18nBridge.tsx b/src/apps/officer-web/lib/I18nBridge.tsx index 7c82d4be..b9f5f7a6 100644 --- a/src/apps/officer-web/lib/I18nBridge.tsx +++ b/src/apps/officer-web/lib/I18nBridge.tsx @@ -1,31 +1,11 @@ import { useEffect } from 'react'; import type { ReactNode } from 'react'; -import type { TranslationMap } from 'i18n'; + import { I18nProvider, localeDirection } from 'i18n'; import { setNameRevision } from 'workspaces/react'; -import { panelCatalogs } from 'officerdev'; -import { uiCatalogs } from '@/components/locales'; import { useSettings } from 'state/useSettings'; import { FALLBACK_LOCALE, SHIPPED_LOCALES } from './locales'; -import appEnGB from '../locales/en-GB.json'; -import appPtPT from '../locales/pt-PT.json'; - -const appCatalogs: TranslationMap = { 'en-GB': appEnGB, 'pt-PT': appPtPT }; - -/** - * Deep-merges the three per-workspace catalogs (D6) into one map per shipped locale — `app.*` from - * officer-web, `panel.*` from officerdev's panel apps, `ui.*` from shared components. - */ -const translations: TranslationMap = Object.fromEntries( - SHIPPED_LOCALES.map(({ code }) => [ - code, - { - app: appCatalogs[code] ?? {}, - panel: panelCatalogs[code] ?? {}, - ui: uiCatalogs[code] ?? {}, - }, - ]), -); +import { catalogues as translations } from './catalogues'; type I18nBridgeProps = { children: ReactNode; diff --git a/src/apps/officer-web/lib/catalogues.test.ts b/src/apps/officer-web/lib/catalogues.test.ts new file mode 100644 index 00000000..f3735193 --- /dev/null +++ b/src/apps/officer-web/lib/catalogues.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from 'bun:test'; +import { catalogues } from './catalogues'; +import { FALLBACK_LOCALE, SHIPPED_LOCALES } from './locales'; + +/** + * A locale is only shipped if it is actually LOADED. + * + * This exists because es-ES, de-DE and fr-FR shipped dead. Every one of their 1,129 keys was translated and + * in parity, all three were in `SHIPPED_LOCALES` so the picker offered them, and choosing one rendered the + * entire UI in raw keys — because none of the three catalogue files had been given the `import`. + * + * The parity tests could not have caught it and never will: they compare JSON files to each other, and an + * unimported file is as parity-correct as an imported one. The gap was between "the translation exists" and + * "the app can reach it", and nothing was asking that question. + * + * Adding a locale touches four files. This is the one that knows whether all four happened. + */ +describe('every shipped locale is reachable', () => { + const NAMESPACES = ['app', 'panel', 'ui'] as const; + + test('SHIPPED_LOCALES is not empty and contains the fallback', () => { + expect(SHIPPED_LOCALES.length).toBeGreaterThan(0); + expect(SHIPPED_LOCALES.map((l) => l.code)).toContain(FALLBACK_LOCALE); + }); + + for (const { code } of SHIPPED_LOCALES) { + describe(code, () => { + test('has a catalogue at all', () => { + expect(catalogues[code]).toBeDefined(); + }); + + for (const ns of NAMESPACES) { + // Non-empty, not merely present. The failure being pinned produced `{}` for all three, which is + // exactly what `?? {}` yields for a locale nobody imported — present, and useless. + test(`${ns}.* is populated`, () => { + const catalogue = (catalogues[code] as Record | undefined)?.[ns]; + expect(catalogue).toBeDefined(); + expect(Object.keys(catalogue ?? {}).length).toBeGreaterThan(0); + }); + } + + // Parity against the fallback is tested per-workspace already; this is the cheaper cross-check that + // the MERGED catalogue is the same size, which catches a namespace wired to the wrong locale's file. + test('is the same size as the fallback', () => { + const count = (o: unknown): number => + typeof o === 'object' && o !== null ? Object.values(o).reduce((n, v) => n + count(v), 0) : 1; + expect(count(catalogues[code])).toBe(count(catalogues[FALLBACK_LOCALE])); + }); + }); + } +}); diff --git a/src/apps/officer-web/lib/catalogues.ts b/src/apps/officer-web/lib/catalogues.ts new file mode 100644 index 00000000..3af2629c --- /dev/null +++ b/src/apps/officer-web/lib/catalogues.ts @@ -0,0 +1,47 @@ +import type { TranslationMap } from 'i18n'; +import { panelCatalogs } from 'officerdev'; +import { uiCatalogs } from '@/components/locales'; +import { SHIPPED_LOCALES } from './locales'; +import appDeDE from '../locales/de-DE.json'; +import appEnGB from '../locales/en-GB.json'; +import appEsES from '../locales/es-ES.json'; +import appFrFR from '../locales/fr-FR.json'; +import appPtPT from '../locales/pt-PT.json'; + +/** + * The three per-workspace catalogues, merged into one map per shipped locale (D6): `app.*` from + * officer-web, `panel.*` from officerdev's panel apps, `ui.*` from shared components. + * + * ── Why this is a separate file from `I18nBridge` ── + * + * So it can be tested without mounting React, and it is tested because of a real failure. es-ES, de-DE and + * fr-FR shipped translated, in perfect key parity, listed in the picker — and completely dead. All three + * `import` lists still named only en-GB and pt-PT, so `catalogues[code]` was three empty objects and the + * whole UI rendered untranslated in three of five languages. + * + * Nothing caught it. The parity tests compare the JSON FILES against each other and every file was correct; + * a locale that is never imported is invisible to them by construction. `SHIPPED_LOCALES` is what the picker + * reads, and it had been updated. Adding a locale was four edits in four files with no single place that + * knew all four had happened. + * + * `catalogues.test.ts` is now that place: it asserts every shipped locale actually resolves to a populated + * catalogue in all three namespaces. + */ +const appCatalogs: TranslationMap = { + 'en-GB': appEnGB, + 'pt-PT': appPtPT, + 'es-ES': appEsES, + 'de-DE': appDeDE, + 'fr-FR': appFrFR, +}; + +export const catalogues: TranslationMap = Object.fromEntries( + SHIPPED_LOCALES.map(({ code }) => [ + code, + { + app: appCatalogs[code] ?? {}, + panel: panelCatalogs[code] ?? {}, + ui: uiCatalogs[code] ?? {}, + }, + ]), +); diff --git a/src/workspaces/components/locales/index.ts b/src/workspaces/components/locales/index.ts index d8f20679..aa400b8d 100644 --- a/src/workspaces/components/locales/index.ts +++ b/src/workspaces/components/locales/index.ts @@ -1,8 +1,17 @@ import type { TranslationMap } from 'i18n'; +import deDE from './de-DE.json'; import enGB from './en-GB.json'; +import esES from './es-ES.json'; +import frFR from './fr-FR.json'; import ptPT from './pt-PT.json'; /** Shared components' own catalog, per locale — merged into the app-wide map by `I18nBridge` under `ui.*`. */ -export const uiCatalogs: TranslationMap = { 'en-GB': enGB, 'pt-PT': ptPT }; +export const uiCatalogs: TranslationMap = { + 'en-GB': enGB, + 'pt-PT': ptPT, + 'es-ES': esES, + 'de-DE': deDE, + 'fr-FR': frFR, +}; export { useTranslation } from './useTranslation'; diff --git a/src/workspaces/officerdev/src/locales/index.ts b/src/workspaces/officerdev/src/locales/index.ts index cbea9026..e8652db7 100644 --- a/src/workspaces/officerdev/src/locales/index.ts +++ b/src/workspaces/officerdev/src/locales/index.ts @@ -1,9 +1,18 @@ import type { TranslationMap } from 'i18n'; +import deDE from './de-DE.json'; import enGB from './en-GB.json'; +import esES from './es-ES.json'; +import frFR from './fr-FR.json'; import ptPT from './pt-PT.json'; /** Panel apps' own catalog, per locale — merged into the app-wide map by `I18nBridge` under `panel.*`. */ -export const panelCatalogs: TranslationMap = { 'en-GB': enGB, 'pt-PT': ptPT }; +export const panelCatalogs: TranslationMap = { + 'en-GB': enGB, + 'pt-PT': ptPT, + 'es-ES': esES, + 'de-DE': deDE, + 'fr-FR': frFR, +}; export { useTranslation } from './useTranslation'; export type { PanelTranslationKey } from './useTranslation';