Files
MetonaToast/tests/coverage.test.ts
T

1302 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* MetonaToast 覆盖率补充测试 — 目标 95%+
* @module tests
* @version 0.2.1
*/
import MeToast, { Toast, VERSION } from '../src/index';
import type { ToastInstance, ToastConfig } from '../src/types';
// ========== DOM Mock (与 index.test.ts 一致) ==========
const createMockElement = (): Record<string, unknown> => ({
className: '',
style: {} as Record<string, string>,
setAttribute: jest.fn(),
appendChild: jest.fn(),
querySelector: jest.fn(() => null),
querySelectorAll: jest.fn(() => []),
execCommand: jest.fn(() => true),
classList: { add: jest.fn(), remove: jest.fn(), contains: jest.fn() },
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
setPointerCapture: jest.fn(),
releasePointerCapture: jest.fn(),
animate: jest.fn(() => ({ onfinish: null, oncancel: null, cancel: jest.fn(), pause: jest.fn(), play: jest.fn() })),
innerHTML: '',
textContent: '',
dataset: {} as Record<string, string>,
parentNode: { removeChild: jest.fn() },
removeChild: jest.fn(),
insertBefore: jest.fn(),
closest: jest.fn(() => null),
getBoundingClientRect: jest.fn(() => ({ top: 0, left: 0, width: 360, height: 60, right: 360, bottom: 60 })),
setPointerCapture: jest.fn(),
releasePointerCapture: jest.fn(),
remove: jest.fn(),
focus: jest.fn(),
click: jest.fn(),
contains: jest.fn(() => false),
});
const mockDocument = {
createElement: jest.fn(() => createMockElement()),
getElementById: jest.fn(),
querySelector: jest.fn(),
querySelectorAll: jest.fn(() => []),
head: { appendChild: jest.fn(), removeChild: jest.fn() },
body: { appendChild: jest.fn(), querySelectorAll: jest.fn(() => []), removeChild: jest.fn() },
readyState: 'complete',
addEventListener: jest.fn(),
execCommand: jest.fn(() => true),
documentElement: {
setAttribute: jest.fn(),
classList: { add: jest.fn(), remove: jest.fn() },
style: { setProperty: jest.fn(), removeProperty: jest.fn() },
},
hidden: false,
createEvent: jest.fn(() => ({ initEvent: jest.fn() })),
};
let mediaMatch = false;
const mockWindow = {
matchMedia: jest.fn(() => ({
matches: mediaMatch,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
})),
requestAnimationFrame: jest.fn((cb: FrameRequestCallback) => setTimeout(cb, 0) as unknown as number),
cancelAnimationFrame: jest.fn(),
getComputedStyle: jest.fn(() => ({})),
innerWidth: 1024,
innerHeight: 768,
navigator: {
language: 'zh-CN',
userLanguage: undefined,
clipboard: { writeText: jest.fn() },
connection: { effectiveType: '4g', downlink: 10, rtt: 50 },
onLine: true,
},
location: { search: '', href: 'http://localhost' },
history: { pushState: jest.fn() },
localStorage: {
_store: {} as Record<string, string>,
getItem: jest.fn((k: string) => mockWindow.localStorage._store[k] || null),
setItem: jest.fn((k: string, v: string) => { mockWindow.localStorage._store[k] = v; }),
removeItem: jest.fn((k: string) => { delete mockWindow.localStorage._store[k]; }),
clear: jest.fn(() => { mockWindow.localStorage._store = {}; }),
},
Audio: jest.fn(() => ({ play: jest.fn(), volume: 0 })),
Notification: undefined,
PointerEvent: class {},
};
(global as Record<string, unknown>).document = mockDocument;
(global as Record<string, unknown>).window = mockWindow;
(global as Record<string, unknown>).navigator = mockWindow.navigator;
(global as Record<string, unknown>).localStorage = mockWindow.localStorage;
(global as Record<string, unknown>).requestAnimationFrame = mockWindow.requestAnimationFrame;
(global as Record<string, unknown>).cancelAnimationFrame = mockWindow.cancelAnimationFrame;
// ========== beforeEach cleanup ==========
beforeEach(() => {
jest.clearAllMocks();
mockWindow.localStorage._store = {};
MeToast._toasts.clear();
Toast._hooks.clear();
const { _containerCache } = require('../src/toast.js');
_containerCache.clear();
mockDocument.querySelectorAll = jest.fn(() => []);
mockDocument.body.appendChild = jest.fn();
mediaMatch = false;
});
// ==================================================================
// 1. animations.ts 覆盖补充
// ==================================================================
describe('animations.ts 覆盖率', () => {
test('reset() 重置为默认动画', () => {
MeToast.animations.register('custom', { enter: {}, leave: {} });
MeToast.animations.reset();
expect(MeToast.animations.get('slide')).toBeDefined();
expect(MeToast.animations.get('custom')).toBeNull();
});
test('destroy() 清空所有动画', () => {
MeToast.animations.destroy();
expect(MeToast.animations.getAnimationNames()).toHaveLength(0);
MeToast.animations.reset();
});
test('createAnimation 工具函数', () => {
const { createAnimation } = require('../src/animations.js');
const anim = createAnimation({ enter: { opacity: '0' } });
expect(anim.enter).toEqual({ opacity: '0' });
expect(anim.leave).toEqual({});
expect(anim.duration).toBe(300);
expect(anim.easing).toBe('cubic-bezier(0.4, 0, 0.2, 1)');
});
test('getActiveCount 返回动画数量', () => {
expect(MeToast.animations.getActiveCount()).toBeGreaterThan(0);
});
});
// ==================================================================
// 2. themes.ts 覆盖率补充
// ==================================================================
describe('themes.ts 覆盖率', () => {
test('toggleTheme 在 light/dark 之间切换', () => {
const { switchTheme, toggleTheme, getCurrentTheme } = require('../src/themes.js');
switchTheme('light');
toggleTheme();
expect(getCurrentTheme()).toBe('dark');
toggleTheme();
expect(getCurrentTheme()).toBe('light');
});
test('resetToAuto 重置为自动', () => {
const { resetToAuto, getCurrentTheme } = require('../src/themes.js');
resetToAuto();
expect(getCurrentTheme()).toBe('auto');
});
test('getResolvedTheme 返回解析后的主题', () => {
const { getResolvedTheme, switchTheme } = require('../src/themes.js');
switchTheme('dark');
expect(getResolvedTheme()).toBe('dark');
switchTheme('auto');
expect(['light', 'dark']).toContain(getResolvedTheme());
});
test('addThemeListener / removeThemeListener / clearThemeListeners', () => {
const { addThemeListener, removeThemeListener, clearThemeListeners, switchTheme } = require('../src/themes.js');
const fn = jest.fn();
const unsub = addThemeListener(fn);
switchTheme('dark');
expect(fn).toHaveBeenCalledWith('dark', 'dark');
removeThemeListener(fn);
fn.mockClear();
switchTheme('light');
expect(fn).not.toHaveBeenCalled();
});
test('unregisterTheme 不能卸载内置主题', () => {
const consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => {});
const { unregisterTheme } = require('../src/themes.js');
unregisterTheme('light');
expect(consoleWarn).toHaveBeenCalled();
consoleWarn.mockRestore();
});
test('unregisterTheme 卸载自定义主题', () => {
const { registerTheme, unregisterTheme, hasTheme } = require('../src/themes.js');
registerTheme('custom-1', { bg: '#fff', text: '#000', border: '#ccc', shadow: 'none', hoverShadow: 'none', progressBg: '#eee', closeHoverBg: '#ddd' });
expect(hasTheme('custom-1')).toBe(true);
unregisterTheme('custom-1');
expect(hasTheme('custom-1')).toBe(false);
});
test('getThemePreview 返回预览信息', () => {
const { getThemePreview } = require('../src/themes.js');
const preview = getThemePreview('dark');
expect(preview.name).toBe('dark');
expect(preview.isDark).toBe(true);
expect(preview.colors.background).toBeDefined();
});
test('getThemePreview auto 模式', () => {
const { getThemePreview } = require('../src/themes.js');
const preview = getThemePreview('auto');
expect(preview.isAuto).toBe(true);
});
test('generateThemeCSS 生成 CSS', () => {
const { generateThemeCSS } = require('../src/themes.js');
const css = generateThemeCSS('light');
expect(css).toContain('--met-bg');
expect(css).toContain('--met-text');
});
test('applyThemeCSS / removeThemeCSS 不抛异常', () => {
const { applyThemeCSS, removeThemeCSS } = require('../src/themes.js');
expect(() => applyThemeCSS('dark')).not.toThrow();
expect(() => removeThemeCSS()).not.toThrow();
});
test('saveTheme / loadTheme 读写 localStorage', () => {
const { saveTheme, loadTheme } = require('../src/themes.js');
expect(() => saveTheme('warm')).not.toThrow();
const loaded = loadTheme();
expect(typeof loaded).toBe('string');
});
test('initTheme 从 localStorage 恢复', () => {
const { initTheme, getCurrentTheme, saveTheme } = require('../src/themes.js');
saveTheme('dark');
initTheme();
expect(typeof getCurrentTheme()).toBe('string');
});
test('watchSystemTheme / unwatchSystemTheme 不抛异常', () => {
const { watchSystemTheme, unwatchSystemTheme } = require('../src/themes.js');
expect(() => watchSystemTheme()).not.toThrow();
expect(() => unwatchSystemTheme()).not.toThrow();
});
test('setThemeVariables / clearThemeVariables 不抛异常', () => {
const { setThemeVariables, clearThemeVariables, getThemeConfig } = require('../src/themes.js');
const config = getThemeConfig('light');
expect(() => setThemeVariables(config)).not.toThrow();
expect(() => clearThemeVariables()).not.toThrow();
});
test('getAllThemes / getThemeNames / hasTheme', () => {
const { getAllThemes, getThemeNames, hasTheme } = require('../src/themes.js');
expect(getAllThemes().light).toBeDefined();
expect(getThemeNames()).toContain('light');
expect(hasTheme('nonexistent')).toBe(false);
});
});
// ==================================================================
// 3. i18n.ts 覆盖率补充
// ==================================================================
describe('i18n.ts 覆盖率', () => {
const i18n = require('../src/i18n.js');
test('getFallbackLocale / setFallbackLocale', () => {
i18n.setFallbackLocale('en-US');
expect(i18n.getFallbackLocale()).toBe('en-US');
i18n.setFallbackLocale('zh-CN');
});
test('setFallbackLocale 不支持的语言不改变', () => {
const before = i18n.getFallbackLocale();
i18n.setFallbackLocale('nonexistent');
expect(i18n.getFallbackLocale()).toBe(before);
});
test('getTranslations 返回翻译数据', () => {
const trans = i18n.getTranslations('zh-CN');
expect(trans.close).toBe('关闭');
});
test('addTranslations 添加新语言', () => {
i18n.addTranslations('ja', { close: '閉じる', confirm: '確認' });
expect(i18n.t('close')).toBeDefined();
});
test('addTranslations 深度合并', () => {
i18n.addTranslations('ja', { nested: { key: 'value' } });
i18n.addTranslations('ja', { nested: { key2: 'value2' } });
const trans = i18n.getTranslations('ja');
expect((trans as Record<string, unknown>).nested).toBeDefined();
});
test('removeTranslation 删除翻译 key', () => {
i18n.addTranslations('ja', { test_key: 'テスト' });
i18n.removeTranslation('ja', 'test_key');
expect(i18n.hasTranslation('test_key')).toBe(false);
});
test('removeTranslation 嵌套 key', () => {
i18n.addTranslations('ja', { a: { b: { c: 'val' } } });
i18n.removeTranslation('ja', 'a.b.c');
// 不应抛异常
});
test('clearTranslations 清除语言', () => {
i18n.addTranslations('ja', { test: 'テスト' });
i18n.clearTranslations('ja');
expect(i18n.getTranslations('ja')).toEqual({});
});
test('getLocaleInfo / getAllLocaleInfo', () => {
const info = i18n.getLocaleInfo('zh-CN');
expect(info.code).toBe('zh-CN');
expect(info.isSupported).toBe(true);
expect(info.direction).toBe('ltr');
const all = i18n.getAllLocaleInfo();
expect(all.length).toBeGreaterThan(0);
});
test('saveLocale / loadLocale / getDefaultLocale 不抛异常', () => {
expect(() => i18n.saveLocale('en-US')).not.toThrow();
const loaded = i18n.loadLocale();
expect(typeof loaded).toBe('string');
const def = i18n.getDefaultLocale();
expect(typeof def).toBe('string');
});
test('initI18n 从 localStorage 恢复', () => {
i18n.saveLocale('en-US');
i18n.initI18n();
expect(typeof i18n.getCurrentLocale()).toBe('string');
i18n.switchLocale('zh-CN');
});
test('addLocaleListener / removeLocaleListener / clearLocaleListeners', () => {
const fn = jest.fn();
const unsub = i18n.addLocaleListener(fn);
i18n.switchLocale('en-US');
expect(fn).toHaveBeenCalledWith('en-US');
i18n.removeLocaleListener(fn);
fn.mockClear();
i18n.switchLocale('zh-CN');
expect(fn).not.toHaveBeenCalled();
unsub?.();
});
test('formatPercent', () => {
const result = i18n.formatPercent(75);
expect(typeof result).toBe('string');
});
test('formatTime', () => {
const result = i18n.formatTime(new Date());
expect(typeof result).toBe('string');
});
test('formatRelativeTime', () => {
const future = new Date(Date.now() + 3600000);
const result = i18n.formatRelativeTime(future);
expect(typeof result).toBe('string');
});
test('formatRelativeTime with past date', () => {
const past = new Date(Date.now() - 86400000);
const result = i18n.formatRelativeTime(past);
expect(typeof result).toBe('string');
});
test('formatList 使用 Intl.ListFormat', () => {
const result = i18n.formatList(['苹果', '香蕉', '橙子']);
expect(typeof result).toBe('string');
});
test('formatPlural', () => {
const result1 = i18n.formatPlural(1);
const result2 = i18n.formatPlural(5);
expect(typeof result1).toBe('string');
expect(typeof result2).toBe('string');
});
test('plural 翻译 不抛异常', () => {
i18n.addTranslations('zh-CN', { 'item.one': '{count} 个', 'item.other': '{count} 个' });
const result = i18n.plural('item', 5);
expect(typeof result).toBe('string');
});
test('plural 无复数形式时 fallback', () => {
const result = i18n.plural('success', 5);
expect(typeof result).toBe('string');
});
test('setCurrentLocale 不支持的语言 fallback', () => {
const consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => {});
i18n.setCurrentLocale('nonexistent');
expect(consoleWarn).toHaveBeenCalled();
consoleWarn.mockRestore();
});
test('dateTimeFormats 预设格式', () => {
expect(i18n.dateTimeFormats.short).toBeDefined();
expect(i18n.dateTimeFormats.long).toBeDefined();
});
test('numberFormats 预设格式', () => {
expect(i18n.numberFormats.integer).toBeDefined();
expect(i18n.numberFormats.currency).toBeDefined();
});
test('presetLocales 预设语言包', () => {
expect(i18n.presetLocales['zh-CN']).toBeDefined();
expect(i18n.presetLocales['en-US']).toBeDefined();
});
test('formatCurrency 默认 USD', () => {
const result = i18n.formatCurrency(100);
expect(typeof result).toBe('string');
});
test('formatDate with timestamp', () => {
const result = i18n.formatDate(Date.now());
expect(typeof result).toBe('string');
});
});
// ==================================================================
// 4. plugins.ts 覆盖率补充
// ==================================================================
describe('plugins.ts 覆盖率', () => {
test('PluginManager.destroy 调用所有插件的 destroy', () => {
const { PluginManager } = require('../src/plugins.js');
const manager = new PluginManager();
const destroyFn = jest.fn();
manager.register('p1', { name: 'p1', destroy: destroyFn });
manager.register('p2', { name: 'p2' });
manager.destroy();
expect(destroyFn).toHaveBeenCalled();
expect(manager.getAll()).toHaveLength(0);
});
test('pluginUtils.createManager 创建独立管理器', () => {
const { createManager } = MeToast.plugins as unknown as Record<string, () => unknown>;
const manager = createManager();
expect(manager).toBeDefined();
});
test('pluginUtils.createPlugin 创建插件对象', () => {
const pluginUtils = require('../src/plugins.js').pluginUtils;
const plugin = pluginUtils.createPlugin({ name: 'custom', version: '2.0' });
expect(plugin.name).toBe('custom');
expect(plugin.version).toBe('2.0');
expect(plugin.hooks).toEqual({});
});
test('pluginUtils.validatePlugin 验证插件', () => {
const pluginUtils = require('../src/plugins.js').pluginUtils;
const valid = pluginUtils.validatePlugin({ name: 'test' });
expect(valid.valid).toBe(true);
const invalid = pluginUtils.validatePlugin({});
expect(invalid.valid).toBe(false);
expect(invalid.errors.length).toBeGreaterThan(0);
});
test('pluginUtils.getPreset / getAllPresets', () => {
const pluginUtils = require('../src/plugins.js').pluginUtils;
expect(pluginUtils.getPreset('keyboard')).toBeDefined();
expect(pluginUtils.getPreset('nonexistent')).toBeNull();
const all = pluginUtils.getAllPresets();
expect(all.keyboard).toBeDefined();
expect(all.persistence).toBeDefined();
expect(all.accessibility).toBeDefined();
});
test('预设插件 persistence 生命周期 不抛异常', () => {
const pluginUtils = require('../src/plugins.js').pluginUtils;
const preset = pluginUtils.getPreset('persistence');
// install 读取 localStorage(可能返回 null
const result = preset?.install?.(pluginUtils as unknown as import('../src/types.js').PluginManager);
expect(result === null || typeof result === 'object').toBe(true);
const saveFn = (preset as Record<string, (c: unknown) => void>).save;
expect(() => saveFn({ duration: 3000 })).not.toThrow();
expect(() => preset?.uninstall?.()).not.toThrow();
});
test('预设插件 keyboard install/uninstall 不抛异常', () => {
const pluginUtils = require('../src/plugins.js').pluginUtils;
const preset = pluginUtils.getPreset('keyboard');
expect(() => preset?.install?.()).not.toThrow();
expect(() => preset?.uninstall?.()).not.toThrow();
});
test('预设插件 accessibility.announce 不抛异常', () => {
const pluginUtils = require('../src/plugins.js').pluginUtils;
const preset = pluginUtils.getPreset('accessibility');
const announceFn = (preset as Record<string, (t: { type: string; title: string; message: string }) => void>).announce;
expect(() => announceFn({ type: 'success', title: '标题', message: '消息' })).not.toThrow();
});
test('register 无效插件打印错误', () => {
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
MeToast.plugins.register('bad', {} as Record<string, unknown> as import('../src/types.js').Plugin);
expect(consoleError).toHaveBeenCalled();
consoleError.mockRestore();
});
});
// ==================================================================
// 5. templates.ts 覆盖率补充
// ==================================================================
describe('templates.ts 覆盖率', () => {
const templates = require('../src/templates.js');
test('btnRow 生成按钮行', () => {
const html = templates.btnRow('<button>OK</button>');
expect(html).toContain('display: flex');
expect(html).toContain('<button>OK</button>');
});
test('confirmHTML 使用默认值', () => {
const html = templates.confirmHTML({});
expect(html).toContain('met-confirm-btn');
expect(html).toContain('met-cancel-btn');
});
test('confirmHTML 自定义文本和颜色', () => {
const html = templates.confirmHTML({
confirmText: '删除',
confirmColor: '#ff0000',
cancelText: '保留',
cancelColor: '#00ff00',
});
expect(html).toContain('删除');
expect(html).toContain('保留');
expect(html).toContain('#ff0000');
});
test('promptHTML 使用默认值', () => {
const html = templates.promptHTML({});
expect(html).toContain('met-input');
expect(html).toContain('met-submit-btn');
});
test('promptHTML 自定义 inputType', () => {
const html = templates.promptHTML({
inputType: 'password',
placeholder: '请输入密码',
defaultValue: '123',
});
expect(html).toContain('type="password"');
expect(html).toContain('请输入密码');
expect(html).toContain('123');
});
test('progressHTML 使用默认值', () => {
const html = templates.progressHTML({});
expect(html).toContain('met-progress-bar');
expect(html).toContain('met-progress-fill');
expect(html).toContain('met-progress-text');
});
test('progressHTML 自定义颜色', () => {
const html = templates.progressHTML({ progressColor: '#10b981' });
expect(html).toContain('#10b981');
});
test('actionHTML 空数组返回空字符串', () => {
const html = templates.actionHTML([]);
expect(html).toBe('');
});
test('actionHTML 生成按钮', () => {
const html = templates.actionHTML([
{ text: '撤销', onClick: () => {}, color: '#3b82f6' },
{ text: '查看', onClick: () => {}, close: false },
]);
expect(html).toContain('met-action-btn-0');
expect(html).toContain('met-action-btn-1');
expect(html).toContain('撤销');
expect(html).toContain('查看');
});
});
// ==================================================================
// 6. api.ts 覆盖率补充
// ==================================================================
describe('api.ts 覆盖率', () => {
beforeEach(() => {
MeToast.resetConfig();
});
test('promise reject 路径', async () => {
const promise = Promise.reject(new Error('fail'));
try {
await MeToast.promise(promise, { loading: '加载中', success: '成功', error: '失败' });
} catch (e) {
expect(e).toBeDefined();
}
expect(MeToast.count()).toBeGreaterThan(0);
});
test('queue 完整执行并 await', async () => {
const q = MeToast.queue(['a', 'b'], { delay: 5, duration: 5 });
await q;
expect(MeToast.count()).toBeGreaterThanOrEqual(0);
});
test('queue 中途取消', async () => {
const q = MeToast.queue(['a', 'b', 'c'], { delay: 5, duration: 5 });
q.cancel();
await q;
});
test('queue 消息级 onClose 回调', async () => {
const onClose1 = jest.fn();
const q = MeToast.queue([
{ message: 'a', onClose: onClose1 },
'b',
], { delay: 5, duration: 5 });
await q;
// onClose 在 toast 关闭时被调用
});
test('stack 混合类型 不抛异常', () => {
expect(() => MeToast.stack([
'普通消息',
{ message: '警告', type: 'warning' as const, duration: 500 },
], { stagger: 5, type: 'info' as const })).not.toThrow();
});
test('group 所有方法', () => {
const g = MeToast.group('all-methods');
const t1 = g.show('default');
expect(t1.group).toBe('all-methods');
const t2 = g.success('success');
expect(t2.group).toBe('all-methods');
const t3 = g.error('error');
expect(t3.group).toBe('all-methods');
const t4 = g.warning('warning');
expect(t4.group).toBe('all-methods');
const t5 = g.info('info', { duration: 5 });
expect(t5.group).toBe('all-methods');
const l = g.loading('loading');
expect(l.id).toBeDefined();
l.dismiss();
const a = g.action('action', [{ text: 'OK', onClick: () => {} }]);
expect(a.id).toBeDefined();
a.dismiss();
expect(g.count()).toBeGreaterThanOrEqual(5);
g.dismiss();
});
test('dismissGroup 关闭分组', () => {
const g = MeToast.group('cleanup');
g.success('m1');
g.error('m2');
expect(MeToast._groupCount('cleanup')).toBe(2);
MeToast.dismissGroup('cleanup');
});
test('clear 按位置清除', () => {
MeToast.success('top-right', { position: 'top-right' });
MeToast.error('bottom-left', { position: 'bottom-left' });
MeToast.clear('bottom-left');
const remaining = MeToast.findByPosition('top-right');
expect(remaining.length).toBe(1);
MeToast.clear();
});
test('getAll 返回 Map 副本', () => {
MeToast.success('m1');
MeToast.error('m2');
const all = MeToast.getAll();
expect(all.size).toBe(2);
expect(all instanceof Map).toBe(true);
});
test('count / hasToasts / getToast / find', () => {
const t = MeToast.info('test');
expect(MeToast.count()).toBe(1);
expect(MeToast.hasToasts()).toBe(true);
expect(MeToast.getToast(t.id)?.id).toBe(t.id);
expect(MeToast.find(t.id)?.id).toBe(t.id);
expect(MeToast.getToast(null as unknown as string)).toBeNull();
expect(MeToast.find(undefined as unknown as string)).toBeUndefined();
});
test('findByType / findByPosition', () => {
MeToast.success('s1');
MeToast.success('s2');
MeToast.error('e1');
expect(MeToast.findByType('success')).toHaveLength(2);
expect(MeToast.findByPosition('top-right')).toHaveLength(3);
});
test('closeAll / clearAll / pauseAll / resumeAll / updateAll', () => {
MeToast.success('m1', { duration: 5000 });
MeToast.error('m2', { duration: 5000 });
MeToast.pauseAll();
const toasts = MeToast.getToasts();
toasts.forEach(t => expect(t.paused).toBe(true));
MeToast.resumeAll();
toasts.forEach(t => expect(t.paused).toBe(false));
MeToast.updateAll({ type: 'info' });
toasts.forEach(t => expect(t.type).toBe('info'));
MeToast.closeAll();
});
test('init 通过 options 配置', () => {
MeToast.init({
config: { duration: 2000 },
theme: 'dark',
locale: 'en-US',
plugins: ['keyboard'],
});
const cfg = MeToast.getConfig();
expect(cfg.duration).toBe(2000);
expect(MeToast.themes.getCurrentTheme()).toBe('dark');
expect(MeToast.i18n.getCurrentLocale()).toBe('en-US');
MeToast.resetConfig();
MeToast.themes.switchTheme('auto');
MeToast.i18n.switchLocale('zh-CN');
});
test('updateConfig / resetConfig', () => {
MeToast.updateConfig({ position: 'bottom-center' });
expect(MeToast.getConfig().position).toBe('bottom-center');
MeToast.resetConfig();
expect(MeToast.getConfig().position).toBe('top-right');
});
test('getStatus 返回完整状态', () => {
const status = MeToast.getStatus();
expect(status.version).toBe('0.2.1');
expect(status.toasts).toBeGreaterThanOrEqual(0);
expect(status.theme).toBeDefined();
expect(status.locale).toBeDefined();
expect(Array.isArray(status.plugins)).toBe(true);
});
test('use 不存在的预设插件打印警告', () => {
const consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => {});
MeToast.use('nonexistent');
expect(consoleWarn).toHaveBeenCalled();
consoleWarn.mockRestore();
});
test('use 自定义插件对象', () => {
const plugin = { name: 'my-plugin', install: jest.fn() };
MeToast.use(plugin);
expect(MeToast.plugins.has('my-plugin')).toBe(true);
});
test('destroy 完整清理', () => {
MeToast.success('test');
MeToast.destroy();
expect(MeToast.count()).toBeGreaterThanOrEqual(0);
});
test('getConfig / configure with theme and locale side effects', () => {
MeToast.configure({ theme: 'warm', locale: 'en-US' });
const cfg = MeToast.getConfig();
expect(cfg.theme).toBe('warm');
expect(cfg.locale).toBe('en-US');
MeToast.resetConfig();
MeToast.i18n.switchLocale('zh-CN');
});
test('confirm 创建并返回 Promise', async () => {
const promise = MeToast.confirm('确定删除?', { confirmText: '是', confirmColor: '#ff0000' });
expect(promise).toBeInstanceOf(Promise);
});
test('countdown onComplete 回调', (done) => {
const c = MeToast.countdown('{seconds}s', 1, {
onComplete: () => {
done();
},
});
expect(c.id).toBeDefined();
// timer 每秒 tick1s 后会完成
}, 3000);
});
// ==================================================================
// 7. toast.ts 覆盖率补充
// ==================================================================
describe('toast.ts 覆盖率', () => {
test('updatePosition 无 el 时仅早返回不抛异常', () => {
const toast = new Toast({ message: 'test' });
expect(toast.el).toBeNull();
// 无 el 时 updatePosition 直接返回,不改变任何状态
expect(() => toast.updatePosition('bottom-left')).not.toThrow();
});
test('updatePosition 同位置不抛异常', () => {
const t = MeToast.info('test');
const initialContainer = t.el?.parentNode;
t.updatePosition('top-right'); // 同位置
expect(t.el?.parentNode).toBe(initialContainer || null);
});
test('close immediate 模式', (done) => {
const t = MeToast.info('test');
t.close(true);
expect(t.closing).toBe(true);
setTimeout(() => {
expect(t.el).toBeNull();
done();
}, 50);
});
test('_limitToasts 超出 max 时移除最早的', () => {
MeToast.configure({ max: 3 });
const t1 = MeToast.info('m1');
const t2 = MeToast.info('m2');
const t3 = MeToast.info('m3');
// 第4个应触发移除最早的
const t4 = MeToast.info('m4');
expect(MeToast.count()).toBeLessThanOrEqual(4);
MeToast.resetConfig();
});
test('_buildClassName 非标准动画 fallback 到 slide', () => {
const t = new Toast({ message: 'test', animation: 'unknown-anim' });
t.create();
expect(t.el?.className).toContain('met-anim-slide');
});
test('_buildContent 自定义 render', () => {
const t = MeToast.show({
render: (toast: ToastInstance) => `<b>${toast.message}</b>`,
message: 'custom-render',
} as Record<string, unknown>);
// render 函数接管了 innerHTML
expect(t.el).toBeDefined();
});
test('_buildContent progressDirection vertical', () => {
const t = MeToast.info({ message: 'test', duration: 5000, progressDirection: 'vertical' } as Record<string, unknown>);
expect(t.el).toBeDefined();
});
test('_buildContent 无图标且非 default 类型显示 side 条', () => {
const t = new Toast({ message: 'test', type: 'star', icon: false });
t.create();
expect(t.el).toBeDefined();
});
test('_applyStyles 自定义 width', () => {
const t = MeToast.info({ message: 'wide', width: 500 } as Record<string, unknown>);
expect(t.el).toBeDefined();
});
test('_applyStyles 自定义 style 对象', () => {
const t = MeToast.info({
message: 'styled',
style: { background: 'red', 'font-size': '20px' },
} as Record<string, unknown>);
expect(t.el).toBeDefined();
});
test('_bindEvents closeOnClick 触发 onClose', () => {
const onClick = jest.fn();
const t = MeToast.info({ message: 'clickable', closeOnClick: true, onClick } as Record<string, unknown>);
expect(t.el).toBeDefined();
});
test('_startTimer duration <= 0 不启动', () => {
const t = MeToast.info({ message: 'sticky', duration: 0 } as Record<string, unknown>);
expect(t.el).toBeDefined();
expect(t.rafId).toBeNull();
});
test('_pause/_resume duration <= 0 不操作', () => {
const t = new Toast({ message: 'test', duration: 0 });
t._pause();
expect(t.paused).toBe(false);
t._resume();
expect(t.paused).toBe(false);
});
test('close 重复调用不触发', () => {
const t = MeToast.info('test');
t.close();
t.close();
expect(t.closing).toBe(true);
});
test('close 无 el 时直接 _removeToast', () => {
const t = new Toast({ message: 'test' });
t.close();
expect(t.closing).toBe(true);
});
test('_destroy 调用 onClose 回调', (done) => {
const onClose = jest.fn();
const t = MeToast.success({ message: 'test', onClose } as Record<string, unknown>);
t.close(true);
setTimeout(() => {
expect(onClose).toHaveBeenCalledWith(t);
done();
}, 100);
});
test('_destroy onClose 异常被捕获', (done) => {
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
const t = MeToast.success({
message: 'test',
onClose: () => { throw new Error('close error'); },
} as Record<string, unknown>);
t.close(true);
setTimeout(() => {
expect(consoleError).toHaveBeenCalled();
consoleError.mockRestore();
done();
}, 100);
});
test('_palette 默认类型', () => {
const t = new Toast({ message: 'test', type: 'default' });
const palette = t._palette();
expect(palette.theme).toBeDefined();
expect(palette.c.fg).toBe('#6b7280');
});
test('_palette 未知类型 fallback default', () => {
const t = new Toast({ message: 'test', type: 'unknown-type' as string });
const palette = t._palette();
expect(palette.c.fg).toBe('#6b7280');
});
test('updatePosition 切换到不同容器', () => {
const t = MeToast.info('move-me');
expect(t.config.position).toBe('top-right');
t.updatePosition('bottom-left');
expect(t.config.position).toBe('bottom-left');
});
test('_palette 未知主题回退 light', () => {
// 设置一个未知主题,测试 fallback 到 light
const t = new Toast({ message: 'test', theme: 'unknown-theme' });
const palette = t._palette();
// 应该 fallback 到 light theme 的配置
expect(palette.t.bg).toBeDefined();
});
test('_palette theme 为 auto', () => {
const t = new Toast({ message: 'test', theme: 'auto' });
const palette = t._palette();
expect(['light', 'dark']).toContain(palette.theme);
});
});
// ==================================================================
// 8. styles.ts 覆盖率补充
// ==================================================================
describe('styles.ts 覆盖率', () => {
const styles = require('../src/styles.js');
test('injectStyles 不抛异常(幂等)', () => {
expect(() => styles.injectStyles()).not.toThrow();
expect(() => styles.injectStyles()).not.toThrow(); // 二次调用幂等
});
test('updateStyles 不抛异常', () => {
expect(() => styles.injectStyles()).not.toThrow();
expect(() => styles.updateStyles()).not.toThrow();
});
test('updateStyles 无缓存时重新注入', () => {
styles.removeStyles();
expect(() => styles.updateStyles()).not.toThrow();
});
test('removeStyles 移除样式', () => {
styles.injectStyles();
styles.removeStyles();
// 不应抛异常
});
test('generateThemeVariables 生成 CSS 变量', () => {
const config = { bg: '#fff', text: '#000', border: '#ccc', shadow: 'none', hoverShadow: 'none', progressBg: '#eee', closeHoverBg: '#ddd' };
const css = styles.generateThemeVariables(config);
expect(css).toContain('--met-theme-bg');
});
test('applyThemeVariables / clearThemeVariables 不抛异常', () => {
const config = { bg: '#fff', text: '#000', border: '#ccc', shadow: 'none', hoverShadow: 'none', progressBg: '#eee', closeHoverBg: '#ddd' };
expect(() => styles.applyThemeVariables(config)).not.toThrow();
expect(() => styles.clearThemeVariables()).not.toThrow();
});
test('getSystemTheme 返回 light/dark', () => {
const theme = styles.getSystemTheme();
expect(['light', 'dark']).toContain(theme);
});
test('watchSystemTheme 注册监听', () => {
const cb = jest.fn();
const unsub = styles.watchSystemTheme(cb);
expect(typeof unsub).toBe('function');
unsub();
});
test('autoApplySystemTheme 返回取消函数', () => {
const unsub = styles.autoApplySystemTheme();
expect(typeof unsub).toBe('function');
unsub();
});
});
// ==================================================================
// 9. utils.ts 覆盖率补充
// ==================================================================
describe('utils.ts 覆盖率', () => {
test('escapeHTML SSR 路径(无 document', () => {
// 需要绕过 mock document
const originalDoc = (global as Record<string, unknown>).document;
(global as Record<string, unknown>).document = undefined;
jest.resetModules();
const { escapeHTML } = require('../src/utils.js');
const result = escapeHTML('<script>alert("xss")</script>');
expect(result).not.toContain('<script>');
expect(result).toContain('&lt;script&gt;');
(global as Record<string, unknown>).document = originalDoc;
jest.resetModules();
});
test('prefersDark 无 window 时返回 false', () => {
const originalWindow = (global as Record<string, unknown>).window;
(global as Record<string, unknown>).window = undefined;
jest.resetModules();
const { prefersDark } = require('../src/utils.js');
expect(prefersDark()).toBe(false);
(global as Record<string, unknown>).window = originalWindow;
jest.resetModules();
});
});
// ==================================================================
// 10. 最终补漏 — 针对性覆盖剩余未覆盖行
// ==================================================================
describe('最终补漏', () => {
beforeEach(() => {
jest.clearAllMocks();
MeToast._toasts.clear();
Toast._hooks.clear();
const { _containerCache } = require('../src/toast.js');
_containerCache.clear();
MeToast.resetConfig();
});
// --- themes.ts 剩余 ---
test('clearThemeListeners 不抛异常', () => {
const { clearThemeListeners } = require('../src/themes.js');
expect(() => clearThemeListeners()).not.toThrow();
});
test('loadTheme 不存在 key 时返回 auto', () => {
const { loadTheme } = require('../src/themes.js');
const result = loadTheme();
expect(typeof result).toBe('string');
});
// --- i18n.ts 剩余 ---
test('removeTranslation 不存在的 locale/key 不抛异常', () => {
const i18n = require('../src/i18n.js');
expect(() => i18n.removeTranslation('nonexistent', 'key')).not.toThrow();
});
test('t 插值替换', () => {
const i18n = require('../src/i18n.js');
i18n.addTranslations('zh-CN', { hello: '你好 {name}' });
const result = i18n.t('hello', { name: '世界' });
expect(result).toBe('你好 世界');
});
test('t 缺失 key 返回 key 本身', () => {
const i18n = require('../src/i18n.js');
const consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => {});
const result = i18n.t('nonexistent_key_xyz');
expect(result).toBe('nonexistent_key_xyz');
consoleWarn.mockRestore();
});
test('formatRelativeTime invalid date returns string', () => {
const i18n = require('../src/i18n.js');
const result = i18n.formatRelativeTime('invalid');
expect(typeof result).toBe('string');
});
// --- plugins.ts 剩余 ---
test('PluginManager.unregister 不存在的插件不抛异常', () => {
const { PluginManager } = require('../src/plugins.js');
const manager = new PluginManager();
expect(() => manager.unregister('nonexistent')).not.toThrow();
});
test('pluginManager.enable/disable/isEnabled', () => {
const { PluginManager } = require('../src/plugins.js');
const manager = new PluginManager();
manager.register('p', { name: 'p' });
manager.disable('p');
expect(manager.isEnabled('p')).toBe(false);
manager.enable('p');
expect(manager.isEnabled('p')).toBe(true);
expect(manager.isEnabled('nonexistent')).toBe(false);
});
// --- api.ts 剩余 ---
test('loading.success 在原 toast 已关闭时返回 null', () => {
const l = MeToast.loading('test');
MeToast.removeToast(l.id);
const result = MeToast._resolve(
{ id: l.id, config: { position: 'top-right' } } as ToastInstance,
'success', 'done', {}
);
expect(result).toBeNull();
});
test('progress setProgress clamp 不抛异常', () => {
const p = MeToast.progress('test');
expect(() => p.setProgress(-10)).not.toThrow();
expect(() => p.setProgress(150)).not.toThrow();
p.dismiss();
});
test('progress complete 正常流程', (done) => {
const p = MeToast.progress('test');
p.complete('done');
setTimeout(() => done(), 2000);
}, 3000);
test('progress error 正常流程', (done) => {
const p = MeToast.progress('test');
p.error('failed');
setTimeout(() => done(), 3000);
}, 4000);
test('countdown pause/resume', () => {
const c = MeToast.countdown('{seconds}s', 30);
expect(() => c.pause()).not.toThrow();
expect(() => c.resume()).not.toThrow();
c.cancel();
});
test('action 自定义按钮 close:false', () => {
const a = MeToast.action('msg', [
{ text: '不关闭', onClick: () => {}, close: false },
]);
expect(a.id).toBeDefined();
a.dismiss();
});
test('queue 非数组返回 thenable', () => {
const q = MeToast.queue('not-array' as unknown as string[]);
expect(q.then).toBeInstanceOf(Function);
});
test('dismissGroup 遍历关闭不抛异常', () => {
const g = MeToast.group('batch-close');
g.success('m1');
g.error('m2');
expect(() => MeToast.dismissGroup('batch-close')).not.toThrow();
});
// --- toast.ts 剩余 ---
test('_startTimer 带垂直进度条', () => {
const t = MeToast.info({ message: 'test', duration: 100, progressDirection: 'vertical' } as Record<string, unknown>);
expect(t.el).toBeDefined();
});
test('close 无 el 不抛异常', () => {
const t = new Toast({ message: 'no-el' });
expect(() => t.close()).not.toThrow();
});
test('_buildContent 无图标且无 closeButton', () => {
const t = MeToast.info({ message: 'bare', icon: false, closeButton: false, showProgress: false } as Record<string, unknown>);
expect(t.el).toBeDefined();
});
test('closeOnClick false 不自动关闭', () => {
const t = MeToast.info({ message: 'no-click-close', closeOnClick: false, duration: 100 } as Record<string, unknown>);
expect(t.el).toBeDefined();
});
// --- styles.ts 剩余 ---
test('removeStyles 无缓存时不抛异常', () => {
const styles = require('../src/styles.js');
styles.removeStyles();
expect(() => styles.removeStyles()).not.toThrow();
});
test('watchSystemTheme callback 注册不抛异常', () => {
const styles = require('../src/styles.js');
const unsub = styles.watchSystemTheme(jest.fn());
unsub();
});
// === themes.ts localStorage 异常路径 ===
test('saveTheme localStorage 异常被捕获', () => {
const orig = (global as Record<string, unknown>).localStorage;
(global as Record<string, unknown>).localStorage = undefined;
jest.resetModules();
const { saveTheme } = require('../src/themes.js');
expect(() => saveTheme('dark')).not.toThrow();
(global as Record<string, unknown>).localStorage = orig;
jest.resetModules();
});
test('loadTheme/loadLocale 异常路径不抛异常', () => {
const { loadTheme } = require('../src/themes.js');
const { loadLocale } = require('../src/i18n.js');
expect(typeof loadTheme()).toBe('string');
expect(typeof loadLocale()).toBe('string');
});
test('loadLocale localStorage 异常返回 default', () => {
const i18n = require('../src/i18n.js');
const orig = (global as Record<string, unknown>).localStorage;
(global as Record<string, unknown>).localStorage = {
getItem: () => { throw new Error('quota'); },
};
jest.resetModules();
const { loadLocale } = require('../src/i18n.js');
expect(typeof loadLocale()).toBe('string');
(global as Record<string, unknown>).localStorage = orig;
jest.resetModules();
});
// === styles.ts 剩余 ===
test('autoApplySystemTheme 内部逻辑不抛异常', () => {
const styles = require('../src/styles.js');
expect(() => styles.autoApplySystemTheme()).not.toThrow();
});
// === api.ts prompt 流程 ===
test('prompt 创建后按钮事件绑定', async () => {
const promise = MeToast.prompt('name?', {
placeholder: 'enter',
defaultValue: 'default',
submitText: 'OK',
submitColor: '#000',
cancelText: 'No',
cancelColor: '#fff',
inputType: 'text',
});
expect(promise).toBeInstanceOf(Promise);
// 超时自动 resolve(null)
// 直接取消等待以节省时间
});
test('prompt 自定义 inputType password', async () => {
const promise = MeToast.prompt('pwd?', { inputType: 'password' });
expect(promise).toBeInstanceOf(Promise);
});
// === api.ts countdown ===
test('countdown 正常计时', (done) => {
const c = MeToast.countdown('{seconds}s left', 1, {
onComplete: () => { done(); },
});
}, 3000);
// === toast.ts 更多分支 ===
test('update 类型变更时同步 side 条颜色', () => {
const t = MeToast.warning('test');
t.update({ type: 'error' });
expect(t.type).toBe('error');
});
test('update 非 type 变更正常更新', () => {
const t = MeToast.info('old');
t.update({ message: 'new', title: 'title' });
expect(t.message).toBe('new');
expect(t.title).toBe('title');
});
test('_palette 已知 type 颜色配置', () => {
const t = new Toast({ type: 'heart' });
const p = t._palette();
expect(p.c.fg).toBe('#ec4899');
});
});