diff --git a/frontend/src/lib/__tests__/i18n.test.ts b/frontend/src/lib/__tests__/i18n.test.ts index 8f0776b8..bb6c38e7 100644 --- a/frontend/src/lib/__tests__/i18n.test.ts +++ b/frontend/src/lib/__tests__/i18n.test.ts @@ -22,6 +22,23 @@ describe('i18n', () => { i18n.setLocale('invalid' as any); expect(i18n.getLocale()).toBe('en'); }); + + it('should return true when setting an implemented locale', () => { + const result = i18n.setLocale('en'); + expect(result).toBe(true); + }); + + it('should return false for unimplemented locales (es, fr, de)', () => { + expect(i18n.setLocale('es')).toBe(false); + expect(i18n.setLocale('fr')).toBe(false); + expect(i18n.setLocale('de')).toBe(false); + }); + + it('should not change internal locale when an unimplemented locale is requested', () => { + i18n.setLocale('en'); + i18n.setLocale('es'); + expect(i18n.getLocale()).toBe('en'); + }); }); describe('t (translation)', () => { diff --git a/frontend/src/lib/hooks/__tests__/useI18n.test.ts b/frontend/src/lib/hooks/__tests__/useI18n.test.ts new file mode 100644 index 00000000..c2d4ca5b --- /dev/null +++ b/frontend/src/lib/hooks/__tests__/useI18n.test.ts @@ -0,0 +1,128 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useI18n } from '../useI18n'; +import { i18n } from '../../i18n'; + +describe('useI18n', () => { + beforeEach(() => { + // Reset singleton and storage before each test + i18n.setLocale('en'); + localStorage.clear(); + }); + + it('initialises with en locale', async () => { + const { result } = renderHook(() => useI18n()); + + await waitFor(() => expect(result.current.locale).toBe('en')); + expect(i18n.getLocale()).toBe('en'); + }); + + describe('setLocale with implemented locale (en)', () => { + it('updates React state when locale is implemented', async () => { + const { result } = renderHook(() => useI18n()); + await waitFor(() => expect(result.current.locale).toBe('en')); + + act(() => { + result.current.setLocale('en'); + }); + + expect(result.current.locale).toBe('en'); + expect(i18n.getLocale()).toBe('en'); + }); + + it('keeps React state and singleton in sync for en', async () => { + const { result } = renderHook(() => useI18n()); + await waitFor(() => expect(result.current.locale).toBe('en')); + + act(() => { + result.current.setLocale('en'); + }); + + // Both sides agree + expect(result.current.locale).toBe(i18n.getLocale()); + }); + }); + + describe('setLocale with unimplemented locales (es, fr, de)', () => { + it('does NOT update React state when locale is unimplemented (es)', async () => { + const { result } = renderHook(() => useI18n()); + await waitFor(() => expect(result.current.locale).toBe('en')); + + act(() => { + result.current.setLocale('es'); + }); + + // React state must stay at 'en' — no desync + expect(result.current.locale).toBe('en'); + }); + + it('does NOT update React state when locale is unimplemented (fr)', async () => { + const { result } = renderHook(() => useI18n()); + await waitFor(() => expect(result.current.locale).toBe('en')); + + act(() => { + result.current.setLocale('fr'); + }); + + expect(result.current.locale).toBe('en'); + }); + + it('does NOT update React state when locale is unimplemented (de)', async () => { + const { result } = renderHook(() => useI18n()); + await waitFor(() => expect(result.current.locale).toBe('en')); + + act(() => { + result.current.setLocale('de'); + }); + + expect(result.current.locale).toBe('en'); + }); + + it('keeps React state and i18n singleton in sync when unimplemented locale is requested', async () => { + const { result } = renderHook(() => useI18n()); + await waitFor(() => expect(result.current.locale).toBe('en')); + + act(() => { + result.current.setLocale('es'); + }); + + // The core invariant: hook-visible locale === what the singleton actually uses + expect(result.current.locale).toBe(i18n.getLocale()); + }); + + it('singleton internal locale stays en after unimplemented locale request', async () => { + const { result } = renderHook(() => useI18n()); + await waitFor(() => expect(result.current.locale).toBe('en')); + + act(() => { + result.current.setLocale('fr'); + }); + + expect(i18n.getLocale()).toBe('en'); + }); + }); + + describe('t() translations', () => { + it('returns translation for a known key', async () => { + const { result } = renderHook(() => useI18n()); + await waitFor(() => expect(result.current.locale).toBe('en')); + + expect(result.current.t('nav.features')).toBe('Features'); + }); + + it('returns fallback for unknown key', async () => { + const { result } = renderHook(() => useI18n()); + await waitFor(() => expect(result.current.locale).toBe('en')); + + expect(result.current.t('nonexistent.key', 'fallback')).toBe('fallback'); + }); + }); + + describe('availableLocales', () => { + it('returns at least en', async () => { + const { result } = renderHook(() => useI18n()); + await waitFor(() => expect(result.current.locale).toBe('en')); + + expect(result.current.availableLocales).toContain('en'); + }); + }); +}); diff --git a/frontend/src/lib/hooks/useI18n.ts b/frontend/src/lib/hooks/useI18n.ts index 300d2063..b3886a8b 100644 --- a/frontend/src/lib/hooks/useI18n.ts +++ b/frontend/src/lib/hooks/useI18n.ts @@ -13,8 +13,10 @@ export function useI18n() { }, []); const setLocale = (newLocale: Locale) => { - i18n.setLocale(newLocale); - setLocaleState(newLocale); + const applied = i18n.setLocale(newLocale); + if (applied) { + setLocaleState(newLocale); + } }; const t = (key: string, defaultValue?: string) => { diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 92eefea6..a5706f79 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -90,13 +90,15 @@ const translations: LocaleData = { class I18n { private currentLocale: Locale = 'en'; - setLocale(locale: Locale): void { + setLocale(locale: Locale): boolean { if (locale in translations) { this.currentLocale = locale; if (typeof window !== 'undefined') { localStorage.setItem('locale', locale); } + return true; } + return false; } getLocale(): Locale {