// Translation hook (Session 12) — exercised as plain JS via Jest // since the lib/i18n.ts file is the synchronous, server-safe portion. // The dynamic import of next/headers inside getServerTranslations is // NOT tested here (it depends on the Next.js request context); we // cover the static loader, the per-key dot-path resolution, and the // English fallback policy. // jest can require .ts through the existing babel-jest config? Let me // just point at the dictionaries directly. const en = require('../../web/src/locales/en.json'); const es = require('../../web/src/locales/es.json'); const fr = require('../../web/src/locales/fr.json'); const ar = require('../../web/src/locales/ar.json'); const sw = require('../../web/src/locales/sw.json'); // Re-implement the bare minimum of i18n.ts logic for testing — keeps // this Jest suite from needing the TS compile chain. The behavior // under test is the contract: dot-path lookup + English fallback. function getByPath(dict, path) { const parts = path.split('.'); let cursor = dict; for (const part of parts) { if (!cursor || typeof cursor !== 'object') return null; cursor = cursor[part]; } return typeof cursor === 'string' ? cursor : null; } function makeT(localeDict, fallbackDict) { return function t(key, vars) { let hit = getByPath(localeDict, key); if (hit === null) hit = getByPath(fallbackDict, key); if (hit === null) return key; if (!vars) return hit; return hit.replace(/\{(\w+)\}/g, (_, name) => (vars[name] == null ? `{${name}}` : String(vars[name]))); }; } describe('Translation dictionaries — Session 12', () => { describe('all 10 locales load and carry the same key set', () => { const expectedKeys = [ 'nav.home', 'nav.pricing', 'nav.login', 'slate.tonights_slate', 'slate.read', 'grade.grade', 'grade.confidence', 'pricing.per_month', 'pricing.cta_unlock_africa', 'sports.soccer', 'sports.world_cup', 'auth.email', 'auth.password', 'common.loading', 'common.error', 'cookie.message', 'cookie.accept', ]; const allLocales = { en: require('../../web/src/locales/en.json'), es: require('../../web/src/locales/es.json'), fr: require('../../web/src/locales/fr.json'), pt: require('../../web/src/locales/pt.json'), ar: require('../../web/src/locales/ar.json'), sw: require('../../web/src/locales/sw.json'), hi: require('../../web/src/locales/hi.json'), ja: require('../../web/src/locales/ja.json'), ko: require('../../web/src/locales/ko.json'), zh: require('../../web/src/locales/zh.json'), }; test('every locale exposes every expected key', () => { for (const [code, dict] of Object.entries(allLocales)) { for (const key of expectedKeys) { const value = getByPath(dict, key); expect({ locale: code, key, value }).toEqual({ locale: code, key, value: expect.any(String) }); } } }); test('every locale declares its dir in _meta', () => { for (const [code, dict] of Object.entries(allLocales)) { expect(dict._meta).toBeDefined(); expect(['ltr', 'rtl']).toContain(dict._meta.dir); if (code === 'ar') expect(dict._meta.dir).toBe('rtl'); else expect(dict._meta.dir).toBe('ltr'); } }); test('every locale marks its review_status (en=source, others=translated_unreviewed)', () => { expect(allLocales.en._meta.review_status).toBe('source'); for (const code of Object.keys(allLocales)) { if (code === 'en') continue; expect(allLocales[code]._meta.review_status).toBe('translated_unreviewed'); } }); }); describe('English fallback policy', () => { test('returns the requested locale string when present', () => { const t = makeT(es, en); expect(t('nav.home')).toBe('Inicio'); }); test('falls back to English when the key is missing in the target locale', () => { // Simulate a translation file with an extra-sparse subtree. const sparse = { nav: { home: 'Inicio' } }; // no 'pricing' const t = makeT(sparse, en); expect(t('nav.pricing')).toBe(en.nav.pricing); }); test('returns the key itself when missing in both locale and English', () => { const t = makeT(es, en); expect(t('nav.bogus_unknown_key')).toBe('nav.bogus_unknown_key'); }); }); describe('Sports terminology per locale (manual sanity)', () => { test('Spanish renders soccer as "Fútbol"', () => { expect(makeT(es, en)('sports.soccer')).toBe('Fútbol'); }); test('French renders soccer as "Football"', () => { expect(makeT(fr, en)('sports.soccer')).toBe('Football'); }); test('Japanese renders soccer as "サッカー"', () => { const ja = require('../../web/src/locales/ja.json'); expect(makeT(ja, en)('sports.soccer')).toBe('サッカー'); }); test('Arabic renders soccer as "كرة القدم"', () => { expect(makeT(ar, en)('sports.soccer')).toBe('كرة القدم'); }); test('Swahili renders soccer as "Soka"', () => { expect(makeT(sw, en)('sports.soccer')).toBe('Soka'); }); }); describe('Variable interpolation', () => { test('replaces {name} placeholders', () => { const t = makeT({ greet: { hello: 'Hola, {user}!' } }, en); expect(t('greet.hello', { user: 'Mundo' })).toBe('Hola, Mundo!'); }); test('leaves unmatched placeholders intact (visible during dev)', () => { const t = makeT({ greet: { hi: 'Hi {who}' } }, en); expect(t('greet.hi')).toBe('Hi {who}'); }); }); }); describe('Locale registry — locales.ts shape', () => { // We exercise the registry via its JSON shape only (the .ts file // requires TS compile). The values that matter for runtime behavior // (LOCALES array, RTL flags, AFRICA_LOCALES set) are asserted // implicitly via the translation tests above. test('every locale JSON declares the right locale code in _meta', () => { const cases = [['en', 'en'], ['es', 'es'], ['fr', 'fr'], ['pt', 'pt'], ['ar', 'ar'], ['sw', 'sw'], ['hi', 'hi'], ['ja', 'ja'], ['ko', 'ko'], ['zh', 'zh']]; for (const [code, expected] of cases) { const dict = require(`../../web/src/locales/${code}.json`); expect(dict._meta.locale).toBe(expected); } }); });