diff --git a/docs/ISSUE-226-SHARED-KEYMAP-LOCALE-CONTRACT.md b/docs/ISSUE-226-SHARED-KEYMAP-LOCALE-CONTRACT.md new file mode 100644 index 0000000..ed305b4 --- /dev/null +++ b/docs/ISSUE-226-SHARED-KEYMAP-LOCALE-CONTRACT.md @@ -0,0 +1,30 @@ +# ISSUE-226 — Shared key-map + locale-kontrakt (backend/frontend) + +## Source of truth +- Single shared artifact: `shared/i18n/lobby.json` +- Ownership is documented under `contract.ownership` in the same artifact. + +## Locale contract +Defined under `contract.locale`: +- default locale: `en` +- supported locales: `en`, `da` +- fallback rule: use default locale when requested locale is unsupported or a key translation is missing. + +## Shared backend→frontend key-map +Defined under `contract.backend_to_frontend_error_keys`. + +Examples: +- `session_not_found -> session_not_found` +- `session_not_joinable -> join_failed` +- `round_start_invalid_phase -> start_round_failed` + +This allows backend error codes to remain stable while frontend copy keys stay UX-oriented. + +## da/en example values +From `shared/i18n/lobby.json`: +- `frontend.errors.session_code_required.en = "Session code is required."` +- `frontend.errors.session_code_required.da = "Sessionskoden er påkrævet."` + +## Verification +- Backend: `python manage.py test lobby.tests.I18nResolverTests` +- Frontend: `npm test -- --run tests/lobby-i18n.contract.test.ts` diff --git a/frontend/tests/lobby-i18n.contract.test.ts b/frontend/tests/lobby-i18n.contract.test.ts index 6137824..c75c6e8 100644 --- a/frontend/tests/lobby-i18n.contract.test.ts +++ b/frontend/tests/lobby-i18n.contract.test.ts @@ -14,10 +14,16 @@ describe('shared i18n keyspace contract', () => { } }); - 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(); + it('keeps backend error-code keyspace aligned with shared backend→frontend map and backend translations', () => { + for (const [code, backendKey] of Object.entries(lobbyCatalog.backend.error_codes)) { + const frontendKey = + lobbyCatalog.contract.backend_to_frontend_error_keys[ + code as keyof typeof lobbyCatalog.contract.backend_to_frontend_error_keys + ]; + + expect(lobbyCatalog.backend.errors[backendKey as keyof typeof lobbyCatalog.backend.errors]).toBeDefined(); + expect(frontendKey, `missing frontend mapping for ${code}`).toBeTruthy(); + expect(lobbyCatalog.frontend.errors[frontendKey as keyof typeof lobbyCatalog.frontend.errors]).toBeDefined(); } for (const [key, translations] of Object.entries(lobbyCatalog.backend.errors)) { @@ -42,7 +48,7 @@ describe('lobbyMessage locale handling', () => { ).toBe('Sessionskoden er ugyldig, eller sessionen findes ikke længere.'); }); - it('falls back when backend error code has no frontend translation key', () => { + it('uses shared backend→frontend key-map when backend key differs from frontend copy key', () => { expect( lobbyMessageFromApiPayload( { error_code: 'session_not_joinable', locale: 'da' }, @@ -50,4 +56,13 @@ describe('lobbyMessage locale handling', () => { ), ).toBe('Kunne ikke joine. Tjek kode eller kaldenavn og prøv igen.'); }); + + it('falls back to caller-provided fallback key for unknown backend error codes', () => { + expect( + lobbyMessageFromApiPayload( + { error_code: 'unknown_backend_key', 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 e3bcb97..15ba351 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -20,7 +20,7 @@ from fupogfakta.models import ( RoundConfig, RoundQuestion, ) -from lobby.i18n import i18n_locale_config, lobby_i18n_catalog, resolve_error_message +from lobby.i18n import i18n_locale_config, lobby_i18n_catalog, resolve_error_message, resolve_locale User = get_user_model() @@ -1216,6 +1216,27 @@ class SmokeStagingCommandTests(TestCase): class I18nResolverTests(TestCase): + def test_resolve_locale_accepts_language_tags_and_normalizes_to_supported_base_locale(self): + response = self.client.post( + reverse("lobby:join_session"), + data={"code": "", "nickname": "Luna"}, + content_type="application/json", + HTTP_ACCEPT_LANGUAGE="da-DK,da;q=0.9,en;q=0.8", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(resolve_locale(response.wsgi_request), "da") + + def test_resolve_locale_defaults_to_en_when_header_missing(self): + response = self.client.post( + reverse("lobby:join_session"), + data={"code": "", "nickname": "Luna"}, + content_type="application/json", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(resolve_locale(response.wsgi_request), "en") + def test_missing_backend_key_returns_key_deterministically(self): self.assertEqual(resolve_error_message(key="missing_key", locale="da"), "missing_key") @@ -1252,10 +1273,14 @@ class I18nResolverTests(TestCase): 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): + def test_backend_error_codes_map_via_shared_backend_frontend_key_map(self): catalog = lobby_i18n_catalog() backend_errors = catalog["backend"]["errors"] + frontend_errors = catalog["frontend"]["errors"] + shared_map = catalog["contract"]["backend_to_frontend_error_keys"] - for code, key in catalog["backend"]["error_codes"].items(): - self.assertEqual(code, key) - self.assertIn(key, backend_errors) + for code, backend_key in catalog["backend"]["error_codes"].items(): + frontend_key = shared_map.get(code) + self.assertIn(backend_key, backend_errors) + self.assertTrue(frontend_key, f"missing frontend mapping for backend code: {code}") + self.assertIn(frontend_key, frontend_errors) diff --git a/shared/i18n/lobby.json b/shared/i18n/lobby.json index 2fb4e32..9081cff 100644 --- a/shared/i18n/lobby.json +++ b/shared/i18n/lobby.json @@ -321,5 +321,31 @@ "da": "Runden er allerede konfigureret" } } + }, + "contract": { + "ownership": { + "artifact": "shared/i18n/lobby.json", + "backend": "lobby/* reads backend/errors + backend/error_codes", + "frontend": "frontend/* reads frontend/errors + frontend/ui + contract/backend_to_frontend_error_keys" + }, + "locale": { + "default": "en", + "supported": [ + "en", + "da" + ], + "fallback": "Use default locale when requested locale is unsupported or key translation is missing." + }, + "backend_to_frontend_error_keys": { + "session_code_required": "session_code_required", + "nickname_invalid": "nickname_invalid", + "session_not_found": "session_not_found", + "session_not_joinable": "join_failed", + "nickname_taken": "nickname_taken", + "category_slug_required": "start_round_failed", + "category_not_found": "start_round_failed", + "round_start_invalid_phase": "start_round_failed", + "round_already_configured": "start_round_failed" + } } -} \ No newline at end of file +}