@mzzsfy/dsh-toast 0.1.0
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 +62 -0
- package/package.json +38 -0
- package/src/client.js +153 -0
- package/src/index.js +6 -0
- package/test/client-id.test.mjs +16 -0
- package/test/mount.test.mjs +114 -0
- package/test/toast.test.mjs +130 -0
package/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# @mzzsfy/dsh-toast
|
|
2
|
+
|
|
3
|
+
DSH 全局浮出通知 Toast 库:多条并存栈式展示,自动消失与常驻确认两种生命周期。供各插件发送全局操作反馈与事件通知,替代各插件自写通知 UI。
|
|
4
|
+
|
|
5
|
+
**本包是普通 npm 依赖,不是 dsh 插件**:不声明 `dsh.bundle.patch`,不进 profile 插件层,无需也不应 `dsh plugin add`。消费插件在 `dependencies` 中声明本包(pnpm 随装),并在自身 `cordis.patch.yml` 中代挂本包宿主占位条目使 client 进入客户端模块表。
|
|
6
|
+
|
|
7
|
+
## 消费方接入
|
|
8
|
+
|
|
9
|
+
1. `package.json`:
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
{
|
|
13
|
+
"dependencies": { "@mzzsfy/dsh-toast": "^0.1.0" },
|
|
14
|
+
"dsh": {
|
|
15
|
+
"client": {
|
|
16
|
+
"external": ["@mzzsfy/dsh-toast/client"]
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
2. `cordis.patch.yml`(insert 列表追加一条,使本包 client 进入模块表;id 必须带消费插件前缀,name 才是包解析键)。**占位条目全仓唯一**:全仓只允许一个插件代挂本包占位(当前为 session-manager)——client-modules 按 npm 名做多源检查,两个占位条目装载基不同即 fatal 拖垮整树;其余消费插件**不代挂占位**,改为可选消费:
|
|
23
|
+
|
|
24
|
+
```js
|
|
25
|
+
// 模块表缺失(权威消费方未安装)时干净降级,不代挂占位
|
|
26
|
+
let toast = null
|
|
27
|
+
try { toast = require('@mzzsfy/dsh-toast/client').show } catch {}
|
|
28
|
+
// 调用点:toast?.(...)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
3. client.js 的 factory 内:
|
|
32
|
+
|
|
33
|
+
```js
|
|
34
|
+
const { show: toast } = require('@mzzsfy/dsh-toast/client')
|
|
35
|
+
|
|
36
|
+
toast('已保存', { kind: 'ok' }) // 成功反馈,自动消失
|
|
37
|
+
toast('保存失败:' + reason, { kind: 'error', sticky: true }) // 常驻待确认
|
|
38
|
+
toast('回合完成', { holdMs: 6 * 1000 }) // 自定义展示期
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## API
|
|
42
|
+
|
|
43
|
+
- `show(text, opts)` → `id`:入栈一条通知。`opts.kind` 为 `'info' | 'ok' | 'error'`(非法归 `info`,默认深色 / 成功绿 / 错误红);`opts.sticky` 真值常驻不自动消失,渲染「知道了」按钮;`opts.holdMs` 正数自定义展示期(默认 4 秒)。`text` 非字符串或为空时忽略并返回 `null`
|
|
44
|
+
- `dismiss(id)`:移除指定条目,幂等
|
|
45
|
+
- `mount()`:显式挂载渲染容器(一般无需调用,首次 `show` 惰性自举)
|
|
46
|
+
|
|
47
|
+
## 行为规格(BDD)
|
|
48
|
+
|
|
49
|
+
- Given 库已加载,When `show(text)`,Then 通知顶部居中显示,默认 4 秒后自动消失
|
|
50
|
+
- Given 栈内已有 4 条,When 再入栈一条,Then 最旧条目立即移除(含 sticky,新通知优先)
|
|
51
|
+
- Given `show(text, {sticky: true})`,Then 通知常驻,点「知道了」或 `dismiss(id)` 后消失
|
|
52
|
+
- Given `kind: 'error'`,Then 红色变体渲染
|
|
53
|
+
- Given 首次 `show`,Then 渲染容器与样式惰性挂载(容器直挂 body,不受设置页全屏层 z-index 遮挡)
|
|
54
|
+
- Given HMR 重载产生同 id 旧容器,When 新代首次挂载,Then 旧容器移除、新容器就位
|
|
55
|
+
- Given 用户系统开启减弱动态效果,Then 入场动画禁用
|
|
56
|
+
|
|
57
|
+
## 实现说明
|
|
58
|
+
|
|
59
|
+
- 位置顶部居中:不遮挡聊天输入区;容器直挂 body,层级高于设置全屏层
|
|
60
|
+
- 样式全部取宿主 `--dsw-alias-*` 令牌(错误变体文字色除外),双主题自适应;样式挂宿主文档级、幂等且内容变化原位替换
|
|
61
|
+
- store 位于模块闭包单例;渲染容器惰性自举、幂等、自愈(旧 root 卸载后重建),不依赖宿主生命周期
|
|
62
|
+
- react / react-dom/client 由宿主平台模块表提供,peerDependencies 声明 react 与 react-dom
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mzzsfy/dsh-toast",
|
|
3
|
+
"description": "全局浮出通知 Toast 库:多条并存栈式展示,自动消失与常驻确认两种生命周期;普通 npm 依赖(非 dsh 插件),由消费插件的 cordis.patch.yml 代挂宿主占位条目",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js",
|
|
9
|
+
"./client": "./src/client.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"dsh": {
|
|
13
|
+
"client": {
|
|
14
|
+
"platform": "web"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "node --test \"test/*.test.mjs\"",
|
|
19
|
+
"npmPublish": "npm publish --access public --provenance=false --registry=https://registry.npmjs.org"
|
|
20
|
+
},
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://git.1014.top:83/mzzsfy/dsh-plugin.git"
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=22"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"react": "^18.2.0",
|
|
31
|
+
"react-dom": "^18.2.0"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"src",
|
|
35
|
+
"test",
|
|
36
|
+
"README.md"
|
|
37
|
+
]
|
|
38
|
+
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// dsh-toast Client 半区:全局浮出通知 Toast 库,多条并存栈式展示。
|
|
2
|
+
// 以 DSH client-modules 自注册格式发布(__ModuleLoader__.load),消费插件经
|
|
3
|
+
// dsh.client.external require('@mzzsfy/dsh-toast/client') 使用。本包不声明
|
|
4
|
+
// dsh.bundle.patch,不进 profile 插件层;宿主占位条目由消费方 cordis.patch.yml
|
|
5
|
+
// 代挂,本包 client 由此进入客户端模块表。
|
|
6
|
+
// 渲染容器惰性自举:首次 show 挂载,容器与样式幂等,挂载前清同 id 旧代残留
|
|
7
|
+
// (HMR 重载模块后新代自愈,旧代闭包随容器移除失效)。
|
|
8
|
+
|
|
9
|
+
window.__ModuleLoader__.load({
|
|
10
|
+
id: '@mzzsfy/dsh-toast',
|
|
11
|
+
factory(require) {
|
|
12
|
+
const React = require('react')
|
|
13
|
+
const { useSyncExternalStore } = React
|
|
14
|
+
const { createRoot } = require('react-dom/client')
|
|
15
|
+
|
|
16
|
+
// Toast 持续时长与栈上限:展示期定值;突发事件裁剪最旧条目
|
|
17
|
+
const TOAST_HOLD_MS = 4 * 1000
|
|
18
|
+
const TOAST_MAX = 4
|
|
19
|
+
const KINDS = ['info', 'ok', 'error']
|
|
20
|
+
const HOST_ID = 'dsh-toast-host'
|
|
21
|
+
const STYLE_ID = 'dsh-toast-style'
|
|
22
|
+
|
|
23
|
+
const CSS = [
|
|
24
|
+
'.dsh-toast-stack { position:fixed; left:50%; top:16px; transform:translateX(-50%); z-index:1100;',
|
|
25
|
+
' 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; }',
|
|
31
|
+
'.dsh-toast__close { border:0; background:transparent; cursor:pointer; color:inherit;',
|
|
32
|
+
' font:var(--dsw-font-xs-strong-13); padding:0 2px; opacity:.8; }',
|
|
33
|
+
'.dsh-toast__close:hover { opacity:1; }',
|
|
34
|
+
'@keyframes dsh-toast-in { from { transform:translateY(-8px); opacity:0; } to { transform:translateY(0); opacity:1; } }',
|
|
35
|
+
'@media (prefers-reduced-motion: reduce) { .dsh-toast { animation:none; } }',
|
|
36
|
+
].join('\n')
|
|
37
|
+
|
|
38
|
+
function h(type, props) {
|
|
39
|
+
const children = Array.prototype.slice.call(arguments, 2)
|
|
40
|
+
return React.createElement.apply(React, [type, props || null].concat(children))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---- store:模块闭包单例,幂等无状态快照 ----
|
|
44
|
+
|
|
45
|
+
let seq = 0
|
|
46
|
+
let items = []
|
|
47
|
+
const listeners = new Set()
|
|
48
|
+
const emit = () => { for (const listener of listeners) listener() }
|
|
49
|
+
|
|
50
|
+
function normalizeKind(kind) {
|
|
51
|
+
return KINDS.indexOf(kind) >= 0 ? kind : 'info'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 展示期:sticky 常驻不计时;非 sticky 取调用方正值,否则默认
|
|
55
|
+
function resolveHoldMs(opts) {
|
|
56
|
+
const holdMs = opts && opts.holdMs
|
|
57
|
+
return typeof holdMs === 'number' && holdMs > 0 ? holdMs : TOAST_HOLD_MS
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 幂等移除:未命中不产生新快照,不触发重渲染
|
|
61
|
+
function dismiss(id) {
|
|
62
|
+
const next = items.filter((item) => item.id !== id)
|
|
63
|
+
if (next.length === items.length) return
|
|
64
|
+
items = next
|
|
65
|
+
emit()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 入栈即返回单调 id;超上限裁最旧(含 sticky,极端场景下错误提示让位于新通知);
|
|
69
|
+
// 非 sticky 条目入栈即挂自动消失计时,经条目引用持 id,与裁剪无关
|
|
70
|
+
function show(text, opts) {
|
|
71
|
+
if (typeof text !== 'string' || text === '') return null
|
|
72
|
+
const sticky = Boolean(opts && opts.sticky)
|
|
73
|
+
const entry = { id: ++seq, text, kind: normalizeKind(opts && opts.kind), sticky }
|
|
74
|
+
items = items.concat([entry]).slice(-TOAST_MAX)
|
|
75
|
+
emit()
|
|
76
|
+
mount()
|
|
77
|
+
if (!sticky) setTimeout(() => dismiss(entry.id), resolveHoldMs(opts))
|
|
78
|
+
return entry.id
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const source = {
|
|
82
|
+
getSnapshot: () => items,
|
|
83
|
+
subscribe(listener) {
|
|
84
|
+
listeners.add(listener)
|
|
85
|
+
return () => listeners.delete(listener)
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---- 渲染:容器直挂 body,不依赖宿主生命周期 ----
|
|
90
|
+
|
|
91
|
+
// 样式内容原位比对:一致跳过,不一致(HMR 新代 CSS 变化)原位替换
|
|
92
|
+
function ensureStyle() {
|
|
93
|
+
const stale = document.getElementById(STYLE_ID)
|
|
94
|
+
if (stale !== null) {
|
|
95
|
+
if (stale.textContent !== CSS) stale.textContent = CSS
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
const style = document.createElement('style')
|
|
99
|
+
style.id = STYLE_ID
|
|
100
|
+
style.textContent = CSS
|
|
101
|
+
document.head.appendChild(style)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 容器自愈:本代容器在场即幂等;否则清理一切旧容器(本代被外部移除的、
|
|
105
|
+
// HMR 上一代残留的),逐个卸载 React root(detached 树不再续渲染)后移除重建
|
|
106
|
+
let root = null
|
|
107
|
+
let host = null
|
|
108
|
+
function mount() {
|
|
109
|
+
if (root !== null && document.getElementById(HOST_ID) === host) return
|
|
110
|
+
for (const stale of new Set([host, document.getElementById(HOST_ID)])) {
|
|
111
|
+
if (stale === null) continue
|
|
112
|
+
if (stale.__toastRoot !== undefined) stale.__toastRoot.unmount()
|
|
113
|
+
stale.remove()
|
|
114
|
+
}
|
|
115
|
+
ensureStyle()
|
|
116
|
+
host = document.createElement('div')
|
|
117
|
+
host.id = HOST_ID
|
|
118
|
+
document.body.appendChild(host)
|
|
119
|
+
root = createRoot(host)
|
|
120
|
+
host.__toastRoot = root
|
|
121
|
+
root.render(React.createElement(ToastHost))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function ToastHost() {
|
|
125
|
+
const current = useSyncExternalStore(source.subscribe, source.getSnapshot)
|
|
126
|
+
return h('div', { className: 'dsh-toast-stack' },
|
|
127
|
+
current.map((item) => h(ToastItem, { key: item.id, item })),
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function ToastItem(props) {
|
|
132
|
+
const item = props.item
|
|
133
|
+
return h('div', { className: 'dsh-toast dsh-toast--' + item.kind, role: 'alert' },
|
|
134
|
+
h('span', null, item.text),
|
|
135
|
+
item.sticky
|
|
136
|
+
? h('button', { className: 'dsh-toast__close', onClick: () => dismiss(item.id) }, '知道了')
|
|
137
|
+
: null,
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
show,
|
|
143
|
+
dismiss,
|
|
144
|
+
mount,
|
|
145
|
+
// 浏览器 cordis loader 经裸名 id 装载本包的宿主占位条目(树内 insert,name
|
|
146
|
+
// = 包名,模块表 stripClientSuffix 后裸名与 /client 共享同一记录),要求本
|
|
147
|
+
// 导出呈插件形态;缺 apply 即判 invalid plugin 拖垮整树。装载体为空:容器
|
|
148
|
+
// 惰性自举在首次 show 时发生,无需生命周期介入
|
|
149
|
+
apply() {},
|
|
150
|
+
__test: { show, dismiss, source, normalizeKind, resolveHoldMs, getItems: () => items },
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
})
|
package/src/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// dsh-toast Host 半区:无宿主逻辑。本包是普通 npm 依赖(不声明 dsh.bundle.patch,
|
|
2
|
+
// 不进 profile 插件层),宿主占位条目由消费插件的 cordis.patch.yml 代挂;cordis
|
|
3
|
+
// 装载该条目时经本入口激活空插件,同时使 client 半区进入客户端模块表。
|
|
4
|
+
// 形态对齐 dsh-think-expand 的纯前端宿主入口:仅导出空 apply,不带 name/inject
|
|
5
|
+
// (带 name+inject 的命名空间形态经 dsh-web-app 装载链被判 invalid plugin)。
|
|
6
|
+
export function apply() {}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// client.js 自注册格式守卫:ModuleLoader id 必须与 npm 包名一致,
|
|
2
|
+
// 宿主按包名解析 client bundle。
|
|
3
|
+
import test from 'node:test'
|
|
4
|
+
import assert from 'node:assert/strict'
|
|
5
|
+
import { readFileSync } from 'node:fs'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { dirname, join } from 'node:path'
|
|
8
|
+
|
|
9
|
+
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
10
|
+
|
|
11
|
+
test('client.js 以本包名注册 ModuleLoader 模块', () => {
|
|
12
|
+
const source = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
|
|
13
|
+
const match = source.match(/__ModuleLoader__\.load\(\{\s*id:\s*'([^']+)'/)
|
|
14
|
+
assert.ok(match, 'client.js 缺少 __ModuleLoader__.load 注册')
|
|
15
|
+
assert.equal(match[1], '@mzzsfy/dsh-toast')
|
|
16
|
+
})
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// 挂载自愈测试:首次 show 惰性挂容器与样式;重复 show 幂等;外部移除与 HMR
|
|
2
|
+
// 旧代残留均在下一次挂载时卸 root 重建;样式内容不一致原位替换。
|
|
3
|
+
// 加载真实 src/client.js 驱动完整 DOM 交互路径(mock.timers 冻结自动消失计时)。
|
|
4
|
+
import test from 'node:test'
|
|
5
|
+
import assert from 'node:assert/strict'
|
|
6
|
+
import { readFileSync } from 'node:fs'
|
|
7
|
+
import { fileURLToPath } from 'node:url'
|
|
8
|
+
import { dirname, join } from 'node:path'
|
|
9
|
+
import { mock } from 'node:test'
|
|
10
|
+
|
|
11
|
+
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
12
|
+
|
|
13
|
+
const reactStub = {
|
|
14
|
+
useSyncExternalStore: () => [],
|
|
15
|
+
createElement: () => null,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function makeState() {
|
|
19
|
+
return { byId: new Map(), bodyAppends: 0, appended: [] }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function makeDocument(state) {
|
|
23
|
+
const byId = state.byId
|
|
24
|
+
return {
|
|
25
|
+
getElementById: (id) => byId.get(id) ?? null,
|
|
26
|
+
createElement: (tag) => ({ tag, id: '', textContent: '', style: {}, remove() { byId.delete(this.id) } }),
|
|
27
|
+
head: { appendChild: (node) => byId.set(node.id, node) },
|
|
28
|
+
body: {
|
|
29
|
+
appendChild: (node) => { state.bodyAppends += 1; state.appended.push(node); byId.set(node.id, node) },
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function loadModule(docState) {
|
|
35
|
+
const source = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
|
|
36
|
+
const modules = []
|
|
37
|
+
const windowStub = { __ModuleLoader__: { load: (module) => modules.push(module) } }
|
|
38
|
+
const roots = []
|
|
39
|
+
const createRoot = (host) => {
|
|
40
|
+
const root = { host, renderCalls: 0, unmounted: false, render() { this.renderCalls += 1 }, unmount() { this.unmounted = true } }
|
|
41
|
+
roots.push(root)
|
|
42
|
+
return root
|
|
43
|
+
}
|
|
44
|
+
const requireStub = (name) => (name === 'react-dom/client' ? { createRoot } : reactStub)
|
|
45
|
+
const factory = new Function('window', 'require', 'document', source + '\n;return null')
|
|
46
|
+
factory(windowStub, requireStub, makeDocument(docState))
|
|
47
|
+
assert.equal(modules.length, 1, 'client.js 模块未被捕获')
|
|
48
|
+
const mod = modules[0].factory(requireStub)
|
|
49
|
+
return { mod, roots }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const HOST_ID = 'dsh-toast-host'
|
|
53
|
+
const STYLE_ID = 'dsh-toast-style'
|
|
54
|
+
|
|
55
|
+
test.beforeEach(() => { mock.timers.enable({ apis: ['setTimeout'] }) })
|
|
56
|
+
test.afterEach(() => { mock.timers.reset() })
|
|
57
|
+
|
|
58
|
+
test('首次 show 惰性挂载:容器与样式就位', () => {
|
|
59
|
+
const state = makeState()
|
|
60
|
+
const { mod } = loadModule(state)
|
|
61
|
+
assert.equal(state.byId.get(HOST_ID), undefined, '加载即挂载是错误的(应惰性)')
|
|
62
|
+
mod.show('触发挂载')
|
|
63
|
+
assert.notEqual(state.byId.get(HOST_ID), undefined)
|
|
64
|
+
assert.notEqual(state.byId.get(STYLE_ID), undefined)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('重复 show 幂等:容器只创建一次', () => {
|
|
68
|
+
const state = makeState()
|
|
69
|
+
const { mod } = loadModule(state)
|
|
70
|
+
mod.show('一')
|
|
71
|
+
mod.show('二')
|
|
72
|
+
assert.equal(state.bodyAppends, 1, '容器只 append 一次')
|
|
73
|
+
assert.equal(state.byId.get(HOST_ID), state.appended[0])
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('外部移除容器后重建:旧 root 卸载,新容器就位', () => {
|
|
77
|
+
const state = makeState()
|
|
78
|
+
const { mod, roots } = loadModule(state)
|
|
79
|
+
mod.show('一')
|
|
80
|
+
const firstRoot = roots[0]
|
|
81
|
+
state.byId.get(HOST_ID).remove()
|
|
82
|
+
mod.show('二')
|
|
83
|
+
assert.equal(firstRoot.unmounted, true, '被移除容器的 root 已卸载')
|
|
84
|
+
assert.equal(state.bodyAppends, 2, '新容器重建')
|
|
85
|
+
assert.notEqual(state.byId.get(HOST_ID), undefined)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('HMR 跨代自愈:新代首挂卸载旧代 root 并重建容器', () => {
|
|
89
|
+
const state = makeState()
|
|
90
|
+
const first = loadModule(state)
|
|
91
|
+
first.mod.show('旧代')
|
|
92
|
+
const staleRoot = first.roots[0]
|
|
93
|
+
const staleHost = state.byId.get(HOST_ID)
|
|
94
|
+
const second = loadModule(state)
|
|
95
|
+
second.mod.show('新代')
|
|
96
|
+
assert.equal(staleRoot.unmounted, true, '旧代 root 已卸载')
|
|
97
|
+
assert.equal(state.byId.get(HOST_ID), state.appended[state.appended.length - 1], '新代容器在场')
|
|
98
|
+
assert.notEqual(state.byId.get(HOST_ID), staleHost, '新容器是新对象')
|
|
99
|
+
assert.equal(second.roots[0].host, state.byId.get(HOST_ID), '新 root 绑定新容器')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('样式内容不一致原位替换:HMR 新代 CSS 变化即生效', () => {
|
|
103
|
+
const state = makeState()
|
|
104
|
+
const { mod } = loadModule(state)
|
|
105
|
+
mod.show('建立样式')
|
|
106
|
+
const styleNode = state.byId.get(STYLE_ID)
|
|
107
|
+
const currentCss = styleNode.textContent
|
|
108
|
+
// 模拟旧代残留:DOM 中样式内容与本代 CSS 不一致(HMR 改了 CSS 数组)
|
|
109
|
+
styleNode.textContent = '/* 残留的旧代样式 */'
|
|
110
|
+
const second = loadModule(state)
|
|
111
|
+
second.mod.show('触发重建路径')
|
|
112
|
+
assert.equal(state.byId.get(STYLE_ID), styleNode, '样式节点原位保留')
|
|
113
|
+
assert.equal(styleNode.textContent, currentCss, '内容恢复为当前 CSS')
|
|
114
|
+
})
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// store 行为测试:入栈/裁剪/自动消失/常驻/守卫/快照稳定性。
|
|
2
|
+
// 加载真实 src/client.js(stub 注入),直接驱动 factory 层 store,不涉渲染。
|
|
3
|
+
import test from 'node:test'
|
|
4
|
+
import assert from 'node:assert/strict'
|
|
5
|
+
import { readFileSync } from 'node:fs'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { dirname, join } from 'node:path'
|
|
8
|
+
import { mock } from 'node:test'
|
|
9
|
+
|
|
10
|
+
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
11
|
+
|
|
12
|
+
// 全文件冻结 setTimeout:show 的自动消失计时不拖住测试进程,
|
|
13
|
+
// 「自动消失」用例经 mock.timers.tick 手动推进
|
|
14
|
+
test.beforeEach(() => { mock.timers.enable({ apis: ['setTimeout'] }) })
|
|
15
|
+
test.afterEach(() => { mock.timers.reset() })
|
|
16
|
+
|
|
17
|
+
const reactStub = {
|
|
18
|
+
useSyncExternalStore: () => [],
|
|
19
|
+
createElement: () => null,
|
|
20
|
+
}
|
|
21
|
+
const createRootStub = () => ({ render() {} })
|
|
22
|
+
const requireStub = (name) => (name === 'react-dom/client' ? { createRoot: createRootStub } : reactStub)
|
|
23
|
+
|
|
24
|
+
// 加载 client.js 并执行 factory,返回模块导出(含 __test 钩子)
|
|
25
|
+
function loadModule() {
|
|
26
|
+
const source = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
|
|
27
|
+
const modules = []
|
|
28
|
+
const windowStub = { __ModuleLoader__: { load: (module) => modules.push(module) } }
|
|
29
|
+
const factory = new Function('window', 'require', 'document', source + '\n;return null')
|
|
30
|
+
factory(windowStub, requireStub, makeDocument())
|
|
31
|
+
assert.equal(modules.length, 1, 'client.js 模块未被捕获')
|
|
32
|
+
return modules[0].factory(requireStub)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function makeDocument(state) {
|
|
36
|
+
const ids = state || { byId: new Map() }
|
|
37
|
+
return {
|
|
38
|
+
getElementById: (id) => ids.byId.get(id) ?? null,
|
|
39
|
+
createElement: (tag) => ({ tag, id: '', style: {}, remove() { ids.byId.delete(this.id) } }),
|
|
40
|
+
head: { appendChild: () => {} },
|
|
41
|
+
body: {
|
|
42
|
+
appendChild: (node) => ids.byId.set(node.id, node),
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
test('show:多条并存,id 单调,快照按序', () => {
|
|
48
|
+
const mod = loadModule()
|
|
49
|
+
const first = mod.__test.show('第一条')
|
|
50
|
+
const second = mod.__test.show('第二条', { kind: 'ok' })
|
|
51
|
+
assert.equal(typeof first, 'number')
|
|
52
|
+
assert.equal(second, first + 1)
|
|
53
|
+
const items = mod.__test.getItems()
|
|
54
|
+
assert.deepEqual(items.map((item) => item.text), ['第一条', '第二条'])
|
|
55
|
+
assert.deepEqual(items.map((item) => item.kind), ['info', 'ok'])
|
|
56
|
+
assert.deepEqual(items.map((item) => item.sticky), [false, false])
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('show:栈上限裁最旧,新条目保留', () => {
|
|
60
|
+
const mod = loadModule()
|
|
61
|
+
for (let index = 1; index <= 4; index += 1) mod.__test.show('条目' + index)
|
|
62
|
+
const newest = mod.__test.show('条目5')
|
|
63
|
+
const items = mod.__test.getItems()
|
|
64
|
+
assert.equal(items.length, 4)
|
|
65
|
+
assert.equal(items[0].text, '条目2', '最旧条目被裁剪')
|
|
66
|
+
assert.equal(items[items.length - 1].id, newest)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('show:非法入参守卫,text 忽略返回 null', () => {
|
|
70
|
+
const mod = loadModule()
|
|
71
|
+
assert.equal(mod.__test.show(''), null)
|
|
72
|
+
assert.equal(mod.__test.show(null), null)
|
|
73
|
+
assert.equal(mod.__test.show(42), null)
|
|
74
|
+
assert.equal(mod.__test.getItems().length, 0)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('show:kind 非法归 info,sticky 按 truthy 判定', () => {
|
|
78
|
+
const mod = loadModule()
|
|
79
|
+
mod.__test.show('a', { kind: 'bogus' })
|
|
80
|
+
mod.__test.show('b', { sticky: 'yes' })
|
|
81
|
+
mod.__test.show('c', { sticky: 0 })
|
|
82
|
+
const items = mod.__test.getItems()
|
|
83
|
+
assert.deepEqual(items.map((item) => item.kind), ['info', 'info', 'info'])
|
|
84
|
+
assert.deepEqual(items.map((item) => item.sticky), [false, true, false])
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('dismiss:命中移除,未命中幂等不动快照', () => {
|
|
88
|
+
const mod = loadModule()
|
|
89
|
+
const id = mod.__test.show('可移除')
|
|
90
|
+
const snapshot = mod.__test.source.getSnapshot()
|
|
91
|
+
mod.__test.dismiss(99999)
|
|
92
|
+
assert.equal(mod.__test.source.getSnapshot(), snapshot, '未命中不产生新快照')
|
|
93
|
+
mod.__test.dismiss(id)
|
|
94
|
+
assert.equal(mod.__test.getItems().length, 0)
|
|
95
|
+
assert.notEqual(mod.__test.source.getSnapshot(), snapshot, '命中产生新快照')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('自动消失:默认展示期后移除,sticky 不计时,dismiss 即消失', () => {
|
|
99
|
+
const mod = loadModule()
|
|
100
|
+
mod.__test.show('自动')
|
|
101
|
+
mod.__test.show('常驻', { kind: 'error', sticky: true })
|
|
102
|
+
assert.equal(mod.__test.getItems().length, 2)
|
|
103
|
+
mock.timers.tick(mod.__test.resolveHoldMs(null))
|
|
104
|
+
const items = mod.__test.getItems()
|
|
105
|
+
assert.deepEqual(items.map((item) => item.text), ['常驻'], '非 sticky 已消失,sticky 保留')
|
|
106
|
+
mod.__test.dismiss(items[0].id)
|
|
107
|
+
assert.equal(mod.__test.getItems().length, 0)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('自动消失:holdMs 正值生效,非法回落默认', () => {
|
|
111
|
+
const mod = loadModule()
|
|
112
|
+
mod.__test.show('快闪', { holdMs: 500 })
|
|
113
|
+
mod.__test.show('默认')
|
|
114
|
+
mock.timers.tick(500)
|
|
115
|
+
assert.deepEqual(mod.__test.getItems().map((item) => item.text), ['默认'])
|
|
116
|
+
mock.timers.tick(mod.__test.resolveHoldMs({ holdMs: -1 }))
|
|
117
|
+
assert.equal(mod.__test.getItems().length, 0)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('subscribe:入栈与移除均通知订阅者', () => {
|
|
121
|
+
const mod = loadModule()
|
|
122
|
+
let calls = 0
|
|
123
|
+
const unsubscribe = mod.__test.source.subscribe(() => { calls += 1 })
|
|
124
|
+
const id = mod.__test.show('通知')
|
|
125
|
+
mod.__test.dismiss(id)
|
|
126
|
+
assert.equal(calls, 2)
|
|
127
|
+
unsubscribe()
|
|
128
|
+
mod.__test.show('不再通知')
|
|
129
|
+
assert.equal(calls, 2)
|
|
130
|
+
})
|