/** * i18n.js 单元测试 * 覆盖国际化系统:翻译 / 语言切换 / 插值 / 格式化 / 监听 / 持久化 * * 注意:i18n.js 使用模块级状态(currentLocale / localeListeners / fallbackLocale), * 测试间需要重置状态以避免相互影响。 */ import { i18nUtils, t, getCurrentLocale, setCurrentLocale, switchLocale, hasTranslation, getTranslations, addTranslations, getSupportedLocales, isLocaleSupported, getLocaleName, getLocaleDirection, formatNumber, formatCurrency, formatDate, addLocaleListener, removeLocaleListener, clearLocaleListeners, saveLocale, loadLocale, getDefaultLocale, initI18n, presetLocales, createI18nManager, } from '../src/i18n.js'; import { LOCALES } from '../src/constants.js'; // 确保 localStorage 存在(jsdom 兼容) if (typeof global.localStorage === 'undefined') { const store = {}; global.localStorage = { getItem: (k) => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); }, removeItem: (k) => { delete store[k]; }, clear: () => { Object.keys(store).forEach((k) => delete store[k]); }, }; } describe('当前语言', () => { beforeEach(() => { clearLocaleListeners(); setCurrentLocale('zh-CN'); localStorage.clear(); }); test('getCurrentLocale 默认 zh-CN', () => { expect(getCurrentLocale()).toBe('zh-CN'); }); test('setCurrentLocale 切换语言', () => { setCurrentLocale('en-US'); expect(getCurrentLocale()).toBe('en-US'); }); test('setCurrentLocale 未知语言回退到 fallback', () => { const spy = jest.spyOn(console, 'warn').mockImplementation(() => {}); setCurrentLocale('fr-FR'); expect(spy).toHaveBeenCalled(); expect(getCurrentLocale()).toBe('zh-CN'); spy.mockRestore(); }); test('switchLocale 是 setCurrentLocale 的别名', () => { switchLocale('en-US'); expect(getCurrentLocale()).toBe('en-US'); }); }); describe('翻译函数 t', () => { beforeEach(() => { setCurrentLocale('zh-CN'); }); test('返回当前语言的翻译', () => { expect(t('bold')).toBe('粗体'); expect(t('italic')).toBe('斜体'); }); test('切换语言后返回新语言翻译', () => { setCurrentLocale('en-US'); expect(t('bold')).toBe('Bold'); expect(t('italic')).toBe('Italic'); }); test('未知 key 返回 key 本身', () => { const spy = jest.spyOn(console, 'warn').mockImplementation(() => {}); expect(t('non-existent-key')).toBe('non-existent-key'); spy.mockRestore(); }); test('插值替换占位符', () => { // 临时添加带占位符的翻译 addTranslations('zh-CN', { greeting: '你好,{name}!' }); expect(t('greeting', { name: '爸爸' })).toBe('你好,爸爸!'); }); test('插值未提供占位符值时保留原占位符', () => { addTranslations('zh-CN', { greeting: '你好,{name}!' }); expect(t('greeting', {})).toBe('你好,{name}!'); }); test('fallback 语言提供翻译', () => { // 临时切换到不存在的语言会回退;这里用 en-US 但 key 只在 zh-CN 中 setCurrentLocale('en-US'); // en-US 中也有 bold,所以构造一个只在 zh-CN 中的场景较难, // 改为直接验证:en-US 切换后 t('bold') 来自 en-US 而非 zh-CN expect(t('bold')).toBe('Bold'); }); }); describe('翻译查询', () => { test('hasTranslation 已知 key 返回 true', () => { setCurrentLocale('zh-CN'); expect(hasTranslation('bold')).toBe(true); expect(hasTranslation('italic')).toBe(true); }); test('hasTranslation 未知 key 返回 false', () => { expect(hasTranslation('non-existent')).toBe(false); }); test('getTranslations 返回指定语言的翻译对象', () => { const tr = getTranslations('zh-CN'); expect(tr.bold).toBe('粗体'); expect(tr.italic).toBe('斜体'); }); test('getTranslations 未知语言返回空对象', () => { expect(getTranslations('fr-FR')).toEqual({}); }); }); describe('添加翻译', () => { beforeEach(() => { setCurrentLocale('zh-CN'); }); test('addTranslations 添加新语言', () => { addTranslations('ja', { bold: '太字' }); expect(LOCALES.ja).toBeDefined(); expect(LOCALES.ja.bold).toBe('太字'); setCurrentLocale('ja'); expect(t('bold')).toBe('太字'); // 清理 delete LOCALES.ja; }); test('addTranslations 深度合并到已有语言', () => { addTranslations('zh-CN', { customKey: '自定义值' }); expect(LOCALES['zh-CN'].customKey).toBe('自定义值'); // 原有 key 不受影响 expect(LOCALES['zh-CN'].bold).toBe('粗体'); delete LOCALES['zh-CN'].customKey; }); }); describe('语言支持查询', () => { test('getSupportedLocales 返回内置语言', () => { const locales = getSupportedLocales(); expect(locales).toContain('zh-CN'); expect(locales).toContain('en-US'); }); test('isLocaleSupported 已知语言返回 true', () => { expect(isLocaleSupported('zh-CN')).toBe(true); expect(isLocaleSupported('en-US')).toBe(true); }); test('isLocaleSupported 未知语言返回 false', () => { expect(isLocaleSupported('fr-FR')).toBe(false); expect(isLocaleSupported('')).toBe(false); }); }); describe('语言元信息', () => { test('getLocaleName 返回语言显示名', () => { expect(getLocaleName('zh-CN')).toBe('简体中文'); expect(getLocaleName('en-US')).toBe('English (US)'); expect(getLocaleName('ja')).toBe('日本語'); }); test('getLocaleName 未知语言返回语言代码本身', () => { expect(getLocaleName('unknown')).toBe('unknown'); }); test('getLocaleDirection LTR 语言', () => { expect(getLocaleDirection('zh-CN')).toBe('ltr'); expect(getLocaleDirection('en-US')).toBe('ltr'); }); test('getLocaleDirection RTL 语言', () => { expect(getLocaleDirection('ar')).toBe('rtl'); expect(getLocaleDirection('he')).toBe('rtl'); expect(getLocaleDirection('fa')).toBe('rtl'); }); }); describe('格式化函数', () => { beforeEach(() => { setCurrentLocale('en-US'); }); test('formatNumber 格式化数字', () => { const r = formatNumber(1234567); expect(typeof r).toBe('string'); expect(r).toContain('1'); expect(r).toContain('234'); }); test('formatNumber 接受 options', () => { const r = formatNumber(0.5, { style: 'percent' }); expect(r).toContain('50'); }); test('formatCurrency 格式化货币', () => { const r = formatCurrency(99.99, 'USD'); expect(typeof r).toBe('string'); expect(r).toContain('99'); }); test('formatDate 格式化日期', () => { const r = formatDate('2024-01-15'); expect(typeof r).toBe('string'); // 至少包含年份信息 expect(r.length).toBeGreaterThan(0); }); test('formatDate 接受 Date 对象', () => { const r = formatDate(new Date('2024-06-15')); expect(typeof r).toBe('string'); expect(r.length).toBeGreaterThan(0); }); test('formatNumber 无效输入回退为字符串', () => { const r = formatNumber('not-a-number'); expect(typeof r).toBe('string'); }); }); describe('语言监听', () => { beforeEach(() => { clearLocaleListeners(); setCurrentLocale('zh-CN'); }); test('addLocaleListener 注册回调', () => { const fn = jest.fn(); addLocaleListener(fn); setCurrentLocale('en-US'); expect(fn).toHaveBeenCalledWith('en-US'); }); test('addLocaleListener 返回取消函数', () => { const fn = jest.fn(); const unsub = addLocaleListener(fn); expect(typeof unsub).toBe('function'); unsub(); setCurrentLocale('en-US'); expect(fn).not.toHaveBeenCalled(); }); test('removeLocaleListener 移除回调', () => { const fn = jest.fn(); addLocaleListener(fn); removeLocaleListener(fn); setCurrentLocale('en-US'); expect(fn).not.toHaveBeenCalled(); }); test('clearLocaleListeners 清空所有回调', () => { const fn1 = jest.fn(); const fn2 = jest.fn(); addLocaleListener(fn1); addLocaleListener(fn2); clearLocaleListeners(); setCurrentLocale('en-US'); expect(fn1).not.toHaveBeenCalled(); expect(fn2).not.toHaveBeenCalled(); }); test('监听器抛错不影响其他监听器', () => { const errFn = jest.fn(() => { throw new Error('boom'); }); const okFn = jest.fn(); const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); addLocaleListener(errFn); addLocaleListener(okFn); setCurrentLocale('en-US'); expect(errFn).toHaveBeenCalled(); expect(okFn).toHaveBeenCalled(); spy.mockRestore(); }); }); describe('语言持久化', () => { beforeEach(() => { localStorage.clear(); setCurrentLocale('zh-CN'); }); test('saveLocale 写入 localStorage', () => { saveLocale('en-US'); expect(localStorage.getItem('metona-editor-locale')).toBe('en-US'); }); test('loadLocale 无保存时返回默认语言', () => { // 无保存时调用 getDefaultLocale const r = loadLocale(); expect(typeof r).toBe('string'); expect(r.length).toBeGreaterThan(0); }); test('loadLocale 读取已保存值', () => { localStorage.setItem('metona-editor-locale', 'en-US'); expect(loadLocale()).toBe('en-US'); }); test('initI18n 加载已保存的语言', () => { localStorage.setItem('metona-editor-locale', 'en-US'); initI18n(); expect(getCurrentLocale()).toBe('en-US'); }); }); describe('默认语言探测', () => { test('getDefaultLocale 返回字符串', () => { const r = getDefaultLocale(); expect(typeof r).toBe('string'); expect(r.length).toBeGreaterThan(0); }); test('navigator 不支持时返回 fallback', () => { const origNav = global.navigator; Object.defineProperty(global, 'navigator', { value: undefined, configurable: true }); expect(getDefaultLocale()).toBe('zh-CN'); Object.defineProperty(global, 'navigator', { value: origNav, configurable: true }); }); }); describe('fallback 语言', () => { test('getFallbackLocale 默认 zh-CN', () => { expect(i18nUtils.getFallbackLocale()).toBe('zh-CN'); }); test('setFallbackLocale 修改 fallback', () => { i18nUtils.setFallbackLocale('en-US'); expect(i18nUtils.getFallbackLocale()).toBe('en-US'); // 恢复 i18nUtils.setFallbackLocale('zh-CN'); }); }); describe('i18nUtils 代理层', () => { test('暴露所有 i18n API', () => { [ 't', 'getCurrentLocale', 'setCurrentLocale', 'switchLocale', 'getFallbackLocale', 'setFallbackLocale', 'hasTranslation', 'getTranslations', 'addTranslations', 'getSupportedLocales', 'isLocaleSupported', 'getLocaleName', 'getLocaleDirection', 'formatNumber', 'formatCurrency', 'formatDate', 'addLocaleListener', 'removeLocaleListener', 'clearLocaleListeners', 'initI18n', 'saveLocale', 'loadLocale', 'getDefaultLocale', ].forEach((fn) => { expect(typeof i18nUtils[fn]).toBe('function'); }); }); test('代理方法与具名导出一致', () => { expect(i18nUtils.t).toBe(t); expect(i18nUtils.getCurrentLocale).toBe(getCurrentLocale); expect(i18nUtils.switchLocale).toBe(switchLocale); }); }); describe('presetLocales', () => { test('包含 zh-CN 和 en-US', () => { expect(presetLocales['zh-CN']).toBeDefined(); expect(presetLocales['en-US']).toBeDefined(); }); test('包含 name / nativeName / direction / translations', () => { const z = presetLocales['zh-CN']; expect(z.name).toBeDefined(); expect(z.nativeName).toBeDefined(); expect(z.direction).toBe('ltr'); expect(z.translations).toBe(LOCALES['zh-CN']); }); }); describe('createI18nManager', () => { test('返回包含全部方法的管理器对象', () => { const m = createI18nManager(); expect(typeof m.t).toBe('function'); expect(typeof m.setCurrentLocale).toBe('function'); expect(typeof m.formatNumber).toBe('function'); }); test('管理器方法可独立调用', () => { const m = createI18nManager(); m.setCurrentLocale('en-US'); expect(m.getCurrentLocale()).toBe('en-US'); expect(m.t('bold')).toBe('Bold'); }); });