@mzzsfy/dsh-usage-dash 0.10.3 → 0.10.5
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 +8 -8
- package/package.json +1 -1
- package/src/client.js +134 -69
- package/src/routes.js +18 -13
- package/src/store.js +31 -7
- package/test/pricing-current.test.mjs +179 -0
- package/test/routes.test.mjs +2 -0
- package/test/stats-line-render.test.mjs +1 -1
- package/test/store-owner.test.mjs +45 -0
- package/test/store.test.mjs +163 -36
- package/test/trend-tooltip.test.mjs +182 -0
package/test/store.test.mjs
CHANGED
|
@@ -81,9 +81,13 @@ function fakeDomain({ failUpdate = () => null } = {}) {
|
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
// F14 契约:record 按档保留资格过滤。夹具时钟固定在样本日次日,样本全部
|
|
85
|
+
// 落在留存期内;超期丢弃语义由专项用例验证
|
|
86
|
+
const FIXED_NOW = local(2026, 8, 3, 0, 0)
|
|
84
87
|
const facilityOf = (domain, backupDomain) => ({
|
|
85
88
|
open: async (definition) => (definition?.name === 'usage_stats_backup' ? (backupDomain ?? domain) : domain),
|
|
86
89
|
})
|
|
90
|
+
const makeStore = (domain, options = {}) => new UsageStore(facilityOf(domain), { now: () => FIXED_NOW, ...options })
|
|
87
91
|
|
|
88
92
|
const tokenSample = (time, extra = {}) => ({
|
|
89
93
|
time,
|
|
@@ -134,7 +138,7 @@ test('跨日跨时跨分边界:毫秒推进即换桶', () => {
|
|
|
134
138
|
|
|
135
139
|
test('token 样本同刻落三粒度三行,行内无 g 字段', async () => {
|
|
136
140
|
const domain = fakeDomain()
|
|
137
|
-
const store =
|
|
141
|
+
const store = makeStore(domain)
|
|
138
142
|
await store.record(tokenSample(local(2026, 8, 2, 14, 37)))
|
|
139
143
|
await store.flushNow()
|
|
140
144
|
const keys = ['D|2026-08-02', 'H|2026-08-02T14', 'M|2026-08-02T14:30'].map(
|
|
@@ -156,7 +160,7 @@ test('token 样本同刻落三粒度三行,行内无 g 字段', async () => {
|
|
|
156
160
|
|
|
157
161
|
test('turn 样本落合成行:provider default 与 model (turns)', async () => {
|
|
158
162
|
const domain = fakeDomain()
|
|
159
|
-
const store =
|
|
163
|
+
const store = makeStore(domain)
|
|
160
164
|
await store.record({ time: local(2026, 8, 2, 14, 37), turn: true })
|
|
161
165
|
await store.flushNow()
|
|
162
166
|
const seen = domain.rows.get('D|2026-08-02|default|(turns)')
|
|
@@ -167,7 +171,7 @@ test('turn 样本落合成行:provider default 与 model (turns)', async () => {
|
|
|
167
171
|
|
|
168
172
|
test('request 样本只计请求数,无归因落 (unknown)', async () => {
|
|
169
173
|
const domain = fakeDomain()
|
|
170
|
-
const store =
|
|
174
|
+
const store = makeStore(domain)
|
|
171
175
|
await store.record({ time: local(2026, 8, 2, 14, 37), request: true, model: 'deepseek-chat' })
|
|
172
176
|
await store.record({ time: local(2026, 8, 2, 14, 38), request: true })
|
|
173
177
|
await store.flushNow()
|
|
@@ -182,7 +186,7 @@ test('request 样本只计请求数,无归因落 (unknown)', async () => {
|
|
|
182
186
|
|
|
183
187
|
test('token 样本时长落三粒度累加,request 与 turn 行时长恒 0', async () => {
|
|
184
188
|
const domain = fakeDomain()
|
|
185
|
-
const store =
|
|
189
|
+
const store = makeStore(domain)
|
|
186
190
|
const at = local(2026, 8, 2, 14, 37)
|
|
187
191
|
await store.record(tokenSample(at, { durationMs: 6500 }))
|
|
188
192
|
await store.record(tokenSample(at + 1000, { durationMs: 2500 }))
|
|
@@ -204,7 +208,7 @@ test('token 样本时长落三粒度累加,request 与 turn 行时长恒 0', asy
|
|
|
204
208
|
|
|
205
209
|
test('老行缺 durationMs 字段:增量求和不产 NaN', async () => {
|
|
206
210
|
const domain = fakeDomain()
|
|
207
|
-
const store =
|
|
211
|
+
const store = makeStore(domain)
|
|
208
212
|
const at = local(2026, 8, 2, 14, 37)
|
|
209
213
|
const key = 'D|2026-08-02|deepseek|deepseek/deepseek-chat'
|
|
210
214
|
await store.record(tokenSample(at))
|
|
@@ -221,7 +225,7 @@ test('老行缺 durationMs 字段:增量求和不产 NaN', async () => {
|
|
|
221
225
|
|
|
222
226
|
test('decode 配对与首字样本落三粒度累加,timing 样本不重复计 token', async () => {
|
|
223
227
|
const domain = fakeDomain()
|
|
224
|
-
const store =
|
|
228
|
+
const store = makeStore(domain)
|
|
225
229
|
const at = local(2026, 8, 2, 14, 37)
|
|
226
230
|
// chunk 先发 token 样本:只有 token 桶
|
|
227
231
|
await store.record(tokenSample(at, { durationMs: undefined }))
|
|
@@ -245,7 +249,7 @@ test('decode 配对与首字样本落三粒度累加,timing 样本不重复计 t
|
|
|
245
249
|
|
|
246
250
|
test('timing 缺失样本:decodeTokens 与 ttft 字段按零累计', async () => {
|
|
247
251
|
const domain = fakeDomain()
|
|
248
|
-
const store =
|
|
252
|
+
const store = makeStore(domain)
|
|
249
253
|
const at = local(2026, 8, 2, 14, 37)
|
|
250
254
|
await store.record(tokenSample(at))
|
|
251
255
|
await store.flushNow()
|
|
@@ -258,7 +262,7 @@ test('timing 缺失样本:decodeTokens 与 ttft 字段按零累计', async () =>
|
|
|
258
262
|
|
|
259
263
|
test('老行缺 decode/ttft 字段:增量求和不产 NaN', async () => {
|
|
260
264
|
const domain = fakeDomain()
|
|
261
|
-
const store =
|
|
265
|
+
const store = makeStore(domain)
|
|
262
266
|
const at = local(2026, 8, 2, 14, 37)
|
|
263
267
|
const key = 'D|2026-08-02|deepseek|deepseek/deepseek-chat'
|
|
264
268
|
await store.record(tokenSample(at))
|
|
@@ -279,7 +283,7 @@ test('老行缺 decode/ttft 字段:增量求和不产 NaN', async () => {
|
|
|
279
283
|
|
|
280
284
|
test('missing-record 首写竞态:put 种子后重试写入真值', async () => {
|
|
281
285
|
const domain = fakeDomain()
|
|
282
|
-
const store =
|
|
286
|
+
const store = makeStore(domain)
|
|
283
287
|
await store.record(tokenSample(local(2026, 8, 2, 14, 37)))
|
|
284
288
|
await store.flushNow()
|
|
285
289
|
assert.equal(domain.stats.puts, 3)
|
|
@@ -296,7 +300,7 @@ test('flush 中单粒度失败:错误经 onFlushError 上抛,失败行留 pendin
|
|
|
296
300
|
return new Error('simulated hour failure')
|
|
297
301
|
},
|
|
298
302
|
})
|
|
299
|
-
const store =
|
|
303
|
+
const store = makeStore(domain)
|
|
300
304
|
const errors = []
|
|
301
305
|
store.onFlushError = (err) => errors.push(err)
|
|
302
306
|
await store.readyPromise()
|
|
@@ -317,7 +321,7 @@ test('非 missing 错误不触发种子重试', async () => {
|
|
|
317
321
|
const domain = fakeDomain({
|
|
318
322
|
failUpdate: (candidate) => (candidate === key ? new Error('backend write failed') : null),
|
|
319
323
|
})
|
|
320
|
-
const store =
|
|
324
|
+
const store = makeStore(domain)
|
|
321
325
|
const errors = []
|
|
322
326
|
store.onFlushError = (err) => errors.push(err)
|
|
323
327
|
await store.readyPromise()
|
|
@@ -331,7 +335,7 @@ test('非 missing 错误不触发种子重试', async () => {
|
|
|
331
335
|
|
|
332
336
|
test('rangeRows 按粒度前缀与桶串闭区间扫描', async () => {
|
|
333
337
|
const domain = fakeDomain()
|
|
334
|
-
const store =
|
|
338
|
+
const store = makeStore(domain)
|
|
335
339
|
const table = domain.table()
|
|
336
340
|
await table.put('D|2026-07-31|a|m', rowOf('2026-07-31', 'a', 'm'))
|
|
337
341
|
await table.put('D|2026-08-01|a|m', rowOf('2026-08-01', 'a', 'm'))
|
|
@@ -348,7 +352,7 @@ test('rangeRows 按粒度前缀与桶串闭区间扫描', async () => {
|
|
|
348
352
|
|
|
349
353
|
test('pruneMinutes 按保留窗口清理分钟桶且不碰其他粒度', async () => {
|
|
350
354
|
const domain = fakeDomain()
|
|
351
|
-
const store =
|
|
355
|
+
const store = makeStore(domain, { now: () => local(2026, 8, 10, 12, 0) })
|
|
352
356
|
const table = domain.table()
|
|
353
357
|
await table.put('M|2026-08-08T11:00|a|m', rowOf('2026-08-08T11:00', 'a', 'm'))
|
|
354
358
|
await table.put('M|2026-08-08T12:00|a|m', rowOf('2026-08-08T12:00', 'a', 'm'))
|
|
@@ -365,7 +369,7 @@ test('pruneMinutes 按保留窗口清理分钟桶且不碰其他粒度', async (
|
|
|
365
369
|
|
|
366
370
|
test('保留窗口为 0 时分钟桶全部清理', async () => {
|
|
367
371
|
const domain = fakeDomain()
|
|
368
|
-
const store =
|
|
372
|
+
const store = makeStore(domain, { now: () => local(2026, 8, 10, 12, 0) })
|
|
369
373
|
const table = domain.table()
|
|
370
374
|
await table.put('M|2026-08-10T11:00|a|m', rowOf('2026-08-10T11:00', 'a', 'm'))
|
|
371
375
|
await table.put('D|2026-08-10|a|m', rowOf('2026-08-10', 'a', 'm'))
|
|
@@ -376,7 +380,7 @@ test('保留窗口为 0 时分钟桶全部清理', async () => {
|
|
|
376
380
|
|
|
377
381
|
test('分钟保留超上限时 clamp 到 7 天窗口', async () => {
|
|
378
382
|
const domain = fakeDomain()
|
|
379
|
-
const store =
|
|
383
|
+
const store = makeStore(domain, { now: () => local(2026, 8, 10, 12, 0) })
|
|
380
384
|
const table = domain.table()
|
|
381
385
|
// clamp 后窗口起点 = 08-03T12:00,7 天前的桶一律清理,即使注入 30 天
|
|
382
386
|
await table.put('M|2026-08-01T00:00|a|m', rowOf('2026-08-01T00:00', 'a', 'm'))
|
|
@@ -388,7 +392,7 @@ test('分钟保留超上限时 clamp 到 7 天窗口', async () => {
|
|
|
388
392
|
|
|
389
393
|
test('pruneHours 固定 15 天窗口清理小时桶且不碰其他粒度', async () => {
|
|
390
394
|
const domain = fakeDomain()
|
|
391
|
-
const store =
|
|
395
|
+
const store = makeStore(domain, { now: () => local(2026, 8, 10, 12, 0) })
|
|
392
396
|
const table = domain.table()
|
|
393
397
|
await table.put('H|2026-07-25T00:00|a|m', rowOf('2026-07-25T00:00', 'a', 'm'))
|
|
394
398
|
await table.put('H|2026-07-26T12:00|a|m', rowOf('2026-07-26T12:00', 'a', 'm'))
|
|
@@ -404,7 +408,7 @@ test('pruneHours 固定 15 天窗口清理小时桶且不碰其他粒度', async
|
|
|
404
408
|
test('每日本地日首次 flush 触发清理,同日后续 flush 不重复触发', async () => {
|
|
405
409
|
let clock = local(2026, 8, 10, 12, 0)
|
|
406
410
|
const domain = fakeDomain()
|
|
407
|
-
const store =
|
|
411
|
+
const store = makeStore(domain, { now: () => clock, retentionDays: () => 0 })
|
|
408
412
|
const table = domain.table()
|
|
409
413
|
await table.put('M|2026-08-01T00:00|a|m', rowOf('2026-08-01T00:00', 'a', 'm'))
|
|
410
414
|
await store.record(tokenSample(local(2026, 8, 10, 12, 0)))
|
|
@@ -422,7 +426,7 @@ test('每日本地日首次 flush 触发清理,同日后续 flush 不重复触
|
|
|
422
426
|
|
|
423
427
|
test('并发 markSeenSessions 不丢 id', async () => {
|
|
424
428
|
const domain = fakeDomain()
|
|
425
|
-
const store =
|
|
429
|
+
const store = makeStore(domain)
|
|
426
430
|
await Promise.all([
|
|
427
431
|
store.markSeenSessions(['a']),
|
|
428
432
|
store.markSeenSessions(['b']),
|
|
@@ -433,7 +437,7 @@ test('并发 markSeenSessions 不丢 id', async () => {
|
|
|
433
437
|
|
|
434
438
|
test('游标链串行:reset 排在先前 markSeen 之后,行与游标一致重置', async () => {
|
|
435
439
|
const domain = fakeDomain()
|
|
436
|
-
const store =
|
|
440
|
+
const store = makeStore(domain)
|
|
437
441
|
const pending = store.markSeenSessions(['a'])
|
|
438
442
|
await store.record(tokenSample(local(2026, 8, 2, 14, 37)))
|
|
439
443
|
await store.reset(new Map([['live-a', 7]]))
|
|
@@ -444,7 +448,7 @@ test('游标链串行:reset 排在先前 markSeen 之后,行与游标一致重
|
|
|
444
448
|
|
|
445
449
|
test('reset 不带边界时游标清空', async () => {
|
|
446
450
|
const domain = fakeDomain()
|
|
447
|
-
const store =
|
|
451
|
+
const store = makeStore(domain)
|
|
448
452
|
await store.markLiveSequences([['s', 5]])
|
|
449
453
|
await store.reset()
|
|
450
454
|
assert.deepEqual(domain.global.get(), { backfilledSessions: [], liveFirstSeq: {} })
|
|
@@ -452,7 +456,7 @@ test('reset 不带边界时游标清空', async () => {
|
|
|
452
456
|
|
|
453
457
|
test('markLiveSequences 保留最小 seq 且无变化不写 global', async () => {
|
|
454
458
|
const domain = fakeDomain()
|
|
455
|
-
const store =
|
|
459
|
+
const store = makeStore(domain)
|
|
456
460
|
await store.markLiveSequences([['s', 5]])
|
|
457
461
|
assert.deepEqual(await store.liveSequences(), new Map([['s', 5]]))
|
|
458
462
|
await store.markLiveSequences([['s', 3], ['t', 9]])
|
|
@@ -474,7 +478,7 @@ test('游标写单次失败不断链', async () => {
|
|
|
474
478
|
}
|
|
475
479
|
return originalSet(value)
|
|
476
480
|
}
|
|
477
|
-
const store =
|
|
481
|
+
const store = makeStore(domain)
|
|
478
482
|
await assert.rejects(store.markSeenSessions(['a']))
|
|
479
483
|
await store.markSeenSessions(['b'])
|
|
480
484
|
assert.deepEqual([...(await store.seenSessions())].sort(), ['b'])
|
|
@@ -483,7 +487,7 @@ test('游标写单次失败不断链', async () => {
|
|
|
483
487
|
test('启动时游标为空且已有行则清空待重建', async () => {
|
|
484
488
|
const domain = fakeDomain()
|
|
485
489
|
await domain.table().put('D|2026-08-02|a|m', rowOf('2026-08-02', 'a', 'm'))
|
|
486
|
-
const store =
|
|
490
|
+
const store = makeStore(domain)
|
|
487
491
|
await store.readyPromise()
|
|
488
492
|
assert.equal(domain.rows.size, 0)
|
|
489
493
|
})
|
|
@@ -492,7 +496,7 @@ test('启动时游标非空则既有行保留', async () => {
|
|
|
492
496
|
const domain = fakeDomain()
|
|
493
497
|
await domain.table().put('D|2026-08-02|a|m', rowOf('2026-08-02', 'a', 'm'))
|
|
494
498
|
await domain.global.set({ backfilledSessions: ['old'], liveFirstSeq: {} })
|
|
495
|
-
const store =
|
|
499
|
+
const store = makeStore(domain)
|
|
496
500
|
await store.readyPromise()
|
|
497
501
|
assert.equal(domain.rows.size, 1)
|
|
498
502
|
})
|
|
@@ -500,7 +504,7 @@ test('启动时游标非空则既有行保留', async () => {
|
|
|
500
504
|
test('reset 前自动快照,restore 整体回退到快照态', async () => {
|
|
501
505
|
const domain = fakeDomain()
|
|
502
506
|
const backup = fakeDomain()
|
|
503
|
-
const store = new UsageStore(facilityOf(domain, backup))
|
|
507
|
+
const store = new UsageStore(facilityOf(domain, backup), { now: () => FIXED_NOW })
|
|
504
508
|
await store.record(tokenSample(local(2026, 8, 2, 14, 0)))
|
|
505
509
|
await store.markSeenSessions(['s1', 's2'])
|
|
506
510
|
assert.equal(store.backupInfo().available, false)
|
|
@@ -521,7 +525,7 @@ test('reset 前自动快照,restore 整体回退到快照态', async () => {
|
|
|
521
525
|
test('restore 前先快照当前态:回退动作自身可再回退', async () => {
|
|
522
526
|
const domain = fakeDomain()
|
|
523
527
|
const backup = fakeDomain()
|
|
524
|
-
const store = new UsageStore(facilityOf(domain, backup))
|
|
528
|
+
const store = new UsageStore(facilityOf(domain, backup), { now: () => FIXED_NOW })
|
|
525
529
|
await store.record(tokenSample(local(2026, 8, 2, 14, 0)))
|
|
526
530
|
await store.reset()
|
|
527
531
|
await store.record(tokenSample(local(2026, 8, 3, 14, 0), { inputTokens: 99 }))
|
|
@@ -544,7 +548,7 @@ test('备份域不可用:重建照常,backupInfo 标记不可用,restore 报错'
|
|
|
544
548
|
return domain
|
|
545
549
|
},
|
|
546
550
|
}
|
|
547
|
-
const store = new UsageStore(facility)
|
|
551
|
+
const store = new UsageStore(facility, { now: () => FIXED_NOW })
|
|
548
552
|
await store.record(tokenSample(local(2026, 8, 2, 14, 0)))
|
|
549
553
|
await store.reset()
|
|
550
554
|
assert.equal(domain.rows.size, 0)
|
|
@@ -563,20 +567,125 @@ test('域打开失败进入降级:ready 可等待,操作按调用失败', async
|
|
|
563
567
|
assert.deepEqual(await store.liveSequences(), new Map())
|
|
564
568
|
})
|
|
565
569
|
|
|
566
|
-
test('globalThis
|
|
570
|
+
test('globalThis 单例按 facility 关联:同 facility 同实例,异 facility 新实例', async () => {
|
|
567
571
|
__resetSharedStoreForTests()
|
|
568
|
-
const
|
|
569
|
-
|
|
572
|
+
const facilityA = facilityOf(fakeDomain())
|
|
573
|
+
const a = await sharedStore(facilityA)
|
|
574
|
+
assert.equal(await sharedStore(facilityA), a)
|
|
575
|
+
assert.notEqual(await sharedStore(facilityOf(fakeDomain())), a)
|
|
570
576
|
__resetSharedStoreForTests()
|
|
571
|
-
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
test('降级实例不复用:同 facility 再取重建新实例', async () => {
|
|
580
|
+
__resetSharedStoreForTests()
|
|
581
|
+
const facility = { open: () => Promise.reject(new Error("domain 'usage_stats' is already open")) }
|
|
582
|
+
const degraded = await sharedStore(facility)
|
|
583
|
+
await degraded.readyPromise()
|
|
584
|
+
assert.ok(degraded.degradation)
|
|
585
|
+
const fresh = sharedStore(facility)
|
|
586
|
+
assert.notEqual(fresh, degraded)
|
|
587
|
+
__resetSharedStoreForTests()
|
|
588
|
+
})
|
|
589
|
+
|
|
590
|
+
test('非对象 facility(服务缺席桩)不缓存:每次新建', () => {
|
|
591
|
+
__resetSharedStoreForTests()
|
|
592
|
+
assert.notEqual(sharedStore(undefined), sharedStore(undefined))
|
|
572
593
|
__resetSharedStoreForTests()
|
|
573
594
|
})
|
|
574
595
|
|
|
596
|
+
// 同名域占用不证明无主;get 诊断面不可授权关闭活跃 owner。
|
|
597
|
+
function facilityWithReserved({ withCode = true } = {}) {
|
|
598
|
+
const reserved = new Map()
|
|
599
|
+
const closes = []
|
|
600
|
+
const open = async (spec) => {
|
|
601
|
+
if (reserved.has(spec.name)) {
|
|
602
|
+
const err = new Error(`domain '${spec.name}' is already open`)
|
|
603
|
+
if (withCode) err.code = 'already-open'
|
|
604
|
+
throw err
|
|
605
|
+
}
|
|
606
|
+
const domain = fakeDomain()
|
|
607
|
+
domain.close = async () => {
|
|
608
|
+
closes.push(spec.name)
|
|
609
|
+
reserved.delete(spec.name)
|
|
610
|
+
}
|
|
611
|
+
reserved.set(spec.name, domain)
|
|
612
|
+
return domain
|
|
613
|
+
}
|
|
614
|
+
return { open, get: (name) => reserved.get(name), closes }
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
for (const withCode of [true, false]) {
|
|
618
|
+
test(`Given occupied domain with code=${withCode}, When another store opens, Then owner stays live and newcomer degrades`, async () => {
|
|
619
|
+
__resetSharedStoreForTests()
|
|
620
|
+
const facility = facilityWithReserved({ withCode })
|
|
621
|
+
const owner = await facility.open({ name: 'usage_stats' })
|
|
622
|
+
await facility.open({ name: 'usage_stats_backup' })
|
|
623
|
+
const store = sharedStore(facility)
|
|
624
|
+
await store.readyPromise()
|
|
625
|
+
assert.ok(store.degradation)
|
|
626
|
+
assert.equal(facility.get('usage_stats'), owner)
|
|
627
|
+
assert.deepEqual(facility.closes, [])
|
|
628
|
+
await owner.global.set({ stillLive: true })
|
|
629
|
+
assert.deepEqual(owner.global.get(), { stillLive: true })
|
|
630
|
+
await assert.rejects(store.record(tokenSample(local(2026, 8, 2, 14, 37))), /usage store degraded/)
|
|
631
|
+
__resetSharedStoreForTests()
|
|
632
|
+
})
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
test('UO1 Given old owner with seen cursor and pending tokens, When new generation opens, Then old owner flushes every bucket once', async () => {
|
|
636
|
+
__resetSharedStoreForTests()
|
|
637
|
+
const facility = facilityWithReserved()
|
|
638
|
+
const old = new UsageStore(facility, { now: () => FIXED_NOW })
|
|
639
|
+
await old.readyPromise()
|
|
640
|
+
await old.markSeenSessions(['s1'])
|
|
641
|
+
await old.record(tokenSample(local(2026, 8, 2, 14, 37)))
|
|
642
|
+
clearTimeout(old.flushTimer)
|
|
643
|
+
const newcomer = sharedStore(facility)
|
|
644
|
+
await newcomer.readyPromise()
|
|
645
|
+
assert.deepEqual(facility.closes, [])
|
|
646
|
+
assert.ok(newcomer.degradation)
|
|
647
|
+
await old.flushNow()
|
|
648
|
+
const rows = await old.rangeRows('D', '2026-08-02', '2026-08-02')
|
|
649
|
+
assert.equal(rows.length, 1)
|
|
650
|
+
assert.equal(rows[0].inputTokens, 10)
|
|
651
|
+
assert.equal(rows[0].outputTokens, 20)
|
|
652
|
+
assert.deepEqual(await old.seenSessions(), new Set(['s1']))
|
|
653
|
+
await old.flushNow()
|
|
654
|
+
assert.equal((await old.rangeRows('D', '2026-08-02', '2026-08-02'))[0].inputTokens, 10)
|
|
655
|
+
__resetSharedStoreForTests()
|
|
656
|
+
})
|
|
657
|
+
|
|
658
|
+
test('UO3 Given occupied backup only, When main opens, Then collection works and backup owner is untouched', async () => {
|
|
659
|
+
__resetSharedStoreForTests()
|
|
660
|
+
const facility = facilityWithReserved()
|
|
661
|
+
const backup = await facility.open({ name: 'usage_stats_backup' })
|
|
662
|
+
const store = sharedStore(facility)
|
|
663
|
+
await store.readyPromise()
|
|
664
|
+
assert.equal(store.degradation, undefined)
|
|
665
|
+
assert.equal(facility.get('usage_stats_backup'), backup)
|
|
666
|
+
assert.deepEqual(facility.closes, [])
|
|
667
|
+
await store.record(tokenSample(local(2026, 8, 2, 14, 37)))
|
|
668
|
+
clearTimeout(store.flushTimer)
|
|
669
|
+
await store.flushNow()
|
|
670
|
+
assert.equal((await store.rangeRows('D', '2026-08-02', '2026-08-02'))[0].inputTokens, 10)
|
|
671
|
+
__resetSharedStoreForTests()
|
|
672
|
+
})
|
|
673
|
+
|
|
674
|
+
test('get 缝在场但孤儿已不存在:维持降级,不逃逸', async () => {
|
|
675
|
+
const store = new UsageStore({
|
|
676
|
+
open: () => Promise.reject(new Error("domain 'usage_stats' is already open")),
|
|
677
|
+
get: () => undefined,
|
|
678
|
+
})
|
|
679
|
+
await store.readyPromise()
|
|
680
|
+
assert.ok(store.degradation)
|
|
681
|
+
await assert.rejects(store.record(tokenSample(local(2026, 8, 2, 14, 37))), /usage store degraded/)
|
|
682
|
+
})
|
|
683
|
+
|
|
575
684
|
// —— 写合并(write-behind):record 同步入 pending,flush 周期落盘 ——
|
|
576
685
|
|
|
577
686
|
test('record 只入 pending 不产生持久化写,flushNow 后每脏行恰一次 update', async () => {
|
|
578
687
|
const domain = fakeDomain()
|
|
579
|
-
const store =
|
|
688
|
+
const store = makeStore(domain)
|
|
580
689
|
await store.readyPromise()
|
|
581
690
|
await store.record(tokenSample(local(2026, 8, 2, 14, 37)))
|
|
582
691
|
assert.equal(domain.rows.size, 0)
|
|
@@ -588,7 +697,7 @@ test('record 只入 pending 不产生持久化写,flushNow 后每脏行恰一次
|
|
|
588
697
|
|
|
589
698
|
test('同桶多样本合并:pending 累加,一次 update 落全部计数', async () => {
|
|
590
699
|
const domain = fakeDomain()
|
|
591
|
-
const store =
|
|
700
|
+
const store = makeStore(domain)
|
|
592
701
|
await store.readyPromise()
|
|
593
702
|
await store.record(tokenSample(local(2026, 8, 2, 14, 37)))
|
|
594
703
|
await store.record(tokenSample(local(2026, 8, 2, 14, 38)))
|
|
@@ -601,7 +710,7 @@ test('同桶多样本合并:pending 累加,一次 update 落全部计数', async
|
|
|
601
710
|
|
|
602
711
|
test('flush 周期自动落盘', async () => {
|
|
603
712
|
const domain = fakeDomain()
|
|
604
|
-
const store =
|
|
713
|
+
const store = makeStore(domain, { flushIntervalMs: 5 })
|
|
605
714
|
await store.readyPromise()
|
|
606
715
|
await store.record(tokenSample(local(2026, 8, 2, 14, 37)))
|
|
607
716
|
await new Promise((resolve) => setTimeout(resolve, 30))
|
|
@@ -610,7 +719,7 @@ test('flush 周期自动落盘', async () => {
|
|
|
610
719
|
|
|
611
720
|
test('rangeRows 前自动 flush,读己之写', async () => {
|
|
612
721
|
const domain = fakeDomain()
|
|
613
|
-
const store =
|
|
722
|
+
const store = makeStore(domain)
|
|
614
723
|
await store.readyPromise()
|
|
615
724
|
await store.record(tokenSample(local(2026, 8, 2, 14, 37)))
|
|
616
725
|
const rows = await store.rangeRows('D', '2026-08-02', '2026-08-02')
|
|
@@ -620,7 +729,7 @@ test('rangeRows 前自动 flush,读己之写', async () => {
|
|
|
620
729
|
|
|
621
730
|
test('reset 前 pending 先落盘再清表', async () => {
|
|
622
731
|
const domain = fakeDomain()
|
|
623
|
-
const store =
|
|
732
|
+
const store = makeStore(domain)
|
|
624
733
|
await store.readyPromise()
|
|
625
734
|
await store.record(tokenSample(local(2026, 8, 2, 14, 37)))
|
|
626
735
|
await store.reset(new Map())
|
|
@@ -633,3 +742,21 @@ test('降级态 record 仍按调用失败', async () => {
|
|
|
633
742
|
await store.readyPromise()
|
|
634
743
|
await assert.rejects(store.record(tokenSample(local(2026, 8, 2, 14, 37))), /usage store degraded/)
|
|
635
744
|
})
|
|
745
|
+
|
|
746
|
+
// ── F14: 入队保留资格过滤 ──────────────────────────────────────────────────
|
|
747
|
+
test('F14: 超出档留存期的样本不入 pending 不落盘,回扫同通道受控', async () => {
|
|
748
|
+
const domain = fakeDomain()
|
|
749
|
+
// 时钟"今天" = 样本日次日 + 8 天:8/2 样本对分钟档(7 天)超期、小时档(15 天)与日档在期
|
|
750
|
+
const now = local(2026, 8, 10, 0, 0)
|
|
751
|
+
const store = new UsageStore(facilityOf(domain), { now: () => now })
|
|
752
|
+
await store.record(tokenSample(local(2026, 8, 2, 14, 37)))
|
|
753
|
+
await store.flushNow()
|
|
754
|
+
const prefixes = [...domain.rows.keys()].map((key) => key.split('|').slice(0, 2).join('|'))
|
|
755
|
+
assert.ok(!prefixes.some((p) => p.startsWith('M|')), `分钟档超期不得落行: ${prefixes.join(',')}`)
|
|
756
|
+
assert.ok(prefixes.some((p) => p.startsWith('D|')), '日档无留存上限恒写入')
|
|
757
|
+
assert.ok(prefixes.some((p) => p.startsWith('H|')), '小时档 15 天内在期')
|
|
758
|
+
// 分钟档保留 0(关闭):分钟样本一律不入队
|
|
759
|
+
const offStore = new UsageStore(facilityOf(fakeDomain()), { now: () => local(2026, 8, 2, 14, 38), retentionDays: () => 0 })
|
|
760
|
+
await offStore.record(tokenSample(local(2026, 8, 2, 14, 37)))
|
|
761
|
+
assert.equal(offStore.pending.size, 2, '关闭分钟档后仅日/小时两行入队')
|
|
762
|
+
})
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { CLIENT_SOURCE, CLIENT_BODY, DECLARATION_NAMES } from './client-eval.mjs'
|
|
4
|
+
|
|
5
|
+
const core = new Function(`${CLIENT_BODY}\nreturn { ${DECLARATION_NAMES.join(', ')} }`)()
|
|
6
|
+
const nodes = node => !node || typeof node !== 'object' ? [] : [node, ...(node.children ?? []).flatMap(nodes)]
|
|
7
|
+
const text = node => typeof node === 'string' ? node : (node?.children ?? []).map(text).join('')
|
|
8
|
+
const OTHER = core.OTHER_MODEL
|
|
9
|
+
const slot = {
|
|
10
|
+
day: '2026-10-28', total: 16000, byModel: { 'p/a': 1000, 'p/b': 9000, [OTHER]: 6000 },
|
|
11
|
+
otherByModel: { 'p/c': 2000, 'p/d': 4000 }, cost: 12,
|
|
12
|
+
byCostModel: { 'p/a': 2, 'p/b': 9, [OTHER]: 1 }, otherCostByModel: { 'p/c': 0.75, 'p/d': 0.25 },
|
|
13
|
+
cacheHit: 3000, cacheMiss: 1000, speed: 12, ttft: 300,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function fixture({ money = false, costDisplay = true, currency = '¥', lang = 'zh', data = [slot] } = {}) {
|
|
17
|
+
let factory, cursor = 0
|
|
18
|
+
const states = [], refs = []
|
|
19
|
+
const React = {
|
|
20
|
+
createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity).filter(x => x != null && x !== false) }),
|
|
21
|
+
memo: fn => fn, useId: () => 'trend-test',
|
|
22
|
+
useState(seed) {
|
|
23
|
+
const i = cursor++
|
|
24
|
+
if (!(i in states)) states[i] = typeof seed === 'function' ? seed() : seed
|
|
25
|
+
return [states[i], value => { states[i] = typeof value === 'function' ? value(states[i]) : value }]
|
|
26
|
+
},
|
|
27
|
+
useRef(seed) { const i = cursor++; return refs[i] ??= { current: seed } },
|
|
28
|
+
useEffect() {}, useLayoutEffect() {}, useMemo: fn => fn(), useCallback: fn => fn,
|
|
29
|
+
}
|
|
30
|
+
const window = { __ModuleLoader__: { load: entry => { factory = entry.factory } } }
|
|
31
|
+
const source = CLIENT_SOURCE.replace("inject: ['slots', 'locale'],", "__test: { TrendChart }, inject: ['slots', 'locale'],")
|
|
32
|
+
new Function('window', 'localStorage', source)(window, { getItem: () => JSON.stringify({ costDisplay }), setItem() {} })
|
|
33
|
+
const { TrendChart } = factory(name => name === 'react' ? React : {}).__test
|
|
34
|
+
let tree
|
|
35
|
+
const props = {
|
|
36
|
+
title: 'trend', notes: [], slots: data, tipSlots: data, modelOrder: ['p/a', 'p/b', OTHER],
|
|
37
|
+
colorFor: () => 'currentColor', labelFor: key => key, labelMinPitch: 60,
|
|
38
|
+
legendModels: [], money, costCurrency: currency, t: core.createTranslator(lang === 'en' ? core.MESSAGES_EN : core.MESSAGES_ZH),
|
|
39
|
+
}
|
|
40
|
+
const render = () => {
|
|
41
|
+
cursor = 0
|
|
42
|
+
props.slots = props.money ? core.costSlotsOf(props.tipSlots) : props.tipSlots
|
|
43
|
+
tree = TrendChart(props)
|
|
44
|
+
return tree
|
|
45
|
+
}
|
|
46
|
+
const tip = () => nodes(tree).find(n => n.type?.name === 'ChartTip')
|
|
47
|
+
const rows = () => nodes(tip()).filter(n => n.props.className?.includes('ud-tip-row')).map(text)
|
|
48
|
+
const hover = (index = 0) => {
|
|
49
|
+
nodes(tree).filter(n => n.props.className === 'ud-bar-hit')[index].props.onMouseEnter({ clientX: 20, clientY: 20 })
|
|
50
|
+
render()
|
|
51
|
+
}
|
|
52
|
+
const select = (key, ctrl = false) => {
|
|
53
|
+
nodes(tree).find(n => n.type?.name === 'Legend').props.onItem(key, ctrl)
|
|
54
|
+
render()
|
|
55
|
+
}
|
|
56
|
+
render()
|
|
57
|
+
return { hover, select, rows, tip: () => text(tip()), update: changes => { Object.assign(props, changes); render() } }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
test('Given 金额视图 When 悬浮 Then 总计和各模型展示估算费用,其他明细按费用排序', () => {
|
|
61
|
+
const f = fixture({ money: true }); f.hover()
|
|
62
|
+
assert.deepEqual(f.rows().slice(0, 5), ['估算费用: ≈ ¥12.00', 'p/a: ≈ ¥2.00', 'p/b: ≈ ¥9.00', '其他: ≈ ¥1.00', 'p/c: ≈ ¥0.75'])
|
|
63
|
+
assert.ok(f.rows().includes('p/d: ≈ ¥0.25'))
|
|
64
|
+
assert.doesNotMatch(f.tip(), /16,000|9,000|6,000/)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('Given Token 视图 When 单选模型 Then 总计、费用、均价仅包含可见模型', () => {
|
|
68
|
+
const f = fixture(); f.hover(); f.select('p/a')
|
|
69
|
+
assert.deepEqual(f.rows(), ['所选用量: 1,000 tok', 'p/a: 1,000 tok', '≈ ¥2.00', '均价: ¥2,000.000/M'])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('Given 金额视图 When Ctrl 多选包含其他 Then 合计不重复计其他子项', () => {
|
|
73
|
+
const f = fixture({ money: true }); f.hover(); f.select('p/a'); f.select(OTHER, true)
|
|
74
|
+
assert.equal(f.rows()[0], '所选估算费用: ≈ ¥3.00')
|
|
75
|
+
assert.doesNotMatch(f.tip(), /p\/b:/)
|
|
76
|
+
assert.ok(f.rows().includes('均价: ¥428.571/M'))
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
for (const [key, label, value] of [['speed', '平均生成速度', '12 tok/s'], ['rate', '缓存命中率', '75.0%'], ['ttft', '首 token 延迟', '0.3秒']]) {
|
|
80
|
+
test(`Given 仅选 ${key} 曲线 When 悬浮 Then 只有时段整体指标且不显示模型合计或费用`, () => {
|
|
81
|
+
const f = fixture({ money: true }); f.hover(); f.select(key)
|
|
82
|
+
assert.match(f.tip(), /时段整体指标/)
|
|
83
|
+
assert.deepEqual(f.rows(), [`${label}: ${value}`])
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
test('Given 有其他付费槽和零费用模型 When 在金额视图选择零费用模型 Then 保留零费用读数', () => {
|
|
88
|
+
const data = [{ ...slot, byCostModel: { ...slot.byCostModel, 'p/a': 0 }, cost: 10 }]
|
|
89
|
+
const f = fixture({ money: true, data }); f.hover(); f.select('p/a')
|
|
90
|
+
assert.deepEqual(f.rows(), ['所选估算费用: ≈ ¥0.00', 'p/a: ≈ ¥0.00'])
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('Given 当前槽中选中模型没有用量 When 悬浮 Then 不展示隐藏模型总计或空提示', () => {
|
|
94
|
+
const f = fixture({ data: [{ ...slot, byModel: { ...slot.byModel, 'p/a': 0 } }] }); f.hover(); f.select('p/a')
|
|
95
|
+
assert.equal(f.tip(), '')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('Given 辅助费用偏好关闭 When 切换金额视图 Then 主金额读数仍展示并响应货币变化', () => {
|
|
99
|
+
const f = fixture({ costDisplay: false }); f.hover(); f.select('p/a')
|
|
100
|
+
assert.deepEqual(f.rows(), ['所选用量: 1,000 tok', 'p/a: 1,000 tok'])
|
|
101
|
+
f.update({ money: true, costCurrency: '$' })
|
|
102
|
+
assert.deepEqual(f.rows(), ['所选估算费用: ≈ $2.00', 'p/a: ≈ $2.00', '均价: $2,000.000/M'])
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('Given 悬浮时间桶 When 切换到不含该桶的窗口 Then 清除提示而不复用数组下标', () => {
|
|
106
|
+
const f = fixture(); f.hover()
|
|
107
|
+
f.update({ tipSlots: [{ ...slot, day: '2026-10-27' }] })
|
|
108
|
+
assert.equal(f.tip(), '')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('Given 悬浮时间桶 When 窗口前插时间桶 Then 提示仍绑定原时间桶', () => {
|
|
112
|
+
const f = fixture(); f.hover()
|
|
113
|
+
f.update({ tipSlots: [{ ...slot, day: '2026-10-27' }, slot] })
|
|
114
|
+
assert.match(f.tip(), /^2026-10-28/)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('Given 英文金额视图 When 单选模型 Then 使用英文费用标签及当前货币', () => {
|
|
118
|
+
const f = fixture({ money: true, lang: 'en', currency: '$' }); f.hover(); f.select('p/a')
|
|
119
|
+
assert.equal(f.rows()[0], 'Selected estimated cost: ≈ $2.00')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
for (const [key, label, value] of [['speed', '平均生成速度', '0 tok/s'], ['ttft', '首 token 延迟', '0秒']]) {
|
|
123
|
+
test(`Given 只有 timing 的时间桶 When 仅选 ${key} Then 零值指标仍显示提示`, () => {
|
|
124
|
+
const timing = { ...slot, total: 0, byModel: {}, cost: 0, byCostModel: {}, cacheHit: 0, cacheMiss: 0, speed: 0, ttft: 0 }
|
|
125
|
+
const f = fixture({ data: [timing] }); f.select(key); f.hover()
|
|
126
|
+
assert.deepEqual(f.rows(), [`${label}: ${value}`])
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
for (const key of ['speed', 'ttft', 'rate']) {
|
|
131
|
+
test(`Given 所选 ${key} 在当前桶没有数据 When 悬浮 Then 不显示空指标提示`, () => {
|
|
132
|
+
const emptyMetrics = { ...slot, day: '2026-10-27', speed: undefined, ttft: undefined, cacheHit: 0, cacheMiss: 0 }
|
|
133
|
+
const f = fixture({ data: [emptyMetrics, slot] }); f.select(key); f.hover()
|
|
134
|
+
assert.equal(f.tip(), '')
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
test('Given 单选旧模型 When 新窗口不含该模型 Then 恢复默认可见模型且能继续单选', () => {
|
|
139
|
+
const f = fixture(); f.select('p/a'); f.hover()
|
|
140
|
+
f.update({ tipSlots: [{ ...slot, total: 100, byModel: { 'p/z': 100 }, cost: 1, byCostModel: { 'p/z': 1 } }], modelOrder: ['p/z'] })
|
|
141
|
+
assert.ok(f.rows().includes('p/z: 100 tok'))
|
|
142
|
+
assert.equal(f.rows()[0], '总用量: 100 tok')
|
|
143
|
+
f.select('p/z')
|
|
144
|
+
assert.ok(f.rows().includes('p/z: 100 tok'))
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('Given 单选速度 When 新窗口没有速度 Then 恢复可用模型', () => {
|
|
148
|
+
const f = fixture(); f.select('speed'); f.hover()
|
|
149
|
+
f.update({ tipSlots: [{ ...slot, speed: undefined }] })
|
|
150
|
+
assert.ok(f.rows().includes('p/a: 1,000 tok'))
|
|
151
|
+
assert.doesNotMatch(f.tip(), /平均生成速度:/)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('Given 小时桶悬浮 When 切到分钟桶 Then 旧提示清除且新提示显示分钟桶数据', () => {
|
|
155
|
+
const f = fixture({ data: [{ ...slot, day: '2026-10-28T12' }] }); f.hover()
|
|
156
|
+
f.update({ tipSlots: [{ ...slot, day: '2026-10-28T12:10' }] })
|
|
157
|
+
assert.equal(f.tip(), '')
|
|
158
|
+
f.hover()
|
|
159
|
+
assert.match(f.tip(), /^2026-10-28T12:10/)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test('Given 单选模型在新窗口全部失效 When 返回原窗口 Then 保持默认显示而不复活旧单选', () => {
|
|
163
|
+
const f = fixture(); f.select('p/a'); f.hover()
|
|
164
|
+
f.update({ tipSlots: [{ ...slot, total: 100, byModel: { 'p/z': 100 }, cost: 1, byCostModel: { 'p/z': 1 } }], modelOrder: ['p/z'] })
|
|
165
|
+
f.update({ tipSlots: [slot], modelOrder: ['p/a', 'p/b', OTHER] })
|
|
166
|
+
assert.equal(f.rows()[0], '总用量: 16,000 tok')
|
|
167
|
+
assert.ok(f.rows().includes('p/b: 9,000 tok'))
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('Given 多选中部分模型在新窗口失效 When 返回原窗口 Then 只保留未失效的选择', () => {
|
|
171
|
+
const f = fixture(); f.select('p/a'); f.select('p/b', true); f.hover()
|
|
172
|
+
f.update({ tipSlots: [{ ...slot, total: 1000, byModel: { 'p/a': 1000 }, cost: 2, byCostModel: { 'p/a': 2 } }], modelOrder: ['p/a'] })
|
|
173
|
+
f.update({ tipSlots: [slot], modelOrder: ['p/a', 'p/b', OTHER] })
|
|
174
|
+
assert.equal(f.rows()[0], '所选用量: 1,000 tok')
|
|
175
|
+
assert.doesNotMatch(f.tip(), /p\/b:/)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('Given 未配置费用 When 悬浮 Token 视图 Then 只展示用量及有效曲线', () => {
|
|
179
|
+
const { cost, byCostModel, otherCostByModel, ...unpriced } = slot
|
|
180
|
+
const f = fixture({ data: [unpriced] }); f.select('p/a'); f.hover()
|
|
181
|
+
assert.deepEqual(f.rows(), ['所选用量: 1,000 tok', 'p/a: 1,000 tok'])
|
|
182
|
+
})
|