[READY][i18n][P16] Shared keyspace-kontrakt (Django+Angular) med en-default + da/en matrix #210

Merged
integrator-bot merged 1 commits from feat/issue-204-shared-i18n-keyspace-contract into main 2026-03-01 20:30:23 +01:00
3 changed files with 111 additions and 17 deletions
Showing only changes of commit b55b379134 - Show all commits

View File

@@ -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);
}

View 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.');
});
});

View File

@@ -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)