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
+146
View File
@@ -0,0 +1,146 @@
/**
* animations.js 单元测试
* 覆盖 animationUtils 全部 API + createAnimation 工厂
*/
import { animationUtils, createAnimation, animationPresets } from '../src/animations.js';
import { ANIMATIONS } from '../src/constants.js';
describe('animationUtils - 默认动画注册', () => {
test('默认动画已注册到 animationMap', () => {
const names = animationUtils.getAnimationNames();
// 与 constants.js 中 ANIMATIONS 的键一致
Object.keys(ANIMATIONS).forEach((name) => {
expect(names).toContain(name);
});
});
test('内置动画类型完整', () => {
const names = animationUtils.getAnimationNames();
expect(names).toEqual(expect.arrayContaining([
'slide', 'fade', 'scale', 'bounce', 'flip', 'rotate', 'zoom',
]));
});
});
describe('animationUtils - register / unregister / get', () => {
test('register 注册自定义动画', () => {
animationUtils.register('custom', {
enter: { opacity: 0 },
leave: { opacity: 0 },
duration: 500,
easing: 'ease-in',
});
const a = animationUtils.get('custom');
expect(a).not.toBeNull();
expect(a.name).toBe('custom');
expect(a.duration).toBe(500);
expect(a.easing).toBe('ease-in');
expect(a.enter).toEqual({ opacity: 0 });
});
test('register 缺省字段使用默认值', () => {
animationUtils.register('minimal', {});
const a = animationUtils.get('minimal');
expect(a.enter).toEqual({});
expect(a.leave).toEqual({});
expect(a.duration).toBe(300);
expect(a.easing).toBe('cubic-bezier(0.4, 0, 0.2, 1)');
});
test('unregister 移除动画', () => {
animationUtils.register('tmp', { duration: 100 });
expect(animationUtils.get('tmp')).not.toBeNull();
animationUtils.unregister('tmp');
// 移除后回退到 ANIMATIONS,但 'tmp' 不在 ANIMATIONS 中,返回 null
expect(animationUtils.get('tmp')).toBeNull();
});
test('get 不存在的动画返回 null', () => {
expect(animationUtils.get('non-existent-animation')).toBeNull();
});
test('get 内置动画返回配置', () => {
const a = animationUtils.get('slide');
expect(a).not.toBeNull();
// 内置动画直接存储 ANIMATIONS.slide 配置(不含 name 字段,只有 register() 才添加)
expect(a.duration).toBe(ANIMATIONS.slide.duration);
expect(a.enter).toEqual(ANIMATIONS.slide.enter);
});
});
describe('animationUtils - 查询 API', () => {
test('getAnimationNames 返回数组', () => {
const names = animationUtils.getAnimationNames();
expect(Array.isArray(names)).toBe(true);
expect(names.length).toBeGreaterThan(0);
});
test('getActiveCount 始终返回 0CSS 动画由浏览器管理)', () => {
expect(animationUtils.getActiveCount()).toBe(0);
});
test('cancelAll 不抛错(无操作)', () => {
expect(() => animationUtils.cancelAll()).not.toThrow();
});
});
describe('animationUtils - reset / destroy', () => {
test('reset 清除自定义动画并恢复内置', () => {
animationUtils.register('tmp1', {});
animationUtils.register('tmp2', {});
expect(animationUtils.getAnimationNames()).toContain('tmp1');
animationUtils.reset();
const names = animationUtils.getAnimationNames();
expect(names).not.toContain('tmp1');
expect(names).not.toContain('tmp2');
// 内置动画仍存在
expect(names).toContain('slide');
expect(names).toContain('fade');
});
test('destroy 清空所有动画', () => {
animationUtils.destroy();
expect(animationUtils.getAnimationNames()).toEqual([]);
// destroy 后 reset 可恢复
animationUtils.reset();
expect(animationUtils.getAnimationNames().length).toBeGreaterThan(0);
});
});
describe('createAnimation 工厂', () => {
test('返回完整动画对象', () => {
const a = createAnimation({
enter: { transform: 'translateX(10px)' },
leave: { transform: 'translateX(-10px)' },
duration: 600,
easing: 'linear',
});
expect(a.enter).toEqual({ transform: 'translateX(10px)' });
expect(a.leave).toEqual({ transform: 'translateX(-10px)' });
expect(a.duration).toBe(600);
expect(a.easing).toBe('linear');
});
test('缺省字段使用默认值', () => {
const a = createAnimation({});
expect(a.enter).toEqual({});
expect(a.leave).toEqual({});
expect(a.duration).toBe(300);
expect(a.easing).toBe('cubic-bezier(0.4, 0, 0.2, 1)');
});
test('无参数也能创建', () => {
const a = createAnimation();
expect(a).toBeDefined();
expect(a.duration).toBe(300);
});
});
describe('animationPresets', () => {
test('导出为对象', () => {
expect(typeof animationPresets).toBe('object');
});
});
+1227
View File
File diff suppressed because it is too large Load Diff
+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');
});
});
+353
View File
@@ -0,0 +1,353 @@
/**
* parser.js 单元测试
* 覆盖 Markdown 解析器的所有语法分支与安全特性
*/
import { parseMarkdown, safeUrl, slugify } from '../src/parser.js';
describe('parseMarkdown - 基础', () => {
test('空输入返回空字符串', () => {
expect(parseMarkdown('')).toBe('');
expect(parseMarkdown(null)).toBe('');
expect(parseMarkdown(undefined)).toBe('');
});
test('非字符串输入被转换为字符串', () => {
expect(parseMarkdown(123)).toBe('<p>123</p>');
});
test('Windows 换行符被规范化', () => {
const html = parseMarkdown('line1\r\nline2');
expect(html).toContain('line1');
expect(html).toContain('line2');
expect(html).not.toContain('\r');
});
});
describe('parseMarkdown - 标题', () => {
test('h1 到 h6', () => {
expect(parseMarkdown('# T1')).toBe('<h1 id="t1">T1</h1>');
expect(parseMarkdown('## T2')).toBe('<h2 id="t2">T2</h2>');
expect(parseMarkdown('### T3')).toBe('<h3 id="t3">T3</h3>');
expect(parseMarkdown('#### T4')).toBe('<h4 id="t4">T4</h4>');
expect(parseMarkdown('##### T5')).toBe('<h5 id="t5">T5</h5>');
expect(parseMarkdown('###### T6')).toBe('<h6 id="t6">T6</h6>');
});
test('标题文本被内联渲染', () => {
const html = parseMarkdown('# Hello **world**');
expect(html).toContain('<strong>world</strong>');
});
test('标题生成锚点 id', () => {
expect(parseMarkdown('# Hello World')).toContain('id="hello-world"');
});
test('超过 6 个 # 不作为标题', () => {
const html = parseMarkdown('####### not heading');
expect(html).not.toContain('<h7');
expect(html).toContain('<p>');
});
});
describe('parseMarkdown - 段落', () => {
test('单行段落', () => {
expect(parseMarkdown('hello')).toBe('<p>hello</p>');
});
test('多行段落合并', () => {
const html = parseMarkdown('line1\nline2');
expect(html).toBe('<p>line1<br/>line2</p>');
});
test('空行分隔段落', () => {
const html = parseMarkdown('p1\n\np2');
expect(html).toBe('<p>p1</p>\n<p>p2</p>');
});
});
describe('parseMarkdown - 行内格式', () => {
test('粗体 **text**', () => {
expect(parseMarkdown('**bold**')).toBe('<p><strong>bold</strong></p>');
});
test('粗体 __text__', () => {
expect(parseMarkdown('__bold__')).toBe('<p><strong>bold</strong></p>');
});
test('斜体 *text*', () => {
expect(parseMarkdown('a *italic* b')).toBe('<p>a <em>italic</em> b</p>');
});
test('斜体 _text_', () => {
expect(parseMarkdown('a _italic_ b')).toBe('<p>a <em>italic</em> b</p>');
});
test('删除线 ~~text~~', () => {
expect(parseMarkdown('~~del~~')).toBe('<p><del>del</del></p>');
});
test('行内代码 `code`', () => {
const html = parseMarkdown('use `code` here');
expect(html).toContain('<code>code</code>');
});
test('行内代码内容不被解析为格式', () => {
const html = parseMarkdown('`a **b** c`');
expect(html).toContain('<code>a **b** c</code>');
expect(html).not.toContain('<strong>');
});
test('行内代码内容被转义', () => {
const html = parseMarkdown('`<script>`');
expect(html).toContain('&lt;script&gt;');
});
});
describe('parseMarkdown - 代码块', () => {
test('围栏代码块 ```', () => {
const html = parseMarkdown('```\ncode\n```');
expect(html).toContain('<pre><code');
expect(html).toContain('code');
});
test('带语言的代码块', () => {
const html = parseMarkdown('```js\nconsole.log(1)\n```');
expect(html).toContain('class="language-js"');
});
test('波浪线围栏 ~~~', () => {
const html = parseMarkdown('~~~\ncode\n~~~');
expect(html).toContain('<pre><code');
});
test('代码块内容被转义', () => {
const html = parseMarkdown('```\n<a>\n```');
expect(html).toContain('&lt;a&gt;');
});
test('highlight 钩子被调用', () => {
const env = {
highlight: (code, lang) => `<span class="hl">${code}-${lang}</span>`,
};
const html = parseMarkdown('```js\nfoo\n```', env);
expect(html).toContain('<span class="hl">foo-js</span>');
});
test('highlight 抛错时回退为转义输出', () => {
const env = {
highlight: () => { throw new Error('boom'); },
};
const html = parseMarkdown('```js\nfoo\n```', env);
expect(html).toContain('foo');
expect(html).toContain('<pre><code');
});
});
describe('parseMarkdown - 引用块', () => {
test('单行引用', () => {
const html = parseMarkdown('> quote');
expect(html).toContain('<blockquote>');
expect(html).toContain('quote');
});
test('多行引用', () => {
const html = parseMarkdown('> line1\n> line2');
expect(html).toContain('<blockquote>');
expect(html).toContain('line1');
expect(html).toContain('line2');
});
test('引用内可包含其他语法', () => {
const html = parseMarkdown('> # heading');
expect(html).toContain('<blockquote>');
expect(html).toContain('<h1');
});
});
describe('parseMarkdown - 列表', () => {
test('无序列表 -', () => {
const html = parseMarkdown('- a\n- b');
expect(html).toContain('<ul>');
expect(html).toContain('<li>a</li>');
expect(html).toContain('<li>b</li>');
});
test('无序列表 *', () => {
const html = parseMarkdown('* a\n* b');
expect(html).toContain('<ul>');
});
test('无序列表 +', () => {
const html = parseMarkdown('+ a\n+ b');
expect(html).toContain('<ul>');
});
test('有序列表', () => {
const html = parseMarkdown('1. one\n2. two');
expect(html).toContain('<ol>');
expect(html).toContain('<li>one</li>');
expect(html).toContain('<li>two</li>');
});
test('任务列表未完成', () => {
const html = parseMarkdown('- [ ] todo');
expect(html).toContain('me-task-item');
expect(html).toContain('<input type="checkbox" disabled/>');
expect(html).not.toContain('checked');
});
test('任务列表已完成', () => {
const html = parseMarkdown('- [x] done');
expect(html).toContain('checked');
});
test('任务列表大写 X 也算完成', () => {
const html = parseMarkdown('- [X] done');
expect(html).toContain('checked');
});
});
describe('parseMarkdown - 水平线', () => {
test('--- 作为水平线', () => {
expect(parseMarkdown('---')).toBe('<hr/>');
});
test('*** 作为水平线', () => {
expect(parseMarkdown('***')).toBe('<hr/>');
});
test('___ 作为水平线', () => {
expect(parseMarkdown('___')).toBe('<hr/>');
});
});
describe('parseMarkdown - 表格', () => {
test('基本表格', () => {
const md = '| a | b |\n| --- | --- |\n| 1 | 2 |';
const html = parseMarkdown(md);
expect(html).toContain('<table>');
expect(html).toContain('<thead>');
expect(html).toContain('<th>a</th>');
expect(html).toContain('<th>b</th>');
expect(html).toContain('<td>1</td>');
expect(html).toContain('<td>2</td>');
});
test('表格被 me-table-wrap 包裹', () => {
const md = '| a |\n| --- |\n| 1 |';
expect(parseMarkdown(md)).toContain('me-table-wrap');
});
test('非分隔行不构成表格', () => {
const html = parseMarkdown('| a |\nnot a separator');
expect(html).not.toContain('<table>');
});
});
describe('parseMarkdown - 链接与图片', () => {
test('基本链接', () => {
const html = parseMarkdown('[text](https://example.com)');
expect(html).toContain('<a href="https://example.com"');
expect(html).toContain('target="_blank"');
expect(html).toContain('rel="noopener noreferrer"');
expect(html).toContain('>text</a>');
});
test('自动链接 <url>', () => {
const html = parseMarkdown('<https://example.com>');
expect(html).toContain('<a href="https://example.com"');
});
test('图片', () => {
const html = parseMarkdown('![alt](https://example.com/img.png)');
expect(html).toContain('<img src="https://example.com/img.png"');
expect(html).toContain('alt="alt"');
expect(html).toContain('loading="lazy"');
});
});
describe('parseMarkdown - XSS 防护', () => {
test('文本中的 HTML 标签被转义', () => {
const html = parseMarkdown('<script>alert(1)</script>');
expect(html).toContain('&lt;script&gt;');
expect(html).not.toContain('<script>');
});
test('javascript: 协议链接被过滤', () => {
const html = parseMarkdown('[click](javascript:alert(1))');
expect(html).not.toContain('href="javascript:');
});
test('vbscript: 协议被过滤', () => {
const html = parseMarkdown('[x](vbscript:msgbox(1))');
expect(html).not.toContain('href="vbscript:');
});
test('file: 协议被过滤', () => {
const html = parseMarkdown('[x](file:///etc/passwd)');
expect(html).not.toContain('href="file:');
});
test('非 image 的 data: 协议被过滤', () => {
const html = parseMarkdown('[x](data:text/html,<script>)');
expect(html).not.toContain('href="data:text/html');
});
test('data:image: 协议被允许', () => {
const html = parseMarkdown('![x](data:image/png;base64,abc)');
expect(html).toContain('src="data:image/png;base64,abc"');
});
test('图片 javascript: 协议被过滤', () => {
const html = parseMarkdown('![x](javascript:alert(1))');
expect(html).not.toContain('src="javascript:');
});
});
describe('safeUrl', () => {
test('正常 http url 原样返回', () => {
expect(safeUrl('https://example.com')).toBe('https://example.com');
});
test('空值返回空字符串', () => {
expect(safeUrl('')).toBe('');
expect(safeUrl(null)).toBe('');
expect(safeUrl(undefined)).toBe('');
});
test('javascript: 返回空', () => {
expect(safeUrl('javascript:alert(1)')).toBe('');
});
test('大小写不敏感过滤', () => {
expect(safeUrl('JAVASCRIPT:alert(1)')).toBe('');
expect(safeUrl('JavaScript:alert(1)')).toBe('');
});
test('trim 空白', () => {
expect(safeUrl(' https://example.com ')).toBe('https://example.com');
});
});
describe('slugify', () => {
test('英文转小写连字符', () => {
expect(slugify('Hello World')).toBe('hello-world');
});
test('保留中文', () => {
expect(slugify('你好 世界')).toBe('你好-世界');
});
test('移除特殊字符', () => {
expect(slugify('A! B@ C#')).toBe('a-b-c');
});
test('合并多个连字符', () => {
expect(slugify('a---b')).toBe('a-b');
});
test('去除首尾连字符', () => {
expect(slugify('-hello-')).toBe('hello');
});
});
+869
View File
@@ -0,0 +1,869 @@
/**
* plugins.js 单元测试
* 覆盖预设插件 autoSave / exportTool / searchReplace 及 pluginUtils
*/
import { MarkdownEditor } from '../src/core.js';
import { presetPlugins, pluginUtils, PluginManager } from '../src/plugins.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]); },
};
}
// jsdom 不提供 URL.createObjectURL / revokeObjectURL,导出插件依赖它们
if (typeof URL !== 'undefined' && typeof URL.createObjectURL !== 'function') {
URL.createObjectURL = () => 'blob:mock';
URL.revokeObjectURL = () => {};
}
function makeEditor(opts = {}) {
document.body.innerHTML = '';
const container = document.createElement('div');
document.body.appendChild(container);
return new MarkdownEditor(container, { value: 'hello', ...opts });
}
describe('presetPlugins 注册表', () => {
test('包含三个预设插件', () => {
expect(presetPlugins.autoSave).toBeDefined();
expect(presetPlugins.exportTool).toBeDefined();
expect(presetPlugins.searchReplace).toBeDefined();
});
test('每个插件都有 name 和 install', () => {
Object.values(presetPlugins).forEach((p) => {
expect(typeof p.name).toBe('string');
expect(typeof p.install).toBe('function');
});
});
});
describe('autoSave 插件', () => {
let ed;
beforeEach(() => {
localStorage.clear();
ed = makeEditor();
ed.use(presetPlugins.autoSave);
});
afterEach(() => ed.destroy());
test('安装后暴露草稿 API', () => {
expect(typeof ed.restoreDraft).toBe('function');
expect(typeof ed.clearDraft).toBe('function');
expect(typeof ed.getDraftKey).toBe('function');
});
test('getDraftKey 返回包含 id 的 key', () => {
const key = ed.getDraftKey();
expect(key).toContain('me-draft-');
});
test('change 后内容最终写入 localStorage', (done) => {
ed.setValue('autosaved content');
// autoSave 默认 delay 1000ms,等 1100ms
setTimeout(() => {
const key = ed.getDraftKey();
const stored = localStorage.getItem(key);
expect(stored).toBe('autosaved content');
done();
}, 1150);
});
test('clearDraft 清除存储', () => {
const key = ed.getDraftKey();
localStorage.setItem(key, 'tmp');
ed.clearDraft();
expect(localStorage.getItem(key)).toBeNull();
});
test('restoreDraft 从存储恢复', () => {
const key = ed.getDraftKey();
localStorage.setItem(key, '# restored');
ed.restoreDraft();
expect(ed.getValue()).toBe('# restored');
});
test('destroy 时清理定时器不抛错', () => {
expect(() => ed.destroy()).not.toThrow();
});
});
describe('autoSave 自定义配置', () => {
test('自定义 key', () => {
localStorage.clear();
const ed = makeEditor();
ed.use(presetPlugins.autoSave, { key: 'my-key', delay: 50 });
ed.setValue('data');
expect(ed.getDraftKey()).toBe('my-key');
ed.destroy();
});
test('自定义 delay 生效', (done) => {
localStorage.clear();
const ed = makeEditor();
ed.use(presetPlugins.autoSave, { delay: 30 });
ed.setValue('fast');
setTimeout(() => {
expect(localStorage.getItem(ed.getDraftKey())).toBe('fast');
ed.destroy();
done();
}, 60);
});
});
describe('exportTool 插件', () => {
let ed;
beforeEach(() => {
ed = makeEditor({ value: '# export me' });
ed.use(presetPlugins.exportTool);
});
afterEach(() => ed.destroy());
test('安装后暴露导出方法', () => {
expect(typeof ed.exportMarkdown).toBe('function');
expect(typeof ed.exportHTML).toBe('function');
});
test('exportMarkdown 创建下载链接', () => {
// 在原型层拦截 a.click,避免 mock createElement 导致递归
const clickMock = jest
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {});
ed.exportMarkdown('test.md');
expect(clickMock).toHaveBeenCalled();
clickMock.mockRestore();
});
test('exportHTML 包含 HTML 文档结构', () => {
const clickMock = jest
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {});
expect(() => ed.exportHTML('test.html', { title: 'My Title' })).not.toThrow();
clickMock.mockRestore();
});
test('exportMarkdown 返回 editor(链式)', () => {
const clickMock = jest
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {});
expect(ed.exportMarkdown()).toBe(ed);
clickMock.mockRestore();
});
});
describe('searchReplace 插件', () => {
let ed;
beforeEach(() => {
ed = makeEditor({ value: 'find me here, find again' });
ed.use(presetPlugins.searchReplace);
});
afterEach(() => ed.destroy());
test('Ctrl+F 打开搜索面板', () => {
const ta = ed.textarea;
const ev = new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true });
ta.dispatchEvent(ev);
const panel = ed.el.querySelector('.me-search');
expect(panel).not.toBeNull();
});
test('Escape 关闭面板(通过面板内输入框)', () => {
const ta = ed.textarea;
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true }));
const panel = ed.el.querySelector('.me-search');
expect(panel).not.toBeNull();
const input = panel.querySelector('.me-search-find');
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(ed.el.querySelector('.me-search')).toBeNull();
});
test('查找下一个高亮选中匹配', () => {
const ta = ed.textarea;
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true }));
const panel = ed.el.querySelector('.me-search');
const input = panel.querySelector('.me-search-find');
input.value = 'find';
input.dispatchEvent(new Event('input', { bubbles: true }));
panel.querySelector('.me-search-next').click();
// 选中区域应包含 'find'
expect(ta.value.substring(ta.selectionStart, ta.selectionEnd)).toBe('find');
});
test('全部替换', () => {
const ta = ed.textarea;
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'h', ctrlKey: true, bubbles: true }));
const panel = ed.el.querySelector('.me-search');
panel.querySelector('.me-search-find').value = 'find';
panel.querySelector('.me-search-replace').value = 'FIND';
panel.querySelector('.me-search-replace-all').click();
expect(ed.getValue()).toBe('FIND me here, FIND again');
});
test('单次替换仅替换当前选区匹配', () => {
const ta = ed.textarea;
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'h', ctrlKey: true, bubbles: true }));
const panel = ed.el.querySelector('.me-search');
const findInput = panel.querySelector('.me-search-find');
findInput.value = 'find';
findInput.dispatchEvent(new Event('input', { bubbles: true }));
// 先定位到第一个
panel.querySelector('.me-search-next').click();
panel.querySelector('.me-search-replace').value = 'X';
panel.querySelector('.me-search-replace-one').click();
// 第一个被替换,第二个保留
expect(ed.getValue()).toBe('X me here, find again');
});
test('destroy 移除面板与事件监听', () => {
const ta = ed.textarea;
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true }));
ed.destroy();
// destroy 后 el 为 null,无法查询,但不应抛错
expect(ed.el).toBeNull();
});
});
describe('PluginManager', () => {
test('register / get / has', () => {
const pm = new PluginManager();
pm.register('x', { name: 'x' });
expect(pm.has('x')).toBe(true);
expect(pm.get('x').name).toBe('x');
});
test('重复 register warn', () => {
const pm = new PluginManager();
const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
pm.register('x', { name: 'x' });
pm.register('x', { name: 'x' });
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
test('unregister', () => {
const pm = new PluginManager();
pm.register('x', { name: 'x' });
pm.unregister('x');
expect(pm.has('x')).toBe(false);
});
test('enable / disable / isEnabled', () => {
const pm = new PluginManager();
pm.register('x', { name: 'x' });
expect(pm.isEnabled('x')).toBe(true);
pm.disable('x');
expect(pm.isEnabled('x')).toBe(false);
pm.enable('x');
expect(pm.isEnabled('x')).toBe(true);
});
test('getAll / getNames', () => {
const pm = new PluginManager();
pm.register('a', { name: 'a' });
pm.register('b', { name: 'b' });
expect(pm.getAll().length).toBe(2);
expect(pm.getNames()).toEqual(['a', 'b']);
});
test('destroy 清空', () => {
const pm = new PluginManager();
pm.register('a', { name: 'a' });
pm.destroy();
expect(pm.getAll().length).toBe(0);
});
});
describe('pluginUtils', () => {
test('createManager 返回 PluginManager 实例', () => {
expect(pluginUtils.createManager()).toBeInstanceOf(PluginManager);
});
test('getPreset 返回预设插件副本', () => {
const p = pluginUtils.getPreset('autoSave');
expect(p).not.toBeNull();
expect(p.name).toBe('autoSave');
// 副本:修改不影响原对象
p.name = 'changed';
expect(presetPlugins.autoSave.name).toBe('autoSave');
});
test('getPreset 未知返回 null', () => {
expect(pluginUtils.getPreset('nope')).toBeNull();
});
test('getAllPresets 返回全部副本', () => {
const all = pluginUtils.getAllPresets();
expect(all.autoSave).toBeDefined();
expect(all.exportTool).toBeDefined();
expect(all.searchReplace).toBeDefined();
});
test('createPlugin 构造插件对象', () => {
const p = pluginUtils.createPlugin({
name: 'my',
install: () => {},
});
expect(p.name).toBe('my');
expect(typeof p.install).toBe('function');
expect(typeof p.destroy).toBe('function'); // 默认提供空 destroy
});
test('validatePlugin 校验', () => {
expect(pluginUtils.validatePlugin({ name: 'ok', install: () => {} }).valid).toBe(true);
expect(pluginUtils.validatePlugin(null).valid).toBe(false);
expect(pluginUtils.validatePlugin({ name: 'x', install: 'notfn' }).valid).toBe(false);
});
});
// ============ 补充分支测试 ============
describe('autoSave 插件 - 边界', () => {
let ed;
beforeEach(() => {
document.body.innerHTML = '';
localStorage.clear();
const c = document.createElement('div');
document.body.appendChild(c);
ed = new MarkdownEditor(c, { value: '' });
});
afterEach(() => ed.destroy());
test('未安装插件时 restoreDraft/clearDraft/getDraftKey 不存在', () => {
// 插件方法只在 install 时挂载到 editor 上
expect(ed.restoreDraft).toBeUndefined();
expect(ed.clearDraft).toBeUndefined();
expect(ed.getDraftKey).toBeUndefined();
});
test('安装后 getDraftKey 使用配置 key', () => {
ed.use(presetPlugins.autoSave, { key: 'my-draft' });
expect(ed.getDraftKey()).toBe('my-draft');
});
test('安装后 getDraftKey 默认 key 含 editor id', () => {
ed.use(presetPlugins.autoSave);
const k = ed.getDraftKey();
expect(k).toBe('me-draft-' + ed.id);
});
test('restoreDraft 读取并恢复内容', () => {
localStorage.setItem('my-draft', 'restored content');
ed.use(presetPlugins.autoSave, { key: 'my-draft', delay: 10 });
ed.restoreDraft();
expect(ed.getValue()).toBe('restored content');
});
test('restoreDraft 无草稿时不改变内容', () => {
ed.setValue('original');
ed.use(presetPlugins.autoSave, { key: 'empty-draft', delay: 10 });
const r = ed.restoreDraft();
expect(r).toBeNull();
expect(ed.getValue()).toBe('original');
});
test('restoreDraft 返回草稿值', () => {
localStorage.setItem('my-draft', 'val');
ed.use(presetPlugins.autoSave, { key: 'my-draft', delay: 10 });
expect(ed.restoreDraft()).toBe('val');
});
test('clearDraft 移除 localStorage 中的草稿', () => {
localStorage.setItem('my-draft', 'content');
ed.use(presetPlugins.autoSave, { key: 'my-draft', delay: 10 });
ed.clearDraft();
expect(localStorage.getItem('my-draft')).toBeNull();
});
test('clearDraft 返回 editor(链式)', () => {
ed.use(presetPlugins.autoSave, { key: 'my-draft', delay: 10 });
expect(ed.clearDraft()).toBe(ed);
});
test('保存触发 autosave 事件', (done) => {
ed.use(presetPlugins.autoSave, { key: 'evt-draft', delay: 10 });
ed.on('autosave', (data) => {
expect(data.key).toBe('evt-draft');
expect(data.value).toBe('triggered');
done();
});
ed.setValue('triggered');
});
test('blur 事件立即保存', () => {
ed.use(presetPlugins.autoSave, { key: 'blur-draft', delay: 1000 });
ed.setValue('blur-content');
// blur 触发立即保存
ed.textarea.dispatchEvent(new Event('blur', { bubbles: true }));
expect(localStorage.getItem('blur-draft')).toBe('blur-content');
});
test('save 事件立即保存', () => {
ed.use(presetPlugins.autoSave, { key: 'save-draft', delay: 1000 });
ed.setValue('save-content');
ed._emit('save', ed.getValue());
expect(localStorage.getItem('save-draft')).toBe('save-content');
});
});
describe('exportTool 插件 - 边界', () => {
let ed;
beforeEach(() => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
ed = new MarkdownEditor(c, { value: '# Hello' });
ed.use(presetPlugins.exportTool);
});
afterEach(() => ed.destroy());
test('未安装插件时 exportMarkdown/exportHTML 不存在', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed2 = new MarkdownEditor(c, { value: 'test' });
expect(ed2.exportMarkdown).toBeUndefined();
expect(ed2.exportHTML).toBeUndefined();
ed2.destroy();
});
test('exportMarkdown 默认文件名(含时间戳)', () => {
const clickMock = jest
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {});
// 捕获 download 属性
const origCreate = URL.createObjectURL;
let capturedName = null;
URL.createObjectURL = (blob) => {
return 'blob:mock';
};
// 监听 createElement 返回的 a 元素的 download
const origCE = document.createElement.bind(document);
const spy = jest.spyOn(document, 'createElement').mockImplementation((tag) => {
const el = origCE(tag);
if (tag === 'a') {
Object.defineProperty(el, 'download', {
set(v) { capturedName = v; },
get() { return capturedName; },
configurable: true,
});
el.click = () => {};
}
return el;
});
ed.exportMarkdown();
URL.createObjectURL = origCreate;
clickMock.mockRestore();
spy.mockRestore();
expect(capturedName).toMatch(/^metona-\d{8}-\d{4}\.md$/);
});
test('exportMarkdown 返回 editor(链式)', () => {
const clickMock = jest
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {});
expect(ed.exportMarkdown('test.md')).toBe(ed);
clickMock.mockRestore();
});
test('exportHTML 返回 editor(链式)', () => {
const clickMock = jest
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {});
expect(ed.exportHTML('test.html')).toBe(ed);
clickMock.mockRestore();
});
test('exportHTML 生成完整 HTML 文档', () => {
const clickMock = jest
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {});
let capturedBlob = null;
const origCreate = URL.createObjectURL;
URL.createObjectURL = (blob) => {
capturedBlob = blob;
return 'blob:mock';
};
ed.exportHTML('doc.html', { title: 'My Doc', css: 'body{color:red}' });
URL.createObjectURL = origCreate;
clickMock.mockRestore();
expect(capturedBlob).not.toBeNull();
expect(capturedBlob.type).toBe('text/html;charset=utf-8');
});
});
describe('searchReplace 插件 - 完整流程', () => {
let ed;
beforeEach(() => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
ed = new MarkdownEditor(c, { value: 'foo bar foo baz foo' });
ed.use(presetPlugins.searchReplace);
});
afterEach(() => ed.destroy());
function openPanel(showReplace = false) {
const ev = new KeyboardEvent('keydown', {
key: showReplace ? 'h' : 'f',
ctrlKey: true,
bubbles: true,
cancelable: true,
});
ed.textarea.dispatchEvent(ev);
}
test('Ctrl+F 打开面板', () => {
openPanel();
expect(ed.el.querySelector('.me-search')).not.toBeNull();
});
test('Ctrl+H 打开替换面板', () => {
openPanel(true);
const panel = ed.el.querySelector('.me-search');
expect(panel).not.toBeNull();
expect(panel.dataset.replace).toBe('1');
});
test('面板已打开时 Ctrl+F 复用面板', () => {
openPanel();
const panel1 = ed.el.querySelector('.me-search');
openPanel();
const panel2 = ed.el.querySelector('.me-search');
expect(ed.el.querySelectorAll('.me-search').length).toBe(1);
expect(panel2).toBe(panel1);
});
test('面板已打开时 Ctrl+H 切换到替换模式', () => {
openPanel(false);
expect(ed.el.querySelector('.me-search').dataset.replace).toBe('0');
openPanel(true);
expect(ed.el.querySelector('.me-search').dataset.replace).toBe('1');
});
test('Escape 在 findInput 上关闭面板', () => {
openPanel();
const input = ed.el.querySelector('.me-search-find');
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(ed.el.querySelector('.me-search')).toBeNull();
});
test('查找下一个高亮匹配', () => {
openPanel();
const input = ed.el.querySelector('.me-search-find');
input.value = 'foo';
input.dispatchEvent(new Event('input', { bubbles: true }));
ed.el.querySelector('.me-search-next').click();
expect(ed.textarea.selectionStart).toBeGreaterThanOrEqual(0);
});
test('查找上一个高亮匹配', () => {
openPanel();
const input = ed.el.querySelector('.me-search-find');
input.value = 'foo';
input.dispatchEvent(new Event('input', { bubbles: true }));
ed.el.querySelector('.me-search-next').click();
ed.el.querySelector('.me-search-prev').click();
expect(ed.textarea.selectionStart).toBeGreaterThanOrEqual(0);
});
test('空查询不抛错', () => {
openPanel();
expect(() => ed.el.querySelector('.me-search-next').click()).not.toThrow();
});
test('关闭按钮移除面板', () => {
openPanel();
ed.el.querySelector('.me-search-close').click();
expect(ed.el.querySelector('.me-search')).toBeNull();
});
test('Enter 在 findInput 上查找下一个', () => {
openPanel();
const input = ed.el.querySelector('.me-search-find');
input.value = 'foo';
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
expect(ed.textarea.selectionStart).toBeGreaterThanOrEqual(0);
});
test('Shift+Enter 在 findInput 上查找上一个', () => {
openPanel();
const input = ed.el.querySelector('.me-search-find');
input.value = 'foo';
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', shiftKey: true, bubbles: true }));
expect(ed.textarea.selectionStart).toBeGreaterThanOrEqual(0);
});
test('替换行 display 切换(none ↔ flex', () => {
openPanel(false);
const row = ed.el.querySelector('.me-search-replace-row');
expect(row.style.display).toBe('none');
openPanel(true);
expect(row.style.display).toBe('flex');
});
test('选中区域文本自动填入查找框', () => {
ed.textarea.setSelectionRange(0, 3); // 选中 "foo"
openPanel();
const input = ed.el.querySelector('.me-search-find');
expect(input.value).toBe('foo');
});
test('destroy 移除面板', () => {
openPanel();
const panelCount = ed.el.querySelectorAll('.me-search').length;
expect(panelCount).toBe(1);
ed.destroy();
expect(ed.el).toBeNull();
});
test('注入样式只执行一次', () => {
const styleBefore = document.getElementById('me-search-style');
const id1 = styleBefore ? styleBefore.id : null;
openPanel();
openPanel();
const styles = document.querySelectorAll('#me-search-style');
expect(styles.length).toBe(1);
});
});
describe('pluginUtils - 默认管理器代理', () => {
test('register / unregister / get / has / getNames', () => {
const p = { name: 'proxy-test', install: () => {}, destroy: () => {} };
pluginUtils.register('proxy-test', p);
expect(pluginUtils.has('proxy-test')).toBe(true);
// get 返回包装后的对象(含 enabled 字段),不是原始 p
const got = pluginUtils.get('proxy-test');
expect(got).not.toBeNull();
expect(got.name).toBe('proxy-test');
expect(got.enabled).toBe(true);
expect(pluginUtils.getNames()).toContain('proxy-test');
pluginUtils.unregister('proxy-test');
expect(pluginUtils.has('proxy-test')).toBe(false);
});
test('getAll 返回数组', () => {
const p = { name: 'arr-test', install: () => {} };
pluginUtils.register('arr-test', p);
const all = pluginUtils.getAll();
expect(Array.isArray(all)).toBe(true);
expect(all.some((x) => x.name === 'arr-test')).toBe(true);
pluginUtils.unregister('arr-test');
});
test('enable / disable / isEnabled', () => {
const p = { name: 'en-test', install: () => {} };
pluginUtils.register('en-test', p);
expect(pluginUtils.isEnabled('en-test')).toBe(true);
pluginUtils.disable('en-test');
expect(pluginUtils.isEnabled('en-test')).toBe(false);
pluginUtils.enable('en-test');
expect(pluginUtils.isEnabled('en-test')).toBe(true);
pluginUtils.unregister('en-test');
});
test('manager 暴露默认管理器实例', () => {
expect(pluginUtils.manager).toBeDefined();
expect(typeof pluginUtils.manager.register).toBe('function');
});
test('createManager 返回新管理器实例', () => {
const m = pluginUtils.createManager();
expect(m).not.toBe(pluginUtils.manager);
expect(typeof m.register).toBe('function');
});
test('register 返回 this(链式)', () => {
const m = pluginUtils.createManager();
const p = { name: 'chain', install: () => {} };
expect(m.register('chain', p)).toBe(m);
});
test('重复注册 warn 但不抛错', () => {
const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const m = pluginUtils.createManager();
const p1 = { name: 'dup', install: () => {} };
const p2 = { name: 'dup', install: () => {} };
m.register('dup', p1);
m.register('dup', p2);
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
test('unregister / enable / disable 返回 this(链式)', () => {
const m = pluginUtils.createManager();
const p = { name: 'ret', install: () => {} };
m.register('ret', p);
expect(m.unregister('ret')).toBe(m);
m.register('ret2', p);
expect(m.enable('ret2')).toBe(m);
expect(m.disable('ret2')).toBe(m);
});
test('get 未知返回 null', () => {
expect(pluginUtils.get('non-existent')).toBeNull();
});
test('isEnabled 未知返回 false', () => {
expect(pluginUtils.isEnabled('non-existent')).toBe(false);
});
test('disable 后 get 仍返回对象(enabled=false', () => {
const m = pluginUtils.createManager();
const p = { name: 'dis', install: () => {} };
m.register('dis', p);
m.disable('dis');
const got = m.get('dis');
expect(got).not.toBeNull();
expect(got.enabled).toBe(false);
m.enable('dis');
expect(m.get('dis').enabled).toBe(true);
});
test('destroy 清空所有插件', () => {
const m = pluginUtils.createManager();
m.register('p1', { name: 'p1', install: () => {} });
m.register('p2', { name: 'p2', install: () => {} });
m.destroy();
expect(m.getNames()).toEqual([]);
});
});
describe('createPlugin - 边界', () => {
test('无参数也能创建', () => {
const p = pluginUtils.createPlugin();
expect(p).toBeDefined();
expect(p.name).toBe('custom');
expect(typeof p.install).toBe('function');
expect(typeof p.destroy).toBe('function');
});
test('install 非函数时回退为空函数', () => {
const p = pluginUtils.createPlugin({ name: 'x', install: 'notfn' });
expect(typeof p.install).toBe('function');
});
test('destroy 非函数时回退为空函数', () => {
const p = pluginUtils.createPlugin({ name: 'x', destroy: 'notfn' });
expect(typeof p.destroy).toBe('function');
});
test('保留用户传入的额外字段', () => {
const p = pluginUtils.createPlugin({
name: 'x',
install: () => {},
customField: 'value',
});
expect(p.customField).toBe('value');
});
});
describe('零散分支补全', () => {
test('PluginManager.register 无效插件对象 error', () => {
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
const m = pluginUtils.createManager();
m.register('null', null);
m.register('num', 123);
// name 为 falsy 且 plugin 无 name 才触发无效分支
m.register(undefined, { install: () => {} });
m.register('', { install: () => {} });
expect(spy).toHaveBeenCalled();
expect(m.get('null')).toBeNull();
expect(m.get('num')).toBeNull();
expect(m.get(undefined)).toBeNull();
expect(m.get('')).toBeNull();
spy.mockRestore();
});
test('autoSave save 内部抛错时 warn 不崩溃', () => {
document.body.innerHTML = '';
const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'test' });
ed.use('autoSave');
// 让 editor.getValue 抛错,触发 save 的 catch 分支
jest.spyOn(ed, 'getValue').mockImplementation(() => { throw new Error('get failed'); });
const plugin = ed.getPlugins().find((p) => p.name === 'autoSave');
expect(() => plugin._save()).not.toThrow();
expect(spy).toHaveBeenCalled();
ed.destroy();
spy.mockRestore();
});
test('restoreDraft localStorage.getItem 抛错时返回 null', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'init' });
ed.use('autoSave');
const original = localStorage.getItem;
localStorage.getItem = jest.fn(() => { throw new Error('read error'); });
expect(ed.restoreDraft()).toBeNull();
expect(ed.getValue()).toBe('init');
localStorage.getItem = original;
ed.destroy();
});
test('searchReplace textarea 上 Escape 触发 _close', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'hello world' });
ed.use(presetPlugins.searchReplace);
const plugin = ed.getPlugins().find((p) => p.name === 'searchReplace');
// Ctrl+F 打开面板
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true }));
expect(ed.el.querySelector('.me-search')).not.toBeNull();
expect(plugin._panel).toBeDefined();
// spy _close 验证 textarea 上 Ctrl+Escape 分支被触发
// 注:plugins.js 中 escape 分支在 if (!mod) return 之后,必须带 Ctrl/Meta
const closeSpy = jest.spyOn(plugin, '_close');
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', ctrlKey: true, bubbles: true }));
expect(closeSpy).toHaveBeenCalled();
closeSpy.mockRestore();
ed.destroy();
});
test('searchReplace replaceInput Enter 替换当前匹配', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'foo bar foo' });
ed.use(presetPlugins.searchReplace);
// Ctrl+H 打开替换面板
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'h', ctrlKey: true, bubbles: true }));
const findInput = ed.el.querySelector('.me-search-find');
const replaceInput = ed.el.querySelector('.me-search-replace');
findInput.value = 'foo';
findInput.dispatchEvent(new Event('input', { bubbles: true }));
replaceInput.value = 'baz';
// 先查找下一个选中第一个 foo
ed.el.querySelector('.me-search-next').click();
// 在 replaceInput 上按 Enter 触发 replaceOne
replaceInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
expect(ed.getValue()).toContain('baz');
ed.destroy();
});
test('searchReplace replaceInput Escape 关闭面板', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'hello' });
ed.use(presetPlugins.searchReplace);
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'h', ctrlKey: true, bubbles: true }));
const replaceInput = ed.el.querySelector('.me-search-replace');
replaceInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(ed.el.querySelector('.me-search')).toBeNull();
ed.destroy();
});
});
+433
View File
@@ -0,0 +1,433 @@
/**
* themes.js 单元测试
* 覆盖主题系统:解析 / 切换 / 存储 / 监听 / 自定义主题注册
*
* 注意:themes.js 使用模块级状态(currentTheme / themeListeners),
* 测试间需要重置状态以避免相互影响。
*/
import {
themeUtils,
getSystemTheme,
resolveTheme,
getThemeConfig,
applyTheme,
getCurrentTheme,
getResolvedTheme,
switchTheme,
toggleTheme,
resetToAuto,
saveTheme,
loadTheme,
initTheme,
watchSystemTheme,
unwatchSystemTheme,
addThemeListener,
removeThemeListener,
clearThemeListeners,
registerTheme,
unregisterTheme,
getAllThemes,
getThemeNames,
hasTheme,
getTheme,
setThemeVariables,
presetThemes,
createThemeManager,
} from '../src/themes.js';
import { THEMES } 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]); },
};
}
// jsdom 默认无 matchMedia,需 mock 以测试 watchSystemTheme
const mockMatchMedia = (matches = false) => ({
matches,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
});
describe('主题解析', () => {
test('resolveTheme 非 auto 原样返回', () => {
expect(resolveTheme('light')).toBe('light');
expect(resolveTheme('dark')).toBe('dark');
expect(resolveTheme('warm')).toBe('warm');
});
test('resolveTheme auto 返回系统主题', () => {
// currentTheme 默认为 'auto',调用 resolveTheme('auto') 会返回 getSystemTheme()
const r = resolveTheme('auto');
expect(['light', 'dark']).toContain(r);
});
test('getTheme 是 resolveTheme 的别名', () => {
expect(getTheme('light')).toBe(resolveTheme('light'));
});
test('getSystemTheme 返回 light 或 dark', () => {
const t = getSystemTheme();
expect(['light', 'dark']).toContain(t);
});
test('getThemeConfig 已知主题返回配置对象', () => {
const c = getThemeConfig('light');
expect(c).toBe(THEMES.light);
expect(c.bg).toBeDefined();
expect(c.accent).toBeDefined();
});
test('getThemeConfig 未知主题回退到 light', () => {
const c = getThemeConfig('unknown-theme');
expect(c).toBe(THEMES.light);
});
});
describe('主题切换与应用', () => {
beforeEach(() => {
clearThemeListeners();
resetToAuto();
localStorage.clear();
});
test('applyTheme 设置 currentTheme', () => {
applyTheme('dark');
expect(getCurrentTheme()).toBe('dark');
});
test('applyTheme 在 documentElement 上设置 data-md-theme 属性', () => {
applyTheme('dark');
expect(document.documentElement.getAttribute('data-md-theme')).toBe('dark');
});
test('applyTheme 切换 md-theme-* class', () => {
applyTheme('light');
expect(document.documentElement.classList.contains('md-theme-light')).toBe(true);
expect(document.documentElement.classList.contains('md-theme-dark')).toBe(false);
applyTheme('dark');
expect(document.documentElement.classList.contains('md-theme-dark')).toBe(true);
expect(document.documentElement.classList.contains('md-theme-light')).toBe(false);
});
test('applyTheme 设置 CSS 变量', () => {
applyTheme('dark');
const root = document.documentElement;
expect(root.style.getPropertyValue('--md-bg')).toBe(THEMES.dark.bg);
expect(root.style.getPropertyValue('--md-accent')).toBe(THEMES.dark.accent);
});
test('switchTheme 应用并持久化', () => {
switchTheme('dark');
expect(getCurrentTheme()).toBe('dark');
expect(localStorage.getItem('metona-editor-theme')).toBe('dark');
});
test('toggleTheme 在 light/dark 间切换', () => {
switchTheme('light');
toggleTheme();
expect(getCurrentTheme()).toBe('dark');
toggleTheme();
expect(getCurrentTheme()).toBe('light');
});
test('resetToAuto 切回 auto', () => {
switchTheme('dark');
resetToAuto();
expect(getCurrentTheme()).toBe('auto');
});
test('getResolvedTheme 返回解析后的实际主题', () => {
switchTheme('dark');
expect(getResolvedTheme()).toBe('dark');
switchTheme('light');
expect(getResolvedTheme()).toBe('light');
});
});
describe('主题持久化', () => {
beforeEach(() => {
localStorage.clear();
resetToAuto();
});
test('saveTheme 写入 localStorage', () => {
saveTheme('dark');
expect(localStorage.getItem('metona-editor-theme')).toBe('dark');
});
test('loadTheme 默认返回 auto', () => {
expect(loadTheme()).toBe('auto');
});
test('loadTheme 读取已保存的值', () => {
localStorage.setItem('metona-editor-theme', 'dark');
expect(loadTheme()).toBe('dark');
});
test('initTheme 加载已保存的主题', () => {
localStorage.setItem('metona-editor-theme', 'dark');
initTheme();
expect(getCurrentTheme()).toBe('dark');
});
test('initTheme 无保存时默认 auto', () => {
initTheme();
expect(getCurrentTheme()).toBe('auto');
});
});
describe('主题监听', () => {
beforeEach(() => {
clearThemeListeners();
resetToAuto();
});
test('addThemeListener 注册回调', () => {
const fn = jest.fn();
addThemeListener(fn);
switchTheme('dark');
expect(fn).toHaveBeenCalledWith('dark', 'dark');
});
test('addThemeListener 返回取消函数', () => {
const fn = jest.fn();
const unsub = addThemeListener(fn);
expect(typeof unsub).toBe('function');
unsub();
switchTheme('dark');
expect(fn).not.toHaveBeenCalled();
});
test('removeThemeListener 移除回调', () => {
const fn = jest.fn();
addThemeListener(fn);
removeThemeListener(fn);
switchTheme('dark');
expect(fn).not.toHaveBeenCalled();
});
test('clearThemeListeners 清空所有回调', () => {
const fn1 = jest.fn();
const fn2 = jest.fn();
addThemeListener(fn1);
addThemeListener(fn2);
clearThemeListeners();
switchTheme('dark');
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(() => {});
addThemeListener(errFn);
addThemeListener(okFn);
switchTheme('dark');
expect(errFn).toHaveBeenCalled();
expect(okFn).toHaveBeenCalled();
spy.mockRestore();
});
});
describe('系统主题监听', () => {
beforeEach(() => {
unwatchSystemTheme();
resetToAuto();
});
afterEach(() => {
unwatchSystemTheme();
});
test('watchSystemTheme 无 matchMedia 时不抛错', () => {
const orig = window.matchMedia;
delete window.matchMedia;
expect(() => watchSystemTheme()).not.toThrow();
window.matchMedia = orig;
});
test('watchSystemTheme 注册 matchMedia 监听', () => {
const orig = window.matchMedia;
const mql = mockMatchMedia(false);
window.matchMedia = jest.fn(() => mql);
watchSystemTheme();
expect(mql.addEventListener).toHaveBeenCalledWith('change', expect.any(Function));
window.matchMedia = orig;
});
test('unwatchSystemTheme 移除监听', () => {
const orig = window.matchMedia;
const mql = mockMatchMedia(false);
window.matchMedia = jest.fn(() => mql);
watchSystemTheme();
unwatchSystemTheme();
expect(mql.removeEventListener).toHaveBeenCalledWith('change', expect.any(Function));
window.matchMedia = orig;
});
test('重复 watchSystemTheme 替换旧监听', () => {
const orig = window.matchMedia;
const mql1 = mockMatchMedia(false);
const mql2 = mockMatchMedia(false);
let callCount = 0;
window.matchMedia = jest.fn(() => {
callCount += 1;
return callCount === 1 ? mql1 : mql2;
});
watchSystemTheme();
watchSystemTheme();
expect(mql1.removeEventListener).toHaveBeenCalled();
window.matchMedia = orig;
});
});
describe('自定义主题注册', () => {
beforeEach(() => {
resetToAuto();
});
test('registerTheme 注册新主题', () => {
registerTheme('ocean', {
bg: '#001122',
text: '#aabbcc',
accent: '#00ddff',
});
expect(hasTheme('ocean')).toBe(true);
const c = getThemeConfig('ocean');
expect(c.bg).toBe('#001122');
expect(c.accent).toBe('#00ddff');
// 未提供字段从 light 主题继承
expect(c.muted).toBe(THEMES.light.muted);
});
test('unregisterTheme 移除自定义主题', () => {
registerTheme('tmp', { bg: '#000' });
expect(hasTheme('tmp')).toBe(true);
unregisterTheme('tmp');
expect(hasTheme('tmp')).toBe(false);
});
test('unregisterTheme 拒绝移除内置主题', () => {
const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
unregisterTheme('light');
expect(spy).toHaveBeenCalled();
expect(hasTheme('light')).toBe(true);
spy.mockRestore();
});
test('getAllThemes 返回所有主题副本', () => {
const all = getAllThemes();
expect(all.light).toEqual(THEMES.light);
expect(all.dark).toEqual(THEMES.dark);
// 副本:修改不影响原始
all.light = null;
expect(THEMES.light).toBeDefined();
});
test('getThemeNames 返回主题名数组', () => {
const names = getThemeNames();
expect(names).toContain('light');
expect(names).toContain('dark');
expect(names).toContain('auto');
expect(names).toContain('warm');
});
test('hasTheme 检查主题存在', () => {
expect(hasTheme('light')).toBe(true);
expect(hasTheme('non-existent')).toBe(false);
});
});
describe('setThemeVariables', () => {
test('设置 CSS 变量到 documentElement', () => {
setThemeVariables({
bg: '#fff',
text: '#000',
border: '#eee',
shadow: 'none',
hoverShadow: 'none',
accent: '#abc',
});
const root = document.documentElement;
expect(root.style.getPropertyValue('--md-bg')).toBe('#fff');
expect(root.style.getPropertyValue('--md-accent')).toBe('#abc');
});
test('空 config 不抛错', () => {
expect(() => setThemeVariables(null)).not.toThrow();
expect(() => setThemeVariables(undefined)).not.toThrow();
});
test('部分字段使用默认值', () => {
setThemeVariables({ bg: '#fafafa' });
const root = document.documentElement;
expect(root.style.getPropertyValue('--md-bg')).toBe('#fafafa');
// 未提供的 accent 使用默认
expect(root.style.getPropertyValue('--md-accent')).toBe('#3b82f6');
});
});
describe('themeUtils 代理层', () => {
test('暴露所有主题 API', () => {
[
'getSystemTheme', 'resolveTheme', 'getThemeConfig', 'applyTheme',
'getCurrentTheme', 'getResolvedTheme', 'switchTheme', 'toggleTheme',
'resetToAuto', 'initTheme', 'watchSystemTheme', 'unwatchSystemTheme',
'addThemeListener', 'removeThemeListener', 'clearThemeListeners',
'registerTheme', 'unregisterTheme', 'getAllThemes', 'getThemeNames',
'hasTheme', 'saveTheme', 'loadTheme',
].forEach((fn) => {
expect(typeof themeUtils[fn]).toBe('function');
});
});
test('代理方法与具名导出一致', () => {
expect(themeUtils.getCurrentTheme).toBe(getCurrentTheme);
expect(themeUtils.switchTheme).toBe(switchTheme);
});
});
describe('presetThemes', () => {
test('包含 light/dark/auto/warm', () => {
expect(presetThemes.light).toBeDefined();
expect(presetThemes.dark).toBeDefined();
expect(presetThemes.auto).toBeDefined();
expect(presetThemes.warm).toBeDefined();
});
test('auto 主题 config 为字符串 "auto"', () => {
expect(presetThemes.auto.config).toBe('auto');
});
test('light/dark/warm 主题 config 为对象', () => {
expect(typeof presetThemes.light.config).toBe('object');
expect(typeof presetThemes.dark.config).toBe('object');
expect(typeof presetThemes.warm.config).toBe('object');
});
});
describe('createThemeManager', () => {
test('返回包含全部方法的管理器对象', () => {
const m = createThemeManager();
expect(typeof m.switchTheme).toBe('function');
expect(typeof m.getCurrentTheme).toBe('function');
expect(typeof m.registerTheme).toBe('function');
});
test('管理器方法可独立调用', () => {
const m = createThemeManager();
m.switchTheme('dark');
expect(m.getCurrentTheme()).toBe('dark');
});
});
+246
View File
@@ -0,0 +1,246 @@
/**
* utils.js 单元测试
* 覆盖所有工具函数
*/
import {
generateId,
escapeHTML,
prefersDark,
debounce,
throttle,
deepMerge,
isBrowser,
formatFileSize,
sleep,
} from '../src/utils.js';
describe('generateId', () => {
test('返回字符串', () => {
expect(typeof generateId()).toBe('string');
});
test('以 me- 前缀开头', () => {
expect(generateId()).toMatch(/^me-/);
});
test('多次调用产生不同值', () => {
const ids = new Set();
for (let i = 0; i < 100; i++) ids.add(generateId());
expect(ids.size).toBe(100);
});
});
describe('escapeHTML', () => {
test('转义 < > &', () => {
const out = escapeHTML('<a>&b</a>');
expect(out).toContain('&lt;');
expect(out).toContain('&gt;');
expect(out).toContain('&amp;');
expect(out).not.toContain('<a>');
});
test('null/undefined 转空字符串', () => {
expect(escapeHTML(null)).toBe('');
expect(escapeHTML(undefined)).toBe('');
});
test('数字被转为字符串', () => {
expect(escapeHTML(123)).toBe('123');
});
test('无特殊字符原样返回', () => {
expect(escapeHTML('hello world')).toBe('hello world');
});
});
describe('prefersDark', () => {
test('返回布尔值', () => {
expect(typeof prefersDark()).toBe('boolean');
});
});
describe('debounce', () => {
test('延迟执行', (done) => {
let called = 0;
const fn = debounce(() => { called++; }, 50);
fn();
expect(called).toBe(0);
setTimeout(() => {
expect(called).toBe(1);
done();
}, 100);
});
test('多次调用只执行最后一次', (done) => {
let result = 0;
const fn = debounce((v) => { result = v; }, 50);
fn(1);
fn(2);
fn(3);
setTimeout(() => {
expect(result).toBe(3);
done();
}, 100);
});
test('immediate 模式立即执行', () => {
let called = 0;
const fn = debounce(() => { called++; }, 50, true);
fn();
expect(called).toBe(1);
});
test('保留 this 上下文', (done) => {
const obj = {
val: 42,
getVal: debounce(function () { return this.val; }, 30),
};
const p = obj.getVal();
// immediate=false 时首次不返回,但 this 应正确
setTimeout(() => {
expect(typeof p).toBe('undefined'); // debounce 返回的是 undefined(异步执行)
done();
}, 60);
});
test('传递参数', (done) => {
let captured = null;
const fn = debounce((a, b) => { captured = a + b; }, 30);
fn(1, 2);
setTimeout(() => {
expect(captured).toBe(3);
done();
}, 60);
});
});
describe('throttle', () => {
test('首次立即执行', () => {
let called = 0;
const fn = throttle(() => { called++; }, 50);
fn();
expect(called).toBe(1);
});
test('限流期内不重复执行', () => {
let called = 0;
const fn = throttle(() => { called++; }, 50);
fn();
fn();
fn();
expect(called).toBe(1);
});
test('限流期后可再次执行', (done) => {
let called = 0;
const fn = throttle(() => { called++; }, 40);
fn();
setTimeout(() => {
fn();
expect(called).toBe(2);
done();
}, 60);
});
test('保留 this 与参数', (done) => {
const obj = {
n: 10,
run: throttle(function (x) { this.result = this.n + x; }, 30),
};
obj.run(5);
expect(obj.result).toBe(15);
done();
});
});
describe('deepMerge', () => {
test('浅层合并', () => {
const out = deepMerge({ a: 1, b: 2 }, { b: 3, c: 4 });
expect(out).toEqual({ a: 1, b: 3, c: 4 });
});
test('深层对象合并', () => {
const out = deepMerge({ obj: { x: 1, y: 2 } }, { obj: { y: 3, z: 4 } });
expect(out).toEqual({ obj: { x: 1, y: 3, z: 4 } });
});
test('source 覆盖 target 非对象值', () => {
const out = deepMerge({ a: { x: 1 } }, { a: 'str' });
expect(out.a).toBe('str');
});
test('不修改原始 target', () => {
const target = { a: { x: 1 } };
const source = { a: { y: 2 } };
deepMerge(target, source);
expect(target.a).toEqual({ x: 1 });
});
test('多层嵌套合并', () => {
const out = deepMerge(
{ a: { b: { c: 1, d: 2 } } },
{ a: { b: { d: 3, e: 4 } } }
);
expect(out).toEqual({ a: { b: { c: 1, d: 3, e: 4 } } });
});
});
describe('isBrowser', () => {
test('jsdom 环境下返回 true', () => {
expect(isBrowser()).toBe(true);
});
});
describe('formatFileSize', () => {
test('0 返回 0 Bytes', () => {
expect(formatFileSize(0)).toBe('0 Bytes');
});
test('Bytes 档位', () => {
expect(formatFileSize(512)).toBe('512 Bytes');
});
test('KB 档位', () => {
expect(formatFileSize(1024)).toBe('1 KB');
expect(formatFileSize(1536)).toBe('1.5 KB');
});
test('MB 档位', () => {
expect(formatFileSize(1048576)).toBe('1 MB');
});
test('GB 档位', () => {
expect(formatFileSize(1073741824)).toBe('1 GB');
});
test('小数位数控制', () => {
expect(formatFileSize(1536, 0)).toBe('2 KB');
expect(formatFileSize(1536, 3)).toBe('1.5 KB');
});
test('负 decimals 当作 0', () => {
expect(formatFileSize(1536, -1)).toBe('2 KB');
});
});
describe('sleep', () => {
test('返回 Promise', () => {
expect(sleep(10)).toBeInstanceOf(Promise);
});
test('延迟后 resolve', (done) => {
const start = Date.now();
sleep(50).then(() => {
const elapsed = Date.now() - start;
expect(elapsed).toBeGreaterThanOrEqual(40);
done();
});
});
test('async/await 用法', async () => {
const start = Date.now();
await sleep(30);
expect(Date.now() - start).toBeGreaterThanOrEqual(20);
});
});