[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';
|
import lobbyCatalog from '../../../shared/i18n/lobby.json';
|
||||||
|
|
||||||
type FrontendErrorKey = keyof typeof lobbyCatalog.frontend.errors;
|
|
||||||
|
|
||||||
const frontendErrors = lobbyCatalog.frontend.errors;
|
const frontendErrors = lobbyCatalog.frontend.errors;
|
||||||
|
const localeConfig = lobbyCatalog.locales;
|
||||||
|
|
||||||
const apiErrorMap: Record<string, FrontendErrorKey> = {
|
type FrontendErrorKey = keyof typeof frontendErrors;
|
||||||
session_code_required: 'session_code_required',
|
type SupportedLocale = (typeof localeConfig.supported)[number];
|
||||||
session_not_found: 'session_not_found',
|
|
||||||
nickname_invalid: 'nickname_invalid',
|
|
||||||
nickname_taken: 'nickname_taken'
|
|
||||||
};
|
|
||||||
|
|
||||||
export function lobbyMessage(key: FrontendErrorKey): string {
|
function isFrontendErrorKey(value: string): value is FrontendErrorKey {
|
||||||
return frontendErrors[key] ?? frontendErrors.unknown;
|
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') {
|
if (!payload || typeof payload !== 'object') {
|
||||||
return lobbyMessage(fallbackKey);
|
return lobbyMessage(fallbackKey, locale);
|
||||||
}
|
}
|
||||||
|
|
||||||
const record = payload as Record<string, unknown>;
|
const record = payload as Record<string, unknown>;
|
||||||
const code = typeof record.error_code === 'string' ? record.error_code : '';
|
const code = typeof record.error_code === 'string' ? record.error_code : '';
|
||||||
const mappedKey = apiErrorMap[code];
|
const payloadLocale = typeof record.locale === 'string' ? record.locale : locale;
|
||||||
if (!mappedKey) {
|
if (!isFrontendErrorKey(code)) {
|
||||||
return lobbyMessage(fallbackKey);
|
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,
|
RoundConfig,
|
||||||
RoundQuestion,
|
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()
|
User = get_user_model()
|
||||||
|
|
||||||
@@ -1206,3 +1206,27 @@ class SmokeStagingCommandTests(TestCase):
|
|||||||
class I18nResolverTests(TestCase):
|
class I18nResolverTests(TestCase):
|
||||||
def test_missing_backend_key_returns_key_deterministically(self):
|
def test_missing_backend_key_returns_key_deterministically(self):
|
||||||
self.assertEqual(resolve_error_message(key="missing_key", locale="da"), "missing_key")
|
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