From b55b3791345cc37e9cd55580199397aa3c383fb0 Mon Sep 17 00:00:00 2001 From: Asger Geel Weirsoee Date: Sun, 1 Mar 2026 19:24:12 +0000 Subject: [PATCH] feat(i18n): enforce shared keyspace contract across django and spa --- frontend/src/spa/lobby-i18n.ts | 49 +++++++++++++------- frontend/tests/lobby-i18n.contract.test.ts | 53 ++++++++++++++++++++++ lobby/tests.py | 26 ++++++++++- 3 files changed, 111 insertions(+), 17 deletions(-) create mode 100644 frontend/tests/lobby-i18n.contract.test.ts diff --git a/frontend/src/spa/lobby-i18n.ts b/frontend/src/spa/lobby-i18n.ts index 30184d4..f68e411 100644 --- a/frontend/src/spa/lobby-i18n.ts +++ b/frontend/src/spa/lobby-i18n.ts @@ -1,31 +1,48 @@ import lobbyCatalog from '../../../shared/i18n/lobby.json'; -type FrontendErrorKey = keyof typeof lobbyCatalog.frontend.errors; - const frontendErrors = lobbyCatalog.frontend.errors; +const localeConfig = lobbyCatalog.locales; -const apiErrorMap: Record = { - session_code_required: 'session_code_required', - session_not_found: 'session_not_found', - nickname_invalid: 'nickname_invalid', - nickname_taken: 'nickname_taken' -}; +type FrontendErrorKey = keyof typeof frontendErrors; +type SupportedLocale = (typeof localeConfig.supported)[number]; -export function lobbyMessage(key: FrontendErrorKey): string { - return frontendErrors[key] ?? frontendErrors.unknown; +function isFrontendErrorKey(value: string): value is FrontendErrorKey { + return value in frontendErrors; } -export function lobbyMessageFromApiPayload(payload: unknown, fallbackKey: FrontendErrorKey): string { +function normalizeLocale(rawLocale?: string): SupportedLocale { + const requested = (rawLocale ?? '').trim().toLowerCase(); + if (localeConfig.supported.includes(requested as SupportedLocale)) { + return requested as SupportedLocale; + } + return localeConfig.default; +} + +export function lobbyMessage(key: FrontendErrorKey, locale?: string): string { + const resolvedLocale = normalizeLocale(locale); + const translations = frontendErrors[key] as Record; + + if (translations[resolvedLocale]) { + return translations[resolvedLocale]; + } + if (translations[localeConfig.default]) { + return translations[localeConfig.default]; + } + + return key; +} + +export function lobbyMessageFromApiPayload(payload: unknown, fallbackKey: FrontendErrorKey, locale?: string): string { if (!payload || typeof payload !== 'object') { - return lobbyMessage(fallbackKey); + return lobbyMessage(fallbackKey, locale); } const record = payload as Record; const code = typeof record.error_code === 'string' ? record.error_code : ''; - const mappedKey = apiErrorMap[code]; - if (!mappedKey) { - return lobbyMessage(fallbackKey); + const payloadLocale = typeof record.locale === 'string' ? record.locale : locale; + if (!isFrontendErrorKey(code)) { + return lobbyMessage(fallbackKey, payloadLocale); } - return lobbyMessage(mappedKey); + return lobbyMessage(code, payloadLocale); } diff --git a/frontend/tests/lobby-i18n.contract.test.ts b/frontend/tests/lobby-i18n.contract.test.ts new file mode 100644 index 0000000..6137824 --- /dev/null +++ b/frontend/tests/lobby-i18n.contract.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; + +import lobbyCatalog from '../../shared/i18n/lobby.json'; +import { lobbyMessage, lobbyMessageFromApiPayload } from '../src/spa/lobby-i18n'; + +describe('shared i18n keyspace contract', () => { + it('keeps en as default and da/en matrix for frontend error keys', () => { + expect(lobbyCatalog.locales.default).toBe('en'); + expect(lobbyCatalog.locales.supported).toEqual(expect.arrayContaining(['en', 'da'])); + + for (const [key, translations] of Object.entries(lobbyCatalog.frontend.errors)) { + expect(translations.en, `${key} missing en`).toBeTruthy(); + expect(translations.da, `${key} missing da`).toBeTruthy(); + } + }); + + it('keeps backend error-code keyspace aligned with backend translations', () => { + for (const [code, key] of Object.entries(lobbyCatalog.backend.error_codes)) { + expect(code).toBe(key); + expect(lobbyCatalog.backend.errors[key as keyof typeof lobbyCatalog.backend.errors]).toBeDefined(); + } + + for (const [key, translations] of Object.entries(lobbyCatalog.backend.errors)) { + expect(translations.en, `${key} missing en`).toBeTruthy(); + expect(translations.da, `${key} missing da`).toBeTruthy(); + } + }); +}); + +describe('lobbyMessage locale handling', () => { + it('uses english by default and falls back to default for unsupported locale', () => { + expect(lobbyMessage('session_code_required')).toBe('Session code is required.'); + expect(lobbyMessage('session_code_required', 'fr')).toBe('Session code is required.'); + }); + + it('resolves locale from api payload and maps known backend error codes directly', () => { + expect( + lobbyMessageFromApiPayload( + { error_code: 'session_not_found', locale: 'da' }, + 'join_failed', + ), + ).toBe('Sessionskoden er ugyldig, eller sessionen findes ikke længere.'); + }); + + it('falls back when backend error code has no frontend translation key', () => { + expect( + lobbyMessageFromApiPayload( + { error_code: 'session_not_joinable', locale: 'da' }, + 'join_failed', + ), + ).toBe('Kunne ikke joine. Tjek kode eller kaldenavn og prøv igen.'); + }); +}); diff --git a/lobby/tests.py b/lobby/tests.py index 94010d2..8d047db 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -19,7 +19,7 @@ from fupogfakta.models import ( RoundConfig, RoundQuestion, ) -from lobby.i18n import resolve_error_message +from lobby.i18n import i18n_locale_config, lobby_i18n_catalog, resolve_error_message User = get_user_model() @@ -1206,3 +1206,27 @@ class SmokeStagingCommandTests(TestCase): class I18nResolverTests(TestCase): def test_missing_backend_key_returns_key_deterministically(self): self.assertEqual(resolve_error_message(key="missing_key", locale="da"), "missing_key") + + def test_shared_catalog_uses_en_default_and_da_en_matrix(self): + default_locale, supported_locales = i18n_locale_config() + catalog = lobby_i18n_catalog() + + self.assertEqual(default_locale, "en") + self.assertIn("en", supported_locales) + self.assertIn("da", supported_locales) + + for key, translations in catalog["backend"]["errors"].items(): + self.assertTrue(translations.get("en"), f"backend key {key} missing en") + self.assertTrue(translations.get("da"), f"backend key {key} missing da") + + for key, translations in catalog["frontend"]["errors"].items(): + self.assertTrue(translations.get("en"), f"frontend key {key} missing en") + self.assertTrue(translations.get("da"), f"frontend key {key} missing da") + + def test_backend_error_codes_map_to_same_named_translation_keys(self): + catalog = lobby_i18n_catalog() + backend_errors = catalog["backend"]["errors"] + + for code, key in catalog["backend"]["error_codes"].items(): + self.assertEqual(code, key) + self.assertIn(key, backend_errors) -- 2.39.5