Files
MetonaToast/tests/coverage.test.ts
T
tianhao 95f5652e9c fix: CJS 产物改 .cjs 扩展名并提升 default 导出 — 修复 require() 兼容性
- 包声明 type:module 时 .js 产物被 Node 按 ESM 解析:Node 16/18 下 require 报 ERR_REQUIRE_ESM,Node 20+ 拿到 namespace 对象(success/version 等属性 undefined)
- dist/metona-toast.cjs.js → .cjs,dist/react.cjs.js → .cjs
- 主包 CJS footer 将 default 提升为 module.exports:require() 直接返回 MeToast(兼容 .default / .MeToast / .Met / .Toast / .VERSION 所有用法)
- 版本 0.5.1(0.5.0 已发布带问题产物)
- 真实消费者验证:CJS/ESM/React 子包四路加载全部正常
2026-08-08 15:25:47 +08:00

1744 lines
60 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.5.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);
});
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.5.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();
});
});
// ==================================================================
// 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');
});
// === 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);
});
});
// ==================================================================
// 13. v0.5.0 异常路径与边界覆盖
// ==================================================================
describe('v0.5.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();
});
describe('i18n.ts 异常与 fallback', () => {
const i18n = require('../src/i18n.js');
test('t fallback 到 fallbackLocale', () => {
i18n.setFallbackLocale('en-US');
i18n.removeTranslation('zh-CN', 'retry');
expect(i18n.t('retry')).toBe('Retry');
i18n.addTranslations('zh-CN', { retry: '重试' });
i18n.setFallbackLocale('zh-CN');
});
test('removeTranslation 中间层级缺失提前返回', () => {
i18n.addTranslations('ja', { a: { b: 'x' } });
expect(() => i18n.removeTranslation('ja', 'a.nonexistent.c')).not.toThrow();
});
test('saveLocale localStorage 异常被捕获', () => {
const spy = jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('quota'); });
expect(() => i18n.saveLocale('en-US')).not.toThrow();
spy.mockRestore();
});
test('loadLocale localStorage 异常返回默认', () => {
const spy = jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new Error('quota'); });
expect(typeof i18n.loadLocale()).toBe('string');
spy.mockRestore();
});
test('getDefaultLocale 不支持浏览器语言时回退', () => {
const orig = Object.getOwnPropertyDescriptor(navigator, 'language');
Object.defineProperty(navigator, 'language', { value: 'zz-ZZ', configurable: true });
expect(typeof i18n.getDefaultLocale()).toBe('string');
if (orig) Object.defineProperty(navigator, 'language', orig);
});
test('locale listener 异常不中断其他监听器', () => {
const bad = () => { throw new Error('listener-err'); };
const good = jest.fn();
i18n.addLocaleListener(bad);
i18n.addLocaleListener(good);
expect(() => i18n.switchLocale('en-US')).not.toThrow();
expect(good).toHaveBeenCalled();
i18n.removeLocaleListener(bad);
i18n.removeLocaleListener(good);
i18n.switchLocale('zh-CN');
});
test('Intl 异常时 format 系列降级返回原始值', () => {
const origIntl = (global as Record<string, unknown>).Intl;
(global as Record<string, unknown>).Intl = new Proxy(origIntl as object, {
get: (t: Record<string, unknown>, p: string) => {
if (['NumberFormat', 'DateTimeFormat', 'RelativeTimeFormat', 'PluralRules'].includes(p)) {
return class { constructor() { throw new RangeError('invalid'); } };
}
return (t as Record<string, unknown>)[p];
},
});
expect(i18n.formatNumber(1)).toBe('1');
expect(i18n.formatCurrency(1)).toBe('1');
expect(i18n.formatPercent(50)).toBe('50%');
expect(i18n.formatDate('2026-01-01')).toBe('2026-01-01');
expect(typeof i18n.formatRelativeTime(Date.now())).toBe('string');
expect(i18n.formatPlural(5)).toBe('other');
(global as Record<string, unknown>).Intl = origIntl;
});
test('formatList Intl.ListFormat 缺失时逗号拼接', () => {
const origLF = (Intl as unknown as Record<string, unknown>).ListFormat;
(Intl as unknown as Record<string, unknown>).ListFormat = undefined;
const result = i18n.formatList(['a', 'b']);
expect(result).toBe('a, b');
(Intl as unknown as Record<string, unknown>).ListFormat = origLF;
});
test('plural 缺失时返回原 key', () => {
expect(i18n.plural('missing_plural_key_xyz', 5)).toBe('missing_plural_key_xyz');
});
});
describe('themes.ts 异常与监听', () => {
const themes = require('../src/themes.js');
test('saveTheme/loadTheme localStorage 异常被捕获', () => {
const spy = jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('quota'); });
expect(() => themes.saveTheme('dark')).not.toThrow();
spy.mockRestore();
const spy2 = jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new Error('quota'); });
expect(themes.loadTheme()).toBe('auto');
spy2.mockRestore();
});
test('watchSystemTheme 重复调用先移除旧监听', () => {
expect(() => { themes.watchSystemTheme(); themes.watchSystemTheme(); }).not.toThrow();
themes.unwatchSystemTheme();
});
test('unwatchSystemTheme 清理监听', () => {
themes.watchSystemTheme();
expect(() => themes.unwatchSystemTheme()).not.toThrow();
});
test('theme listener 异常不中断其他监听器', () => {
const bad = () => { throw new Error('theme-listener-err'); };
const good = jest.fn();
themes.addThemeListener(bad);
themes.addThemeListener(good);
expect(() => themes.switchTheme('dark')).not.toThrow();
expect(good).toHaveBeenCalled();
themes.removeThemeListener(bad);
themes.removeThemeListener(good);
themes.switchTheme('auto');
});
test('removeThemeCSS 移除已存在样式', () => {
themes.applyThemeCSS('dark');
expect(() => themes.removeThemeCSS()).not.toThrow();
});
});
describe('styles.ts 边界', () => {
const styles = require('../src/styles.js');
test('applyThemeVariables 先移除旧样式再追加', () => {
const config = { bg: '#fff', text: '#000', border: '#ccc', shadow: 'none', hoverShadow: 'none', progressBg: '#eee', closeHoverBg: '#ddd' };
styles.applyThemeVariables(config);
styles.applyThemeVariables(config);
const els = document.querySelectorAll('#metona-toast-custom-styles');
expect(els.length).toBeLessThanOrEqual(1);
styles.clearThemeVariables();
});
test('watchSystemTheme 无 matchMedia 时返回空函数', () => {
const orig = (window as unknown as Record<string, unknown>).matchMedia;
(window as unknown as Record<string, unknown>).matchMedia = undefined;
const unsub = styles.watchSystemTheme(jest.fn());
expect(unsub).toBeInstanceOf(Function);
(window as unknown as Record<string, unknown>).matchMedia = orig;
});
});
describe('api.ts use 预设插件', () => {
test('use persistence 从 localStorage 恢复配置', () => {
localStorage.setItem('metona-toast-config', JSON.stringify({ duration: 3000 }));
MeToast.use('persistence');
expect(MeToast.getConfig().duration).toBe(3000);
MeToast.plugins.unregister('persistence');
localStorage.removeItem('metona-toast-config');
MeToast.resetConfig();
});
test('use accessibility 注册朗读钩子', () => {
const announceSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
MeToast.use('accessibility');
MeToast.success('announce-test');
MeToast.plugins.unregister('accessibility');
announceSpy.mockRestore();
});
test('重复 use persistence 不重复注册保存钩子', () => {
localStorage.removeItem('metona-toast-config');
MeToast.use('persistence');
MeToast.use('persistence');
MeToast.plugins.unregister('persistence');
expect(Toast._hooks.has('afterClose')).toBe(false);
});
});
});