diff --git a/site/docs.html b/site/docs.html
index ae39646..df53864 100644
--- a/site/docs.html
+++ b/site/docs.html
@@ -90,7 +90,7 @@
API 文档
-MetonaToast v0.4.0 完整 API 参考。所有基础通知方法(show/success/error/warning/info/loading)支持两种调用形式,均可传入任何 配置项 作为可选第二参数。
+MetonaToast v0.5.0 完整 API 参考。所有基础通知方法(show/success/error/warning/info/loading)支持两种调用形式,均可传入任何 配置项 作为可选第二参数。
show(message, opts?)
diff --git a/site/index.html b/site/index.html
index 81790c6..3ed1976 100644
--- a/site/index.html
+++ b/site/index.html
@@ -247,7 +247,7 @@ orderGroup.dismiss(); // 一键关闭
diff --git a/src/api.ts b/src/api.ts
index 2e02752..6dfe420 100644
--- a/src/api.ts
+++ b/src/api.ts
@@ -1,12 +1,11 @@
/**
* MetonaToast API — meToast 核心 API 对象
* @module api
- * @version 0.4.0
+ * @version 0.5.0
*/
import { Toast, _containerCache } from './toast.js';
import { DEFAULTS, VERSION } from './constants.js';
-import { escapeHTML } from './utils.js';
import { t, setCurrentLocale } from './i18n.js';
import { applyTheme } from './themes.js';
import { confirmHTML, promptHTML, progressHTML, actionHTML } from './templates.js';
@@ -395,8 +394,10 @@ const meToast: MeToast = {
actions.forEach((a, i) => {
const btn = toast.el?.querySelector(`.met-action-btn-${i}`) as HTMLElement | null;
if (btn && typeof a.onClick === 'function') {
- btn.addEventListener('click', () => {
- try { a.onClick(toast); } catch (e) { console.error('Action onClick error:', e); }
+ btn.addEventListener('click', (e: Event) => {
+ // 阻止冒泡到 toast 的 closeOnClick 处理器,由 close 选项控制是否关闭
+ e.stopPropagation();
+ try { a.onClick(toast); } catch (err) { console.error('Action onClick error:', err); }
if (a.close !== false) toast.close();
});
}
@@ -658,22 +659,30 @@ const meToast: MeToast = {
}
this.plugins.register(plugin, { ...preset, ...options });
- // 连接插件钩子
+ // 连接插件钩子(重复 use 前先卸载旧钩子,防止重复注册)
+ // 注意:off 引用存在已注册的插件对象上(stored),uninstall 时 this 即该对象
+ const stored = this.plugins.get(plugin) as (Record & Plugin) | null;
if (plugin === 'accessibility') {
- Toast.on('afterShow', (toast: ToastInstance) => {
- const acc = preset as Record void>;
- if (typeof acc.announce === 'function') acc.announce(toast);
- });
+ if (stored && typeof stored._off === 'function') { (stored._off as () => void)(); }
+ const acc = preset as Record void>;
+ if (stored) {
+ stored._off = Toast.on('afterShow', (toast: ToastInstance) => {
+ if (typeof acc.announce === 'function') acc.announce(toast);
+ });
+ }
}
if (plugin === 'persistence') {
+ if (stored && typeof stored._saveOff === 'function') { (stored._saveOff as () => void)(); }
const saved = typeof preset.install === 'function'
? preset.install(this.plugins as unknown as import('./types.js').PluginManager)
: null;
if (saved) this.configure(saved as Partial);
- Toast.on('afterClose', () => {
- const p = preset as Record void>;
- if (typeof p.save === 'function') p.save(this.getConfig());
- });
+ const p = preset as Record void>;
+ if (stored) {
+ stored._saveOff = Toast.on('afterClose', () => {
+ if (typeof p.save === 'function') p.save(this.getConfig());
+ });
+ }
}
} else if (plugin && typeof plugin === 'object') {
this.plugins.register(plugin.name || 'custom', { ...plugin, ...options } as Plugin);
diff --git a/src/constants.ts b/src/constants.ts
index e39346c..beaafbd 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -1,7 +1,7 @@
/**
* MetonaToast Constants — 常量定义
* @module constants
- * @version 0.4.0
+ * @version 0.5.0
*/
import { ICONS } from './icons.js';
@@ -12,7 +12,7 @@ export { ICONS, LOCALES };
/**
* 版本号 — 唯一来源,发布时只需修改此处
*/
-export const VERSION = '0.4.0';
+export const VERSION = '0.5.0';
/**
* 默认配置
diff --git a/src/i18n.ts b/src/i18n.ts
index 1efb5a4..19acf25 100644
--- a/src/i18n.ts
+++ b/src/i18n.ts
@@ -1,7 +1,7 @@
/**
* MetonaToast i18n — 国际化管理
* @module i18n
- * @version 0.4.0
+ * @version 0.5.0
*/
import { LOCALES } from './constants.js';
@@ -405,7 +405,7 @@ export const clearLocaleListeners = (): void => {
export const formatNumber = (number: number, options: Intl.NumberFormatOptions = {}): string => {
try {
return new Intl.NumberFormat(currentLocale, options).format(number);
- } catch (e) {
+ } catch {
return number.toString();
}
};
@@ -420,7 +420,7 @@ export const formatCurrency = (amount: number, currency = 'USD', options: Intl.N
currency,
...options,
}).format(amount);
- } catch (e) {
+ } catch {
return amount.toString();
}
};
@@ -436,7 +436,7 @@ export const formatPercent = (value: number, options: Intl.NumberFormatOptions =
maximumFractionDigits: 2,
...options,
}).format(value / 100);
- } catch (e) {
+ } catch {
return `${value}%`;
}
};
@@ -448,7 +448,7 @@ export const formatDate = (date: Date | number | string, options: Intl.DateTimeF
try {
const dateObj = date instanceof Date ? date : new Date(date);
return new Intl.DateTimeFormat(currentLocale, options).format(dateObj);
- } catch (e) {
+ } catch {
return String(date);
}
};
@@ -497,7 +497,7 @@ export const formatRelativeTime = (date: Date | number | string, options: Intl.R
}
return String(date);
- } catch (e) {
+ } catch {
return String(date);
}
};
@@ -524,7 +524,7 @@ export const formatList = (list: string[], _options: Record = {
export const formatPlural = (count: number, options: Intl.PluralRulesOptions = {}): string => {
try {
return new Intl.PluralRules(currentLocale, options).select(count);
- } catch (e) {
+ } catch {
return count === 1 ? 'one' : 'other';
}
};
diff --git a/src/icons.ts b/src/icons.ts
index 7b0c2d7..7d219d3 100644
--- a/src/icons.ts
+++ b/src/icons.ts
@@ -1,7 +1,7 @@
/**
* MetonaToast Icons — 图标SVG定义
* @module icons
- * @version 0.4.0
+ * @version 0.5.0
* @description 107 个内置 SVG 图标
*/
diff --git a/src/index.ts b/src/index.ts
index db60f63..6a75556 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,7 +1,7 @@
/**
* MetonaToast — 轻量级Toast通知库
* @module metona-toast
- * @version 0.4.0
+ * @version 0.5.0
* @author thzxx
* @license MIT
*/
diff --git a/src/locales.ts b/src/locales.ts
index 6017cef..1744f75 100644
--- a/src/locales.ts
+++ b/src/locales.ts
@@ -1,7 +1,7 @@
/**
* MetonaToast Locales — 国际化翻译数据
* @module locales
- * @version 0.4.0
+ * @version 0.5.0
* @description 内置 zh-CN / en-US 完整翻译
*/
diff --git a/src/plugins.ts b/src/plugins.ts
index 562bcae..ddf569f 100644
--- a/src/plugins.ts
+++ b/src/plugins.ts
@@ -1,7 +1,7 @@
/**
* MetonaToast Plugins — 插件系统
* @module plugins
- * @version 0.4.0
+ * @version 0.5.0
*/
import { t } from './i18n.js';
@@ -154,6 +154,12 @@ const presetPlugins: Record = {
localStorage.removeItem(key);
} catch (_e) { /* noop */ }
}
+ // 卸载 use() 注册的配置保存钩子
+ const meta = this as Record;
+ if (typeof meta._saveOff === 'function') {
+ (meta._saveOff as () => void)();
+ meta._saveOff = null;
+ }
},
},
@@ -176,6 +182,15 @@ const presetPlugins: Record = {
document.body.appendChild(el);
setTimeout(() => { if (el.parentNode) el.parentNode.removeChild(el); }, 3000);
},
+
+ uninstall(this: Plugin) {
+ // 卸载 use() 注册的朗读钩子
+ const meta = this as Record;
+ if (typeof meta._off === 'function') {
+ (meta._off as () => void)();
+ meta._off = null;
+ }
+ },
},
/**
diff --git a/src/react.ts b/src/react.ts
index 3dbc0d5..f20ddb7 100644
--- a/src/react.ts
+++ b/src/react.ts
@@ -1,7 +1,7 @@
/**
* MetonaToast React — React 适配器
* @module react
- * @version 0.4.0
+ * @version 0.5.0
* @description useToast hook + 声明式 组件。主包保持零依赖,React 为 optional peerDependency
*/
diff --git a/src/styles.ts b/src/styles.ts
index 64aefbe..a28a4d5 100644
--- a/src/styles.ts
+++ b/src/styles.ts
@@ -1,7 +1,7 @@
/**
* MetonaToast Styles — 样式管理
* @module styles
- * @version 0.4.0
+ * @version 0.5.0
*/
import type { ThemeConfig } from './types.js';
@@ -595,18 +595,3 @@ export const watchSystemTheme = (callback: (theme: string) => void): (() => void
mediaQuery.removeEventListener('change', handler);
};
};
-
-/**
- * 自动应用系统主题
- */
-export const autoApplySystemTheme = (): (() => void) => {
- const applyTheme = (theme: string): void => {
- updateStyles();
- // _THEMES reference is from constants, but here we just need to use theme string
- // applyThemeVariables is called externally
- };
-
- applyTheme(getSystemTheme());
-
- return watchSystemTheme(applyTheme);
-};
diff --git a/src/templates.ts b/src/templates.ts
index c8b7872..5b315a5 100644
--- a/src/templates.ts
+++ b/src/templates.ts
@@ -1,7 +1,7 @@
/**
* MetonaToast Templates — HTML 模板辅助函数
* @module templates
- * @version 0.4.0
+ * @version 0.5.0
* @description confirm / prompt / progress / action 的 DOM 模板
*/
diff --git a/src/themes.ts b/src/themes.ts
index cde064a..782d022 100644
--- a/src/themes.ts
+++ b/src/themes.ts
@@ -1,7 +1,7 @@
/**
* MetonaToast Themes — 主题管理
* @module themes
- * @version 0.4.0
+ * @version 0.5.0
*/
import { THEMES } from './constants.js';
diff --git a/src/toast.ts b/src/toast.ts
index 4d49942..d50472e 100644
--- a/src/toast.ts
+++ b/src/toast.ts
@@ -1,7 +1,7 @@
/**
* MetonaToast Toast — Toast 类
* @module toast
- * @version 0.4.0
+ * @version 0.5.0
*/
import { generateId, escapeHTML } from './utils.js';
@@ -40,12 +40,18 @@ export class Toast implements ToastInstance {
static on(name: string, fn: (toast: Toast) => boolean | void): () => void {
if (!this._hooks.has(name)) this._hooks.set(name, []);
this._hooks.get(name)!.push(fn);
- return () => this.off(name, fn);
+ // 用类名引用而非 this,保证返回的取消函数在任意调用上下文可用
+ return () => Toast.off(name, fn);
}
static off(name: string, fn: (toast: Toast) => boolean | void): void {
const list = this._hooks.get(name);
- if (list) this._hooks.set(name, list.filter(f => f !== fn));
+ if (list) {
+ const next = list.filter(f => f !== fn);
+ // 空数组直接删除 key,避免残留
+ if (next.length === 0) this._hooks.delete(name);
+ else this._hooks.set(name, next);
+ }
}
/**
diff --git a/src/types.ts b/src/types.ts
index e02477d..f76c863 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,7 +1,7 @@
/**
* MetonaToast — 核心类型定义
* @module types
- * @version 0.4.0
+ * @version 0.5.0
*/
// ========== 基础类型 ==========
diff --git a/src/utils.ts b/src/utils.ts
index 715b22e..05c93e0 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -1,7 +1,7 @@
/**
* MetonaToast Utils — 工具函数
* @module utils
- * @version 0.4.0
+ * @version 0.5.0
*/
/**
diff --git a/tests/coverage.test.ts b/tests/coverage.test.ts
index 5a3bf5c..e674061 100644
--- a/tests/coverage.test.ts
+++ b/tests/coverage.test.ts
@@ -1,7 +1,7 @@
/**
* MetonaToast 覆盖率补充测试 — 目标 95%+
* @module tests
- * @version 0.4.0
+ * @version 0.5.0
*/
import MeToast, { Toast, VERSION } from '../src/index';
@@ -809,7 +809,7 @@ describe('api.ts 覆盖率', () => {
test('getStatus 返回完整状态', () => {
const status = MeToast.getStatus();
- expect(status.version).toBe('0.4.0');
+ expect(status.version).toBe('0.5.0');
expect(status.toasts).toBeGreaterThanOrEqual(0);
expect(status.theme).toBeDefined();
expect(status.locale).toBeDefined();
@@ -1080,12 +1080,6 @@ describe('styles.ts 覆盖率', () => {
expect(typeof unsub).toBe('function');
unsub();
});
-
- test('autoApplySystemTheme 返回取消函数', () => {
- const unsub = styles.autoApplySystemTheme();
- expect(typeof unsub).toBe('function');
- unsub();
- });
});
// ==================================================================
@@ -1261,12 +1255,6 @@ describe('最终补漏', () => {
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?', {
@@ -1568,3 +1556,188 @@ describe('v0.4.0 能力增强', () => {
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).Intl;
+ (global as Record).Intl = new Proxy(origIntl as object, {
+ get: (t: Record, p: string) => {
+ if (['NumberFormat', 'DateTimeFormat', 'RelativeTimeFormat', 'PluralRules'].includes(p)) {
+ return class { constructor() { throw new RangeError('invalid'); } };
+ }
+ return (t as Record)[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).Intl = origIntl;
+ });
+
+ test('formatList Intl.ListFormat 缺失时逗号拼接', () => {
+ const origLF = (Intl as unknown as Record).ListFormat;
+ (Intl as unknown as Record).ListFormat = undefined;
+ const result = i18n.formatList(['a', 'b']);
+ expect(result).toBe('a, b');
+ (Intl as unknown as Record).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).matchMedia;
+ (window as unknown as Record).matchMedia = undefined;
+ const unsub = styles.watchSystemTheme(jest.fn());
+ expect(unsub).toBeInstanceOf(Function);
+ (window as unknown as Record).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);
+ });
+ });
+});
diff --git a/tests/dom.test.ts b/tests/dom.test.ts
new file mode 100644
index 0000000..fa0418a
--- /dev/null
+++ b/tests/dom.test.ts
@@ -0,0 +1,369 @@
+/**
+ * MetonaToast DOM 交互测试 — 基于 jsdom 真实 DOM 事件驱动
+ * @module tests
+ * @version 0.5.0
+ */
+
+import MeToast, { Toast } from '../src/index';
+import type { ToastInstance } from '../src/types';
+
+// jsdom polyfill: PointerEvent 相关方法
+if (typeof Element !== 'undefined') {
+ if (!(Element.prototype as unknown as { setPointerCapture?: unknown }).setPointerCapture) {
+ (Element.prototype as unknown as Record).setPointerCapture = jest.fn();
+ }
+ if (!(Element.prototype as unknown as { releasePointerCapture?: unknown }).releasePointerCapture) {
+ (Element.prototype as unknown as Record).releasePointerCapture = jest.fn();
+ }
+}
+
+const pointer = (type: string, x: number, y: number): PointerEvent =>
+ new MouseEvent(type, { clientX: x, clientY: y, bubbles: true }) as unknown as PointerEvent;
+
+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('toast.ts — DOM 交互', () => {
+ test('onShow 回调异常被捕获', () => {
+ const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
+ MeToast.success('x', { onShow: () => { throw new Error('show-err'); } });
+ expect(consoleError).toHaveBeenCalled();
+ consoleError.mockRestore();
+ });
+
+ test('onUpdate 回调异常被捕获', () => {
+ const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
+ const t = MeToast.success('x', { onUpdate: () => { throw new Error('upd-err'); } });
+ t.update({ message: 'y' });
+ expect(consoleError).toHaveBeenCalled();
+ consoleError.mockRestore();
+ });
+
+ test('_applyStyles 字符串 width 生效', () => {
+ const t = MeToast.info({ message: 'w', width: '80%' });
+ expect(t.el!.style.width).toBe('80%');
+ });
+
+ test('点击 close 按钮关闭 toast 且不触发 click 钩子', () => {
+ const fn = jest.fn();
+ Toast.on('click', fn);
+ const t = MeToast.info('test');
+ const closeBtn = t.el!.querySelector('.met-close') as HTMLElement;
+ closeBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ expect(t.closing).toBe(true);
+ expect(fn).not.toHaveBeenCalled();
+ Toast.off('click', fn);
+ });
+
+ test('点击主体触发 onClick 并关闭', () => {
+ const onClick = jest.fn();
+ const t = MeToast.info('test', { onClick });
+ t.el!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ expect(onClick).toHaveBeenCalledWith(t);
+ expect(t.closing).toBe(true);
+ });
+
+ test('onClick 回调异常被捕获', () => {
+ const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
+ const t = MeToast.info('test', { onClick: () => { throw new Error('click-err'); } });
+ t.el!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ expect(consoleError).toHaveBeenCalled();
+ consoleError.mockRestore();
+ });
+
+ test('closeOnClick false 时点击不关闭但触发 click 钩子', () => {
+ const fn = jest.fn();
+ Toast.on('click', fn);
+ const t = MeToast.info('test', { closeOnClick: false });
+ t.el!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ expect(t.closing).toBe(false);
+ expect(fn).toHaveBeenCalled();
+ Toast.off('click', fn);
+ });
+
+ test('hover 触发暂停/恢复与 hover 钩子', () => {
+ const fn = jest.fn();
+ Toast.on('hover', fn);
+ const t = MeToast.info({ message: 'h', duration: 5000 });
+ t.el!.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
+ expect(t.paused).toBe(true);
+ t.el!.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
+ expect(t.paused).toBe(false);
+ expect(fn.mock.calls.length).toBeGreaterThanOrEqual(2);
+ Toast.off('hover', fn);
+ });
+
+ test('animationstart/animationend 触发钩子', () => {
+ const start = jest.fn();
+ const end = jest.fn();
+ Toast.on('animationStart', start);
+ Toast.on('animationEnd', end);
+ const t = MeToast.info('a');
+ t.el!.dispatchEvent(new Event('animationstart'));
+ t.el!.dispatchEvent(new Event('animationend'));
+ expect(start).toHaveBeenCalled();
+ expect(end).toHaveBeenCalled();
+ Toast.off('animationStart', start);
+ Toast.off('animationEnd', end);
+ });
+
+ test('拖拽 down→move→up 超过阈值触发关闭', (done) => {
+ const t = MeToast.info('drag', { duration: 5000 });
+ const el = t.el!;
+ el.dispatchEvent(pointer('pointerdown', 100, 50));
+ el.dispatchEvent(pointer('pointermove', 250, 60));
+ expect(el.style.transform).toContain('translate(150px');
+ expect(el.style.opacity).toBe('0.25');
+ el.dispatchEvent(pointer('pointerup', 250, 60));
+ expect(el.style.opacity).toBe('0');
+ setTimeout(() => {
+ expect(t.closing).toBe(true);
+ done();
+ }, 350);
+ });
+
+ test('拖拽未超阈值松手恢复', () => {
+ const t = MeToast.info('drag2', { duration: 5000 });
+ const el = t.el!;
+ el.dispatchEvent(pointer('pointerdown', 100, 50));
+ el.dispatchEvent(pointer('pointermove', 150, 60));
+ el.dispatchEvent(pointer('pointerup', 150, 60));
+ expect(t.closing).toBe(false);
+ expect(t.paused).toBe(false);
+ expect(el.style.transform).toBe('');
+ });
+
+ test('拖拽触发 dragStart/dragEnd 钩子', () => {
+ const start = jest.fn();
+ const end = jest.fn();
+ Toast.on('dragStart', start);
+ Toast.on('dragEnd', end);
+ const t = MeToast.info('d', { duration: 5000 });
+ const el = t.el!;
+ el.dispatchEvent(pointer('pointerdown', 0, 0));
+ el.dispatchEvent(pointer('pointerup', 0, 0));
+ expect(start).toHaveBeenCalled();
+ expect(end).toHaveBeenCalled();
+ Toast.off('dragStart', start);
+ Toast.off('dragEnd', end);
+ });
+
+ test('pointercancel 等价于 pointerup', () => {
+ const t = MeToast.info('pc', { duration: 5000 });
+ const el = t.el!;
+ el.dispatchEvent(pointer('pointerdown', 10, 10));
+ el.dispatchEvent(pointer('pointercancel', 10, 10));
+ expect(t.closing).toBe(false);
+ });
+
+ test('拖拽从 close 按钮按下时早退', () => {
+ const t = MeToast.info('dc', { duration: 5000 });
+ const closeBtn = t.el!.querySelector('.met-close') as HTMLElement;
+ closeBtn.dispatchEvent(pointer('pointerdown', 0, 0));
+ expect(t.paused).toBe(false);
+ });
+
+ test('close 非 immediate 走离场动画并清理', (done) => {
+ const t = MeToast.info('anim-close');
+ t.close();
+ expect(t.el!.classList.contains('met-leaving')).toBe(true);
+ setTimeout(() => {
+ expect(t.el).toBeNull();
+ expect(MeToast.count()).toBe(0);
+ done();
+ }, 400);
+ });
+
+ test('updatePosition 移动到新容器', () => {
+ const t = MeToast.info('move', { position: 'top-left' });
+ t.updatePosition('bottom-right');
+ expect(t.config.position).toBe('bottom-right');
+ expect(t.el!.parentElement!.className).toContain('bottom-right');
+ });
+
+ test('updatePosition 同位置直接返回', () => {
+ const t = MeToast.info('same');
+ const container = t.el!.parentElement;
+ t.updatePosition('top-right');
+ expect(t.el!.parentElement).toBe(container);
+ });
+
+ test('_limitToasts registry 无实例时走 _removeToast 兜底', () => {
+ const el = document.createElement('div');
+ const ghost = document.createElement('div');
+ ghost.dataset.id = 'ghost-id';
+ (el.querySelectorAll as unknown) = () => [ghost];
+ const t = new Toast({ message: 'x', max: 0 });
+ expect(() => t._limitToasts(el)).not.toThrow();
+ });
+
+ test('remove() 清理注册表与内存', () => {
+ const t = MeToast.info('rm');
+ expect(Toast._registry.has(t.id)).toBe(true);
+ t.remove();
+ expect(Toast._registry.has(t.id)).toBe(false);
+ expect(MeToast.count()).toBe(0);
+ });
+});
+
+describe('api.ts — 对话框与按钮交互', () => {
+ test('confirm 点击确认按钮 resolve true', async () => {
+ const promise = MeToast.confirm('确定删除?');
+ await new Promise(r => setTimeout(r, 10));
+ const toast = MeToast.getToasts()[0];
+ (toast.el!.querySelector('.met-confirm-btn') as HTMLElement).click();
+ const result = await promise;
+ expect(result).toBe(true);
+ });
+
+ test('confirm 点击取消按钮 resolve false', async () => {
+ const promise = MeToast.confirm('确定删除?');
+ await new Promise(r => setTimeout(r, 10));
+ const toast = MeToast.getToasts()[0];
+ (toast.el!.querySelector('.met-cancel-btn') as HTMLElement).click();
+ const result = await promise;
+ expect(result).toBe(false);
+ });
+
+ test('prompt Enter 提交输入值', async () => {
+ const promise = MeToast.prompt('请输入姓名');
+ await new Promise(r => setTimeout(r, 10));
+ const toast = MeToast.getToasts()[0];
+ const input = toast.el!.querySelector('.met-input') as HTMLInputElement;
+ input.value = '张三';
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
+ const result = await promise;
+ expect(result).toBe('张三');
+ });
+
+ test('prompt 点击提交按钮', async () => {
+ const promise = MeToast.prompt('请输入姓名');
+ await new Promise(r => setTimeout(r, 10));
+ const toast = MeToast.getToasts()[0];
+ const input = toast.el!.querySelector('.met-input') as HTMLInputElement;
+ input.value = '李四';
+ (toast.el!.querySelector('.met-submit-btn') as HTMLElement).click();
+ const result = await promise;
+ expect(result).toBe('李四');
+ });
+
+ test('prompt 点击取消按钮 resolve null', async () => {
+ const promise = MeToast.prompt('请输入姓名');
+ await new Promise(r => setTimeout(r, 10));
+ const toast = MeToast.getToasts()[0];
+ (toast.el!.querySelector('.met-cancel-btn') as HTMLElement).click();
+ const result = await promise;
+ expect(result).toBeNull();
+ });
+
+ test('action 按钮点击触发回调并关闭', async () => {
+ const onClick = jest.fn();
+ const a = MeToast.action('msg', [{ text: 'OK', onClick }]);
+ await new Promise(r => setTimeout(r, 10));
+ (a.toast.el!.querySelector('.met-action-btn-0') as HTMLElement).click();
+ expect(onClick).toHaveBeenCalledWith(a.toast);
+ expect(a.toast.closing).toBe(true);
+ });
+
+ test('action 按钮 close:false 点击不关闭', async () => {
+ const onClick = jest.fn();
+ const a = MeToast.action('msg', [{ text: 'Keep', onClick, close: false }]);
+ await new Promise(r => setTimeout(r, 10));
+ (a.toast.el!.querySelector('.met-action-btn-0') as HTMLElement).click();
+ expect(a.toast.closing).toBe(false);
+ });
+
+ test('action 按钮 onClick 异常被捕获', async () => {
+ const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
+ const a = MeToast.action('msg', [{ text: 'Boom', onClick: () => { throw new Error('act-err'); } }]);
+ await new Promise(r => setTimeout(r, 10));
+ (a.toast.el!.querySelector('.met-action-btn-0') as HTMLElement).click();
+ expect(consoleError).toHaveBeenCalled();
+ consoleError.mockRestore();
+ });
+
+ test('countdown onComplete 异常被捕获', (done) => {
+ const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
+ MeToast.countdown('{seconds} 秒', 1, { onComplete: () => { throw new Error('cd-err'); } });
+ setTimeout(() => {
+ expect(consoleError).toHaveBeenCalled();
+ consoleError.mockRestore();
+ done();
+ }, 1600);
+ });
+
+ test('loading control 支持 update 与 dismiss', () => {
+ const l = MeToast.loading('l1');
+ l.update({ message: 'l2' });
+ const toast = MeToast.find(l.id);
+ expect(toast!.message).toBe('l2');
+ l.dismiss();
+ expect(toast!.closing).toBe(true);
+ });
+
+ test('group 单对象参数分支', () => {
+ const g = MeToast.group('obj-group');
+ const t = g.show({ message: 'obj-arg' });
+ expect(t.group).toBe('obj-group');
+ expect(t.message).toBe('obj-arg');
+ g.dismiss();
+ });
+
+ test('queue 消息级 onClose 异常被静默捕获', async () => {
+ const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
+ const q = MeToast.queue([
+ { message: 'a', onClose: () => { throw new Error('q-err'); } },
+ ], { delay: 5, duration: 10 });
+ await q;
+ expect(consoleError).not.toHaveBeenCalled();
+ consoleError.mockRestore();
+ });
+
+ test('_remove 从内存移除指定 id', () => {
+ const t = MeToast.info('x');
+ MeToast._remove(t.id);
+ expect(MeToast.count()).toBe(0);
+ });
+});
+
+describe('plugins.ts — 异常与钩子管理', () => {
+ test('插件 install 抛错被捕获', () => {
+ const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
+ MeToast.plugins.register('bad-install', { name: 'bad-install', install: () => { throw new Error('install-err'); } });
+ expect(consoleError).toHaveBeenCalled();
+ consoleError.mockRestore();
+ });
+
+ test('keyboard 插件 install/uninstall 移除事件监听', () => {
+ MeToast.use('keyboard');
+ MeToast.plugins.unregister('keyboard');
+ expect(MeToast.plugins.has('keyboard')).toBe(false);
+ });
+
+ test('persistence 插件 save 异常被捕获', () => {
+ const spy = jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('quota'); });
+ const pluginUtils = require('../src/plugins.js').pluginUtils;
+ const preset = pluginUtils.getPreset('persistence');
+ const saveFn = (preset as Record void>).save;
+ expect(() => saveFn({ duration: 3000 })).not.toThrow();
+ spy.mockRestore();
+ });
+
+ test('persistence 插件 uninstall removeItem 异常被捕获', () => {
+ const spy = jest.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => { throw new Error('quota'); });
+ const pluginUtils = require('../src/plugins.js').pluginUtils;
+ const preset = pluginUtils.getPreset('persistence');
+ expect(() => preset?.uninstall?.()).not.toThrow();
+ spy.mockRestore();
+ });
+});
diff --git a/tests/index.test.ts b/tests/index.test.ts
index b68dc5d..68d5dd4 100644
--- a/tests/index.test.ts
+++ b/tests/index.test.ts
@@ -1,7 +1,7 @@
/**
* MetonaToast 单元测试
* @module tests
- * @version 0.4.0
+ * @version 0.5.0
*/
import MeToast, { Toast, VERSION } from '../src/index';
@@ -105,8 +105,8 @@ const mockWindow = {
describe('版本信息', () => {
test('应该有正确的版本号', () => {
- expect(VERSION).toBe('0.4.0');
- expect(MeToast.version).toBe('0.4.0');
+ expect(VERSION).toBe('0.5.0');
+ expect(MeToast.version).toBe('0.5.0');
});
});
@@ -526,7 +526,7 @@ const mockWindow = {
test('应该能够获取状态信息', () => {
MeToast.success('消息');
const status = MeToast.getStatus();
- expect(status.version).toBe('0.4.0');
+ expect(status.version).toBe('0.5.0');
expect(status.toasts).toBeGreaterThanOrEqual(0);
expect(status.theme).toBeDefined();
expect(status.locale).toBeDefined();
diff --git a/tests/react.test.ts b/tests/react.test.ts
index 26dd4c0..71b9c37 100644
--- a/tests/react.test.ts
+++ b/tests/react.test.ts
@@ -1,15 +1,19 @@
/**
* MetonaToast React 适配器测试
* @module tests
- * @version 0.4.0
+ * @version 0.5.0
*/
import React from 'react';
import { renderToString } from 'react-dom/server';
+import { createRoot } from 'react-dom/client';
+import { act } from 'react';
import MeToast, { Toast } from '../src/index';
-import { createBoundApi, Toast as ToastComponent } from '../src/react';
+import { createBoundApi, Toast as ToastComponent, useToast } from '../src/react';
import type { ToastInstance } from '../src/types';
+(globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true;
+
beforeEach(() => {
jest.clearAllMocks();
MeToast._toasts.clear();
@@ -87,4 +91,59 @@ describe('React 适配器 — Toast 组件', () => {
const html = renderToString(React.createElement(ToastComponent, { message: 'y', type: 'custom-type' }));
expect(html).toBe('');
});
+
+ test('组件挂载创建 toast,卸载自动移除', () => {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+ act(() => { root.render(React.createElement(ToastComponent, { message: 'mounted', type: 'success' })); });
+ expect(MeToast.count()).toBe(1);
+ act(() => { root.unmount(); });
+ expect(MeToast.count()).toBe(0);
+ host.remove();
+ });
+
+ test('组件 autoClose:false 卸载不移除', () => {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+ act(() => { root.render(React.createElement(ToastComponent, { message: 'keep', type: 'error', autoClose: false })); });
+ expect(MeToast.count()).toBe(1);
+ act(() => { root.unmount(); });
+ expect(MeToast.count()).toBe(1);
+ MeToast.dismiss();
+ host.remove();
+ });
+
+ test('组件 message 变化重新创建实例', () => {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+ act(() => { root.render(React.createElement(ToastComponent, { message: 'v1', type: 'info' })); });
+ act(() => { root.render(React.createElement(ToastComponent, { message: 'v2', type: 'warning' })); });
+ expect(MeToast.count()).toBe(1);
+ const t = MeToast.getToasts()[0];
+ expect(t.message).toBe('v2');
+ expect(t.type).toBe('warning');
+ act(() => { root.unmount(); });
+ expect(MeToast.count()).toBe(0);
+ host.remove();
+ });
+
+ test('useToast 组件内创建的 toast 在卸载时清理', () => {
+ const Harness = (): React.ReactElement => {
+ const toast = useToast() as Record ToastInstance>;
+ return React.createElement('button', { onClick: () => toast.info('tracked') });
+ };
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+ act(() => { root.render(React.createElement(Harness)); });
+ const btn = host.querySelector('button')!;
+ act(() => { btn.dispatchEvent(new MouseEvent('click', { bubbles: true })); });
+ expect(MeToast.count()).toBe(1);
+ act(() => { root.unmount(); });
+ expect(MeToast.count()).toBe(0);
+ host.remove();
+ });
});
diff --git a/tests/ssr.test.ts b/tests/ssr.test.ts
index e58d9d1..5f1bb61 100644
--- a/tests/ssr.test.ts
+++ b/tests/ssr.test.ts
@@ -2,29 +2,38 @@
* 环境隔离测试 — 需要切换 document/window/localStorage 环境并调用 jest.resetModules()。
* 独立成文件:resetModules 会清空模块缓存,若与其他测试同文件会导致
* 后续 require 拿到新模块实例、与 import 时绑定的实例状态分裂。
+ * 注:jsdom 以 getter 暴露 document/window,普通赋值无效,须用 defineProperty 覆盖。
* @module tests
- * @version 0.4.0
+ * @version 0.5.0
*/
+/** 临时移除全局对象,返回恢复函数 */
+const hideGlobal = (key: string): (() => void) => {
+ const desc = Object.getOwnPropertyDescriptor(global, key);
+ Object.defineProperty(global, key, { value: undefined, configurable: true, writable: true });
+ return () => {
+ if (desc) Object.defineProperty(global, key, desc);
+ else delete (global as Record)[key];
+ };
+};
+
describe('环境隔离(SSR / localStorage 异常路径)', () => {
test('escapeHTML SSR 路径(无 document)', () => {
- const originalDoc = (global as Record).document;
- (global as Record).document = undefined;
+ const restore = hideGlobal('document');
jest.resetModules();
const { escapeHTML } = require('../src/utils.js');
const result = escapeHTML('');
expect(result).not.toContain('