Initial commit: MetonaEditor v0.1.0
This commit is contained in:
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user