/** * MetonaToast DOM 交互测试 — 基于 jsdom 真实 DOM 事件驱动 * @module tests * @version 0.5.1 */ 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(); }); });