[MVP][READY] #226 Shared key-map + locale-kontrakt mellem backend/frontend #230
30
docs/ISSUE-226-SHARED-KEYMAP-LOCALE-CONTRACT.md
Normal file
30
docs/ISSUE-226-SHARED-KEYMAP-LOCALE-CONTRACT.md
Normal file
@@ -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`
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user