Files
MetonaEditor/tests/i18n.test.ts
T
thzxx e83fc211dc feat: v0.2.0 — TypeScript full rewrite, 95%+ core coverage
BREAKING CHANGE: All source files converted from JavaScript to TypeScript.
- 12 .ts source files with strict types, full EditorOptions/Plugin/Token interfaces
- 7 .ts test files, 610 total tests (27 new), 7 suites all passing
- tsc --noEmit: 0 errors
- rollup-plugin-typescript build: 5 artifacts (UMD/ESM/CJS/Min/DTS)
- @babel/preset-typescript for jest
- New tsconfig.json, updated babel/jest/rollup configs
- Coverage: parser 99.5%, utils 95.7%, themes 96.2%, core 88.8%, plugins 89.5%
- Removed types/ folder (types now inline in .ts + auto-generated .d.ts)
- Desktop-only, no backward compatibility
2026-07-24 22:28:38 +08:00

558 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
loadRemote,
presetLocales,
createI18nManager,
} from '../src/i18n';
import { LOCALES } from '../src/constants';
// 确保 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');
});
});
// ============ v0.1.6 新增测试 ============
describe('v0.1.6 实例级 i18n', () => {
const { createInstanceI18n } = require('../src/i18n');
test('createInstanceI18n 创建独立上下文', () => {
const mockEditor = {
config: { locale: 'en-US' },
el: document.createElement('div'),
textarea: document.createElement('textarea'),
toolbarEl: document.createElement('div'),
_emit: jest.fn(),
};
document.body.appendChild(mockEditor.el);
const ctx = createInstanceI18n(mockEditor);
expect(ctx.get()).toBe('en-US');
expect(typeof ctx.set).toBe('function');
expect(typeof ctx.t).toBe('function');
document.body.removeChild(mockEditor.el);
});
test('实例 setLocale 触发 localeChange 事件', () => {
const mockEditor = {
config: { locale: 'zh-CN' },
el: document.createElement('div'),
textarea: document.createElement('textarea'),
toolbarEl: document.createElement('div'),
_emit: jest.fn(),
};
document.body.appendChild(mockEditor.el);
const ctx = createInstanceI18n(mockEditor);
ctx.set('en-US');
expect(mockEditor._emit).toHaveBeenCalledWith('localeChange', expect.objectContaining({ locale: 'en-US' }));
document.body.removeChild(mockEditor.el);
});
test('实例 t 函数正确翻译', () => {
const mockEditor = {
config: { locale: 'zh-CN' },
el: document.createElement('div'),
textarea: document.createElement('textarea'),
toolbarEl: document.createElement('div'),
_emit: jest.fn(),
};
document.body.appendChild(mockEditor.el);
const ctx = createInstanceI18n(mockEditor);
expect(ctx.t('bold')).toBe('粗体');
document.body.removeChild(mockEditor.el);
});
});
describe('v0.1.6 复数规则', () => {
const { addTranslations, t, setCurrentLocale } = require('../src/i18n');
beforeEach(() => {
setCurrentLocale('en-US');
});
test('单数 one 形式', () => {
addTranslations('en-US', { items: { one: '{count} item', other: '{count} items' } });
expect(t('items', { count: 1 })).toBe('1 item');
});
test('复数 other 形式', () => {
addTranslations('en-US', { items: { one: '{count} item', other: '{count} items' } });
expect(t('items', { count: 5 })).toBe('5 items');
});
test('中文 always other', () => {
addTranslations('zh-CN', { items: { other: '{count} 个项目' } });
setCurrentLocale('zh-CN');
expect(t('items', { count: 3 })).toBe('3 个项目');
});
});
// ============ v0.2.0 覆盖率补齐 ============
describe('v0.2.0 复数规则', () => {
test('俄语 few 形式', () => {
addTranslations('ru', { apples: { one: '{count} яблоко', few: '{count} яблока', many: '{count} яблок', other: '{count} яблок' } });
setCurrentLocale('ru');
expect(t('apples', { count: 2 })).toContain('яблока');
expect(t('apples', { count: 5 })).toContain('яблок');
expect(t('apples', { count: 1 })).toContain('яблоко');
});
});
describe('v0.2.0 翻译回退链', () => {
test('缺翻译回退到 fallback 语言', () => {
setCurrentLocale('en-US');
// 'outline' key exists in en-US
expect(t('outline')).toBe('Outline');
});
});
describe('v0.2.0 loadRemote 错误处理', () => {
test('loadRemote 网络错误返回 false', async () => {
// jsdom doesn't expose fetch on globalThis by default
const origFetch = (globalThis as any).fetch;
(globalThis as any).fetch = jest.fn().mockRejectedValue(new Error('network'));
const result = await loadRemote('https://example.com/locale.json', 'en-US');
expect(result).toBe(false);
(globalThis as any).fetch = origFetch;
});
});
describe('v0.2.0 格式化异常处理', () => {
test('formatNumber 异常回退为字符串', () => {
// 先用一个已知 locale
setCurrentLocale('en-US');
const r = formatNumber(123);
expect(typeof r).toBe('string');
});
test('formatCurrency 正常输出', () => {
setCurrentLocale('en-US');
const r = formatCurrency(10, 'USD');
expect(r).toContain('10');
});
test('formatDate 异常回退为字符串', () => {
setCurrentLocale('en-US');
const r = formatDate('2024-01-15');
expect(typeof r).toBe('string');
});
});
describe('v0.2.0 实例级 i18n 工具栏', () => {
test('createInstanceI18n set 更新 toolbar tooltips', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new (require('../src/core').MarkdownEditor)(c, {});
// 设置语言为英文
ed.setLocale('en-US');
// 验证工具栏按钮 title 更新
const boldBtn = ed.toolbarEl.querySelector('.me-btn[data-action="bold"]') as HTMLElement;
if (boldBtn) {
expect(boldBtn.title).toBe('Bold');
}
ed.destroy();
});
});