[READY][i18n][P16] Shared keyspace-kontrakt (Django+Angular) med en-default + da/en matrix #210
@@ -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<string, FrontendErrorKey> = {
|
||||
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<string, string>;
|
||||
|
||||
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<string, unknown>;
|
||||
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);
|
||||
}
|
||||
|
||||
53
frontend/tests/lobby-i18n.contract.test.ts
Normal file
53
frontend/tests/lobby-i18n.contract.test.ts
Normal file
@@ -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.');
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user