type MockLocale = { languageCode?: string; languageTag?: string; }; type I18nModule = typeof import('@/i18n'); async function flushMicrotasks() { await Promise.resolve(); await Promise.resolve(); } function loadI18nModule(options?: { locales?: MockLocale[]; platformOS?: 'android' | 'ios'; }) { let currentLocales = options?.locales ?? [{ languageTag: 'zh-CN' }]; const platformOS = options?.platformOS ?? 'ios'; const remove = jest.fn(); const addEventListener = jest.fn( (_type: string, listener: (state: string) => void) => ({ listener, remove, }), ); jest.resetModules(); jest.doMock('expo-localization', () => ({ getLocales: () => currentLocales, })); jest.doMock('react-native', () => { return { AppState: { addEventListener, }, Platform: { OS: platformOS, select: (config: Record) => config[platformOS] ?? config.default, }, }; }); const module = require('@/i18n') as I18nModule; return { ...module, addEventListener, remove, setLocales: (nextLocales: MockLocale[]) => { currentLocales = nextLocales; }, }; } describe('i18n device synchronization', () => { let consoleInfoSpy: jest.SpyInstance; beforeEach(() => { consoleInfoSpy = jest.spyOn(console, 'info').mockImplementation(() => {}); }); afterEach(() => { consoleInfoSpy.mockRestore(); jest.resetModules(); jest.clearAllMocks(); }); test('resolves zh when the device locale starts with zh', async () => { const { getDeviceLanguage } = loadI18nModule({ locales: [{ languageTag: 'zh-Hans-CN' }], }); expect(getDeviceLanguage()).toBe('zh'); }); test('falls back to zh when the device locale is missing', async () => { const { getDeviceLanguage } = loadI18nModule({ locales: [{}], }); expect(getDeviceLanguage()).toBe('zh'); }); test('syncLanguageWithDevice updates i18n when the current language differs', async () => { const { default: i18n, syncLanguageWithDevice } = loadI18nModule({ locales: [{ languageTag: 'en-US' }], }); await i18n.changeLanguage('zh'); await syncLanguageWithDevice(); expect(i18n.resolvedLanguage).toBe('en'); }); test('startLocaleSync subscribes on Android and refreshes language when the app becomes active', async () => { const { addEventListener, default: i18n, setLocales, startLocaleSync, } = loadI18nModule({ locales: [{ languageTag: 'zh-CN' }], platformOS: 'android', }); await i18n.changeLanguage('zh'); const subscription = startLocaleSync(); expect(addEventListener).toHaveBeenCalledWith( 'change', expect.any(Function), ); const listener = addEventListener.mock.calls[0][1] as ( state: string, ) => void; setLocales([{ languageTag: 'en-US' }]); listener('background'); await flushMicrotasks(); expect(i18n.resolvedLanguage).toBe('zh'); listener('active'); await flushMicrotasks(); expect(i18n.resolvedLanguage).toBe('en'); subscription.remove(); }); test('startLocaleSync is a no-op on iOS', async () => { const { addEventListener, startLocaleSync } = loadI18nModule({ locales: [{ languageTag: 'en-US' }], platformOS: 'ios', }); const subscription = startLocaleSync(); expect(addEventListener).not.toHaveBeenCalled(); expect(subscription.remove).toEqual(expect.any(Function)); subscription.remove(); }); });