feat(issue-226): add shared backend-frontend key-map and locale contract
All checks were successful
CI / test-and-quality (push) Successful in 3m41s
CI / test-and-quality (pull_request) Successful in 3m17s

This commit is contained in:
2026-03-01 22:14:08 +00:00
parent 508d462bb6
commit cd6fb06343
4 changed files with 82 additions and 10 deletions

View 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`

View File

@@ -14,10 +14,16 @@ describe('shared i18n keyspace contract', () => {
} }
}); });
it('keeps backend error-code keyspace aligned with backend translations', () => { it('keeps backend error-code keyspace aligned with shared backend→frontend map and backend translations', () => {
for (const [code, key] of Object.entries(lobbyCatalog.backend.error_codes)) { for (const [code, backendKey] of Object.entries(lobbyCatalog.backend.error_codes)) {
expect(code).toBe(key); const frontendKey =
expect(lobbyCatalog.backend.errors[key as keyof typeof lobbyCatalog.backend.errors]).toBeDefined(); 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)) { 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.'); ).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( expect(
lobbyMessageFromApiPayload( lobbyMessageFromApiPayload(
{ error_code: 'session_not_joinable', locale: 'da' }, { 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.'); ).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.');
});
}); });

View File

@@ -1279,7 +1279,8 @@ class I18nResolverTests(TestCase):
frontend_errors = catalog["frontend"]["errors"] frontend_errors = catalog["frontend"]["errors"]
shared_map = catalog["contract"]["backend_to_frontend_error_keys"] shared_map = catalog["contract"]["backend_to_frontend_error_keys"]
for code, key in catalog["backend"]["error_codes"].items(): for code, backend_key in catalog["backend"]["error_codes"].items():
self.assertEqual(shared_map.get(code), key) frontend_key = shared_map.get(code)
self.assertIn(key, backend_errors) self.assertIn(backend_key, backend_errors)
self.assertIn(key, frontend_errors) self.assertTrue(frontend_key, f"missing frontend mapping for backend code: {code}")
self.assertIn(frontend_key, frontend_errors)

View File

@@ -321,5 +321,31 @@
"da": "Runden er allerede konfigureret" "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"
}
} }
} }