@mzzsfy/dsh-toast 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -7
- package/package.json +1 -1
- package/src/client.js +77 -27
- package/test/client-id.test.mjs +32 -1
- package/test/mount.test.mjs +163 -26
- package/test/toast.test.mjs +69 -0
package/README.md
CHANGED
|
@@ -40,23 +40,25 @@ toast('回合完成', { holdMs: 6 * 1000 }) // 自定义展示期
|
|
|
40
40
|
|
|
41
41
|
## API
|
|
42
42
|
|
|
43
|
-
- `show(text, opts)` → `id`:入栈一条通知。`opts.kind` 为 `'info' | 'ok' | 'error'`(非法归 `info`,默认深色 / 成功绿 / 错误红);`opts.sticky` 真值常驻不自动消失,渲染「知道了」按钮;`opts.holdMs`
|
|
44
|
-
- `dismiss(id)
|
|
43
|
+
- `show(text, opts)` → `id`:入栈一条通知。`opts.kind` 为 `'info' | 'ok' | 'error'`(非法归 `info`,默认深色 / 成功绿 / 错误红);`opts.sticky` 真值常驻不自动消失,渲染「知道了」按钮;`opts.holdMs` 有限正值自定义展示期(默认 4 秒,非有限值或超出 setTimeout 钳位上界回落默认)。`text` 非字符串或纯空白时忽略并返回 `null`
|
|
44
|
+
- `dismiss(id)`:移除指定条目并撤销其自动消失计时,幂等
|
|
45
45
|
- `mount()`:显式挂载渲染容器(一般无需调用,首次 `show` 惰性自举)
|
|
46
|
+
- `__test`:非公开 API,仅供本包测试驱动 store 消费,无兼容承诺,消费方禁用
|
|
46
47
|
|
|
47
48
|
## 行为规格(BDD)
|
|
48
49
|
|
|
49
50
|
- Given 库已加载,When `show(text)`,Then 通知顶部居中显示,默认 4 秒后自动消失
|
|
50
|
-
- Given 栈内已有 4 条,When 再入栈一条,Then 最旧条目立即移除(含 sticky,新通知优先)
|
|
51
|
+
- Given 栈内已有 4 条,When 再入栈一条,Then 最旧条目立即移除(含 sticky,新通知优先),被裁条目的自动消失计时同步撤销
|
|
51
52
|
- Given `show(text, {sticky: true})`,Then 通知常驻,点「知道了」或 `dismiss(id)` 后消失
|
|
52
53
|
- Given `kind: 'error'`,Then 红色变体渲染
|
|
53
54
|
- Given 首次 `show`,Then 渲染容器与样式惰性挂载(容器直挂 body,不受设置页全屏层 z-index 遮挡)
|
|
54
|
-
- Given HMR 重载产生同 id 旧容器,When 新代首次挂载,Then
|
|
55
|
+
- Given HMR 重载产生同 id 旧容器,When 新代首次挂载,Then 旧容器移除、新容器就位;旧代闭包再调 `show`/`mount` 时发现在位容器属更新代际即退避,不拆新代容器
|
|
56
|
+
- Given 容器在场而样式节点被外部移除,When 再次 `show`,Then 样式补挂(容器与样式同级自愈)
|
|
55
57
|
- Given 用户系统开启减弱动态效果,Then 入场动画禁用
|
|
56
58
|
|
|
57
59
|
## 实现说明
|
|
58
60
|
|
|
59
|
-
- 位置顶部居中:不遮挡聊天输入区;容器直挂 body,层级高于设置全屏层
|
|
60
|
-
- 样式全部取宿主 `--dsw
|
|
61
|
-
- store 位于模块闭包单例;渲染容器惰性自举、幂等、自愈(旧 root 卸载后重建)
|
|
61
|
+
- 位置顶部居中:不遮挡聊天输入区;容器直挂 body,层级高于设置全屏层(z-index 1100 为对宿主层级的显式假设,宿主层级调整时回归核对)
|
|
62
|
+
- 样式全部取宿主 `--dsw-*` 令牌且带就近 fallback(宿主升级更名令牌时降级为可用默认形态,不静默失效);样式挂宿主文档级、幂等且内容变化原位替换,容器在场时随每次挂载自愈
|
|
63
|
+
- store 位于模块闭包单例;渲染容器惰性自举、幂等、自愈(旧 root 卸载后重建),容器带代际标记防 HMR 两代互拆;不依赖宿主生命周期
|
|
62
64
|
- react / react-dom/client 由宿主平台模块表提供,peerDependencies 声明 react 与 react-dom
|
package/package.json
CHANGED
package/src/client.js
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
// dsh.client.external require('@mzzsfy/dsh-toast/client') 使用。本包不声明
|
|
4
4
|
// dsh.bundle.patch,不进 profile 插件层;宿主占位条目由消费方 cordis.patch.yml
|
|
5
5
|
// 代挂,本包 client 由此进入客户端模块表。
|
|
6
|
-
// 渲染容器惰性自举:首次 show 挂载,容器与样式幂等,挂载前清同 id
|
|
7
|
-
//
|
|
6
|
+
// 渲染容器惰性自举:首次 show 挂载,容器与样式幂等,挂载前清同 id 旧代残留。
|
|
7
|
+
// 容器带代际标记:HMR 旧代闭包再调 mount 时发现在位容器属更新代际即退避,
|
|
8
|
+
// 不拆新代容器;旧代迟到通知不可见(一次性告警),刷新页面恢复。
|
|
8
9
|
|
|
9
10
|
window.__ModuleLoader__.load({
|
|
10
11
|
id: '@mzzsfy/dsh-toast',
|
|
@@ -15,21 +16,28 @@ window.__ModuleLoader__.load({
|
|
|
15
16
|
|
|
16
17
|
// Toast 持续时长与栈上限:展示期定值;突发事件裁剪最旧条目
|
|
17
18
|
const TOAST_HOLD_MS = 4 * 1000
|
|
19
|
+
// setTimeout 延迟的规范钳位上界:超出即被浏览器钳到上界本身(约 24.8 天),
|
|
20
|
+
// 非 sticky 条目将近乎永久驻留且无关闭按钮,超界值一律回落默认
|
|
21
|
+
const HOLD_MS_MAX = 2 ** 31 - 1
|
|
18
22
|
const TOAST_MAX = 4
|
|
19
23
|
const KINDS = ['info', 'ok', 'error']
|
|
20
24
|
const HOST_ID = 'dsh-toast-host'
|
|
21
25
|
const STYLE_ID = 'dsh-toast-style'
|
|
26
|
+
// 层级假设的显式化:承诺"高于设置全屏层"绑定此值,宿主层级调整时回归核对
|
|
27
|
+
const TOAST_Z_INDEX = 1100
|
|
22
28
|
|
|
29
|
+
// 令牌 fallback:宿主 dsh 升级更名 --dsw-* 变量时降级为可用默认形态,
|
|
30
|
+
// 不静默失效为透明底/不可读字
|
|
23
31
|
const CSS = [
|
|
24
|
-
'.dsh-toast-stack { position:fixed; left:50%; top:16px; transform:translateX(-50%); z-index:
|
|
32
|
+
'.dsh-toast-stack { position:fixed; left:50%; top:16px; transform:translateX(-50%); z-index:' + TOAST_Z_INDEX + ';',
|
|
25
33
|
' display:flex; flex-direction:column; align-items:center; gap:8px; pointer-events:none; }',
|
|
26
|
-
'.dsh-toast { pointer-events:auto; background:var(--dsw-alias-toast-bg); color:var(--dsw-alias-label-primary-inverted);',
|
|
27
|
-
' font:var(--dsw-font-xs-13); padding:8px 14px; border-radius:10px; box-shadow:var(--dsw-shadow-lv2);',
|
|
28
|
-
' animation:dsh-toast-in 0.18s ease-out; max-width:520px; display:flex; align-items:center; gap:10px; }',
|
|
29
|
-
'.dsh-toast--ok { background:var(--dsw-alias-state-success-primary); color:var(--dsw-alias-label-primary-inverted); }',
|
|
30
|
-
'.dsh-toast--error { background:var(--dsw-alias-state-error-primary); color:#fff; }',
|
|
34
|
+
'.dsh-toast { pointer-events:auto; background:var(--dsw-alias-toast-bg, rgba(32,33,36,.92)); color:var(--dsw-alias-label-primary-inverted, #fff);',
|
|
35
|
+
' font:var(--dsw-font-xs-13, 13px/1.5 system-ui, sans-serif); padding:8px 14px; border-radius:10px; box-shadow:var(--dsw-shadow-lv2, 0 2px 8px rgba(0,0,0,.25));',
|
|
36
|
+
' animation:dsh-toast-in 0.18s ease-out; max-width:520px; max-height:60vh; overflow:auto; overflow-wrap:anywhere; display:flex; align-items:safe center; gap:10px; }',
|
|
37
|
+
'.dsh-toast--ok { background:var(--dsw-alias-state-success-primary, #1a7f37); color:var(--dsw-alias-label-primary-inverted, #fff); }',
|
|
38
|
+
'.dsh-toast--error { background:var(--dsw-alias-state-error-primary, #d93025); color:#fff; }',
|
|
31
39
|
'.dsh-toast__close { border:0; background:transparent; cursor:pointer; color:inherit;',
|
|
32
|
-
' font:var(--dsw-font-xs-strong-13); padding:0 2px; opacity:.8; }',
|
|
40
|
+
' font:var(--dsw-font-xs-strong-13, 13px system-ui, sans-serif); padding:0 2px; opacity:.8; }',
|
|
33
41
|
'.dsh-toast__close:hover { opacity:1; }',
|
|
34
42
|
'@keyframes dsh-toast-in { from { transform:translateY(-8px); opacity:0; } to { transform:translateY(0); opacity:1; } }',
|
|
35
43
|
'@media (prefers-reduced-motion: reduce) { .dsh-toast { animation:none; } }',
|
|
@@ -51,30 +59,52 @@ window.__ModuleLoader__.load({
|
|
|
51
59
|
return KINDS.indexOf(kind) >= 0 ? kind : 'info'
|
|
52
60
|
}
|
|
53
61
|
|
|
54
|
-
// 展示期:sticky 常驻不计时;非 sticky
|
|
62
|
+
// 展示期:sticky 常驻不计时;非 sticky 取调用方有限正值,否则默认
|
|
63
|
+
// (Infinity / 超钳位上界的值经浏览器 WebIDL 钳为约 24.8 天,近乎永久驻留,
|
|
64
|
+
// 与展示期意图相反,必须拦;Node 语义才是溢出钳 1ms,勿混)
|
|
55
65
|
function resolveHoldMs(opts) {
|
|
56
66
|
const holdMs = opts && opts.holdMs
|
|
57
|
-
return typeof holdMs === 'number' && holdMs > 0 ? holdMs : TOAST_HOLD_MS
|
|
67
|
+
return typeof holdMs === 'number' && Number.isFinite(holdMs) && holdMs > 0 && holdMs <= HOLD_MS_MAX ? holdMs : TOAST_HOLD_MS
|
|
58
68
|
}
|
|
59
69
|
|
|
60
|
-
//
|
|
70
|
+
// 幂等移除:未命中零分配早退;命中先撤销自动消失计时,不入滞留空转
|
|
61
71
|
function dismiss(id) {
|
|
62
|
-
const
|
|
63
|
-
if (
|
|
64
|
-
|
|
72
|
+
const item = items.find((entry) => entry.id === id)
|
|
73
|
+
if (item === undefined) return
|
|
74
|
+
if (item.timer !== undefined) {
|
|
75
|
+
clearTimeout(item.timer)
|
|
76
|
+
item.timer = undefined
|
|
77
|
+
}
|
|
78
|
+
items = items.filter((entry) => entry.id !== id)
|
|
65
79
|
emit()
|
|
66
80
|
}
|
|
67
81
|
|
|
68
|
-
|
|
69
|
-
|
|
82
|
+
const clearTimer = (entry) => {
|
|
83
|
+
if (entry.timer !== undefined) {
|
|
84
|
+
clearTimeout(entry.timer)
|
|
85
|
+
entry.timer = undefined
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 入栈即返回单调 id;超上限裁最旧(含 sticky,极端场景下错误提示让位于新通知),
|
|
90
|
+
// 被裁条目的自动消失计时同步撤销;非 sticky 条目入栈即挂自动消失计时
|
|
70
91
|
function show(text, opts) {
|
|
71
|
-
if (typeof text !== 'string' || text === '') return null
|
|
92
|
+
if (typeof text !== 'string' || text.trim() === '') return null
|
|
72
93
|
const sticky = Boolean(opts && opts.sticky)
|
|
73
|
-
const entry = { id: ++seq, text, kind: normalizeKind(opts && opts.kind), sticky }
|
|
74
|
-
|
|
94
|
+
const entry = { id: ++seq, text, kind: normalizeKind(opts && opts.kind), sticky, timer: undefined }
|
|
95
|
+
const merged = items.concat(entry)
|
|
96
|
+
if (merged.length > TOAST_MAX) {
|
|
97
|
+
for (const dropped of merged.slice(0, merged.length - TOAST_MAX)) clearTimer(dropped)
|
|
98
|
+
}
|
|
99
|
+
items = merged.slice(-TOAST_MAX)
|
|
75
100
|
emit()
|
|
76
|
-
|
|
77
|
-
|
|
101
|
+
// 挂载失败不抛给消费方:条目已在 store,后续 show 的自愈重建会补展示
|
|
102
|
+
try {
|
|
103
|
+
mount()
|
|
104
|
+
} catch (error) {
|
|
105
|
+
console.warn('dsh-toast: 容器挂载失败,待下次触发自愈: ' + (error instanceof Error ? error.message : String(error)))
|
|
106
|
+
}
|
|
107
|
+
if (!sticky) entry.timer = setTimeout(() => dismiss(entry.id), resolveHoldMs(opts))
|
|
78
108
|
return entry.id
|
|
79
109
|
}
|
|
80
110
|
|
|
@@ -101,15 +131,33 @@ window.__ModuleLoader__.load({
|
|
|
101
131
|
document.head.appendChild(style)
|
|
102
132
|
}
|
|
103
133
|
|
|
104
|
-
//
|
|
105
|
-
|
|
134
|
+
// 代际标记:window 级自增,旧代闭包经共享载体比对在位容器代际决定接管或退避
|
|
135
|
+
const GEN = (window.__dshToastGen = (window.__dshToastGen || 0) + 1)
|
|
136
|
+
|
|
137
|
+
// 容器自愈:本代容器在场即幂等(样式顺带自愈);在位容器属更新代际则本代
|
|
138
|
+
// 退避不动 DOM(一次性告警,迟到通知静默蒸发是排障盲区);否则清理旧容器
|
|
139
|
+
// (本代被外部移除的、HMR 上一代残留的、无归属标记的外部同 id 节点),
|
|
140
|
+
// 逐个卸载 React root(detached 树不再续渲染)后移除重建
|
|
106
141
|
let root = null
|
|
107
142
|
let host = null
|
|
143
|
+
let retireWarned = false
|
|
108
144
|
function mount() {
|
|
109
|
-
|
|
110
|
-
|
|
145
|
+
const inPlace = document.getElementById(HOST_ID)
|
|
146
|
+
if (root !== null && inPlace === host) {
|
|
147
|
+
ensureStyle()
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
if (inPlace !== null && inPlace.__toastGen !== undefined && inPlace.__toastGen > GEN) {
|
|
151
|
+
if (!retireWarned) {
|
|
152
|
+
retireWarned = true
|
|
153
|
+
console.warn('dsh-toast: 本代已退役(在位容器属更新代际),本次通知不可见,请刷新页面恢复')
|
|
154
|
+
}
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
for (const stale of new Set([host, inPlace])) {
|
|
111
158
|
if (stale === null) continue
|
|
112
159
|
if (stale.__toastRoot !== undefined) stale.__toastRoot.unmount()
|
|
160
|
+
else console.warn('dsh-toast: 移除无归属标记的同 id 容器(外部或其他代际残留)')
|
|
113
161
|
stale.remove()
|
|
114
162
|
}
|
|
115
163
|
ensureStyle()
|
|
@@ -118,6 +166,7 @@ window.__ModuleLoader__.load({
|
|
|
118
166
|
document.body.appendChild(host)
|
|
119
167
|
root = createRoot(host)
|
|
120
168
|
host.__toastRoot = root
|
|
169
|
+
host.__toastGen = GEN
|
|
121
170
|
root.render(React.createElement(ToastHost))
|
|
122
171
|
}
|
|
123
172
|
|
|
@@ -147,7 +196,8 @@ window.__ModuleLoader__.load({
|
|
|
147
196
|
// 导出呈插件形态;缺 apply 即判 invalid plugin 拖垮整树。装载体为空:容器
|
|
148
197
|
// 惰性自举在首次 show 时发生,无需生命周期介入
|
|
149
198
|
apply() {},
|
|
150
|
-
|
|
199
|
+
// 非公开 API,仅供本包测试驱动 store 消费,无兼容承诺,消费方禁用
|
|
200
|
+
__test: { show, dismiss, source, normalizeKind, resolveHoldMs, getItems: () => items.slice() },
|
|
151
201
|
}
|
|
152
202
|
},
|
|
153
203
|
})
|
package/test/client-id.test.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// client.js 自注册格式守卫:ModuleLoader id 必须与 npm 包名一致,
|
|
2
|
-
// 宿主按包名解析 client bundle
|
|
2
|
+
// 宿主按包名解析 client bundle;factory 返回值必须呈插件形态(含空 apply),
|
|
3
|
+
// 缺 apply 即被浏览器 cordis loader 判 invalid plugin 拖垮整树。
|
|
4
|
+
// 宿主入口 index.js 同锁:仅空 apply 形态,不带 name/inject。
|
|
3
5
|
import test from 'node:test'
|
|
4
6
|
import assert from 'node:assert/strict'
|
|
5
7
|
import { readFileSync } from 'node:fs'
|
|
@@ -14,3 +16,32 @@ test('client.js 以本包名注册 ModuleLoader 模块', () => {
|
|
|
14
16
|
assert.ok(match, 'client.js 缺少 __ModuleLoader__.load 注册')
|
|
15
17
|
assert.equal(match[1], '@mzzsfy/dsh-toast')
|
|
16
18
|
})
|
|
19
|
+
|
|
20
|
+
test('factory 执行后返回值含空 apply 与公开 API', () => {
|
|
21
|
+
const source = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
|
|
22
|
+
const modules = []
|
|
23
|
+
const windowStub = { __ModuleLoader__: { load: (module) => modules.push(module) } }
|
|
24
|
+
const reactStub = { useSyncExternalStore: () => [], createElement: () => null }
|
|
25
|
+
const requireStub = (name) => (name === 'react-dom/client' ? { createRoot: () => ({ render() {} }) } : reactStub)
|
|
26
|
+
const docStub = {
|
|
27
|
+
getElementById: () => null,
|
|
28
|
+
createElement: () => ({ id: '', style: {}, remove() {} }),
|
|
29
|
+
head: { appendChild: () => {} },
|
|
30
|
+
body: { appendChild: () => {} },
|
|
31
|
+
}
|
|
32
|
+
const factory = new Function('window', 'require', 'document', source + '\n;return null')
|
|
33
|
+
factory(windowStub, requireStub, docStub)
|
|
34
|
+
assert.equal(modules.length, 1, 'client.js 模块未被捕获')
|
|
35
|
+
const mod = modules[0].factory(requireStub)
|
|
36
|
+
assert.equal(typeof mod.apply, 'function', '缺 apply 即 invalid plugin 拖垮整树')
|
|
37
|
+
assert.equal(typeof mod.show, 'function')
|
|
38
|
+
assert.equal(typeof mod.dismiss, 'function')
|
|
39
|
+
assert.equal(typeof mod.mount, 'function')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('宿主入口 index.js 仅空 apply 形态,不带 name/inject', () => {
|
|
43
|
+
const source = readFileSync(join(PKG_ROOT, 'src', 'index.js'), 'utf8')
|
|
44
|
+
assert.match(source, /export function apply\(\) \{\}/)
|
|
45
|
+
assert.doesNotMatch(source, /export const name/, '带 name 的命名空间形态经装载链被判 invalid plugin')
|
|
46
|
+
assert.doesNotMatch(source, /export const inject/, '带 inject 的命名空间形态经装载链被判 invalid plugin')
|
|
47
|
+
})
|
package/test/mount.test.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// 挂载自愈测试:首次 show 惰性挂容器与样式;重复 show 幂等;外部移除与 HMR
|
|
2
|
-
// 旧代残留均在下一次挂载时卸 root
|
|
2
|
+
// 旧代残留均在下一次挂载时卸 root 重建;样式内容不一致原位替换、一致跳过零写入;
|
|
3
|
+
// 代际退避:更新代际容器在场时旧代 mount 不拆台。
|
|
3
4
|
// 加载真实 src/client.js 驱动完整 DOM 交互路径(mock.timers 冻结自动消失计时)。
|
|
5
|
+
// 渲染层用记录型 createElement 产 vnode 树,断言类名/role/按钮接线。
|
|
4
6
|
import test from 'node:test'
|
|
5
7
|
import assert from 'node:assert/strict'
|
|
6
8
|
import { readFileSync } from 'node:fs'
|
|
@@ -10,67 +12,108 @@ import { mock } from 'node:test'
|
|
|
10
12
|
|
|
11
13
|
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
12
14
|
|
|
13
|
-
const reactStub = {
|
|
14
|
-
useSyncExternalStore: () => [],
|
|
15
|
-
createElement: () => null,
|
|
16
|
-
}
|
|
17
|
-
|
|
18
15
|
function makeState() {
|
|
19
|
-
return { byId: new Map(), bodyAppends: 0, appended: [] }
|
|
16
|
+
return { byId: new Map(), bodyAppends: 0, appended: [], headChildren: [], bodyChildren: [], warns: [], window: undefined }
|
|
20
17
|
}
|
|
21
18
|
|
|
19
|
+
// head/body 目的地分开记录,样式挂载位置契约可测;
|
|
20
|
+
// textContent 经 setter 计数,一致跳过的零写入可断言;
|
|
21
|
+
// console.warn 捕获供无归属节点清理留痕断言
|
|
22
22
|
function makeDocument(state) {
|
|
23
23
|
const byId = state.byId
|
|
24
|
-
|
|
24
|
+
const createElement = (tag) => {
|
|
25
|
+
const node = { tag, id: '', style: {}, writes: 0, remove() { byId.delete(this.id) } }
|
|
26
|
+
Object.defineProperty(node, 'textContent', {
|
|
27
|
+
get() { return node._text || '' },
|
|
28
|
+
set(value) { node._text = value; node.writes += 1 },
|
|
29
|
+
})
|
|
30
|
+
return node
|
|
31
|
+
}
|
|
32
|
+
const doc = {
|
|
25
33
|
getElementById: (id) => byId.get(id) ?? null,
|
|
26
|
-
createElement
|
|
27
|
-
head: { appendChild: (node) => byId.set(node.id, node) },
|
|
34
|
+
createElement,
|
|
35
|
+
head: { appendChild: (node) => { state.headChildren.push(node); byId.set(node.id, node) } },
|
|
28
36
|
body: {
|
|
29
|
-
appendChild: (node) => { state.bodyAppends += 1; state.appended.push(node); byId.set(node.id, node) },
|
|
37
|
+
appendChild: (node) => { state.bodyAppends += 1; state.appended.push(node); state.bodyChildren.push(node); byId.set(node.id, node) },
|
|
30
38
|
},
|
|
31
39
|
}
|
|
40
|
+
state.doc = doc
|
|
41
|
+
return doc
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 渲染记录型 stub:createElement 产 vnode 树,useSyncExternalStore 取真实快照,
|
|
45
|
+
// 使 ToastHost/ToastItem 产物可断言;modRef 延迟解引用加载产物
|
|
46
|
+
function makeRenderReactStub(modRef) {
|
|
47
|
+
return {
|
|
48
|
+
useSyncExternalStore: () => (modRef.current !== null ? modRef.current.__test.source.getSnapshot() : []),
|
|
49
|
+
createElement: (type, props, ...children) => ({ type, props: props || null, children }),
|
|
50
|
+
}
|
|
32
51
|
}
|
|
33
52
|
|
|
34
|
-
function loadModule(docState) {
|
|
53
|
+
function loadModule(docState, reactStub = { useSyncExternalStore: () => [], createElement: () => null }) {
|
|
35
54
|
const source = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
|
|
36
55
|
const modules = []
|
|
37
|
-
|
|
56
|
+
// window 跨 loadModule 复用:代际计数(window.__dshToastGen)随真实 HMR 同样递增;
|
|
57
|
+
// load 回调重绑本次数组,捕获本次加载的模块
|
|
58
|
+
if (docState.window === undefined) docState.window = { __ModuleLoader__: { load: () => {} } }
|
|
59
|
+
docState.window.__ModuleLoader__.load = (module) => modules.push(module)
|
|
38
60
|
const roots = []
|
|
61
|
+
const warns = docState.warns
|
|
39
62
|
const createRoot = (host) => {
|
|
40
|
-
const root = { host, renderCalls: 0, unmounted: false, render() { this.renderCalls += 1 }, unmount() { this.unmounted = true } }
|
|
63
|
+
const root = { host, renderCalls: 0, vnode: null, unmounted: false, render(vnode) { this.renderCalls += 1; this.vnode = vnode }, unmount() { this.unmounted = true } }
|
|
41
64
|
roots.push(root)
|
|
42
65
|
return root
|
|
43
66
|
}
|
|
44
67
|
const requireStub = (name) => (name === 'react-dom/client' ? { createRoot } : reactStub)
|
|
45
|
-
const factory = new Function('window', 'require', 'document', source + '\n;return null')
|
|
46
|
-
|
|
68
|
+
const factory = new Function('window', 'require', 'document', 'console', source + '\n;return null')
|
|
69
|
+
const consoleStub = { warn: (message) => warns.push(message), log: () => {} }
|
|
70
|
+
factory(docState.window, requireStub, makeDocument(docState), consoleStub)
|
|
47
71
|
assert.equal(modules.length, 1, 'client.js 模块未被捕获')
|
|
48
72
|
const mod = modules[0].factory(requireStub)
|
|
49
73
|
return { mod, roots }
|
|
50
74
|
}
|
|
51
75
|
|
|
76
|
+
function loadRenderModule(docState) {
|
|
77
|
+
const modRef = { current: null }
|
|
78
|
+
const result = loadModule(docState, makeRenderReactStub(modRef))
|
|
79
|
+
modRef.current = result.mod
|
|
80
|
+
return result
|
|
81
|
+
}
|
|
82
|
+
|
|
52
83
|
const HOST_ID = 'dsh-toast-host'
|
|
53
84
|
const STYLE_ID = 'dsh-toast-style'
|
|
54
85
|
|
|
55
86
|
test.beforeEach(() => { mock.timers.enable({ apis: ['setTimeout'] }) })
|
|
56
87
|
test.afterEach(() => { mock.timers.reset() })
|
|
57
88
|
|
|
58
|
-
test('首次 show
|
|
89
|
+
test('首次 show 惰性挂载:容器挂 body、样式挂 head', () => {
|
|
59
90
|
const state = makeState()
|
|
60
91
|
const { mod } = loadModule(state)
|
|
61
92
|
assert.equal(state.byId.get(HOST_ID), undefined, '加载即挂载是错误的(应惰性)')
|
|
62
93
|
mod.show('触发挂载')
|
|
63
94
|
assert.notEqual(state.byId.get(HOST_ID), undefined)
|
|
64
|
-
assert.
|
|
95
|
+
assert.equal(state.headChildren.some((node) => node.id === STYLE_ID), true, '样式挂宿主文档级')
|
|
96
|
+
assert.equal(state.bodyChildren.some((node) => node.id === HOST_ID), true, '容器直挂 body')
|
|
65
97
|
})
|
|
66
98
|
|
|
67
|
-
test('重复 show
|
|
99
|
+
test('重复 show 幂等:容器只创建一次,样式一致零写入', () => {
|
|
68
100
|
const state = makeState()
|
|
69
101
|
const { mod } = loadModule(state)
|
|
70
102
|
mod.show('一')
|
|
103
|
+
const styleNode = state.byId.get(STYLE_ID)
|
|
104
|
+
assert.equal(styleNode.writes, 1, '创建时写入一次')
|
|
71
105
|
mod.show('二')
|
|
72
106
|
assert.equal(state.bodyAppends, 1, '容器只 append 一次')
|
|
73
|
-
assert.equal(
|
|
107
|
+
assert.equal(styleNode.writes, 1, '同代内容一致跳过,零写入')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('样式被外部移除后自愈:容器在场也补挂', () => {
|
|
111
|
+
const state = makeState()
|
|
112
|
+
const { mod } = loadModule(state)
|
|
113
|
+
mod.show('建立')
|
|
114
|
+
state.byId.get(STYLE_ID).remove()
|
|
115
|
+
mod.show('再触发')
|
|
116
|
+
assert.notEqual(state.byId.get(STYLE_ID), undefined, '幂等路径顺带样式自愈')
|
|
74
117
|
})
|
|
75
118
|
|
|
76
119
|
test('外部移除容器后重建:旧 root 卸载,新容器就位', () => {
|
|
@@ -99,16 +142,110 @@ test('HMR 跨代自愈:新代首挂卸载旧代 root 并重建容器', () => {
|
|
|
99
142
|
assert.equal(second.roots[0].host, state.byId.get(HOST_ID), '新 root 绑定新容器')
|
|
100
143
|
})
|
|
101
144
|
|
|
102
|
-
test('
|
|
145
|
+
test('代际退避:更新代际容器在场时,旧代 mount 不拆台且告警留痕', () => {
|
|
146
|
+
const state = makeState()
|
|
147
|
+
const first = loadModule(state)
|
|
148
|
+
first.mod.show('旧代建立')
|
|
149
|
+
const second = loadModule(state)
|
|
150
|
+
second.mod.show('新代接管')
|
|
151
|
+
const hostSecond = state.byId.get(HOST_ID)
|
|
152
|
+
const rootSecond = second.roots[0]
|
|
153
|
+
const appendsBefore = state.bodyAppends
|
|
154
|
+
|
|
155
|
+
// 旧代闭包持引用再调 show/mount:发现更新代际在场,退避不动 DOM
|
|
156
|
+
first.mod.show('旧代迟条')
|
|
157
|
+
assert.equal(state.byId.get(HOST_ID), hostSecond, '新代容器未被拆除')
|
|
158
|
+
assert.equal(rootSecond.unmounted, false, '新代 root 未被卸载')
|
|
159
|
+
assert.equal(state.bodyAppends, appendsBefore, '无重建')
|
|
160
|
+
assert.equal(state.warns.filter((message) => message.includes('已退役')).length, 1, '退役告警恰一次')
|
|
161
|
+
first.mod.show('旧代再迟')
|
|
162
|
+
assert.equal(state.warns.filter((message) => message.includes('已退役')).length, 1, '告警限频不风暴')
|
|
163
|
+
|
|
164
|
+
// 新代继续 show 正常服务
|
|
165
|
+
second.mod.show('新代续条')
|
|
166
|
+
assert.equal(state.bodyAppends, appendsBefore, '新代容器仍幂等在场')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test('双 stale 清理:闭包旧容器与外部同 id 节点同轮被清,无标记节点留痕', () => {
|
|
170
|
+
const state = makeState()
|
|
171
|
+
const { mod, roots } = loadModule(state)
|
|
172
|
+
mod.show('建立')
|
|
173
|
+
const closureHost = state.byId.get(HOST_ID)
|
|
174
|
+
assert.equal(roots.length, 1)
|
|
175
|
+
|
|
176
|
+
// 外部无标记同 id 节点顶替在位容器:闭包 host 与陌生节点同时非空
|
|
177
|
+
const alien = state.doc.createElement('div')
|
|
178
|
+
alien.id = HOST_ID
|
|
179
|
+
state.byId.set(HOST_ID, alien)
|
|
180
|
+
mod.show('触发双清')
|
|
181
|
+
|
|
182
|
+
assert.equal(roots[0].unmounted, true, '闭包容器 root 卸载')
|
|
183
|
+
assert.notEqual(state.byId.get(HOST_ID), alien, '陌生节点被清走')
|
|
184
|
+
assert.notEqual(state.byId.get(HOST_ID), closureHost, '闭包容器被移除重建')
|
|
185
|
+
assert.equal(state.warns.filter((message) => message.includes('无归属标记')).length, 1, '陌生节点清理留痕一次')
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
test('mount 直调:空 store 显式挂载,连调幂等', () => {
|
|
189
|
+
const state = makeState()
|
|
190
|
+
const { mod } = loadModule(state)
|
|
191
|
+
mod.mount()
|
|
192
|
+
mod.mount()
|
|
193
|
+
assert.equal(state.bodyAppends, 1, '直调幂等')
|
|
194
|
+
assert.notEqual(state.byId.get(HOST_ID), undefined)
|
|
195
|
+
assert.notEqual(state.byId.get(STYLE_ID), undefined)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
test('渲染产物:栈容器类名、变体类、role 与 sticky 按钮接线', () => {
|
|
199
|
+
const state = makeState()
|
|
200
|
+
const { mod, roots } = loadRenderModule(state)
|
|
201
|
+
mod.show('普通', { kind: 'ok' })
|
|
202
|
+
const stickyId = mod.show('常驻', { kind: 'error', sticky: true })
|
|
203
|
+
|
|
204
|
+
const root = roots[roots.length - 1]
|
|
205
|
+
assert.equal(typeof root.vnode.type, 'function', 'render 收到 ToastHost 元素')
|
|
206
|
+
const tree = root.vnode.type()
|
|
207
|
+
assert.equal(tree.type, 'div')
|
|
208
|
+
assert.equal(tree.props.className, 'dsh-toast-stack')
|
|
209
|
+
|
|
210
|
+
const items = tree.children.flat()
|
|
211
|
+
const okItem = items.find((vnode) => vnode.props.item.text === '普通')
|
|
212
|
+
const okTree = okItem.type(okItem.props)
|
|
213
|
+
assert.equal(okTree.props.className, 'dsh-toast dsh-toast--ok')
|
|
214
|
+
assert.equal(okTree.props.role, 'alert')
|
|
215
|
+
assert.equal(okTree.children[1], null, '非 sticky 无按钮')
|
|
216
|
+
|
|
217
|
+
const stickyItem = items.find((vnode) => vnode.props.item.text === '常驻')
|
|
218
|
+
const stickyTree = stickyItem.type(stickyItem.props)
|
|
219
|
+
assert.equal(stickyTree.props.className, 'dsh-toast dsh-toast--error')
|
|
220
|
+
const button = stickyTree.children[1]
|
|
221
|
+
assert.equal(button.type, 'button')
|
|
222
|
+
assert.equal(button.props.className, 'dsh-toast__close')
|
|
223
|
+
button.props.onClick()
|
|
224
|
+
assert.equal(mod.__test.getItems().some((item) => item.id === stickyId), false, '按钮接线 dismiss 生效')
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
test('样式内容不一致原位替换:节点不重建,内容与写入计数还原', () => {
|
|
103
228
|
const state = makeState()
|
|
104
229
|
const { mod } = loadModule(state)
|
|
105
230
|
mod.show('建立样式')
|
|
106
231
|
const styleNode = state.byId.get(STYLE_ID)
|
|
107
232
|
const currentCss = styleNode.textContent
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
233
|
+
assert.equal(styleNode.writes, 1)
|
|
234
|
+
// 模拟 HMR 期间 CSS 变更或旧代残留:在位样式内容与本代不一致
|
|
235
|
+
// (直写 _text 绕过写入计数,只统计实现侧的写入)
|
|
236
|
+
styleNode._text = '/* 残留的旧代样式 */'
|
|
237
|
+
mod.show('触发替换')
|
|
112
238
|
assert.equal(state.byId.get(STYLE_ID), styleNode, '样式节点原位保留')
|
|
113
|
-
assert.equal(styleNode.
|
|
239
|
+
assert.equal(styleNode.writes, 2, '替换恰新增一次写入')
|
|
240
|
+
assert.equal(styleNode.textContent, currentCss, '内容还原为当前 CSS')
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
test('CSS 内容齐备:选择器、变体、动画与减弱动态块', () => {
|
|
244
|
+
const state = makeState()
|
|
245
|
+
const { mod } = loadModule(state)
|
|
246
|
+
mod.show('建立样式')
|
|
247
|
+
const css = state.byId.get(STYLE_ID).textContent
|
|
248
|
+
for (const needle of ['.dsh-toast-stack', '.dsh-toast {', '.dsh-toast--ok', '.dsh-toast--error', '.dsh-toast__close', '@keyframes dsh-toast-in', 'animation:dsh-toast-in', 'prefers-reduced-motion']) {
|
|
249
|
+
assert.ok(css.includes(needle), 'CSS 缺少 ' + needle)
|
|
250
|
+
}
|
|
114
251
|
})
|
package/test/toast.test.mjs
CHANGED
|
@@ -128,3 +128,72 @@ test('subscribe:入栈与移除均通知订阅者', () => {
|
|
|
128
128
|
mod.__test.show('不再通知')
|
|
129
129
|
assert.equal(calls, 2)
|
|
130
130
|
})
|
|
131
|
+
|
|
132
|
+
test('holdMs 边界:非有限正值一律回落默认', () => {
|
|
133
|
+
const mod = loadModule()
|
|
134
|
+
assert.equal(mod.__test.resolveHoldMs({ holdMs: 0 }), 4 * 1000)
|
|
135
|
+
assert.equal(mod.__test.resolveHoldMs({ holdMs: '500' }), 4 * 1000)
|
|
136
|
+
assert.equal(mod.__test.resolveHoldMs({ holdMs: Number.NaN }), 4 * 1000)
|
|
137
|
+
// Infinity 经浏览器 WebIDL 钳为约 24.8 天近乎永久驻留,与展示期意图相反,必须回落
|
|
138
|
+
assert.equal(mod.__test.resolveHoldMs({ holdMs: Number.POSITIVE_INFINITY }), 4 * 1000)
|
|
139
|
+
assert.equal(mod.__test.resolveHoldMs({ holdMs: 2 ** 31 }), 4 * 1000, '超 setTimeout 钳位上界回落')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('默认展示期:独立字面量断言 4 秒', () => {
|
|
143
|
+
const mod = loadModule()
|
|
144
|
+
mod.__test.show('默认')
|
|
145
|
+
mock.timers.tick(4 * 1000 - 1)
|
|
146
|
+
assert.equal(mod.__test.getItems().length, 1, '展示期内未消失')
|
|
147
|
+
mock.timers.tick(1)
|
|
148
|
+
assert.equal(mod.__test.getItems().length, 0, '展示期满消失')
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test('kind: error 合法直通', () => {
|
|
152
|
+
const mod = loadModule()
|
|
153
|
+
mod.__test.show('错误', { kind: 'error' })
|
|
154
|
+
assert.equal(mod.__test.getItems()[0].kind, 'error')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('纯空白 text 拒绝,非空白正常入栈', () => {
|
|
158
|
+
const mod = loadModule()
|
|
159
|
+
assert.equal(mod.__test.show(' '), null)
|
|
160
|
+
assert.equal(mod.__test.show('\t\n'), null)
|
|
161
|
+
assert.equal(mod.__test.getItems().length, 0)
|
|
162
|
+
const id = mod.__test.show(' 带前后空格的内容 ')
|
|
163
|
+
assert.notEqual(id, null, '含非空白字符的文本照常入栈')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test('sticky 被裁剪:栈满后新条目仍挤掉最旧 sticky', () => {
|
|
167
|
+
const mod = loadModule()
|
|
168
|
+
for (let index = 1; index <= 4; index += 1) mod.__test.show('常驻' + index, { sticky: true })
|
|
169
|
+
mod.__test.show('新条目')
|
|
170
|
+
const items = mod.__test.getItems()
|
|
171
|
+
assert.equal(items.length, 4)
|
|
172
|
+
assert.equal(items[0].text, '常驻2', '最旧 sticky 被裁剪')
|
|
173
|
+
assert.equal(items[items.length - 1].text, '新条目')
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('被裁条目计时同步撤销:条目引用上的 timer 句柄清空', () => {
|
|
177
|
+
const mod = loadModule()
|
|
178
|
+
mod.__test.show('将被裁剪')
|
|
179
|
+
// getItems 为浅拷贝,entry 对象同引用,可直接断言 timer 字段
|
|
180
|
+
const dropped = mod.__test.getItems()[0]
|
|
181
|
+
assert.notEqual(dropped.timer, undefined, '入栈即挂计时')
|
|
182
|
+
for (let index = 1; index <= 4; index += 1) mod.__test.show('常驻' + index, { sticky: true })
|
|
183
|
+
assert.equal(dropped.timer, undefined, '裁剪路径撤销计时')
|
|
184
|
+
const snapshot = mod.__test.source.getSnapshot()
|
|
185
|
+
mock.timers.tick(4 * 1000)
|
|
186
|
+
assert.equal(mod.__test.source.getSnapshot(), snapshot, '无滞留计时副作用')
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
test('dismiss 撤销自动消失计时:句柄清空,移除后再推进无滞留副作用', () => {
|
|
190
|
+
const mod = loadModule()
|
|
191
|
+
const id = mod.__test.show('手动移除')
|
|
192
|
+
const entry = mod.__test.getItems()[0]
|
|
193
|
+
assert.notEqual(entry.timer, undefined, '入栈即挂计时')
|
|
194
|
+
mod.__test.dismiss(id)
|
|
195
|
+
assert.equal(entry.timer, undefined, 'dismiss 路径撤销计时')
|
|
196
|
+
mock.timers.tick(4 * 1000)
|
|
197
|
+
assert.equal(mod.__test.getItems().length, 0)
|
|
198
|
+
assert.equal(mod.__test.source.getSnapshot().length, 0)
|
|
199
|
+
})
|