Initial commit: MetonaEditor v0.1.0

This commit is contained in:
2026-07-23 16:23:07 +08:00
commit 22e867eda8
32 changed files with 17621 additions and 0 deletions
+412
View File
@@ -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');
});
});