From a9f7c35c6c38b30c4feeb5788faa1ffed325a887 Mon Sep 17 00:00:00 2001 From: Asger Geel Weirsoee Date: Sun, 1 Mar 2026 22:14:08 +0000 Subject: [PATCH] feat(issue-226): add shared backend-frontend key-map and locale contract --- ...ISSUE-226-SHARED-KEYMAP-LOCALE-CONTRACT.md | 30 +++++++++++++++++++ frontend/tests/lobby-i18n.contract.test.ts | 25 ++++++++++++---- lobby/tests.py | 9 +++--- shared/i18n/lobby.json | 28 ++++++++++++++++- 4 files changed, 82 insertions(+), 10 deletions(-) create mode 100644 docs/ISSUE-226-SHARED-KEYMAP-LOCALE-CONTRACT.md 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 3677d15..15ba351 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -1279,7 +1279,8 @@ class I18nResolverTests(TestCase): 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(shared_map.get(code), key) - self.assertIn(key, backend_errors) - self.assertIn(key, frontend_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 +}