@mzzsfy/dsh-usage-panel 0.4.3
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 +132 -0
- package/cordis.patch.yml +8 -0
- package/package.json +49 -0
- package/src/client.js +1424 -0
- package/src/history.mjs +117 -0
- package/src/historyStore.mjs +61 -0
- package/src/index.js +777 -0
- package/src/notify.mjs +424 -0
- package/src/parsers.mjs +327 -0
- package/src/poller.mjs +60 -0
- package/test/client-id.test.mjs +16 -0
- package/test/history.test.mjs +170 -0
- package/test/historyStore.test.mjs +107 -0
- package/test/notify-poll.test.mjs +31 -0
- package/test/notify.route.test.mjs +447 -0
- package/test/notify.test.mjs +498 -0
- package/test/parity.test.mjs +119 -0
- package/test/parsers.test.mjs +286 -0
- package/test/poller.test.mjs +88 -0
- package/test/route.test.mjs +171 -0
- package/test/switch-guard.test.mjs +32 -0
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
// 通知纯逻辑层测试:规则合并 / 沿触发评估 / 投影 / 认领 / 校验。
|
|
2
|
+
// 场景以 Given-When-Then 注释锚定,与实现 src/notify.mjs 同步演进。
|
|
3
|
+
|
|
4
|
+
import { test } from 'node:test'
|
|
5
|
+
import assert from 'node:assert/strict'
|
|
6
|
+
import {
|
|
7
|
+
mergeAccountOverride,
|
|
8
|
+
defaultNotifySettings,
|
|
9
|
+
createNotifyState,
|
|
10
|
+
normalizeNotifyState,
|
|
11
|
+
evaluateAccount,
|
|
12
|
+
normalizeImTargets,
|
|
13
|
+
validateNotifyPatch,
|
|
14
|
+
normalizeAccountNotify,
|
|
15
|
+
createProjection,
|
|
16
|
+
decideClaim,
|
|
17
|
+
buildWebhookPayload,
|
|
18
|
+
buildNotifyEvent,
|
|
19
|
+
sendWebhook,
|
|
20
|
+
resolvedNotifySettings,
|
|
21
|
+
publicNotify,
|
|
22
|
+
isValidImBotId,
|
|
23
|
+
KIND_QUOTA,
|
|
24
|
+
KIND_BALANCE,
|
|
25
|
+
KIND_RESET,
|
|
26
|
+
} from '../src/notify.mjs'
|
|
27
|
+
|
|
28
|
+
test('规则合并: 账号覆盖键生效, 其余继承全局', () => {
|
|
29
|
+
// Given 全局规则 quota 90 / balance 20 / resetNotice true
|
|
30
|
+
const global = { ...defaultNotifySettings(), enabled: true }
|
|
31
|
+
// When 账号仅覆盖 quotaThresholdPct 与 balanceThreshold
|
|
32
|
+
const merged = mergeAccountOverride(global, { quotaThresholdPct: 50, balanceThreshold: 5 })
|
|
33
|
+
// Then 合并结果覆盖键取账号值, resetNotice 继承全局
|
|
34
|
+
assert.equal(merged.quotaThresholdPct, 50)
|
|
35
|
+
assert.equal(merged.balanceThreshold, 5)
|
|
36
|
+
assert.equal(merged.resetNotice, true)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('规则合并: 账号 notify 缺失时全部继承全局', () => {
|
|
40
|
+
// Given 全局规则已启用
|
|
41
|
+
const global = { ...defaultNotifySettings(), enabled: true, quotaThresholdPct: 80 }
|
|
42
|
+
// When 账号无 notify 覆盖(缺失与空对象两种形态)
|
|
43
|
+
// Then 两次合并结果均等于全局规则值
|
|
44
|
+
assert.deepEqual(mergeAccountOverride(global, null), { quotaThresholdPct: 80, balanceThreshold: global.balanceThreshold, resetNotice: true })
|
|
45
|
+
assert.deepEqual(mergeAccountOverride(global, {}), { quotaThresholdPct: 80, balanceThreshold: global.balanceThreshold, resetNotice: true })
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// ---- 用量阈值沿触发 + 窗口重置 ----
|
|
49
|
+
|
|
50
|
+
const quotaReading = (utilization, resetsAt, label = '5小时') => ({
|
|
51
|
+
kind: 'quota',
|
|
52
|
+
windows: [{ label, utilization, resetsAt }],
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const quotaAccount = (reading) => ({ id: 'acct-1', name: '账号A', last: { ok: true, reading } })
|
|
56
|
+
|
|
57
|
+
test('用量阈值: 首次上穿阈值产生一条 quota 事件并解除武装', () => {
|
|
58
|
+
// Given 阈值 90, 账号首轮查询 utilization 85 建立基线
|
|
59
|
+
const rule = { quotaThresholdPct: 90, balanceThreshold: null, resetNotice: true }
|
|
60
|
+
let state = createNotifyState()
|
|
61
|
+
const first = evaluateAccount({ account: quotaAccount(quotaReading(85, 'T1')), rule, state, seq: 1, ts: 1000 })
|
|
62
|
+
state = first.state
|
|
63
|
+
assert.equal(first.events.length, 0)
|
|
64
|
+
// When 第二轮查询 utilization 92 上穿阈值
|
|
65
|
+
const second = evaluateAccount({ account: quotaAccount(quotaReading(92, 'T1')), rule, state, seq: 2, ts: 2000 })
|
|
66
|
+
// Then 产生一条 quota 事件, 文本含账号名/窗口/读数/阈值, 新状态解除武装
|
|
67
|
+
assert.equal(second.events.length, 1)
|
|
68
|
+
const event = second.events[0]
|
|
69
|
+
assert.equal(event.kind, KIND_QUOTA)
|
|
70
|
+
assert.equal(event.accountId, 'acct-1')
|
|
71
|
+
assert.equal(event.text, '[dsh] 账号A 5小时窗口用量达 92%(阈值 90%)')
|
|
72
|
+
assert.equal(second.state.windows['5小时'].armed, false)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('用量阈值: 已触发后持续超阈值不重复通知', () => {
|
|
76
|
+
// Given 阈值 90 且已触发解除武装
|
|
77
|
+
const rule = { quotaThresholdPct: 90, balanceThreshold: null, resetNotice: true }
|
|
78
|
+
let state = createNotifyState()
|
|
79
|
+
state = evaluateAccount({ account: quotaAccount(quotaReading(92, 'T1')), rule, state, seq: 1, ts: 1000 }).state
|
|
80
|
+
// When 再次查询 utilization 95 仍超阈值
|
|
81
|
+
const next = evaluateAccount({ account: quotaAccount(quotaReading(95, 'T1')), rule, state, seq: 2, ts: 2000 })
|
|
82
|
+
// Then 无新事件, 峰值随读数更新
|
|
83
|
+
assert.equal(next.events.length, 0)
|
|
84
|
+
assert.equal(next.state.windows['5小时'].peak, 95)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('窗口重置: resetsAt 轮转产生 reset 事件报上一窗口峰值并重新武装', () => {
|
|
88
|
+
// Given 阈值 90, 首轮 92 触发, 次轮 95 峰值更新
|
|
89
|
+
const rule = { quotaThresholdPct: 90, balanceThreshold: null, resetNotice: true }
|
|
90
|
+
let state = createNotifyState()
|
|
91
|
+
state = evaluateAccount({ account: quotaAccount(quotaReading(92, 'T1')), rule, state, seq: 1, ts: 1000 }).state
|
|
92
|
+
state = evaluateAccount({ account: quotaAccount(quotaReading(95, 'T1')), rule, state, seq: 2, ts: 2000 }).state
|
|
93
|
+
// When 第三轮查询 resetsAt 轮转到 T2, 新窗口 utilization 5
|
|
94
|
+
const next = evaluateAccount({ account: quotaAccount(quotaReading(5, 'T2')), rule, state, seq: 3, ts: 3000 })
|
|
95
|
+
// Then 产生一条 reset 事件报上一窗口峰值 95, 新窗口基线重建且重新武装
|
|
96
|
+
assert.equal(next.events.length, 1)
|
|
97
|
+
const event = next.events[0]
|
|
98
|
+
assert.equal(event.kind, KIND_RESET)
|
|
99
|
+
assert.equal(event.text, '[dsh] 账号A 5小时窗口已重置,上一窗口峰值用量 95%')
|
|
100
|
+
assert.equal(next.state.windows['5小时'].armed, true)
|
|
101
|
+
assert.equal(next.state.windows['5小时'].peak, 5)
|
|
102
|
+
// When 新窗口再次上穿阈值
|
|
103
|
+
const again = evaluateAccount({ account: quotaAccount(quotaReading(91, 'T2')), rule, state: next.state, seq: 4, ts: 4000 })
|
|
104
|
+
// Then 再次产生 quota 事件(重置后 re-arm 生效)
|
|
105
|
+
assert.equal(again.events.length, 1)
|
|
106
|
+
assert.equal(again.events[0].kind, KIND_QUOTA)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('窗口重置: resetNotice 关闭时不产生 reset 事件但仍重建基线并重新武装', () => {
|
|
110
|
+
// Given 阈值 90 已触发, 重置通知关闭
|
|
111
|
+
const rule = { quotaThresholdPct: 90, balanceThreshold: null, resetNotice: false }
|
|
112
|
+
let state = createNotifyState()
|
|
113
|
+
state = evaluateAccount({ account: quotaAccount(quotaReading(92, 'T1')), rule, state, seq: 1, ts: 1000 }).state
|
|
114
|
+
// When resetsAt 轮转
|
|
115
|
+
const next = evaluateAccount({ account: quotaAccount(quotaReading(5, 'T2')), rule, state, seq: 2, ts: 2000 })
|
|
116
|
+
// Then 无事件, 武装恢复, 峰值基线已重建
|
|
117
|
+
assert.equal(next.events.length, 0)
|
|
118
|
+
assert.equal(next.state.windows['5小时'].armed, true)
|
|
119
|
+
assert.equal(next.state.windows['5小时'].peak, 5)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('用量阈值: resetsAt 为 null 的窗口仅做阈值判断不做重置检测', () => {
|
|
123
|
+
// Given 阈值 90, 读数无 resetsAt
|
|
124
|
+
const rule = { quotaThresholdPct: 90, balanceThreshold: null, resetNotice: true }
|
|
125
|
+
let state = createNotifyState()
|
|
126
|
+
state = evaluateAccount({ account: quotaAccount(quotaReading(50, null)), rule, state, seq: 1, ts: 1000 }).state
|
|
127
|
+
// When utilization 上穿阈值
|
|
128
|
+
const next = evaluateAccount({ account: quotaAccount(quotaReading(95, null)), rule, state, seq: 2, ts: 2000 })
|
|
129
|
+
// Then 仅产生 quota 事件, 不因 resetsAt 缺失产生 reset 事件
|
|
130
|
+
assert.equal(next.events.length, 1)
|
|
131
|
+
assert.equal(next.events[0].kind, KIND_QUOTA)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test('用量阈值: 读数 utilization 缺失的窗口跳过评估不崩', () => {
|
|
135
|
+
// Given 阈值 90, 窗口无 utilization(余额型读数或字段缺失)
|
|
136
|
+
const rule = { quotaThresholdPct: 90, balanceThreshold: null, resetNotice: true }
|
|
137
|
+
const state = createNotifyState()
|
|
138
|
+
const reading = { kind: 'quota', windows: [{ label: '5小时', utilization: null, resetsAt: 'T1' }] }
|
|
139
|
+
// When 评估该账号
|
|
140
|
+
const result = evaluateAccount({ account: quotaAccount(reading), rule, state, seq: 1, ts: 1000 })
|
|
141
|
+
// Then 无事件无异常
|
|
142
|
+
assert.equal(result.events.length, 0)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
// ---- 余额阈值沿触发 ----
|
|
146
|
+
|
|
147
|
+
const balanceReading = (remaining, currency = 'USD') => ({
|
|
148
|
+
kind: 'balance',
|
|
149
|
+
entries: [{ currency, total: remaining + 100, remaining }],
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
const balanceAccount = (reading) => ({ id: 'acct-2', name: '账号B', last: { ok: true, reading } })
|
|
153
|
+
|
|
154
|
+
test('余额阈值: 下穿阈值产生一条 balance 事件并解除武装', () => {
|
|
155
|
+
// Given 阈值 20, 首轮余额 50
|
|
156
|
+
const rule = { quotaThresholdPct: 90, balanceThreshold: 20, resetNotice: true }
|
|
157
|
+
let state = createNotifyState()
|
|
158
|
+
state = evaluateAccount({ account: balanceAccount(balanceReading(50)), rule, state, seq: 1, ts: 1000 }).state
|
|
159
|
+
// When 余额降到 12.5
|
|
160
|
+
const next = evaluateAccount({ account: balanceAccount(balanceReading(12.5)), rule, state, seq: 2, ts: 2000 })
|
|
161
|
+
// Then 产生一条 balance 事件, 文本含币种与数值
|
|
162
|
+
assert.equal(next.events.length, 1)
|
|
163
|
+
const event = next.events[0]
|
|
164
|
+
assert.equal(event.kind, KIND_BALANCE)
|
|
165
|
+
assert.equal(event.text, '[dsh] 账号B 余额 12.5 USD,低于阈值 20 USD')
|
|
166
|
+
assert.equal(next.state.balanceArmed, false)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test('余额阈值: 持续低于阈值不重复通知, 回升后再次下穿才重新触发', () => {
|
|
170
|
+
// Given 阈值 20 且已触发
|
|
171
|
+
const rule = { quotaThresholdPct: 90, balanceThreshold: 20, resetNotice: true }
|
|
172
|
+
let state = createNotifyState()
|
|
173
|
+
state = evaluateAccount({ account: balanceAccount(balanceReading(12.5)), rule, state, seq: 1, ts: 1000 }).state
|
|
174
|
+
// When 余额仍低于阈值
|
|
175
|
+
let next = evaluateAccount({ account: balanceAccount(balanceReading(10)), rule, state, seq: 2, ts: 2000 })
|
|
176
|
+
// Then 无新事件
|
|
177
|
+
assert.equal(next.events.length, 0)
|
|
178
|
+
// When 充值回升到阈值上方
|
|
179
|
+
next = evaluateAccount({ account: balanceAccount(balanceReading(50)), rule, state: next.state, seq: 3, ts: 3000 })
|
|
180
|
+
assert.equal(next.events.length, 0)
|
|
181
|
+
assert.equal(next.state.balanceArmed, true)
|
|
182
|
+
// When 再次下穿
|
|
183
|
+
next = evaluateAccount({ account: balanceAccount(balanceReading(5)), rule, state: next.state, seq: 4, ts: 4000 })
|
|
184
|
+
// Then 重新产生事件
|
|
185
|
+
assert.equal(next.events.length, 1)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
test('余额阈值: 阈值未配置(null)时不评估不触发', () => {
|
|
189
|
+
// Given 阈值 null, 余额极低
|
|
190
|
+
const rule = { quotaThresholdPct: 90, balanceThreshold: null, resetNotice: true }
|
|
191
|
+
const state = createNotifyState()
|
|
192
|
+
// When 评估余额 1
|
|
193
|
+
const result = evaluateAccount({ account: balanceAccount(balanceReading(1)), rule, state, seq: 1, ts: 1000 })
|
|
194
|
+
// Then 无事件, 武装保持
|
|
195
|
+
assert.equal(result.events.length, 0)
|
|
196
|
+
assert.equal(result.state.balanceArmed, true)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test('余额阈值: 无 remaining 的余额读数回落 total 口径(deepseek 形态)', () => {
|
|
200
|
+
// Given 阈值 20, entry 仅有 total(deepseek 余额形态)
|
|
201
|
+
const rule = { quotaThresholdPct: 90, balanceThreshold: 20, resetNotice: true }
|
|
202
|
+
const state = createNotifyState()
|
|
203
|
+
const reading = { kind: 'balance', entries: [{ currency: 'CNY', total: 10 }] }
|
|
204
|
+
// When 评估
|
|
205
|
+
const result = evaluateAccount({ account: balanceAccount(reading), rule, state, seq: 1, ts: 1000 })
|
|
206
|
+
// Then 以 total 口径触发, 币种 CNY
|
|
207
|
+
assert.equal(result.events.length, 1)
|
|
208
|
+
assert.equal(result.events[0].text, '[dsh] 账号B 余额 10 CNY,低于阈值 20 CNY')
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test('余额阈值: remaining 为 null 回落 total 口径(openrouter/newapi 形态)', () => {
|
|
212
|
+
// Given 阈值 20, entry remaining=null total=10(total_usage 缺失时解析产物)
|
|
213
|
+
const rule = { quotaThresholdPct: 90, balanceThreshold: 20, resetNotice: true }
|
|
214
|
+
let state = createNotifyState()
|
|
215
|
+
const reading = { kind: 'balance', entries: [{ currency: 'USD', total: 10, remaining: null }] }
|
|
216
|
+
// When 评估
|
|
217
|
+
const result = evaluateAccount({ account: balanceAccount(reading), rule, state, seq: 1, ts: 1000 })
|
|
218
|
+
// Then 以 total 口径触发, 不因 Number(null)=0 误报且不断武装
|
|
219
|
+
assert.equal(result.events.length, 1)
|
|
220
|
+
assert.equal(result.events[0].text, '[dsh] 账号B 余额 10 USD,低于阈值 20 USD')
|
|
221
|
+
assert.equal(result.state.balanceArmed, false)
|
|
222
|
+
// When total 回升到阈值上方
|
|
223
|
+
state = result.state
|
|
224
|
+
const recover = evaluateAccount({
|
|
225
|
+
account: balanceAccount({ kind: 'balance', entries: [{ currency: 'USD', total: 100, remaining: null }] }),
|
|
226
|
+
rule, state, seq: 2, ts: 2000,
|
|
227
|
+
})
|
|
228
|
+
// Then 重新武装, 再次下穿才重发(null 口径 armed 周期闭环)
|
|
229
|
+
assert.equal(recover.events.length, 0)
|
|
230
|
+
assert.equal(recover.state.balanceArmed, true)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
test('余额阈值: remaining=null total 充足时回落口径不触发', () => {
|
|
234
|
+
// Given 阈值 20, remaining=null total=100
|
|
235
|
+
const rule = { quotaThresholdPct: 90, balanceThreshold: 20, resetNotice: true }
|
|
236
|
+
const state = createNotifyState()
|
|
237
|
+
const reading = { kind: 'balance', entries: [{ currency: 'USD', total: 100, remaining: null }] }
|
|
238
|
+
// When 评估
|
|
239
|
+
const result = evaluateAccount({ account: balanceAccount(reading), rule, state, seq: 1, ts: 1000 })
|
|
240
|
+
// Then 回落口径 100 不低于阈值, 无事件且武装保持
|
|
241
|
+
assert.equal(result.events.length, 0)
|
|
242
|
+
assert.equal(result.state.balanceArmed, true)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
// ---- imTargets 归一 + 配置校验 ----
|
|
246
|
+
|
|
247
|
+
test('imTargets 归一: 剔除形态非法项, 仅保留两字段', () => {
|
|
248
|
+
// Given 混合合法与非法形态的列表
|
|
249
|
+
const raw = [
|
|
250
|
+
{ botId: 'wx_a', targetId: 'owner' },
|
|
251
|
+
{ botId: 'wx_b' },
|
|
252
|
+
'junk',
|
|
253
|
+
null,
|
|
254
|
+
{ botId: 'wx_c', targetId: 'owner', extra: 1 },
|
|
255
|
+
]
|
|
256
|
+
// When 归一化
|
|
257
|
+
// Then 仅完整两字段项保留且多余字段被剥离
|
|
258
|
+
assert.deepEqual(normalizeImTargets(raw), [{ botId: 'wx_a', targetId: 'owner' }, { botId: 'wx_c', targetId: 'owner' }])
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
test('imTargets 归一: 非数组回空数组', () => {
|
|
262
|
+
// Given 非数组输入
|
|
263
|
+
// Then 归一化结果为空数组
|
|
264
|
+
assert.deepEqual(normalizeImTargets(null), [])
|
|
265
|
+
assert.deepEqual(normalizeImTargets('x'), [])
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
test('notify 配置补丁校验: 白名单外字段拒绝', () => {
|
|
269
|
+
// Given 含未知键的补丁
|
|
270
|
+
// Then 校验失败并给出原因
|
|
271
|
+
const result = validateNotifyPatch({ unknownKey: 1 })
|
|
272
|
+
assert.equal(result.ok, false)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
test('notify 配置补丁校验: 合法补丁归一通过', () => {
|
|
276
|
+
// Given 合法字段与值域内的补丁
|
|
277
|
+
const result = validateNotifyPatch({
|
|
278
|
+
enabled: true,
|
|
279
|
+
quotaThresholdPct: 80,
|
|
280
|
+
balanceThreshold: 5,
|
|
281
|
+
resetNotice: false,
|
|
282
|
+
toast: true,
|
|
283
|
+
webhookUrl: 'https://hooks.example.com/a',
|
|
284
|
+
imTargets: [{ botId: 'wx_a', targetId: 'owner' }],
|
|
285
|
+
})
|
|
286
|
+
// Then 校验通过且补丁仅含白名单键
|
|
287
|
+
assert.equal(result.ok, true)
|
|
288
|
+
assert.deepEqual(Object.keys(result.patch).sort(), ['balanceThreshold', 'enabled', 'imTargets', 'quotaThresholdPct', 'resetNotice', 'toast', 'webhookUrl'])
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
test('notify 配置补丁校验: 阈值与 URL 值域拦截', () => {
|
|
292
|
+
// Given 阈值越界与非法 URL 两个补丁
|
|
293
|
+
// Then 分别拒绝
|
|
294
|
+
assert.equal(validateNotifyPatch({ quotaThresholdPct: 0 }).ok, false)
|
|
295
|
+
assert.equal(validateNotifyPatch({ quotaThresholdPct: 101 }).ok, false)
|
|
296
|
+
assert.equal(validateNotifyPatch({ balanceThreshold: -1 }).ok, false)
|
|
297
|
+
assert.equal(validateNotifyPatch({ webhookUrl: 'ftp://x' }).ok, false)
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
test('账号 notify 覆盖归一: 仅保留三字段且值域合法', () => {
|
|
301
|
+
// Given 混合字段与非法值的账号覆盖对象
|
|
302
|
+
const raw = { quotaThresholdPct: 70, balanceThreshold: 5, enabled: true, resetNotice: 'yes', junk: 1 }
|
|
303
|
+
// When 归一化
|
|
304
|
+
const result = normalizeAccountNotify(raw)
|
|
305
|
+
// Then 仅保留值域合法的覆盖键, 非法值与未知键剔除
|
|
306
|
+
assert.deepEqual(result, { quotaThresholdPct: 70, balanceThreshold: 5 })
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
// ---- 投影 / 认领 / webhook payload(turn-notify 同构语义) ----
|
|
310
|
+
|
|
311
|
+
test('投影: 环形容量截断与 TTL 过期', () => {
|
|
312
|
+
// Given 容量 2 / TTL 100 / 注入时钟
|
|
313
|
+
let now = 1000
|
|
314
|
+
const projection = createProjection({ capacity: 2, ttlMs: 100, now: () => now })
|
|
315
|
+
// When 依次推入 3 条且时间推进超过 TTL
|
|
316
|
+
projection.push({ id: 'a', ts: 1000 })
|
|
317
|
+
projection.push({ id: 'b', ts: 1100 })
|
|
318
|
+
projection.push({ id: 'c', ts: 1350 })
|
|
319
|
+
now = 1400
|
|
320
|
+
// Then 列表只含未过期条目, 超容量最旧者先出
|
|
321
|
+
assert.deepEqual(projection.list().map((unit) => unit.id), ['c'])
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
test('投影: push 递增版本, wait 在版本追平时挂起至唤醒或超时', async () => {
|
|
325
|
+
// Given 空投影
|
|
326
|
+
const projection = createProjection({})
|
|
327
|
+
// Then 初始版本为 0
|
|
328
|
+
assert.equal(projection.version(), 0)
|
|
329
|
+
// When push 一条后以落后 cursor 挂起
|
|
330
|
+
projection.push({ id: 'a', ts: 1 })
|
|
331
|
+
assert.equal(await projection.wait(0, 60 * 1000), 1, '版本已超前 cursor 应立即返回')
|
|
332
|
+
// When 以追平 cursor 挂起再 push 第二条;绑定短 timer 防唤醒失效退化为超时假绿
|
|
333
|
+
const pending = projection.wait(1, 60 * 1000)
|
|
334
|
+
const raceGuard = new Promise((resolve) => { setTimeout(() => resolve('timeout'), 500) })
|
|
335
|
+
projection.push({ id: 'b', ts: 2 })
|
|
336
|
+
// Then 挂起在唤醒而非超时路径收尾,且版本单调
|
|
337
|
+
assert.equal(await Promise.race([pending.then(() => 'woken'), raceGuard]), 'woken')
|
|
338
|
+
assert.equal(await pending, 2)
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
test('投影: wait 超时以当前版本收尾, dispose 唤醒全部等待者', async () => {
|
|
342
|
+
// Given 空投影与两个挂起等待者
|
|
343
|
+
const projection = createProjection({})
|
|
344
|
+
const timeoutMs = 10
|
|
345
|
+
const timedOut = projection.wait(0, timeoutMs)
|
|
346
|
+
const disposed = projection.wait(0, 60 * 1000)
|
|
347
|
+
const raceGuard = new Promise((resolve) => { setTimeout(() => resolve('timeout'), 500) })
|
|
348
|
+
// When 超时窗口经过后 dispose
|
|
349
|
+
assert.equal(await timedOut, 0, '超时应以当前版本收尾不报错')
|
|
350
|
+
projection.dispose()
|
|
351
|
+
// Then 剩余等待者在 dispose 即被唤醒(非等满超时)
|
|
352
|
+
assert.equal(await Promise.race([disposed.then(() => 'woken'), raceGuard]), 'woken')
|
|
353
|
+
assert.equal(await disposed, 0)
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
test('认领决策: 无锁认领 / 有效他锁跳过 / 过期锁接管 / done 终态', () => {
|
|
357
|
+
// Given 锁参数窗口 w1 与 w2
|
|
358
|
+
// Then 四种存储形态各自命中对应决策
|
|
359
|
+
assert.equal(decideClaim({ stored: null, done: null, now: 1000, windowId: 'w1' }), 'claim')
|
|
360
|
+
assert.equal(decideClaim({ stored: JSON.stringify({ wid: 'w2', at: 990 }), done: null, now: 1000, windowId: 'w1' }), 'skip')
|
|
361
|
+
assert.equal(decideClaim({ stored: JSON.stringify({ wid: 'w2', at: 0 }), done: null, now: 31 * 1000, windowId: 'w1' }), 'takeover')
|
|
362
|
+
assert.equal(decideClaim({ stored: null, done: 1, now: 1000, windowId: 'w1' }), 'done')
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
test('webhook payload: 事件单元字段一比一映射且 text 随行', () => {
|
|
366
|
+
// Given 一个 quota 事件单元
|
|
367
|
+
const unit = {
|
|
368
|
+
id: 'un-x-1-quota',
|
|
369
|
+
kind: KIND_QUOTA,
|
|
370
|
+
accountId: 'acct-1',
|
|
371
|
+
accountName: '账号A',
|
|
372
|
+
label: '5小时',
|
|
373
|
+
detail: { value: 92, threshold: 90 },
|
|
374
|
+
text: '[dsh] 账号A 5小时窗口用量达 92%(阈值 90%)',
|
|
375
|
+
ts: 1234,
|
|
376
|
+
}
|
|
377
|
+
// When 构建 webhook payload
|
|
378
|
+
const payload = buildWebhookPayload(unit)
|
|
379
|
+
// Then text 与结构化字段一一对应, 不含凭据
|
|
380
|
+
assert.equal(payload.text, unit.text)
|
|
381
|
+
assert.equal(payload.event, unit.id)
|
|
382
|
+
assert.equal(payload.kind, unit.kind)
|
|
383
|
+
assert.equal(payload.account, unit.accountName)
|
|
384
|
+
assert.equal(payload.accountId, unit.accountId)
|
|
385
|
+
assert.equal(payload.label, unit.label)
|
|
386
|
+
assert.deepEqual(payload.detail, unit.detail)
|
|
387
|
+
assert.equal(payload.ts, unit.ts)
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
// ---- webhook 直发 / 读侧归一 / 凭据脱敏 ----
|
|
391
|
+
|
|
392
|
+
test('sendWebhook: 未配置时如实返回失败且不发起请求', async () => {
|
|
393
|
+
// Given 空 URL
|
|
394
|
+
let called = false
|
|
395
|
+
// When 直发
|
|
396
|
+
const result = await sendWebhook({ url: '', payload: { text: 'x' }, fetchImpl: async () => { called = true; return { ok: true } } })
|
|
397
|
+
// Then 返回失败且未发起请求
|
|
398
|
+
assert.equal(result.ok, false)
|
|
399
|
+
assert.equal(called, false)
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
test('sendWebhook: HTTP 失败不抛出, 错误随结果返回(fire-and-forget 语义)', async () => {
|
|
403
|
+
// Given 会抛错的 fetch 桩
|
|
404
|
+
const result = await sendWebhook({ url: 'https://hooks.example.com/a', payload: {}, fetchImpl: async () => { throw new Error('unreachable') } })
|
|
405
|
+
// Then 结果标记失败并携带原因, 无异常逃逸
|
|
406
|
+
assert.equal(result.ok, false)
|
|
407
|
+
assert.match(result.detail, /unreachable/)
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
test('读侧归一: 残缺配置回退默认值', () => {
|
|
411
|
+
// Given 字段类型异常的 settings 读数
|
|
412
|
+
const resolved = resolvedNotifySettings({ enabled: 'yes', quotaThresholdPct: -5, balanceThreshold: 'x', imTargets: 'bad', webhookUrl: 42 })
|
|
413
|
+
// Then 各字段回默认形态
|
|
414
|
+
assert.equal(resolved.enabled, false)
|
|
415
|
+
assert.equal(resolved.quotaThresholdPct, 90)
|
|
416
|
+
assert.equal(resolved.balanceThreshold, null)
|
|
417
|
+
assert.deepEqual(resolved.imTargets, [])
|
|
418
|
+
assert.equal(resolved.webhookUrl, '')
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
test('凭据脱敏: publicNotify 不回显 webhookUrl 原文, 仅回是否已配置', () => {
|
|
422
|
+
// Given 已配置 webhook 的归一配置
|
|
423
|
+
const resolved = resolvedNotifySettings({ webhookUrl: 'https://hooks.example.com/private' })
|
|
424
|
+
// When 转面板可见形态
|
|
425
|
+
const view = publicNotify(resolved)
|
|
426
|
+
// Then 原文不出主机, webhookConfigured 为 true
|
|
427
|
+
assert.equal(view.webhookUrl, undefined)
|
|
428
|
+
assert.equal(view.webhookConfigured, true)
|
|
429
|
+
assert.equal(view.toast, true)
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
test('botId 校验: 合法字符集与长度', () => {
|
|
433
|
+
// Given dsh-im 规格内的 botId 与非法形态
|
|
434
|
+
// Then 分别判定
|
|
435
|
+
assert.equal(isValidImBotId('wx_abc-1'), true)
|
|
436
|
+
assert.equal(isValidImBotId(''), false)
|
|
437
|
+
assert.equal(isValidImBotId('bad/slash'), false)
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
// ---- 审查修复回归:状态归一 / 写侧拦截 / 基线保留 / 事件构造 ----
|
|
441
|
+
|
|
442
|
+
test('notifyState 归一: peak=null 保持 null 不伪装成 0', () => {
|
|
443
|
+
// Given 持久化层里 peak 为 null(该窗口从未有有效利用率)的账号状态
|
|
444
|
+
const state = normalizeNotifyState({ windows: { '5小时': { resetsAt: 'T1', peak: null, armed: false } }, balanceArmed: true })
|
|
445
|
+
// Then peak 保持 null, 重置检测不会误发"峰值 0%"通知
|
|
446
|
+
assert.equal(state.windows['5小时'].peak, null)
|
|
447
|
+
assert.equal(state.windows['5小时'].armed, false)
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
test('notifyState 归一: 非法形态回新状态', () => {
|
|
451
|
+
// Given 非对象与坏窗口条目
|
|
452
|
+
assert.deepEqual(normalizeNotifyState('junk'), createNotifyState())
|
|
453
|
+
const state = normalizeNotifyState({ windows: { bad: null, '7天': 'junk' } })
|
|
454
|
+
assert.deepEqual(state.windows, {})
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
test('notify 配置补丁校验: imTargets 项按 dsh-im ID 规格拦截', () => {
|
|
458
|
+
// Given 含空格与非法字符的 botId/targetId
|
|
459
|
+
assert.equal(validateNotifyPatch({ imTargets: [{ botId: 'wx a', targetId: 'owner' }] }).ok, false)
|
|
460
|
+
assert.equal(validateNotifyPatch({ imTargets: [{ botId: 'wx_a', targetId: 'own er' }] }).ok, false)
|
|
461
|
+
assert.equal(validateNotifyPatch({ imTargets: [{ botId: 'wx/a', targetId: 'owner' }] }).ok, false)
|
|
462
|
+
// 合法项通过
|
|
463
|
+
assert.equal(validateNotifyPatch({ imTargets: [{ botId: 'wx_a', targetId: 'owner' }] }).ok, true)
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
test('用量阈值: 上游缺失某窗口时不丢弃该窗口基线', () => {
|
|
467
|
+
// Given 两个窗口均已建基线且 5 小时窗已触发解除武装
|
|
468
|
+
const rule = { quotaThresholdPct: 90, balanceThreshold: null, resetNotice: true }
|
|
469
|
+
let state = createNotifyState()
|
|
470
|
+
state = evaluateAccount({
|
|
471
|
+
account: { id: 'a', name: 'A', last: { ok: true, reading: { kind: 'quota', windows: [
|
|
472
|
+
{ label: '5小时', utilization: 95, resetsAt: 'T1' },
|
|
473
|
+
{ label: '7天', utilization: 40, resetsAt: 'L1' },
|
|
474
|
+
] } } },
|
|
475
|
+
rule, state, seq: 1, ts: 1000,
|
|
476
|
+
}).state
|
|
477
|
+
// When 下一轮读数只返回 7 天窗(上游抖动)
|
|
478
|
+
const next = evaluateAccount({
|
|
479
|
+
account: { id: 'a', name: 'A', last: { ok: true, reading: { kind: 'quota', windows: [
|
|
480
|
+
{ label: '7天', utilization: 45, resetsAt: 'L1' },
|
|
481
|
+
] } } },
|
|
482
|
+
rule, state, seq: 2, ts: 2000,
|
|
483
|
+
})
|
|
484
|
+
// Then 5 小时窗基线原样保留, 重现时不会误判新窗口重发通知
|
|
485
|
+
assert.equal(next.state.windows['5小时'].resetsAt, 'T1')
|
|
486
|
+
assert.equal(next.state.windows['5小时'].armed, false)
|
|
487
|
+
assert.equal(next.state.windows['5小时'].peak, 95)
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
test('buildNotifyEvent: 评估产出与测试事件共用同一构造', () => {
|
|
491
|
+
// Given 同一业务字段
|
|
492
|
+
const base = { kind: KIND_QUOTA, accountId: 'a', accountName: 'A', label: '5小时', detail: {}, text: 't' }
|
|
493
|
+
// Then 构造结果带统一 id 形态与 ts
|
|
494
|
+
const event = buildNotifyEvent(base, 3, 1234)
|
|
495
|
+
assert.equal(event.id, 'un-' + (1234).toString(36) + '-3-quota')
|
|
496
|
+
assert.equal(event.ts, 1234)
|
|
497
|
+
assert.equal(event.text, 't')
|
|
498
|
+
})
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// parity 测试:client.js LOGIC 标记段与 src/notify.mjs 同源逻辑对照(turn-notify 模式)。
|
|
2
|
+
// 覆盖认领状态机主干,任一侧漂移即失败。
|
|
3
|
+
|
|
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
|
+
|
|
10
|
+
import { decideClaim as coreDecideClaim, CLAIM_LOCK_TTL_MS,
|
|
11
|
+
imTargetKey as coreImTargetKey,
|
|
12
|
+
toggleImTargetList as coreToggleImTargetList,
|
|
13
|
+
removeImTargetFromList as coreRemoveImTargetFromList,
|
|
14
|
+
unregisterImBotList as coreUnregisterImBotList,
|
|
15
|
+
imBoundBotIds as coreImBoundBotIds } from '../src/notify.mjs'
|
|
16
|
+
import { readFileSync as readFileAt } from 'node:fs'
|
|
17
|
+
|
|
18
|
+
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
19
|
+
|
|
20
|
+
// 从 client.js 提取标记段,构造同接口的纯逻辑实现。
|
|
21
|
+
function clientLogic() {
|
|
22
|
+
const source = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
|
|
23
|
+
const begin = source.indexOf('/* LOGIC-BEGIN */')
|
|
24
|
+
const end = source.indexOf('/* LOGIC-END */')
|
|
25
|
+
assert.ok(begin >= 0 && end > begin, 'client.js 缺少逻辑标记段')
|
|
26
|
+
const section = source.slice(begin + '/* LOGIC-BEGIN */'.length, end)
|
|
27
|
+
const factory = new Function(
|
|
28
|
+
section
|
|
29
|
+
+ '; return { decideClaim, claimEvent, markDone, windowId, localGet, localSet, localDel, CLAIM_LOCK_TTL_MS,'
|
|
30
|
+
+ ' imTargetKey, toggleImTargetList, removeImTargetFromList, unregisterImBotList, imBoundBotIds };',
|
|
31
|
+
)
|
|
32
|
+
return factory()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// window 不可用时 localGet 等内部 try/catch 已兜底,decideClaim 本身不触碰 window
|
|
36
|
+
const client = clientLogic()
|
|
37
|
+
|
|
38
|
+
function defineClaimScenarios(prefix, decide) {
|
|
39
|
+
test(prefix + '认领状态机四态对照', () => {
|
|
40
|
+
const t0 = 1000
|
|
41
|
+
assert.equal(decide({ stored: null, done: null, now: t0, windowId: 'w1', lockTtlMs: CLAIM_LOCK_TTL_MS }), 'claim')
|
|
42
|
+
assert.equal(decide({ stored: JSON.stringify({ wid: 'w2', at: t0 }), done: null, now: t0 + CLAIM_LOCK_TTL_MS - 1, windowId: 'w1', lockTtlMs: CLAIM_LOCK_TTL_MS }), 'skip')
|
|
43
|
+
assert.equal(decide({ stored: JSON.stringify({ wid: 'w1', at: t0 }), done: null, now: t0 + CLAIM_LOCK_TTL_MS - 1, windowId: 'w1', lockTtlMs: CLAIM_LOCK_TTL_MS }), 'claim')
|
|
44
|
+
assert.equal(decide({ stored: JSON.stringify({ wid: 'w2', at: t0 }), done: null, now: t0 + CLAIM_LOCK_TTL_MS + 1, windowId: 'w1', lockTtlMs: CLAIM_LOCK_TTL_MS }), 'takeover')
|
|
45
|
+
assert.equal(decide({ stored: null, done: '1', now: t0, windowId: 'w1', lockTtlMs: CLAIM_LOCK_TTL_MS }), 'done')
|
|
46
|
+
assert.equal(decide({ stored: 'not-json', done: null, now: t0, windowId: 'w1', lockTtlMs: CLAIM_LOCK_TTL_MS }), 'takeover')
|
|
47
|
+
// undefined 域双实现同形:视为无记录而非终态
|
|
48
|
+
assert.equal(decide({ stored: null, done: undefined, now: t0, windowId: 'w1', lockTtlMs: CLAIM_LOCK_TTL_MS }), 'claim')
|
|
49
|
+
assert.equal(decide({ stored: undefined, done: null, now: t0, windowId: 'w1', lockTtlMs: CLAIM_LOCK_TTL_MS }), 'claim')
|
|
50
|
+
assert.equal(decide({ stored: 'not-json', done: undefined, now: t0, windowId: 'w1', lockTtlMs: CLAIM_LOCK_TTL_MS }), 'takeover')
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
defineClaimScenarios('[notify.mjs decideClaim] ', coreDecideClaim)
|
|
55
|
+
|
|
56
|
+
test('[client.js decideClaim] 锁 TTL 与 core 不漂移', () => {
|
|
57
|
+
assert.equal(client.CLAIM_LOCK_TTL_MS, CLAIM_LOCK_TTL_MS)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
// IM 目标列表操作双实现对照:任意操作序列后两侧终态一致即同形。
|
|
61
|
+
test('IM 目标列表操作双实现对照', () => {
|
|
62
|
+
const scenarios = [
|
|
63
|
+
{ list: [], botId: 'wx_a', targetId: 'owner', checked: true },
|
|
64
|
+
{ list: [{ botId: 'wx_a', targetId: 'owner' }], botId: 'wx_a', targetId: 'owner', checked: true },
|
|
65
|
+
{ list: [{ botId: 'wx_a', targetId: 'owner' }, { botId: 'wx_b', targetId: 't1' }], botId: 'wx_a', targetId: 'owner', checked: false },
|
|
66
|
+
{ list: [{ botId: 'wx_a', targetId: 't1' }, { botId: 'wx_a', targetId: 't2' }], botId: 'wx_a', targetId: 't1', checked: true },
|
|
67
|
+
]
|
|
68
|
+
for (const s of scenarios) {
|
|
69
|
+
assert.deepEqual(
|
|
70
|
+
client.toggleImTargetList(s.list, s.botId, s.targetId, s.checked),
|
|
71
|
+
coreToggleImTargetList(s.list, s.botId, s.targetId, s.checked),
|
|
72
|
+
)
|
|
73
|
+
assert.deepEqual(
|
|
74
|
+
client.removeImTargetFromList(s.list, s.botId, s.targetId),
|
|
75
|
+
coreRemoveImTargetFromList(s.list, s.botId, s.targetId),
|
|
76
|
+
)
|
|
77
|
+
assert.deepEqual(client.unregisterImBotList(s.list, s.botId), coreUnregisterImBotList(s.list, s.botId))
|
|
78
|
+
assert.deepEqual(client.imBoundBotIds(s.list), coreImBoundBotIds(s.list))
|
|
79
|
+
assert.deepEqual(
|
|
80
|
+
s.list.map(client.imTargetKey),
|
|
81
|
+
s.list.map(coreImTargetKey),
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
// 勾选幂等:同一目标重复勾选只保留一份,追加到尾部
|
|
85
|
+
const once = client.toggleImTargetList([], 'wx_a', 't1', true)
|
|
86
|
+
assert.deepEqual(client.toggleImTargetList(once, 'wx_a', 't1', true), [{ botId: 'wx_a', targetId: 't1' }])
|
|
87
|
+
// 已绑 bot 按首次绑定顺序去重
|
|
88
|
+
assert.deepEqual(
|
|
89
|
+
client.imBoundBotIds([{ botId: 'b', targetId: 'x' }, { botId: 'a', targetId: 'y' }, { botId: 'b', targetId: 'z' }]),
|
|
90
|
+
['b', 'a'],
|
|
91
|
+
)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
defineClaimScenarios('[client.js decideClaim] ', (args) => client.decideClaim(args.stored, args.done, args.now, args.windowId))
|
|
95
|
+
|
|
96
|
+
// 平台类型清单双端对拍:host normalizeAccounts 白名单与 client TYPE_LABELS 键集必须一致,
|
|
97
|
+
// 漂移即一侧能建另一侧不能渲染(徽章/下拉/表单)。
|
|
98
|
+
test('ACCOUNT_TYPES 双端对拍:index.js 白名单与 client TYPE_LABELS 键集一致', () => {
|
|
99
|
+
const hostSource = readFileAt(join(PKG_ROOT, 'src', 'index.js'), 'utf8')
|
|
100
|
+
const clientSource = readFileAt(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
|
|
101
|
+
// 声明序:TYPE_* 常量定义顺序即 ACCOUNT_TYPES 与 TYPE_LABELS 的共同期望顺序
|
|
102
|
+
const hostTypes = [...hostSource.matchAll(/const TYPE_(?!META|LABELS|_)[A-Z]+ = '([a-z]+)'/g)].map((m) => m[1])
|
|
103
|
+
assert.ok(hostTypes.length >= 7, 'TYPE_* 常量提取失败')
|
|
104
|
+
assert.deepEqual(
|
|
105
|
+
hostTypes.filter((type, index, all) => all.indexOf(type) === index),
|
|
106
|
+
hostTypes,
|
|
107
|
+
'TYPE_* 常量有重复定义',
|
|
108
|
+
)
|
|
109
|
+
const clientLabels = clientSource.match(/const TYPE_LABELS = \{([^}]+)\}/)
|
|
110
|
+
assert.ok(clientLabels, 'client.js 缺少 TYPE_LABELS')
|
|
111
|
+
const clientTypes = [...clientLabels[1].matchAll(/^\s*([a-z]+):/gm)].map((m) => m[1])
|
|
112
|
+
assert.deepEqual(clientTypes, hostTypes, '双端平台类型清单漂移')
|
|
113
|
+
// ACCOUNT_TYPES 数组按常量名展开后与定义序一致,防数组漏项/多序
|
|
114
|
+
const listMatch = hostSource.match(/const ACCOUNT_TYPES = \[([^\]]*)\]/)
|
|
115
|
+
assert.ok(listMatch, 'index.js 缺少 ACCOUNT_TYPES 数组')
|
|
116
|
+
const constMap = new Map([...hostSource.matchAll(/const (TYPE_[A-Z]+) = '([a-z]+)'/g)].map((m) => [m[1], m[2]]))
|
|
117
|
+
const expanded = [...listMatch[1].matchAll(/TYPE_[A-Z]+/g)].map((m) => constMap.get(m[0]))
|
|
118
|
+
assert.deepEqual(expanded, hostTypes, 'ACCOUNT_TYPES 数组与 TYPE_* 常量漂移')
|
|
119
|
+
})
|