Initial commit: MetonaEditor v0.1.0
This commit is contained in:
@@ -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 始终返回 0(CSS 动画由浏览器管理)', () => {
|
||||
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
File diff suppressed because it is too large
Load Diff
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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('<script>');
|
||||
});
|
||||
});
|
||||
|
||||
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('<a>');
|
||||
});
|
||||
|
||||
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('');
|
||||
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('<script>');
|
||||
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('');
|
||||
expect(html).toContain('src="data:image/png;base64,abc"');
|
||||
});
|
||||
|
||||
test('图片 javascript: 协议被过滤', () => {
|
||||
const html = parseMarkdown(')');
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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('<');
|
||||
expect(out).toContain('>');
|
||||
expect(out).toContain('&');
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user