release: v0.3.0 — 自定义动画真实生效、max超限修复、钩子系统完整化、destroy全量清理

- 自定义动画:animations.register() 注册的动画动态注入 keyframes 真实生效(此前仅存 Map 实际 fallback slide)
- max 超限:通过实例注册表真正 close() 最早的 toast(此前仅删内存记录,DOM 残留)
- 钩子收敛:beforeClose/beforeUpdate 支持返回 false 拦截;接入 click/hover/dragStart/dragEnd/animationStart/animationEnd/progressStart/progressEnd/configChange/themeChange/localeChange/beforeInit/afterInit/beforeDestroy/afterDestroy;HOOK_NAMES 移除永不触发的声明
- destroy() 全量清理:hooks、实例注册表、全部 metona-toast-* 样式标签;动画注册表真正清空
- applyThemeVariables 重复注入修复;init() 支持插件对象并复活 _destroyed;AnimationConfig 类型放宽
- VERSION 统一到 constants.ts 唯一维护;删除失效 scripts;新增 .gitattributes 统一 LF
- docs.html 补 v0.2.1 新 API 文档;CHANGELOG 更新;测试 309 用例全过
This commit is contained in:
tianhao
2026-08-08 14:23:41 +08:00
parent cb8dd623db
commit 2937ce5e2d
30 changed files with 1098 additions and 372 deletions
+196 -2
View File
@@ -1,7 +1,7 @@
/**
* MetonaToast 覆盖率补充测试 — 目标 95%+
* @module tests
* @version 0.2.1
* @version 0.3.0
*/
import MeToast, { Toast, VERSION } from '../src/index';
@@ -139,6 +139,70 @@ describe('animations.ts 覆盖率', () => {
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');
});
});
// ==================================================================
@@ -745,7 +809,7 @@ describe('api.ts 覆盖率', () => {
test('getStatus 返回完整状态', () => {
const status = MeToast.getStatus();
expect(status.version).toBe('0.2.1');
expect(status.version).toBe('0.3.0');
expect(status.toasts).toBeGreaterThanOrEqual(0);
expect(status.theme).toBeDefined();
expect(status.locale).toBeDefined();
@@ -1299,3 +1363,133 @@ describe('最终补漏', () => {
});
});
// ==================================================================
// 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);
});
});