370 lines
14 KiB
TypeScript
370 lines
14 KiB
TypeScript
/**
|
|
* 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();
|
|
});
|
|
});
|