- React 适配器子包:@metona-team/metona-toast/react — useToast() hook + 声明式 <Toast /> 组件,组件卸载自动清理;react 为 optional peerDependency,主包保持零依赖 - loading 链式转换改原地 update:id 稳定不重建 DOM,转换后 duration 恢复默认自动关闭;update() 支持 duration 变更动态重启计时器 - _emit 幽灵实例修复:beforeShow 拦截后的 toast 不再注册进 _toasts - dragThreshold 配置项(默认 120px,原硬编码);RTL 容器 dir 属性 + 进度条/side 条/色条镜像 - dedupe 预设插件:相同 type+message 自动去重,支持 uninstall - jest 环境隔离测试独立成 tests/ssr.test.ts(resetModules 不再污染共享模块状态);新增 tests/setup.ts 补 TextEncoder - 文档:README/docs.html 补 React 适配器、dedupe、dragThreshold;CHANGELOG 更新;328 测试全过
1571 lines
53 KiB
TypeScript
1571 lines
53 KiB
TypeScript
/**
|
||
* MetonaToast 覆盖率补充测试 — 目标 95%+
|
||
* @module tests
|
||
* @version 0.4.0
|
||
*/
|
||
|
||
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);
|
||
});
|
||
|
||
test('ensureAnimationCSS 内置动画不注入', () => {
|
||
const { ensureAnimationCSS } = require('../src/animations.js');
|
||
// jsdom 真实 DOM:内置动画由 styles.ts 统一注入,不应产生独立 style 标签
|
||
ensureAnimationCSS('slide');
|
||
expect(document.getElementById('metona-toast-anim-styles')).toBeNull();
|
||
});
|
||
|
||
test('ensureAnimationCSS 自定义动画注入样式(幂等)', () => {
|
||
const { ensureAnimationCSS } = require('../src/animations.js');
|
||
MeToast.animations.destroy(); // 清理模块注入状态,保证测试独立
|
||
MeToast.animations.register('my-anim', {
|
||
enter: { opacity: 0, transform: 'scale(0.5)' },
|
||
leave: { opacity: 1 },
|
||
duration: 400,
|
||
easing: 'ease',
|
||
});
|
||
ensureAnimationCSS('my-anim');
|
||
const styleEl = document.getElementById('metona-toast-anim-styles');
|
||
expect(styleEl).not.toBeNull();
|
||
expect(styleEl!.textContent).toContain('@keyframes met-my-anim-in');
|
||
expect(styleEl!.textContent).toContain('.met-anim-my-anim.met-toast.met-show');
|
||
// 幂等:二次调用不重复追加
|
||
const before = styleEl!.textContent!.length;
|
||
ensureAnimationCSS('my-anim');
|
||
expect(styleEl!.textContent!.length).toBe(before);
|
||
MeToast.animations.unregister('my-anim');
|
||
MeToast.animations.reset();
|
||
});
|
||
|
||
test('ensureAnimationCSS 未注册动画不注入', () => {
|
||
const { ensureAnimationCSS } = require('../src/animations.js');
|
||
// 清理 jsdom 中可能残留的动态 style 元素
|
||
document.getElementById('metona-toast-anim-styles')?.remove();
|
||
ensureAnimationCSS('not-registered');
|
||
expect(document.getElementById('metona-toast-anim-styles')).toBeNull();
|
||
});
|
||
|
||
test('hasAnimation 检查内置与自定义动画', () => {
|
||
const { hasAnimation } = require('../src/animations.js');
|
||
expect(hasAnimation('slide')).toBe(true);
|
||
expect(hasAnimation('nonexistent')).toBe(false);
|
||
MeToast.animations.register('has-anim-test', { enter: {}, leave: {}, duration: 300 });
|
||
expect(hasAnimation('has-anim-test')).toBe(true);
|
||
MeToast.animations.unregister('has-anim-test');
|
||
});
|
||
|
||
test('自定义动画注册后 _buildClassName 使用该动画', () => {
|
||
MeToast.animations.register('custom-anim', {
|
||
enter: { opacity: 0 },
|
||
leave: { opacity: 1 },
|
||
duration: 400,
|
||
easing: 'ease',
|
||
});
|
||
const t = MeToast.info({ message: 'custom', animation: 'custom-anim' });
|
||
expect(t.el?.className).toContain('met-anim-custom-anim');
|
||
MeToast.animations.unregister('custom-anim');
|
||
});
|
||
|
||
test('未注册动画名 fallback 到 slide', () => {
|
||
const t = new Toast({ message: 'test', animation: 'unknown-anim' });
|
||
t.create();
|
||
expect(t.el?.className).toContain('met-anim-slide');
|
||
});
|
||
});
|
||
|
||
// ==================================================================
|
||
// 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.4.0');
|
||
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 每秒 tick,1s 后会完成
|
||
}, 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 覆盖率补充(SSR 分支见 tests/ssr.test.ts — 独立环境隔离)
|
||
// ==================================================================
|
||
|
||
// ==================================================================
|
||
// 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 异常路径(详见 tests/ssr.test.ts) ===
|
||
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');
|
||
});
|
||
|
||
// === 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');
|
||
});
|
||
|
||
});
|
||
|
||
// ==================================================================
|
||
// 11. v0.3.0 修复验证
|
||
// ==================================================================
|
||
describe('v0.3.0 修复验证', () => {
|
||
beforeEach(() => {
|
||
jest.clearAllMocks();
|
||
MeToast._toasts.clear();
|
||
MeToast.resetConfig();
|
||
(MeToast as unknown as { _destroyed?: boolean })._destroyed = false;
|
||
Toast._hooks.clear();
|
||
Toast._registry.clear();
|
||
const { _containerCache } = require('../src/toast.js');
|
||
_containerCache.clear();
|
||
// 清理动画模块状态(injected 集合 + 动态 style 引用),再恢复默认动画
|
||
MeToast.animations.destroy();
|
||
MeToast.animations.reset();
|
||
});
|
||
|
||
test('_limitToasts 超出 max 时真正关闭最早的 toast', () => {
|
||
const t1 = MeToast.info('m1');
|
||
const t2 = MeToast.info('m2');
|
||
const el = createMockElement() as unknown as HTMLElement;
|
||
(el as unknown as { querySelectorAll: unknown }).querySelectorAll = jest.fn(() => [
|
||
{ dataset: { id: t1.id } },
|
||
{ dataset: { id: t2.id } },
|
||
]);
|
||
const newToast = new Toast({ message: 'm3', max: 2 });
|
||
newToast._limitToasts(el as unknown as HTMLElement);
|
||
expect(t1.closing).toBe(true);
|
||
expect(t2.closing).toBe(false);
|
||
});
|
||
|
||
test('beforeClose 钩子返回 false 阻止关闭', () => {
|
||
const t = MeToast.info('test');
|
||
const blocker = () => false;
|
||
Toast.on('beforeClose', blocker);
|
||
t.close();
|
||
expect(t.closing).toBe(false);
|
||
Toast.off('beforeClose', blocker);
|
||
});
|
||
|
||
test('beforeUpdate 钩子返回 false 阻止更新', () => {
|
||
const t = MeToast.info('原始');
|
||
const blocker = () => false;
|
||
Toast.on('beforeUpdate', blocker);
|
||
t.update({ message: '新消息' });
|
||
expect(t.message).toBe('原始');
|
||
Toast.off('beforeUpdate', blocker);
|
||
});
|
||
|
||
test('configure/updateConfig/resetConfig 触发 configChange 钩子', () => {
|
||
const fn = jest.fn();
|
||
Toast.on('configChange', fn);
|
||
MeToast.configure({ duration: 2500 });
|
||
MeToast.updateConfig({ gap: 8 });
|
||
MeToast.resetConfig();
|
||
expect(fn).toHaveBeenCalledTimes(3);
|
||
Toast.off('configChange', fn);
|
||
});
|
||
|
||
test('init 触发 beforeInit/afterInit 钩子', () => {
|
||
const calls: string[] = [];
|
||
const before = jest.fn(() => calls.push('beforeInit'));
|
||
const after = jest.fn(() => calls.push('afterInit'));
|
||
Toast.on('beforeInit', before);
|
||
Toast.on('afterInit', after);
|
||
MeToast.init();
|
||
expect(calls).toEqual(['beforeInit', 'afterInit']);
|
||
Toast.off('beforeInit', before);
|
||
Toast.off('afterInit', after);
|
||
});
|
||
|
||
test('destroy 触发 beforeDestroy/afterDestroy 钩子', () => {
|
||
const calls: string[] = [];
|
||
const before = jest.fn(() => calls.push('beforeDestroy'));
|
||
const after = jest.fn(() => calls.push('afterDestroy'));
|
||
Toast.on('beforeDestroy', before);
|
||
Toast.on('afterDestroy', after);
|
||
MeToast.destroy();
|
||
expect(calls).toEqual(['beforeDestroy', 'afterDestroy']);
|
||
});
|
||
|
||
test('destroy 清理全部钩子', () => {
|
||
const fn = jest.fn();
|
||
Toast.on('afterShow', fn);
|
||
MeToast.destroy();
|
||
MeToast.success('after-destroy');
|
||
expect(fn).not.toHaveBeenCalled();
|
||
MeToast.animations.reset();
|
||
});
|
||
|
||
test('destroy 清理实例注册表', () => {
|
||
MeToast.info('m1');
|
||
expect(Toast._registry.size).toBe(1);
|
||
MeToast.destroy();
|
||
expect(Toast._registry.size).toBe(0);
|
||
MeToast.animations.reset();
|
||
});
|
||
|
||
test('destroy 后 init 恢复可用', () => {
|
||
MeToast.destroy();
|
||
MeToast.init({});
|
||
const t = MeToast.success('revived');
|
||
expect(t).toBeDefined();
|
||
MeToast.animations.reset();
|
||
});
|
||
|
||
test('init plugins 支持插件对象', () => {
|
||
const install = jest.fn();
|
||
MeToast.init({ plugins: [{ name: 'obj-plugin', install }] });
|
||
expect(MeToast.plugins.has('obj-plugin')).toBe(true);
|
||
expect(install).toHaveBeenCalled();
|
||
MeToast.plugins.unregister('obj-plugin');
|
||
});
|
||
|
||
test('animate 生命周期钩子 progressStart/progressEnd 触发', (done) => {
|
||
const start = jest.fn();
|
||
const end = jest.fn();
|
||
Toast.on('progressStart', start);
|
||
Toast.on('progressEnd', end);
|
||
const t = MeToast.info({ message: 'timed', duration: 30 });
|
||
setTimeout(() => {
|
||
expect(start).toHaveBeenCalled();
|
||
Toast.off('progressStart', start);
|
||
Toast.off('progressEnd', end);
|
||
done();
|
||
}, 120);
|
||
});
|
||
});
|
||
|
||
// ==================================================================
|
||
// 12. v0.4.0 能力增强验证
|
||
// ==================================================================
|
||
describe('v0.4.0 能力增强', () => {
|
||
beforeEach(() => {
|
||
jest.clearAllMocks();
|
||
MeToast._toasts.clear();
|
||
MeToast.resetConfig();
|
||
(MeToast as unknown as { _destroyed?: boolean })._destroyed = false;
|
||
Toast._hooks.clear();
|
||
Toast._registry.clear();
|
||
const { _containerCache } = require('../src/toast.js');
|
||
_containerCache.clear();
|
||
MeToast.animations.destroy();
|
||
MeToast.animations.reset();
|
||
});
|
||
|
||
test('loading 链式转换 id 稳定(原地 update)', () => {
|
||
const l = MeToast.loading('正在加载');
|
||
const idBefore = l.id;
|
||
const toast = l.success('加载完成');
|
||
expect(toast).not.toBeNull();
|
||
expect(toast!.id).toBe(idBefore);
|
||
expect(toast!.type).toBe('success');
|
||
expect(toast!.message).toBe('加载完成');
|
||
expect(MeToast.count()).toBe(1);
|
||
});
|
||
|
||
test('loading 转换后 duration 恢复默认自动关闭', () => {
|
||
MeToast.configure({ duration: 4000 });
|
||
const l = MeToast.loading('loading');
|
||
const toast = l.error('失败');
|
||
expect(toast!.config.duration).toBe(4000);
|
||
MeToast.resetConfig();
|
||
});
|
||
|
||
test('loading 转换支持 opts 覆盖 duration', () => {
|
||
const l = MeToast.loading('loading');
|
||
const toast = l.warning('警告', { duration: 8000 });
|
||
expect(toast!.config.duration).toBe(8000);
|
||
});
|
||
|
||
test('update duration 变更重启计时器', () => {
|
||
const t = MeToast.info('test', { duration: 0 });
|
||
expect(t.rafId).toBeNull();
|
||
t.update({ duration: 5000 });
|
||
expect(t.config.duration).toBe(5000);
|
||
expect(t.rafId).not.toBeNull();
|
||
t.update({ duration: 0 });
|
||
expect(t.config.duration).toBe(0);
|
||
expect(t.rafId).toBeNull();
|
||
});
|
||
|
||
test('dragThreshold 配置生效且默认 120', () => {
|
||
expect(MeToast.getConfig().dragThreshold).toBe(120);
|
||
const t = MeToast.info('x', { dragThreshold: 200 });
|
||
expect(t.config.dragThreshold).toBe(200);
|
||
});
|
||
|
||
test('RTL 容器设置 dir 属性', () => {
|
||
const { getCurrentLocale, setCurrentLocale, addTranslations } = require('../src/i18n.js');
|
||
const originalLocale = getCurrentLocale();
|
||
// 'ar' 需先注册翻译,否则 setCurrentLocale fallback 回默认语言
|
||
addTranslations('ar', { close: 'إغلاق' });
|
||
setCurrentLocale('ar');
|
||
try {
|
||
const toast = new Toast({ message: 'test', position: 'top-left' });
|
||
const container = toast._getContainer();
|
||
expect(container.getAttribute('dir')).toBe('rtl');
|
||
toast.close(true);
|
||
} finally {
|
||
setCurrentLocale(originalLocale);
|
||
}
|
||
});
|
||
|
||
test('LTR 容器不设置 dir 属性', () => {
|
||
const { getCurrentLocale, setCurrentLocale } = require('../src/i18n.js');
|
||
const originalLocale = getCurrentLocale();
|
||
setCurrentLocale('zh-CN');
|
||
try {
|
||
const toast = new Toast({ message: 'test' });
|
||
const container = toast._getContainer();
|
||
expect(container.getAttribute('dir')).toBeNull();
|
||
toast.close(true);
|
||
} finally {
|
||
setCurrentLocale(originalLocale);
|
||
}
|
||
});
|
||
|
||
test('dedupe 插件同消息去重', () => {
|
||
MeToast.use('dedupe');
|
||
MeToast.info('same message');
|
||
MeToast.info('same message');
|
||
expect(MeToast.count()).toBe(1);
|
||
MeToast.info('different message');
|
||
expect(MeToast.count()).toBe(2);
|
||
MeToast.plugins.unregister('dedupe');
|
||
// 卸载后不再去重
|
||
MeToast._toasts.clear();
|
||
MeToast.info('same message');
|
||
MeToast.info('same message');
|
||
expect(MeToast.count()).toBe(2);
|
||
});
|
||
|
||
test('dedupe 插件不同 type 不去重', () => {
|
||
MeToast.use('dedupe');
|
||
MeToast.info('msg');
|
||
MeToast.success('msg');
|
||
expect(MeToast.count()).toBe(2);
|
||
MeToast.plugins.unregister('dedupe');
|
||
});
|
||
|
||
test('dedupe 插件 uninstall 移除钩子', () => {
|
||
MeToast.use('dedupe');
|
||
MeToast.plugins.unregister('dedupe');
|
||
MeToast._toasts.clear();
|
||
MeToast.info('m');
|
||
MeToast.info('m');
|
||
expect(MeToast.count()).toBe(2);
|
||
});
|
||
});
|