From b5c670ece36c9d3909eb6e9b202f79c53163d27c Mon Sep 17 00:00:00 2001 From: thzxx 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(); // 一键关闭
-

MetonaToast v0.2.0 · MIT License · Gitea

+

MetonaToast v0.2.1 · MIT License · Gitea

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('