Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions frontend/src/lib/__tests__/i18n.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down
128 changes: 128 additions & 0 deletions frontend/src/lib/hooks/__tests__/useI18n.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
6 changes: 4 additions & 2 deletions frontend/src/lib/hooks/useI18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/lib/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down