From bc1128bd1fc4f0abf73ad705383e6f98d022129e Mon Sep 17 00:00:00 2001
From: thzxx <1440196015@qq.com>
Date: Sat, 25 Jul 2026 13:01:59 +0800
Subject: [PATCH] =?UTF-8?q?release:=20v0.2.1=20=E2=80=94=20bug=E4=BF=AE?=
=?UTF-8?q?=E5=A4=8D=E3=80=81=E4=BB=A3=E7=A0=81=E5=8E=BB=E9=87=8D=E3=80=81?=
=?UTF-8?q?=E5=8A=9F=E8=83=BD=E5=A2=9E=E5=BC=BA=E3=80=81=E8=A6=86=E7=9B=96?=
=?UTF-8?q?=E7=8E=8788.57%?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.eslintrc.json | 2 +
.gitea/workflows/ci.yml | 9 +
CHANGELOG.md | 20 +
package.json | 4 +-
site/demo.html | 4 +-
site/docs.html | 2 +-
site/index.html | 2 +-
src/animations.ts | 2 +-
src/api.ts | 183 ++++--
src/constants.ts | 17 +-
src/i18n.ts | 14 +-
src/icons.ts | 2 +-
src/index.ts | 237 +------
src/locales.ts | 2 +-
src/plugins.ts | 2 +-
src/styles.ts | 2 +-
src/templates.ts | 2 +-
src/themes.ts | 7 +-
src/toast.ts | 121 +++-
src/types.ts | 6 +-
src/utils.ts | 2 +-
tests/coverage.test.ts | 1301 +++++++++++++++++++++++++++++++++++++++
tests/index.test.ts | 80 ++-
23 files changed, 1703 insertions(+), 320 deletions(-)
create mode 100644 tests/coverage.test.ts
diff --git a/.eslintrc.json b/.eslintrc.json
index 8ae6f3e..3317874 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -17,6 +17,8 @@
"no-console": "warn",
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
+ "@typescript-eslint/no-explicit-any": "warn",
+ "prefer-const": "warn",
"no-undef": "off",
"no-empty": "off",
"no-useless-escape": "off",
diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index 4fb0fc9..61f3003 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -40,3 +40,12 @@ jobs:
- name: Build
run: npm run build
+
+ - name: Bundle size
+ run: |
+ echo "=== Build Output ==="
+ ls -lh dist/
+ echo ""
+ echo "=== Minified Gzip Size ==="
+ gzip -c dist/metona-toast.min.js | wc -c | xargs echo "min.js gzip:"
+ gzip -c dist/metona-toast.esm.js | wc -c | xargs echo "esm.js gzip:"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1d60028..67576b5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,26 @@
All notable changes to MetonaToast will be documented in this file.
+## [0.2.1] - 2026-07-25
+
+### Fixed
+- **`auto` 主题配置死值**:`THEMES.auto` 改为合法 CSS 值(复用 light 默认值),避免 `getThemeConfig('auto')` 返回无意义字符串
+- **`resolveTheme` 循环逻辑**:`resolveTheme('auto')` 始终检测系统主题,不再依赖 `currentTheme` 全局状态
+- **`formatList` 国际化**:优先使用 `Intl.ListFormat`,降级到逗号拼接,支持各语言原生列表格式
+- **RTL 容器定位**:RTL 语言自动翻转 left/right 位置,容器创建时正确适配
+- **`_containerCache` 泄露**:`index.ts` 的 `destroy()` 中增加 `_containerCache.clear()`,与 `api.ts` 保持一致
+
+### Changed
+- **代码去重**:消除 `index.ts` 与 `api.ts` 之间 11 个方法的重复定义,统一在 `api.ts` 实现
+- **Toast 回调注入**:`Toast._onError` / `Toast._removeToast` 改为 `Toast.setCallbacks()` 静态方法,消除隐式契约
+- **Stub 方法替换**:`api.ts` 中 7 个 stub 方法替换为完整实现(init/use/destroy/getStatus/getConfig/updateConfig/resetConfig)
+- **ESLint 规则**:新增 `@typescript-eslint/no-explicit-any` (warn) 和 `prefer-const` (warn)
+
+### Added
+- **`beforeShow` 拦截**:钩子函数返回 `false` 可阻止 Toast 显示;配置 `onBeforeShow` 回调支持同样能力
+- **`updatePosition()` 方法**:允许运行时移动 Toast 到不同位置容器
+- **`remove()` + `removeToast()` 方法**:立即移除 Toast(不触发离场动画),提供无动画的快速清除 API
+
## [0.2.0] - 2026-07-25
### Changed
diff --git a/package.json b/package.json
index 727fea7..abb1233 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@metona-team/metona-toast",
- "version": "0.2.0",
+ "version": "0.2.1",
"description": "轻量、零依赖、精致美观的Toast通知库。TypeScript源码,开箱即用。",
"type": "module",
"main": "dist/metona-toast.cjs.js",
@@ -30,7 +30,7 @@
"lint:fix": "eslint 'src/**/*.ts' --fix",
"format": "prettier --write 'src/**/*.ts' 'tests/**/*.ts'",
"typecheck": "tsc --noEmit",
- "prepublishOnly": "npm run typecheck && npm test && npm run build",
+ "prepublishOnly": "npm run typecheck && npm run lint && npm test && npm run build",
"docs": "typedoc src/ --out docs",
"example": "serve examples/"
},
diff --git a/site/demo.html b/site/demo.html
index 428c9c9..cc0e8ea 100644
--- a/site/demo.html
+++ b/site/demo.html
@@ -244,7 +244,7 @@
-
🛡️ onError 错误捕获 v0.2.0
+
🛡️ onError 错误捕获 v0.2.1
@@ -282,7 +282,7 @@
diff --git a/site/docs.html b/site/docs.html
index 9e320b1..fdb48c9 100644
--- a/site/docs.html
+++ b/site/docs.html
@@ -87,7 +87,7 @@
API 文档
-MetonaToast v0.2.0 完整 API 参考。所有基础通知方法(show/success/error/warning/info/loading)支持两种调用形式,均可传入任何 配置项 作为可选第二参数。
+MetonaToast v0.2.1 完整 API 参考。所有基础通知方法(show/success/error/warning/info/loading)支持两种调用形式,均可传入任何 配置项 作为可选第二参数。
show(message, opts?)
diff --git a/site/index.html b/site/index.html
index 8ec211b..87f660f 100644
--- a/site/index.html
+++ b/site/index.html
@@ -247,7 +247,7 @@ orderGroup.dismiss(); // 一键关闭
diff --git a/src/animations.ts b/src/animations.ts
index cb876c7..c16bc39 100644
--- a/src/animations.ts
+++ b/src/animations.ts
@@ -1,7 +1,7 @@
/**
* MetonaToast Animations — 动画管理
* @module animations
- * @version 0.2.0
+ * @version 0.2.1
*/
import { ANIMATIONS } from './constants.js';
diff --git a/src/api.ts b/src/api.ts
index 8eb60c1..697cd4f 100644
--- a/src/api.ts
+++ b/src/api.ts
@@ -1,23 +1,24 @@
/**
* MetonaToast API — meToast 核心 API 对象
* @module api
- * @version 0.2.0
+ * @version 0.2.1
*/
import { Toast, _containerCache } from './toast.js';
import { DEFAULTS } from './constants.js';
import { escapeHTML } from './utils.js';
-import { t } from './i18n.js';
+import { t, setCurrentLocale } from './i18n.js';
import { applyTheme } from './themes.js';
-import { setCurrentLocale } from './i18n.js';
import { confirmHTML, promptHTML, progressHTML, actionHTML } from './templates.js';
+import { presetPlugins } from './plugins.js';
import type {
ToastConfig, ToastOptions, ToastInstance,
LoadingControl, ProgressControl, CountdownControl,
ActionControl, QueueControl, GroupAPI,
ActionButton, PromiseOptions, ConfirmOptions,
PromptOptions, ProgressOptions, CountdownOptions,
- QueueOptions, StackOptions, MeToast, ErrorInfo,
+ QueueOptions, StackOptions, MeToast, ErrorInfo, Plugin,
+ StatusInfo, InitOptions,
} from './types.js';
/**
@@ -47,13 +48,16 @@ const normalizeArgs = (args: unknown[], defaultType = 'default'): ToastOptions =
};
};
+/** 版本号常量 */
+const VERSION = '0.2.1';
+
/**
* meToast API 对象
*/
const meToast: MeToast = {
_toasts: new Map(),
_config: { ...DEFAULTS } as unknown as ToastConfig,
- version: '0.2.0',
+ version: '0.2.1',
configure(opts: Partial): MeToast {
if (!opts || typeof opts !== 'object') return this;
@@ -476,27 +480,21 @@ const meToast: MeToast = {
this._toasts.forEach(t => t.close());
},
+ /**
+ * 立即移除指定 Toast(不触发离场动画)
+ */
+ removeToast(id: string): void {
+ if (!id) return;
+ const t = this._toasts.get(id);
+ if (t) t.remove();
+ },
+
clear(position?: string): void {
this._toasts.forEach(t => {
if (!position || t.config.position === position) t.close();
});
},
- destroy(): void {
- this.dismiss();
-
- if (typeof document !== 'undefined') {
- const containers = document.querySelectorAll('.met-container');
- containers.forEach(c => { if (c.parentNode) c.parentNode.removeChild(c); });
-
- const style = document.getElementById('metona-toast-styles');
- if (style && style.parentNode) style.parentNode.removeChild(style);
- }
-
- this._toasts.clear();
- _containerCache.clear();
- },
-
getAll(): Map {
return new Map(this._toasts);
},
@@ -587,15 +585,126 @@ const meToast: MeToast = {
return this.findToasts(t => t.config.position === position);
},
- // ====== 以下由 index.ts 增强 ======
+ // ====== 以下方法依赖子模块(themes/i18n/plugins/animations),由 index.ts 注入 ======
- init(_options?: Record): MeToast { return this; },
- getStatus() { return { version: '0.2.0', toasts: 0, theme: '', locale: '', plugins: [], animations: 0 }; },
- getConfig(): ToastConfig { return { ...this._config }; },
- updateConfig(_config: Partial): MeToast { return this; },
- resetConfig(): MeToast { return this; },
- use(_plugin: string | Record, _options?: Record): MeToast { return this; },
+ init(options: InitOptions = {}): MeToast {
+ if (options.config) {
+ this.configure(options.config);
+ }
+ if (options.theme && this.themes) {
+ this.themes.switchTheme(options.theme);
+ }
+ if (options.locale && this.i18n) {
+ this.i18n.switchLocale(options.locale);
+ }
+ if (options.plugins && Array.isArray(options.plugins)) {
+ options.plugins.forEach((p) => {
+ this.use(p as string);
+ });
+ }
+ return this;
+ },
+ getStatus(): StatusInfo {
+ return {
+ version: VERSION,
+ toasts: this._toasts.size,
+ theme: this.themes?.getCurrentTheme?.() || 'auto',
+ locale: this.i18n?.getCurrentLocale?.() || 'zh-CN',
+ plugins: this.plugins?.getNames?.() || [],
+ animations: this.animations?.getActiveCount?.() || 0,
+ };
+ },
+
+ getConfig(): ToastConfig {
+ return { ...this._config };
+ },
+
+ updateConfig(config: Partial): MeToast {
+ if (config && typeof config === 'object') {
+ Object.assign(this._config, config);
+ }
+ return this;
+ },
+
+ resetConfig(): MeToast {
+ const keys = Object.keys(this._config);
+ keys.forEach(k => delete (this._config as Record)[k]);
+ Object.assign(this._config, DEFAULTS);
+ return this;
+ },
+
+ use(plugin: string | Plugin, options: Record = {}): MeToast {
+ if (typeof plugin === 'string') {
+ const preset = presetPlugins[plugin];
+ if (!preset) {
+ console.warn(`Preset plugin "${plugin}" not found`);
+ return this;
+ }
+ this.plugins.register(plugin, { ...preset, ...options });
+
+ // 连接插件钩子
+ if (plugin === 'accessibility') {
+ Toast.on('afterShow', (toast: ToastInstance) => {
+ const acc = preset as Record void>;
+ if (typeof acc.announce === 'function') acc.announce(toast);
+ });
+ }
+ if (plugin === 'persistence') {
+ 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());
+ });
+ }
+ } else if (plugin && typeof plugin === 'object') {
+ this.plugins.register(plugin.name || 'custom', { ...plugin, ...options } as Plugin);
+ }
+
+ return this;
+ },
+
+ destroy(): void {
+ if (this._destroyed) return;
+ this._destroyed = true;
+ this.dismiss();
+
+ // 清理子模块
+ if (this.plugins && typeof (this.plugins as unknown as Record).destroy === 'function') {
+ (this.plugins as unknown as Record void>).destroy();
+ }
+ if (this.themes) {
+ if (typeof this.themes.clearThemeListeners === 'function') {
+ this.themes.clearThemeListeners();
+ }
+ if (typeof this.themes.unwatchSystemTheme === 'function') {
+ this.themes.unwatchSystemTheme();
+ }
+ }
+ if (this.i18n && typeof this.i18n.clearLocaleListeners === 'function') {
+ this.i18n.clearLocaleListeners();
+ }
+ if (this.animations && typeof this.animations.cancelAll === 'function') {
+ this.animations.cancelAll();
+ }
+
+ // 清理 DOM
+ if (typeof document !== 'undefined') {
+ const containers = document.querySelectorAll('.met-container');
+ containers.forEach((c) => { if (c.parentNode) c.parentNode.removeChild(c); });
+
+ const style = document.getElementById('metona-toast-styles');
+ if (style && style.parentNode) style.parentNode.removeChild(style);
+ }
+
+ this._toasts.clear();
+ _containerCache.clear();
+ },
+
+ // 子模块引用(由 index.ts 注入实际实现)
animations: null as unknown as MeToast['animations'],
themes: null as unknown as MeToast['themes'],
i18n: null as unknown as MeToast['i18n'],
@@ -604,14 +713,16 @@ const meToast: MeToast = {
};
// 连接 Toast 静态回调到 meToast 实例
-Toast._onError = (info: ErrorInfo) => {
- const onError = meToast._config.onError;
- if (typeof onError === 'function') {
- try { onError(info); } catch (_e) { /* noop */ }
- }
-};
-Toast._removeToast = (id: string) => {
- meToast._toasts.delete(id);
-};
+Toast.setCallbacks({
+ onError: (info: ErrorInfo) => {
+ const onError = meToast._config.onError;
+ if (typeof onError === 'function') {
+ try { onError(info); } catch (_e) { /* noop */ }
+ }
+ },
+ removeToast: (id: string) => {
+ meToast._toasts.delete(id);
+ },
+});
export { meToast as default, meToast };
diff --git a/src/constants.ts b/src/constants.ts
index 25fc3b9..c54960c 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -1,7 +1,7 @@
/**
* MetonaToast Constants — 常量定义
* @module constants
- * @version 0.2.0
+ * @version 0.2.1
*/
import { ICONS } from './icons.js';
@@ -32,6 +32,7 @@ export const DEFAULTS = Object.freeze({
className: '',
style: {} as Record,
onShow: null as ((...args: unknown[]) => void) | null,
+ onBeforeShow: null as ((...args: unknown[]) => boolean | void) | null,
onClose: null as ((...args: unknown[]) => void) | null,
onClick: null as ((...args: unknown[]) => void) | null,
onUpdate: null as ((...args: unknown[]) => void) | null,
@@ -263,13 +264,13 @@ export const THEMES: Record = {}): string => {
- // Intl.ListFormat requires ES2021+; fallback to comma join for wider compat
+ const intl = Intl as typeof Intl & { ListFormat?: new (locale: string, options?: Record) => { format: (items: string[]) => string } };
+ if (typeof intl.ListFormat === 'function') {
+ try {
+ return new intl.ListFormat(currentLocale, {
+ style: 'long',
+ type: 'conjunction',
+ }).format(list);
+ } catch (_e) { /* fallback below */ }
+ }
return list.join(', ');
};
diff --git a/src/icons.ts b/src/icons.ts
index eea2c8b..10d9784 100644
--- a/src/icons.ts
+++ b/src/icons.ts
@@ -1,7 +1,7 @@
/**
* MetonaToast Icons — 图标SVG定义
* @module icons
- * @version 0.2.0
+ * @version 0.2.1
* @description 107 个内置 SVG 图标
*/
diff --git a/src/index.ts b/src/index.ts
index 37c1e78..8dacc6e 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,27 +1,26 @@
/**
* MetonaToast — 轻量级Toast通知库
* @module metona-toast
- * @version 0.2.0
+ * @version 0.2.1
* @author thzxx
* @license MIT
*/
import { meToast } from './api.js';
-import { Toast } from './toast.js';
+import { Toast, _containerCache } from './toast.js';
import { animationUtils } from './animations.js';
import { themeUtils } from './themes.js';
import { i18nUtils } from './i18n.js';
import { pluginUtils, presetPlugins } from './plugins.js';
-import { DEFAULTS } from './constants.js';
-import type { MeToast, ToastConfig, InitOptions, StatusInfo, ToastInstance, ToastOptions, Plugin } from './types.js';
+import type { ToastInstance } from './types.js';
// 版本信息
-const VERSION = '0.2.0';
+const VERSION = '0.2.1';
/**
- * 主对象增强
+ * 增强的 MeToast 对象 — 在 meToast 基础上注入子模块
*/
-const enhancedMeToast: MeToast = {
+const enhancedMeToast = {
...meToast,
version: VERSION,
@@ -31,228 +30,6 @@ const enhancedMeToast: MeToast = {
i18n: i18nUtils,
plugins: pluginUtils,
presetPlugins,
-
- /**
- * 安装插件
- */
- use(plugin: string | Record, options: Record = {}): MeToast {
- if (typeof plugin === 'string') {
- const preset = presetPlugins[plugin];
- if (!preset) {
- console.warn(`Preset plugin "${plugin}" not found`);
- return this;
- }
- this.plugins.register(plugin, { ...preset, ...options });
-
- // 连接插件钩子
- if (plugin === 'accessibility') {
- Toast.on('afterShow', (toast: ToastInstance) => {
- if (typeof preset.announce === 'function') preset.announce(toast);
- });
- }
- if (plugin === 'persistence') {
- const saved = typeof preset.install === 'function' ? preset.install(pluginUtils as unknown as import('./types.js').PluginManager) : null;
- if (saved) this.configure(saved as Partial);
- Toast.on('afterClose', () => {
- if (typeof (preset as Record).save === 'function') {
- (preset as Record void>).save(this.getConfig());
- }
- });
- }
- } else if (plugin && typeof plugin === 'object') {
- const name = (plugin as Record).name || 'custom';
- this.plugins.register(name, { ...plugin, ...options } as unknown as Plugin);
- }
-
- return this;
- },
-
- /**
- * 初始化
- */
- init(options: InitOptions = {}): MeToast {
- if (options.config) {
- this.configure(options.config);
- }
-
- if (options.theme) {
- this.themes.switchTheme(options.theme);
- }
-
- if (options.locale) {
- this.i18n.switchLocale(options.locale);
- }
-
- if (options.plugins && Array.isArray(options.plugins)) {
- options.plugins.forEach((p) => {
- this.use(p as string);
- });
- }
-
- return this;
- },
-
- /**
- * 销毁
- */
- destroy(): void {
- if (this._destroyed) return;
- this._destroyed = true;
- this.dismiss();
-
- if (this.plugins && typeof (this.plugins as unknown as Record).destroy === 'function') {
- (this.plugins as unknown as Record void>).destroy();
- }
-
- if (this.themes) {
- if (typeof this.themes.clearThemeListeners === 'function') {
- this.themes.clearThemeListeners();
- }
- if (typeof this.themes.unwatchSystemTheme === 'function') {
- this.themes.unwatchSystemTheme();
- }
- }
-
- if (this.i18n && typeof this.i18n.clearLocaleListeners === 'function') {
- this.i18n.clearLocaleListeners();
- }
-
- if (this.animations && typeof this.animations.cancelAll === 'function') {
- this.animations.cancelAll();
- }
-
- if (typeof document !== 'undefined') {
- const containers = document.querySelectorAll('.met-container');
- containers.forEach((c) => { if (c.parentNode) c.parentNode.removeChild(c); });
-
- const style = document.getElementById('metona-toast-styles');
- if (style && style.parentNode) style.parentNode.removeChild(style);
- }
-
- this._toasts.clear();
- },
-
- /**
- * 获取状态
- */
- getStatus(): StatusInfo {
- return {
- version: VERSION,
- toasts: this._toasts.size,
- theme: this.themes?.getCurrentTheme?.() || 'auto',
- locale: this.i18n?.getCurrentLocale?.() || 'zh-CN',
- plugins: this.plugins?.getNames?.() || [],
- animations: this.animations?.getActiveCount?.() || 0,
- };
- },
-
- /**
- * 获取配置
- */
- getConfig(): ToastConfig {
- return { ...this._config };
- },
-
- /**
- * 更新配置
- */
- updateConfig(config: Partial): MeToast {
- if (config && typeof config === 'object') {
- Object.assign(this._config, config);
- }
- return this;
- },
-
- /**
- * 重置配置
- */
- resetConfig(): MeToast {
- const keys = Object.keys(this._config);
- keys.forEach(k => delete (this._config as Record)[k]);
- Object.assign(this._config, DEFAULTS);
- return this;
- },
-
- /**
- * 获取Toast列表
- */
- getToasts(): ToastInstance[] {
- return Array.from(this._toasts.values());
- },
-
- /**
- * 检查是否有Toast
- */
- hasToasts(): boolean {
- return this._toasts.size > 0;
- },
-
- /**
- * 获取Toast
- */
- getToast(id: string): ToastInstance | null {
- if (!id) return null;
- return this._toasts.get(id) || null;
- },
-
- /**
- * 关闭所有Toast
- */
- closeAll(): void {
- this._toasts.forEach((t) => t.close());
- },
-
- /**
- * 清除所有Toast
- */
- clearAll(): void {
- this._toasts.forEach((t) => t.close());
- },
-
- /**
- * 暂停所有Toast
- */
- pauseAll(): void {
- this._toasts.forEach((t) => t._pause());
- },
-
- /**
- * 恢复所有Toast
- */
- resumeAll(): void {
- this._toasts.forEach((t) => t._resume());
- },
-
- /**
- * 更新所有Toast
- */
- updateAll(partial: Partial): void {
- if (partial && typeof partial === 'object') {
- this._toasts.forEach((t) => t.update(partial));
- }
- },
-
- /**
- * 查找Toast
- */
- findToasts(predicate: (toast: ToastInstance) => boolean): ToastInstance[] {
- if (typeof predicate !== 'function') return [];
- return Array.from(this._toasts.values()).filter(predicate);
- },
-
- /**
- * 按类型查找Toast
- */
- findByType(type: string): ToastInstance[] {
- return this.findToasts((t) => t.type === type);
- },
-
- /**
- * 按位置查找Toast
- */
- findByPosition(position: string): ToastInstance[] {
- return this.findToasts((t) => t.config.position === position);
- },
};
// 初始化主题和国际化
@@ -287,7 +64,7 @@ if (typeof window !== 'undefined') {
};
Toast.on('afterShow', (toast: ToastInstance) => {
- const config = toast.config as ToastConfig;
+ const config = toast.config;
if (config.notifyWhenHidden) {
((enhancedMeToast as unknown as Record void>)._notify)(toast);
}
diff --git a/src/locales.ts b/src/locales.ts
index 551b359..cf9de23 100644
--- a/src/locales.ts
+++ b/src/locales.ts
@@ -1,7 +1,7 @@
/**
* MetonaToast Locales — 国际化翻译数据
* @module locales
- * @version 0.2.0
+ * @version 0.2.1
* @description 内置 zh-CN / en-US 完整翻译
*/
diff --git a/src/plugins.ts b/src/plugins.ts
index a0b9644..dd1cf6f 100644
--- a/src/plugins.ts
+++ b/src/plugins.ts
@@ -1,7 +1,7 @@
/**
* MetonaToast Plugins — 插件系统
* @module plugins
- * @version 0.2.0
+ * @version 0.2.1
*/
import { t } from './i18n.js';
diff --git a/src/styles.ts b/src/styles.ts
index 3814e6f..caf475f 100644
--- a/src/styles.ts
+++ b/src/styles.ts
@@ -1,7 +1,7 @@
/**
* MetonaToast Styles — 样式管理
* @module styles
- * @version 0.2.0
+ * @version 0.2.1
*/
import type { ThemeConfig } from './types.js';
diff --git a/src/templates.ts b/src/templates.ts
index fe5d55c..fe6d472 100644
--- a/src/templates.ts
+++ b/src/templates.ts
@@ -1,7 +1,7 @@
/**
* MetonaToast Templates — HTML 模板辅助函数
* @module templates
- * @version 0.2.0
+ * @version 0.2.1
* @description confirm / prompt / progress / action 的 DOM 模板
*/
diff --git a/src/themes.ts b/src/themes.ts
index a25e190..eeec723 100644
--- a/src/themes.ts
+++ b/src/themes.ts
@@ -1,7 +1,7 @@
/**
* MetonaToast Themes — 主题管理
* @module themes
- * @version 0.2.0
+ * @version 0.2.1
*/
import { THEMES } from './constants.js';
@@ -30,13 +30,10 @@ export const getTheme = (theme: string): string => {
};
/**
- * 解析主题
+ * 解析主题 — 'auto' 始终按系统主题检测,不依赖当前手动选择的主题
*/
export const resolveTheme = (theme: string): string => {
if (theme === 'auto') {
- if (currentTheme && currentTheme !== 'auto') {
- return currentTheme;
- }
return getSystemTheme();
}
return theme;
diff --git a/src/toast.ts b/src/toast.ts
index 775f3cb..159c8fd 100644
--- a/src/toast.ts
+++ b/src/toast.ts
@@ -1,7 +1,7 @@
/**
* MetonaToast Toast — Toast 类
* @module toast
- * @version 0.2.0
+ * @version 0.2.1
*/
import { generateId, escapeHTML } from './utils.js';
@@ -16,36 +16,55 @@ import type { ToastConfig, ToastOptions, ToastInstance, ErrorInfo, TypeColor, Th
*/
export class Toast implements ToastInstance {
// 静态钩子系统
- static _hooks: Map void>> = new Map();
+ static _hooks: Map boolean | void>> = new Map();
- // 由 api.ts 注入的回调,避免循环依赖
- static _onError: ((errorInfo: ErrorInfo) => void) | null = null;
- static _removeToast: ((id: string) => void) | null = null;
+ // 由 api.ts 注入的回调,通过 setCallbacks() 设置
+ private static _onError: ((errorInfo: ErrorInfo) => void) | null = null;
+ private static _removeToast: ((id: string) => void) | null = null;
- static on(name: string, fn: (toast: Toast) => void): () => void {
+ /**
+ * 设置 Toast 类级别的回调函数
+ */
+ static setCallbacks(callbacks: {
+ onError?: ((errorInfo: ErrorInfo) => void) | null;
+ removeToast?: ((id: string) => void) | null;
+ }): void {
+ if (callbacks.onError !== undefined) Toast._onError = callbacks.onError;
+ if (callbacks.removeToast !== undefined) Toast._removeToast = callbacks.removeToast;
+ }
+
+ 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);
}
- static off(name: string, fn: (toast: Toast) => void): void {
+ 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));
}
- static trigger(name: string, toast: Toast): void {
+ /**
+ * 触发钩子 — 如果任意钩子返回 false 则整体返回 false
+ */
+ static trigger(name: string, toast: Toast): boolean {
const list = this._hooks.get(name);
- if (list) {
- list.forEach(fn => {
- try { fn(toast); }
- catch (e) {
- console.error('Hook error:', name, e);
- if (Toast._onError) {
- try { Toast._onError({ hook: name, error: e as Error, toast }); } catch (_e) { /* noop */ }
- }
+ if (!list || list.length === 0) return true;
+
+ let allow = true;
+ list.forEach(fn => {
+ try {
+ const result = fn(toast);
+ if (result === false) allow = false;
+ }
+ catch (e) {
+ console.error('Hook error:', name, e);
+ if (Toast._onError) {
+ try { Toast._onError({ hook: name, error: e as Error, toast }); } catch (_e) { /* noop */ }
}
- });
- }
+ }
+ });
+ return allow;
}
id: string;
@@ -92,7 +111,18 @@ export class Toast implements ToastInstance {
create(): this {
if (typeof window === 'undefined' || typeof document === 'undefined') return this;
- Toast.trigger('beforeShow', this);
+ // beforeShow 钩子 — 返回 false 可阻止显示
+ if (!Toast.trigger('beforeShow', this)) return this;
+
+ // onBeforeShow 配置回调 — 返回 false 可阻止显示
+ if (typeof this.config.onBeforeShow === 'function') {
+ try {
+ if (this.config.onBeforeShow(this) === false) return this;
+ } catch (e) {
+ console.error('onBeforeShow callback error:', e);
+ }
+ }
+
injectStyles();
const container = this._getContainer();
@@ -142,9 +172,15 @@ export class Toast implements ToastInstance {
}
_getContainer(): HTMLElement {
- const position = this.config.position || 'top-right';
+ const rawPosition = this.config.position || 'top-right';
const zIndex = this.config.zIndex || 9999;
+ // RTL 语言时翻转 left/right
+ const isRTL = getLocaleDirection(getCurrentLocale()) === 'rtl';
+ const position = isRTL
+ ? rawPosition.replace('left', '__TMP__').replace('right', 'left').replace('__TMP__', 'right')
+ : rawPosition;
+
// 从模块级缓存获取 (由 api.ts 管理)
const cached = _containerCache.get(document.body);
if (cached && cached.has(position)) {
@@ -481,6 +517,51 @@ export class Toast implements ToastInstance {
return this;
}
+ /**
+ * 运行时移动 Toast 到新位置
+ */
+ updatePosition(position: string): this {
+ if (!position || !this.el) return this;
+
+ const oldContainer = this.el.parentNode;
+ this.config.position = position as ToastConfig['position'];
+
+ const newContainer = this._getContainer();
+ if (oldContainer === newContainer) return this;
+
+ // 从旧容器移除,加入新容器
+ if (oldContainer && this.el.parentNode === oldContainer) {
+ oldContainer.removeChild(this.el);
+ }
+ const pos = position;
+ if (pos.startsWith('top')) {
+ newContainer.insertBefore(this.el, newContainer.firstChild);
+ } else {
+ newContainer.appendChild(this.el);
+ }
+
+ return this;
+ }
+
+ /**
+ * 立即从 DOM 和内存中移除(不触发离场动画)
+ */
+ remove(): void {
+ if (this.rafId !== null) {
+ cancelAnimationFrame(this.rafId);
+ this.rafId = null;
+ }
+ this._cleanups.forEach(fn => { try { fn(); } catch (_e) { /* noop */ } });
+ this._cleanups = [];
+
+ if (this.el && this.el.parentNode) {
+ this.el.parentNode.removeChild(this.el);
+ }
+ this.el = null;
+
+ if (Toast._removeToast) Toast._removeToast(this.id);
+ }
+
close(immediate = false): void {
if (this.closing) return;
this.closing = true;
diff --git a/src/types.ts b/src/types.ts
index 1dd0234..809a0b7 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,7 +1,7 @@
/**
* MetonaToast — 核心类型定义
* @module types
- * @version 0.2.0
+ * @version 0.2.1
*/
// ========== 基础类型 ==========
@@ -83,6 +83,7 @@ export interface ToastConfig {
className?: string;
style?: Record;
onShow?: ((toast: ToastInstance) => void) | null;
+ onBeforeShow?: ((toast: ToastInstance) => boolean | void) | null;
onClose?: ((toast: ToastInstance) => void) | null;
onClick?: ((toast: ToastInstance) => void) | null;
onUpdate?: ((toast: ToastInstance) => void) | null;
@@ -135,6 +136,8 @@ export interface ToastInstance {
_palette(): { theme: string; c: TypeColor; t: Partial };
create(): this;
update(partial: Partial): this;
+ updatePosition(position: string): this;
+ remove(): void;
close(immediate?: boolean): void;
_pause(): void;
_resume(): void;
@@ -485,6 +488,7 @@ export interface MeToast {
// Toast 管理
find(id: string): ToastInstance | undefined;
dismiss(id?: string): void;
+ removeToast(id: string): void;
clear(position?: ToastPosition): void;
getToasts(): ToastInstance[];
count(): number;
diff --git a/src/utils.ts b/src/utils.ts
index d417458..65d3d67 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -1,7 +1,7 @@
/**
* MetonaToast Utils — 工具函数
* @module utils
- * @version 0.2.0
+ * @version 0.2.1
*/
/**
diff --git a/tests/coverage.test.ts b/tests/coverage.test.ts
new file mode 100644
index 0000000..dd3b22a
--- /dev/null
+++ b/tests/coverage.test.ts
@@ -0,0 +1,1301 @@
+/**
+ * MetonaToast 覆盖率补充测试 — 目标 95%+
+ * @module tests
+ * @version 0.2.1
+ */
+
+import MeToast, { Toast, VERSION } from '../src/index';
+import type { ToastInstance, ToastConfig } from '../src/types';
+
+// ========== DOM Mock (与 index.test.ts 一致) ==========
+const createMockElement = (): Record => ({
+ className: '',
+ style: {} as Record,
+ setAttribute: jest.fn(),
+ appendChild: jest.fn(),
+ querySelector: jest.fn(() => null),
+ querySelectorAll: jest.fn(() => []),
+ execCommand: jest.fn(() => true),
+ classList: { add: jest.fn(), remove: jest.fn(), contains: jest.fn() },
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ setPointerCapture: jest.fn(),
+ releasePointerCapture: jest.fn(),
+ animate: jest.fn(() => ({ onfinish: null, oncancel: null, cancel: jest.fn(), pause: jest.fn(), play: jest.fn() })),
+ innerHTML: '',
+ textContent: '',
+ dataset: {} as Record,
+ parentNode: { removeChild: jest.fn() },
+ removeChild: jest.fn(),
+ insertBefore: jest.fn(),
+ closest: jest.fn(() => null),
+ getBoundingClientRect: jest.fn(() => ({ top: 0, left: 0, width: 360, height: 60, right: 360, bottom: 60 })),
+ setPointerCapture: jest.fn(),
+ releasePointerCapture: jest.fn(),
+ remove: jest.fn(),
+ focus: jest.fn(),
+ click: jest.fn(),
+ contains: jest.fn(() => false),
+});
+
+const mockDocument = {
+ createElement: jest.fn(() => createMockElement()),
+ getElementById: jest.fn(),
+ querySelector: jest.fn(),
+ querySelectorAll: jest.fn(() => []),
+ head: { appendChild: jest.fn(), removeChild: jest.fn() },
+ body: { appendChild: jest.fn(), querySelectorAll: jest.fn(() => []), removeChild: jest.fn() },
+ readyState: 'complete',
+ addEventListener: jest.fn(),
+ execCommand: jest.fn(() => true),
+ documentElement: {
+ setAttribute: jest.fn(),
+ classList: { add: jest.fn(), remove: jest.fn() },
+ style: { setProperty: jest.fn(), removeProperty: jest.fn() },
+ },
+ hidden: false,
+ createEvent: jest.fn(() => ({ initEvent: jest.fn() })),
+};
+
+let mediaMatch = false;
+const mockWindow = {
+ matchMedia: jest.fn(() => ({
+ matches: mediaMatch,
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ })),
+ requestAnimationFrame: jest.fn((cb: FrameRequestCallback) => setTimeout(cb, 0) as unknown as number),
+ cancelAnimationFrame: jest.fn(),
+ getComputedStyle: jest.fn(() => ({})),
+ innerWidth: 1024,
+ innerHeight: 768,
+ navigator: {
+ language: 'zh-CN',
+ userLanguage: undefined,
+ clipboard: { writeText: jest.fn() },
+ connection: { effectiveType: '4g', downlink: 10, rtt: 50 },
+ onLine: true,
+ },
+ location: { search: '', href: 'http://localhost' },
+ history: { pushState: jest.fn() },
+ localStorage: {
+ _store: {} as Record,
+ getItem: jest.fn((k: string) => mockWindow.localStorage._store[k] || null),
+ setItem: jest.fn((k: string, v: string) => { mockWindow.localStorage._store[k] = v; }),
+ removeItem: jest.fn((k: string) => { delete mockWindow.localStorage._store[k]; }),
+ clear: jest.fn(() => { mockWindow.localStorage._store = {}; }),
+ },
+ Audio: jest.fn(() => ({ play: jest.fn(), volume: 0 })),
+ Notification: undefined,
+ PointerEvent: class {},
+};
+
+(global as Record).document = mockDocument;
+(global as Record).window = mockWindow;
+(global as Record).navigator = mockWindow.navigator;
+(global as Record).localStorage = mockWindow.localStorage;
+(global as Record).requestAnimationFrame = mockWindow.requestAnimationFrame;
+(global as Record).cancelAnimationFrame = mockWindow.cancelAnimationFrame;
+
+// ========== beforeEach cleanup ==========
+beforeEach(() => {
+ jest.clearAllMocks();
+ mockWindow.localStorage._store = {};
+ MeToast._toasts.clear();
+ Toast._hooks.clear();
+ const { _containerCache } = require('../src/toast.js');
+ _containerCache.clear();
+ mockDocument.querySelectorAll = jest.fn(() => []);
+ mockDocument.body.appendChild = jest.fn();
+ mediaMatch = false;
+});
+
+// ==================================================================
+// 1. animations.ts 覆盖补充
+// ==================================================================
+describe('animations.ts 覆盖率', () => {
+ test('reset() 重置为默认动画', () => {
+ MeToast.animations.register('custom', { enter: {}, leave: {} });
+ MeToast.animations.reset();
+ expect(MeToast.animations.get('slide')).toBeDefined();
+ expect(MeToast.animations.get('custom')).toBeNull();
+ });
+
+ test('destroy() 清空所有动画', () => {
+ MeToast.animations.destroy();
+ expect(MeToast.animations.getAnimationNames()).toHaveLength(0);
+ MeToast.animations.reset();
+ });
+
+ test('createAnimation 工具函数', () => {
+ const { createAnimation } = require('../src/animations.js');
+ const anim = createAnimation({ enter: { opacity: '0' } });
+ expect(anim.enter).toEqual({ opacity: '0' });
+ expect(anim.leave).toEqual({});
+ expect(anim.duration).toBe(300);
+ expect(anim.easing).toBe('cubic-bezier(0.4, 0, 0.2, 1)');
+ });
+
+ test('getActiveCount 返回动画数量', () => {
+ expect(MeToast.animations.getActiveCount()).toBeGreaterThan(0);
+ });
+});
+
+// ==================================================================
+// 2. themes.ts 覆盖率补充
+// ==================================================================
+describe('themes.ts 覆盖率', () => {
+ test('toggleTheme 在 light/dark 之间切换', () => {
+ const { switchTheme, toggleTheme, getCurrentTheme } = require('../src/themes.js');
+ switchTheme('light');
+ toggleTheme();
+ expect(getCurrentTheme()).toBe('dark');
+ toggleTheme();
+ expect(getCurrentTheme()).toBe('light');
+ });
+
+ test('resetToAuto 重置为自动', () => {
+ const { resetToAuto, getCurrentTheme } = require('../src/themes.js');
+ resetToAuto();
+ expect(getCurrentTheme()).toBe('auto');
+ });
+
+ test('getResolvedTheme 返回解析后的主题', () => {
+ const { getResolvedTheme, switchTheme } = require('../src/themes.js');
+ switchTheme('dark');
+ expect(getResolvedTheme()).toBe('dark');
+ switchTheme('auto');
+ expect(['light', 'dark']).toContain(getResolvedTheme());
+ });
+
+ test('addThemeListener / removeThemeListener / clearThemeListeners', () => {
+ const { addThemeListener, removeThemeListener, clearThemeListeners, switchTheme } = require('../src/themes.js');
+ const fn = jest.fn();
+ const unsub = addThemeListener(fn);
+ switchTheme('dark');
+ expect(fn).toHaveBeenCalledWith('dark', 'dark');
+ removeThemeListener(fn);
+ fn.mockClear();
+ switchTheme('light');
+ expect(fn).not.toHaveBeenCalled();
+ });
+
+ test('unregisterTheme 不能卸载内置主题', () => {
+ const consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => {});
+ const { unregisterTheme } = require('../src/themes.js');
+ unregisterTheme('light');
+ expect(consoleWarn).toHaveBeenCalled();
+ consoleWarn.mockRestore();
+ });
+
+ test('unregisterTheme 卸载自定义主题', () => {
+ const { registerTheme, unregisterTheme, hasTheme } = require('../src/themes.js');
+ registerTheme('custom-1', { bg: '#fff', text: '#000', border: '#ccc', shadow: 'none', hoverShadow: 'none', progressBg: '#eee', closeHoverBg: '#ddd' });
+ expect(hasTheme('custom-1')).toBe(true);
+ unregisterTheme('custom-1');
+ expect(hasTheme('custom-1')).toBe(false);
+ });
+
+ test('getThemePreview 返回预览信息', () => {
+ const { getThemePreview } = require('../src/themes.js');
+ const preview = getThemePreview('dark');
+ expect(preview.name).toBe('dark');
+ expect(preview.isDark).toBe(true);
+ expect(preview.colors.background).toBeDefined();
+ });
+
+ test('getThemePreview auto 模式', () => {
+ const { getThemePreview } = require('../src/themes.js');
+ const preview = getThemePreview('auto');
+ expect(preview.isAuto).toBe(true);
+ });
+
+ test('generateThemeCSS 生成 CSS', () => {
+ const { generateThemeCSS } = require('../src/themes.js');
+ const css = generateThemeCSS('light');
+ expect(css).toContain('--met-bg');
+ expect(css).toContain('--met-text');
+ });
+
+ test('applyThemeCSS / removeThemeCSS 不抛异常', () => {
+ const { applyThemeCSS, removeThemeCSS } = require('../src/themes.js');
+ expect(() => applyThemeCSS('dark')).not.toThrow();
+ expect(() => removeThemeCSS()).not.toThrow();
+ });
+
+ test('saveTheme / loadTheme 读写 localStorage', () => {
+ const { saveTheme, loadTheme } = require('../src/themes.js');
+ expect(() => saveTheme('warm')).not.toThrow();
+ const loaded = loadTheme();
+ expect(typeof loaded).toBe('string');
+ });
+
+ test('initTheme 从 localStorage 恢复', () => {
+ const { initTheme, getCurrentTheme, saveTheme } = require('../src/themes.js');
+ saveTheme('dark');
+ initTheme();
+ expect(typeof getCurrentTheme()).toBe('string');
+ });
+
+ test('watchSystemTheme / unwatchSystemTheme 不抛异常', () => {
+ const { watchSystemTheme, unwatchSystemTheme } = require('../src/themes.js');
+ expect(() => watchSystemTheme()).not.toThrow();
+ expect(() => unwatchSystemTheme()).not.toThrow();
+ });
+
+ test('setThemeVariables / clearThemeVariables 不抛异常', () => {
+ const { setThemeVariables, clearThemeVariables, getThemeConfig } = require('../src/themes.js');
+ const config = getThemeConfig('light');
+ expect(() => setThemeVariables(config)).not.toThrow();
+ expect(() => clearThemeVariables()).not.toThrow();
+ });
+
+ test('getAllThemes / getThemeNames / hasTheme', () => {
+ const { getAllThemes, getThemeNames, hasTheme } = require('../src/themes.js');
+ expect(getAllThemes().light).toBeDefined();
+ expect(getThemeNames()).toContain('light');
+ expect(hasTheme('nonexistent')).toBe(false);
+ });
+});
+
+// ==================================================================
+// 3. i18n.ts 覆盖率补充
+// ==================================================================
+describe('i18n.ts 覆盖率', () => {
+ const i18n = require('../src/i18n.js');
+
+ test('getFallbackLocale / setFallbackLocale', () => {
+ i18n.setFallbackLocale('en-US');
+ expect(i18n.getFallbackLocale()).toBe('en-US');
+ i18n.setFallbackLocale('zh-CN');
+ });
+
+ test('setFallbackLocale 不支持的语言不改变', () => {
+ const before = i18n.getFallbackLocale();
+ i18n.setFallbackLocale('nonexistent');
+ expect(i18n.getFallbackLocale()).toBe(before);
+ });
+
+ test('getTranslations 返回翻译数据', () => {
+ const trans = i18n.getTranslations('zh-CN');
+ expect(trans.close).toBe('关闭');
+ });
+
+ test('addTranslations 添加新语言', () => {
+ i18n.addTranslations('ja', { close: '閉じる', confirm: '確認' });
+ expect(i18n.t('close')).toBeDefined();
+ });
+
+ test('addTranslations 深度合并', () => {
+ i18n.addTranslations('ja', { nested: { key: 'value' } });
+ i18n.addTranslations('ja', { nested: { key2: 'value2' } });
+ const trans = i18n.getTranslations('ja');
+ expect((trans as Record).nested).toBeDefined();
+ });
+
+ test('removeTranslation 删除翻译 key', () => {
+ i18n.addTranslations('ja', { test_key: 'テスト' });
+ i18n.removeTranslation('ja', 'test_key');
+ expect(i18n.hasTranslation('test_key')).toBe(false);
+ });
+
+ test('removeTranslation 嵌套 key', () => {
+ i18n.addTranslations('ja', { a: { b: { c: 'val' } } });
+ i18n.removeTranslation('ja', 'a.b.c');
+ // 不应抛异常
+ });
+
+ test('clearTranslations 清除语言', () => {
+ i18n.addTranslations('ja', { test: 'テスト' });
+ i18n.clearTranslations('ja');
+ expect(i18n.getTranslations('ja')).toEqual({});
+ });
+
+ test('getLocaleInfo / getAllLocaleInfo', () => {
+ const info = i18n.getLocaleInfo('zh-CN');
+ expect(info.code).toBe('zh-CN');
+ expect(info.isSupported).toBe(true);
+ expect(info.direction).toBe('ltr');
+
+ const all = i18n.getAllLocaleInfo();
+ expect(all.length).toBeGreaterThan(0);
+ });
+
+ test('saveLocale / loadLocale / getDefaultLocale 不抛异常', () => {
+ expect(() => i18n.saveLocale('en-US')).not.toThrow();
+ const loaded = i18n.loadLocale();
+ expect(typeof loaded).toBe('string');
+ const def = i18n.getDefaultLocale();
+ expect(typeof def).toBe('string');
+ });
+
+ test('initI18n 从 localStorage 恢复', () => {
+ i18n.saveLocale('en-US');
+ i18n.initI18n();
+ expect(typeof i18n.getCurrentLocale()).toBe('string');
+ i18n.switchLocale('zh-CN');
+ });
+
+ test('addLocaleListener / removeLocaleListener / clearLocaleListeners', () => {
+ const fn = jest.fn();
+ const unsub = i18n.addLocaleListener(fn);
+ i18n.switchLocale('en-US');
+ expect(fn).toHaveBeenCalledWith('en-US');
+ i18n.removeLocaleListener(fn);
+ fn.mockClear();
+ i18n.switchLocale('zh-CN');
+ expect(fn).not.toHaveBeenCalled();
+ unsub?.();
+ });
+
+ test('formatPercent', () => {
+ const result = i18n.formatPercent(75);
+ expect(typeof result).toBe('string');
+ });
+
+ test('formatTime', () => {
+ const result = i18n.formatTime(new Date());
+ expect(typeof result).toBe('string');
+ });
+
+ test('formatRelativeTime', () => {
+ const future = new Date(Date.now() + 3600000);
+ const result = i18n.formatRelativeTime(future);
+ expect(typeof result).toBe('string');
+ });
+
+ test('formatRelativeTime with past date', () => {
+ const past = new Date(Date.now() - 86400000);
+ const result = i18n.formatRelativeTime(past);
+ expect(typeof result).toBe('string');
+ });
+
+ test('formatList 使用 Intl.ListFormat', () => {
+ const result = i18n.formatList(['苹果', '香蕉', '橙子']);
+ expect(typeof result).toBe('string');
+ });
+
+ test('formatPlural', () => {
+ const result1 = i18n.formatPlural(1);
+ const result2 = i18n.formatPlural(5);
+ expect(typeof result1).toBe('string');
+ expect(typeof result2).toBe('string');
+ });
+
+ test('plural 翻译 不抛异常', () => {
+ i18n.addTranslations('zh-CN', { 'item.one': '{count} 个', 'item.other': '{count} 个' });
+ const result = i18n.plural('item', 5);
+ expect(typeof result).toBe('string');
+ });
+
+ test('plural 无复数形式时 fallback', () => {
+ const result = i18n.plural('success', 5);
+ expect(typeof result).toBe('string');
+ });
+
+ test('setCurrentLocale 不支持的语言 fallback', () => {
+ const consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => {});
+ i18n.setCurrentLocale('nonexistent');
+ expect(consoleWarn).toHaveBeenCalled();
+ consoleWarn.mockRestore();
+ });
+
+ test('dateTimeFormats 预设格式', () => {
+ expect(i18n.dateTimeFormats.short).toBeDefined();
+ expect(i18n.dateTimeFormats.long).toBeDefined();
+ });
+
+ test('numberFormats 预设格式', () => {
+ expect(i18n.numberFormats.integer).toBeDefined();
+ expect(i18n.numberFormats.currency).toBeDefined();
+ });
+
+ test('presetLocales 预设语言包', () => {
+ expect(i18n.presetLocales['zh-CN']).toBeDefined();
+ expect(i18n.presetLocales['en-US']).toBeDefined();
+ });
+
+ test('formatCurrency 默认 USD', () => {
+ const result = i18n.formatCurrency(100);
+ expect(typeof result).toBe('string');
+ });
+
+ test('formatDate with timestamp', () => {
+ const result = i18n.formatDate(Date.now());
+ expect(typeof result).toBe('string');
+ });
+});
+
+// ==================================================================
+// 4. plugins.ts 覆盖率补充
+// ==================================================================
+describe('plugins.ts 覆盖率', () => {
+ test('PluginManager.destroy 调用所有插件的 destroy', () => {
+ const { PluginManager } = require('../src/plugins.js');
+ const manager = new PluginManager();
+ const destroyFn = jest.fn();
+ manager.register('p1', { name: 'p1', destroy: destroyFn });
+ manager.register('p2', { name: 'p2' });
+ manager.destroy();
+ expect(destroyFn).toHaveBeenCalled();
+ expect(manager.getAll()).toHaveLength(0);
+ });
+
+ test('pluginUtils.createManager 创建独立管理器', () => {
+ const { createManager } = MeToast.plugins as unknown as Record unknown>;
+ const manager = createManager();
+ expect(manager).toBeDefined();
+ });
+
+ test('pluginUtils.createPlugin 创建插件对象', () => {
+ const pluginUtils = require('../src/plugins.js').pluginUtils;
+ const plugin = pluginUtils.createPlugin({ name: 'custom', version: '2.0' });
+ expect(plugin.name).toBe('custom');
+ expect(plugin.version).toBe('2.0');
+ expect(plugin.hooks).toEqual({});
+ });
+
+ test('pluginUtils.validatePlugin 验证插件', () => {
+ const pluginUtils = require('../src/plugins.js').pluginUtils;
+ const valid = pluginUtils.validatePlugin({ name: 'test' });
+ expect(valid.valid).toBe(true);
+ const invalid = pluginUtils.validatePlugin({});
+ expect(invalid.valid).toBe(false);
+ expect(invalid.errors.length).toBeGreaterThan(0);
+ });
+
+ test('pluginUtils.getPreset / getAllPresets', () => {
+ const pluginUtils = require('../src/plugins.js').pluginUtils;
+ expect(pluginUtils.getPreset('keyboard')).toBeDefined();
+ expect(pluginUtils.getPreset('nonexistent')).toBeNull();
+ const all = pluginUtils.getAllPresets();
+ expect(all.keyboard).toBeDefined();
+ expect(all.persistence).toBeDefined();
+ expect(all.accessibility).toBeDefined();
+ });
+
+ test('预设插件 persistence 生命周期 不抛异常', () => {
+ const pluginUtils = require('../src/plugins.js').pluginUtils;
+ const preset = pluginUtils.getPreset('persistence');
+ // install 读取 localStorage(可能返回 null)
+ const result = preset?.install?.(pluginUtils as unknown as import('../src/types.js').PluginManager);
+ expect(result === null || typeof result === 'object').toBe(true);
+
+ const saveFn = (preset as Record void>).save;
+ expect(() => saveFn({ duration: 3000 })).not.toThrow();
+ expect(() => preset?.uninstall?.()).not.toThrow();
+ });
+
+ test('预设插件 keyboard install/uninstall 不抛异常', () => {
+ const pluginUtils = require('../src/plugins.js').pluginUtils;
+ const preset = pluginUtils.getPreset('keyboard');
+ expect(() => preset?.install?.()).not.toThrow();
+ expect(() => preset?.uninstall?.()).not.toThrow();
+ });
+
+ test('预设插件 accessibility.announce 不抛异常', () => {
+ const pluginUtils = require('../src/plugins.js').pluginUtils;
+ const preset = pluginUtils.getPreset('accessibility');
+ const announceFn = (preset as Record void>).announce;
+ expect(() => announceFn({ type: 'success', title: '标题', message: '消息' })).not.toThrow();
+ });
+
+ test('register 无效插件打印错误', () => {
+ const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
+ MeToast.plugins.register('bad', {} as Record as import('../src/types.js').Plugin);
+ expect(consoleError).toHaveBeenCalled();
+ consoleError.mockRestore();
+ });
+});
+
+// ==================================================================
+// 5. templates.ts 覆盖率补充
+// ==================================================================
+describe('templates.ts 覆盖率', () => {
+ const templates = require('../src/templates.js');
+
+ test('btnRow 生成按钮行', () => {
+ const html = templates.btnRow('');
+ expect(html).toContain('display: flex');
+ expect(html).toContain('');
+ });
+
+ test('confirmHTML 使用默认值', () => {
+ const html = templates.confirmHTML({});
+ expect(html).toContain('met-confirm-btn');
+ expect(html).toContain('met-cancel-btn');
+ });
+
+ test('confirmHTML 自定义文本和颜色', () => {
+ const html = templates.confirmHTML({
+ confirmText: '删除',
+ confirmColor: '#ff0000',
+ cancelText: '保留',
+ cancelColor: '#00ff00',
+ });
+ expect(html).toContain('删除');
+ expect(html).toContain('保留');
+ expect(html).toContain('#ff0000');
+ });
+
+ test('promptHTML 使用默认值', () => {
+ const html = templates.promptHTML({});
+ expect(html).toContain('met-input');
+ expect(html).toContain('met-submit-btn');
+ });
+
+ test('promptHTML 自定义 inputType', () => {
+ const html = templates.promptHTML({
+ inputType: 'password',
+ placeholder: '请输入密码',
+ defaultValue: '123',
+ });
+ expect(html).toContain('type="password"');
+ expect(html).toContain('请输入密码');
+ expect(html).toContain('123');
+ });
+
+ test('progressHTML 使用默认值', () => {
+ const html = templates.progressHTML({});
+ expect(html).toContain('met-progress-bar');
+ expect(html).toContain('met-progress-fill');
+ expect(html).toContain('met-progress-text');
+ });
+
+ test('progressHTML 自定义颜色', () => {
+ const html = templates.progressHTML({ progressColor: '#10b981' });
+ expect(html).toContain('#10b981');
+ });
+
+ test('actionHTML 空数组返回空字符串', () => {
+ const html = templates.actionHTML([]);
+ expect(html).toBe('');
+ });
+
+ test('actionHTML 生成按钮', () => {
+ const html = templates.actionHTML([
+ { text: '撤销', onClick: () => {}, color: '#3b82f6' },
+ { text: '查看', onClick: () => {}, close: false },
+ ]);
+ expect(html).toContain('met-action-btn-0');
+ expect(html).toContain('met-action-btn-1');
+ expect(html).toContain('撤销');
+ expect(html).toContain('查看');
+ });
+});
+
+// ==================================================================
+// 6. api.ts 覆盖率补充
+// ==================================================================
+describe('api.ts 覆盖率', () => {
+ beforeEach(() => {
+ MeToast.resetConfig();
+ });
+
+ test('promise reject 路径', async () => {
+ const promise = Promise.reject(new Error('fail'));
+ try {
+ await MeToast.promise(promise, { loading: '加载中', success: '成功', error: '失败' });
+ } catch (e) {
+ expect(e).toBeDefined();
+ }
+ expect(MeToast.count()).toBeGreaterThan(0);
+ });
+
+ test('queue 完整执行并 await', async () => {
+ const q = MeToast.queue(['a', 'b'], { delay: 5, duration: 5 });
+ await q;
+ expect(MeToast.count()).toBeGreaterThanOrEqual(0);
+ });
+
+ test('queue 中途取消', async () => {
+ const q = MeToast.queue(['a', 'b', 'c'], { delay: 5, duration: 5 });
+ q.cancel();
+ await q;
+ });
+
+ test('queue 消息级 onClose 回调', async () => {
+ const onClose1 = jest.fn();
+ const q = MeToast.queue([
+ { message: 'a', onClose: onClose1 },
+ 'b',
+ ], { delay: 5, duration: 5 });
+ await q;
+ // onClose 在 toast 关闭时被调用
+ });
+
+ test('stack 混合类型 不抛异常', () => {
+ expect(() => MeToast.stack([
+ '普通消息',
+ { message: '警告', type: 'warning' as const, duration: 500 },
+ ], { stagger: 5, type: 'info' as const })).not.toThrow();
+ });
+
+ test('group 所有方法', () => {
+ const g = MeToast.group('all-methods');
+ const t1 = g.show('default');
+ expect(t1.group).toBe('all-methods');
+
+ const t2 = g.success('success');
+ expect(t2.group).toBe('all-methods');
+
+ const t3 = g.error('error');
+ expect(t3.group).toBe('all-methods');
+
+ const t4 = g.warning('warning');
+ expect(t4.group).toBe('all-methods');
+
+ const t5 = g.info('info', { duration: 5 });
+ expect(t5.group).toBe('all-methods');
+
+ const l = g.loading('loading');
+ expect(l.id).toBeDefined();
+ l.dismiss();
+
+ const a = g.action('action', [{ text: 'OK', onClick: () => {} }]);
+ expect(a.id).toBeDefined();
+ a.dismiss();
+
+ expect(g.count()).toBeGreaterThanOrEqual(5);
+ g.dismiss();
+ });
+
+ test('dismissGroup 关闭分组', () => {
+ const g = MeToast.group('cleanup');
+ g.success('m1');
+ g.error('m2');
+ expect(MeToast._groupCount('cleanup')).toBe(2);
+ MeToast.dismissGroup('cleanup');
+ });
+
+ test('clear 按位置清除', () => {
+ MeToast.success('top-right', { position: 'top-right' });
+ MeToast.error('bottom-left', { position: 'bottom-left' });
+ MeToast.clear('bottom-left');
+ const remaining = MeToast.findByPosition('top-right');
+ expect(remaining.length).toBe(1);
+ MeToast.clear();
+ });
+
+ test('getAll 返回 Map 副本', () => {
+ MeToast.success('m1');
+ MeToast.error('m2');
+ const all = MeToast.getAll();
+ expect(all.size).toBe(2);
+ expect(all instanceof Map).toBe(true);
+ });
+
+ test('count / hasToasts / getToast / find', () => {
+ const t = MeToast.info('test');
+ expect(MeToast.count()).toBe(1);
+ expect(MeToast.hasToasts()).toBe(true);
+ expect(MeToast.getToast(t.id)?.id).toBe(t.id);
+ expect(MeToast.find(t.id)?.id).toBe(t.id);
+ expect(MeToast.getToast(null as unknown as string)).toBeNull();
+ expect(MeToast.find(undefined as unknown as string)).toBeUndefined();
+ });
+
+ test('findByType / findByPosition', () => {
+ MeToast.success('s1');
+ MeToast.success('s2');
+ MeToast.error('e1');
+ expect(MeToast.findByType('success')).toHaveLength(2);
+ expect(MeToast.findByPosition('top-right')).toHaveLength(3);
+ });
+
+ test('closeAll / clearAll / pauseAll / resumeAll / updateAll', () => {
+ MeToast.success('m1', { duration: 5000 });
+ MeToast.error('m2', { duration: 5000 });
+
+ MeToast.pauseAll();
+ const toasts = MeToast.getToasts();
+ toasts.forEach(t => expect(t.paused).toBe(true));
+
+ MeToast.resumeAll();
+ toasts.forEach(t => expect(t.paused).toBe(false));
+
+ MeToast.updateAll({ type: 'info' });
+ toasts.forEach(t => expect(t.type).toBe('info'));
+
+ MeToast.closeAll();
+ });
+
+ test('init 通过 options 配置', () => {
+ MeToast.init({
+ config: { duration: 2000 },
+ theme: 'dark',
+ locale: 'en-US',
+ plugins: ['keyboard'],
+ });
+ const cfg = MeToast.getConfig();
+ expect(cfg.duration).toBe(2000);
+ expect(MeToast.themes.getCurrentTheme()).toBe('dark');
+ expect(MeToast.i18n.getCurrentLocale()).toBe('en-US');
+ MeToast.resetConfig();
+ MeToast.themes.switchTheme('auto');
+ MeToast.i18n.switchLocale('zh-CN');
+ });
+
+ test('updateConfig / resetConfig', () => {
+ MeToast.updateConfig({ position: 'bottom-center' });
+ expect(MeToast.getConfig().position).toBe('bottom-center');
+ MeToast.resetConfig();
+ expect(MeToast.getConfig().position).toBe('top-right');
+ });
+
+ test('getStatus 返回完整状态', () => {
+ const status = MeToast.getStatus();
+ expect(status.version).toBe('0.2.1');
+ expect(status.toasts).toBeGreaterThanOrEqual(0);
+ expect(status.theme).toBeDefined();
+ expect(status.locale).toBeDefined();
+ expect(Array.isArray(status.plugins)).toBe(true);
+ });
+
+ test('use 不存在的预设插件打印警告', () => {
+ const consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => {});
+ MeToast.use('nonexistent');
+ expect(consoleWarn).toHaveBeenCalled();
+ consoleWarn.mockRestore();
+ });
+
+ test('use 自定义插件对象', () => {
+ const plugin = { name: 'my-plugin', install: jest.fn() };
+ MeToast.use(plugin);
+ expect(MeToast.plugins.has('my-plugin')).toBe(true);
+ });
+
+ test('destroy 完整清理', () => {
+ MeToast.success('test');
+ MeToast.destroy();
+ expect(MeToast.count()).toBeGreaterThanOrEqual(0);
+ });
+
+ test('getConfig / configure with theme and locale side effects', () => {
+ MeToast.configure({ theme: 'warm', locale: 'en-US' });
+ const cfg = MeToast.getConfig();
+ expect(cfg.theme).toBe('warm');
+ expect(cfg.locale).toBe('en-US');
+ MeToast.resetConfig();
+ MeToast.i18n.switchLocale('zh-CN');
+ });
+
+ test('confirm 创建并返回 Promise', async () => {
+ const promise = MeToast.confirm('确定删除?', { confirmText: '是', confirmColor: '#ff0000' });
+ expect(promise).toBeInstanceOf(Promise);
+ });
+
+ test('countdown onComplete 回调', (done) => {
+ const c = MeToast.countdown('{seconds}s', 1, {
+ onComplete: () => {
+ done();
+ },
+ });
+ expect(c.id).toBeDefined();
+ // timer 每秒 tick,1s 后会完成
+ }, 3000);
+});
+
+// ==================================================================
+// 7. toast.ts 覆盖率补充
+// ==================================================================
+describe('toast.ts 覆盖率', () => {
+ test('updatePosition 无 el 时仅早返回不抛异常', () => {
+ const toast = new Toast({ message: 'test' });
+ expect(toast.el).toBeNull();
+ // 无 el 时 updatePosition 直接返回,不改变任何状态
+ expect(() => toast.updatePosition('bottom-left')).not.toThrow();
+ });
+
+ test('updatePosition 同位置不抛异常', () => {
+ const t = MeToast.info('test');
+ const initialContainer = t.el?.parentNode;
+ t.updatePosition('top-right'); // 同位置
+ expect(t.el?.parentNode).toBe(initialContainer || null);
+ });
+
+ test('close immediate 模式', (done) => {
+ const t = MeToast.info('test');
+ t.close(true);
+ expect(t.closing).toBe(true);
+ setTimeout(() => {
+ expect(t.el).toBeNull();
+ done();
+ }, 50);
+ });
+
+ test('_limitToasts 超出 max 时移除最早的', () => {
+ MeToast.configure({ max: 3 });
+ const t1 = MeToast.info('m1');
+ const t2 = MeToast.info('m2');
+ const t3 = MeToast.info('m3');
+ // 第4个应触发移除最早的
+ const t4 = MeToast.info('m4');
+ expect(MeToast.count()).toBeLessThanOrEqual(4);
+ MeToast.resetConfig();
+ });
+
+ test('_buildClassName 非标准动画 fallback 到 slide', () => {
+ const t = new Toast({ message: 'test', animation: 'unknown-anim' });
+ t.create();
+ expect(t.el?.className).toContain('met-anim-slide');
+ });
+
+ test('_buildContent 自定义 render', () => {
+ const t = MeToast.show({
+ render: (toast: ToastInstance) => `${toast.message}`,
+ message: 'custom-render',
+ } as Record);
+ // render 函数接管了 innerHTML
+ expect(t.el).toBeDefined();
+ });
+
+ test('_buildContent progressDirection vertical', () => {
+ const t = MeToast.info({ message: 'test', duration: 5000, progressDirection: 'vertical' } as Record);
+ expect(t.el).toBeDefined();
+ });
+
+ test('_buildContent 无图标且非 default 类型显示 side 条', () => {
+ const t = new Toast({ message: 'test', type: 'star', icon: false });
+ t.create();
+ expect(t.el).toBeDefined();
+ });
+
+ test('_applyStyles 自定义 width', () => {
+ const t = MeToast.info({ message: 'wide', width: 500 } as Record);
+ expect(t.el).toBeDefined();
+ });
+
+ test('_applyStyles 自定义 style 对象', () => {
+ const t = MeToast.info({
+ message: 'styled',
+ style: { background: 'red', 'font-size': '20px' },
+ } as Record);
+ expect(t.el).toBeDefined();
+ });
+
+ test('_bindEvents closeOnClick 触发 onClose', () => {
+ const onClick = jest.fn();
+ const t = MeToast.info({ message: 'clickable', closeOnClick: true, onClick } as Record);
+ expect(t.el).toBeDefined();
+ });
+
+ test('_startTimer duration <= 0 不启动', () => {
+ const t = MeToast.info({ message: 'sticky', duration: 0 } as Record);
+ expect(t.el).toBeDefined();
+ expect(t.rafId).toBeNull();
+ });
+
+ test('_pause/_resume duration <= 0 不操作', () => {
+ const t = new Toast({ message: 'test', duration: 0 });
+ t._pause();
+ expect(t.paused).toBe(false);
+ t._resume();
+ expect(t.paused).toBe(false);
+ });
+
+ test('close 重复调用不触发', () => {
+ const t = MeToast.info('test');
+ t.close();
+ t.close();
+ expect(t.closing).toBe(true);
+ });
+
+ test('close 无 el 时直接 _removeToast', () => {
+ const t = new Toast({ message: 'test' });
+ t.close();
+ expect(t.closing).toBe(true);
+ });
+
+ test('_destroy 调用 onClose 回调', (done) => {
+ const onClose = jest.fn();
+ const t = MeToast.success({ message: 'test', onClose } as Record);
+ t.close(true);
+ setTimeout(() => {
+ expect(onClose).toHaveBeenCalledWith(t);
+ done();
+ }, 100);
+ });
+
+ test('_destroy onClose 异常被捕获', (done) => {
+ const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
+ const t = MeToast.success({
+ message: 'test',
+ onClose: () => { throw new Error('close error'); },
+ } as Record);
+ t.close(true);
+ setTimeout(() => {
+ expect(consoleError).toHaveBeenCalled();
+ consoleError.mockRestore();
+ done();
+ }, 100);
+ });
+
+ test('_palette 默认类型', () => {
+ const t = new Toast({ message: 'test', type: 'default' });
+ const palette = t._palette();
+ expect(palette.theme).toBeDefined();
+ expect(palette.c.fg).toBe('#6b7280');
+ });
+
+ test('_palette 未知类型 fallback default', () => {
+ const t = new Toast({ message: 'test', type: 'unknown-type' as string });
+ const palette = t._palette();
+ expect(palette.c.fg).toBe('#6b7280');
+ });
+
+ test('updatePosition 切换到不同容器', () => {
+ const t = MeToast.info('move-me');
+ expect(t.config.position).toBe('top-right');
+ t.updatePosition('bottom-left');
+ expect(t.config.position).toBe('bottom-left');
+ });
+
+ test('_palette 未知主题回退 light', () => {
+ // 设置一个未知主题,测试 fallback 到 light
+ const t = new Toast({ message: 'test', theme: 'unknown-theme' });
+ const palette = t._palette();
+ // 应该 fallback 到 light theme 的配置
+ expect(palette.t.bg).toBeDefined();
+ });
+
+ test('_palette theme 为 auto', () => {
+ const t = new Toast({ message: 'test', theme: 'auto' });
+ const palette = t._palette();
+ expect(['light', 'dark']).toContain(palette.theme);
+ });
+});
+
+// ==================================================================
+// 8. styles.ts 覆盖率补充
+// ==================================================================
+describe('styles.ts 覆盖率', () => {
+ const styles = require('../src/styles.js');
+
+ test('injectStyles 不抛异常(幂等)', () => {
+ expect(() => styles.injectStyles()).not.toThrow();
+ expect(() => styles.injectStyles()).not.toThrow(); // 二次调用幂等
+ });
+
+ test('updateStyles 不抛异常', () => {
+ expect(() => styles.injectStyles()).not.toThrow();
+ expect(() => styles.updateStyles()).not.toThrow();
+ });
+
+ test('updateStyles 无缓存时重新注入', () => {
+ styles.removeStyles();
+ expect(() => styles.updateStyles()).not.toThrow();
+ });
+
+ test('removeStyles 移除样式', () => {
+ styles.injectStyles();
+ styles.removeStyles();
+ // 不应抛异常
+ });
+
+ test('generateThemeVariables 生成 CSS 变量', () => {
+ const config = { bg: '#fff', text: '#000', border: '#ccc', shadow: 'none', hoverShadow: 'none', progressBg: '#eee', closeHoverBg: '#ddd' };
+ const css = styles.generateThemeVariables(config);
+ expect(css).toContain('--met-theme-bg');
+ });
+
+ test('applyThemeVariables / clearThemeVariables 不抛异常', () => {
+ const config = { bg: '#fff', text: '#000', border: '#ccc', shadow: 'none', hoverShadow: 'none', progressBg: '#eee', closeHoverBg: '#ddd' };
+ expect(() => styles.applyThemeVariables(config)).not.toThrow();
+ expect(() => styles.clearThemeVariables()).not.toThrow();
+ });
+
+ test('getSystemTheme 返回 light/dark', () => {
+ const theme = styles.getSystemTheme();
+ expect(['light', 'dark']).toContain(theme);
+ });
+
+ test('watchSystemTheme 注册监听', () => {
+ const cb = jest.fn();
+ const unsub = styles.watchSystemTheme(cb);
+ expect(typeof unsub).toBe('function');
+ unsub();
+ });
+
+ test('autoApplySystemTheme 返回取消函数', () => {
+ const unsub = styles.autoApplySystemTheme();
+ expect(typeof unsub).toBe('function');
+ unsub();
+ });
+});
+
+// ==================================================================
+// 9. utils.ts 覆盖率补充
+// ==================================================================
+describe('utils.ts 覆盖率', () => {
+ test('escapeHTML SSR 路径(无 document)', () => {
+ // 需要绕过 mock document
+ const originalDoc = (global as Record).document;
+ (global as Record).document = undefined;
+ jest.resetModules();
+ const { escapeHTML } = require('../src/utils.js');
+ const result = escapeHTML('');
+ expect(result).not.toContain('