@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.
@@ -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 = new UsageStore(facilityOf(domain))
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 = new UsageStore(facilityOf(domain))
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 = new UsageStore(facilityOf(domain))
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 = new UsageStore(facilityOf(domain))
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 = new UsageStore(facilityOf(domain))
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 = new UsageStore(facilityOf(domain))
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 = new UsageStore(facilityOf(domain))
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 = new UsageStore(facilityOf(domain))
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 = new UsageStore(facilityOf(domain))
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 = new UsageStore(facilityOf(domain))
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 = new UsageStore(facilityOf(domain))
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 = new UsageStore(facilityOf(domain))
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 = new UsageStore(facilityOf(domain), { now: () => local(2026, 8, 10, 12, 0) })
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 = new UsageStore(facilityOf(domain), { now: () => local(2026, 8, 10, 12, 0) })
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 = new UsageStore(facilityOf(domain), { now: () => local(2026, 8, 10, 12, 0) })
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 = new UsageStore(facilityOf(domain), { now: () => local(2026, 8, 10, 12, 0) })
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 = new UsageStore(facilityOf(domain), { now: () => clock, retentionDays: () => 0 })
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 = new UsageStore(facilityOf(domain))
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 = new UsageStore(facilityOf(domain))
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 = new UsageStore(facilityOf(domain))
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 = new UsageStore(facilityOf(domain))
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 = new UsageStore(facilityOf(domain))
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 = new UsageStore(facilityOf(domain))
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 = new UsageStore(facilityOf(domain))
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 first = sharedStore(facilityOf(fakeDomain()))
569
- assert.equal(sharedStore(facilityOf(fakeDomain())), first)
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
- assert.notEqual(sharedStore(facilityOf(fakeDomain())), first)
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 = new UsageStore(facilityOf(domain))
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 = new UsageStore(facilityOf(domain))
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 = new UsageStore(facilityOf(domain), { flushIntervalMs: 5 })
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 = new UsageStore(facilityOf(domain))
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 = new UsageStore(facilityOf(domain))
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
+ })