@mzzsfy/dsh-toast 0.1.0 → 0.1.2

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 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` 正数自定义展示期(默认 4 )。`text` 非字符串或为空时忽略并返回 `null`
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-alias-*` 令牌(错误变体文字色除外),双主题自适应;样式挂宿主文档级、幂等且内容变化原位替换
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-toast",
3
3
  "description": "全局浮出通知 Toast 库:多条并存栈式展示,自动消失与常驻确认两种生命周期;普通 npm 依赖(非 dsh 插件),由消费插件的 cordis.patch.yml 代挂宿主占位条目",
4
- "version": "0.1.0",
4
+ "version": "0.1.2",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
@@ -21,7 +21,7 @@
21
21
  "license": "MIT",
22
22
  "repository": {
23
23
  "type": "git",
24
- "url": "git+https://git.1014.top:83/mzzsfy/dsh-plugin.git"
24
+ "url": "git+https://github.com/mzzsfy/dsh-plugin.git"
25
25
  },
26
26
  "engines": {
27
27
  "node": ">=22"
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
- // (HMR 重载模块后新代自愈,旧代闭包随容器移除失效)。
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:1100;',
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 next = items.filter((item) => item.id !== id)
63
- if (next.length === items.length) return
64
- items = next
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
- // 入栈即返回单调 id;超上限裁最旧( sticky,极端场景下错误提示让位于新通知);
69
- // sticky 条目入栈即挂自动消失计时,经条目引用持 id,与裁剪无关
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
- items = items.concat([entry]).slice(-TOAST_MAX)
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
- mount()
77
- if (!sticky) setTimeout(() => dismiss(entry.id), resolveHoldMs(opts))
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
- // HMR 上一代残留的),逐个卸载 React root(detached 树不再续渲染)后移除重建
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
- if (root !== null && document.getElementById(HOST_ID) === host) return
110
- for (const stale of new Set([host, document.getElementById(HOST_ID)])) {
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
- __test: { show, dismiss, source, normalizeKind, resolveHoldMs, getItems: () => items },
199
+ // 非公开 API,仅供本包测试驱动 store 消费,无兼容承诺,消费方禁用
200
+ __test: { show, dismiss, source, normalizeKind, resolveHoldMs, getItems: () => items.slice() },
151
201
  }
152
202
  },
153
203
  })
@@ -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
+ })
@@ -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
- return {
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: (tag) => ({ tag, id: '', textContent: '', style: {}, remove() { byId.delete(this.id) } }),
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
- const windowStub = { __ModuleLoader__: { load: (module) => modules.push(module) } }
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
- factory(windowStub, requireStub, makeDocument(docState))
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.notEqual(state.byId.get(STYLE_ID), undefined)
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(state.byId.get(HOST_ID), state.appended[0])
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('样式内容不一致原位替换:HMR 新代 CSS 变化即生效', () => {
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
- // 模拟旧代残留:DOM 中样式内容与本代 CSS 不一致(HMR 改了 CSS 数组)
109
- styleNode.textContent = '/* 残留的旧代样式 */'
110
- const second = loadModule(state)
111
- second.mod.show('触发重建路径')
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.textContent, currentCss, '内容恢复为当前 CSS')
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
  })
@@ -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
+ })