release: v0.5.0 — 覆盖率96.42%达标、lint零警告、覆盖率门禁、CI强化、发布自动化

- 覆盖率 96.42%(行):新增 59 测试共 385 个,覆盖 DOM 交互事件、对话框按钮交互、Intl/localStorage 异常路径、React 渲染生命周期
- 测试环境重构:SSR 分支用 defineProperty 真正覆盖 document/window;新增 tests/dom.test.ts、tests/setup.ts
- jest 覆盖率门禁 lines >= 95%;CI lint 改必过;新增 publish.yml(v* tag 自动发布到 Gitea registry)
- 修复:Toast.off 空数组残留、Toast.on 取消函数 this 绑定、action close:false 冒泡关闭、use() 重复注册钩子、i18n catch 引用错误
- 删除死代码 autoApplySystemTheme;lint 零警告(no-console 策略 + caughtErrorsIgnorePattern)
This commit is contained in:
tianhao
2026-08-08 15:02:51 +08:00
parent bbff631f4f
commit 1f718c4ef8
41 changed files with 1146 additions and 281 deletions
+187 -14
View File
@@ -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<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);
});
});
});
+369
View File
@@ -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<string, unknown>).setPointerCapture = jest.fn();
}
if (!(Element.prototype as unknown as { releasePointerCapture?: unknown }).releasePointerCapture) {
(Element.prototype as unknown as Record<string, unknown>).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<string, (c: unknown) => 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();
});
});
+4 -4
View File
@@ -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();
+61 -2
View File
@@ -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<string, unknown>).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<string, (m: string) => 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();
});
});
+16 -7
View File
@@ -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<string, unknown>)[key];
};
};
describe('环境隔离(SSR / localStorage 异常路径)', () => {
test('escapeHTML SSR 路径(无 document', () => {
const originalDoc = (global as Record<string, unknown>).document;
(global as Record<string, unknown>).document = undefined;
const restore = hideGlobal('document');
jest.resetModules();
const { escapeHTML } = require('../src/utils.js');
const result = escapeHTML('<script>alert("xss")</script>');
expect(result).not.toContain('<script>');
expect(result).toContain('&lt;script&gt;');
(global as Record<string, unknown>).document = originalDoc;
restore();
});
test('prefersDark 无 window 时返回 false', () => {
const originalWindow = (global as Record<string, unknown>).window;
(global as Record<string, unknown>).window = undefined;
const restore = hideGlobal('window');
jest.resetModules();
const { prefersDark } = require('../src/utils.js');
expect(prefersDark()).toBe(false);
(global as Record<string, unknown>).window = originalWindow;
restore();
});
test('saveTheme localStorage 异常被捕获', () => {