@mzzsfy/dsh-usage-dash 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,19 +1,14 @@
1
1
  // client 纯函数层测试:滚动窗口映射/信封解析/上限裁剪/文案/格式化/分组/柱状几何
2
2
  // Given/When/Then 场景内嵌于用例描述
3
- // client.js 为非模块 script(bundle 求值形态,禁 import/export),整源求值后按顶层声明名收集
3
+ // client.js 为经典 script bundle( import/export),整文件 IIFE 书挡;经 client-eval 剥壳后整源求值,按顶层声明名收集
4
4
  import { test } from 'node:test'
5
5
  import assert from 'node:assert/strict'
6
6
  import { readFileSync } from 'node:fs'
7
7
  import { fileURLToPath } from 'node:url'
8
8
  import { dirname, join } from 'node:path'
9
+ import { CLIENT_BODY, DECLARATION_NAMES } from './client-eval.mjs'
9
10
 
10
- const CLIENT_SOURCE = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'client.js'), 'utf8')
11
- const DECLARATION_NAMES = [
12
- ...new Set(
13
- [...CLIENT_SOURCE.matchAll(/^(?:const|function|let) ([A-Za-z_$][\w$]*)/gm)].map((match) => match[1])
14
- ),
15
- ]
16
- const core = new Function(`${CLIENT_SOURCE}\nreturn { ${DECLARATION_NAMES.join(', ')} }`)()
11
+ const core = new Function(`${CLIENT_BODY}\nreturn { ${DECLARATION_NAMES.join(', ')} }`)()
17
12
 
18
13
  const {
19
14
  CHART_HEIGHT,
@@ -39,12 +34,16 @@ const {
39
34
  aggregateCurrencyOf,
40
35
  applyCurrencyToRules,
41
36
  cacheRateText,
37
+ coerceConditions,
38
+ coercePricingRules,
42
39
  costOf,
43
40
  costTitleText,
44
41
  createTranslator,
45
42
  daysInRange,
43
+ defaultCondition,
46
44
  defaultPricingRule,
47
45
  donutSegments,
46
+ pointStatsMatches,
48
47
  formatCompact,
49
48
  formatCost,
50
49
  formatDuration,
@@ -59,10 +58,12 @@ const {
59
58
  hourTickLabel,
60
59
  indexOfDay,
61
60
  isEmptyRange,
61
+ logTimeOf,
62
62
  matchPrice,
63
63
  maxSlotsFor,
64
64
  minuteTickLabel,
65
65
  modelSegmentLabel,
66
+ modelSpeedText,
66
67
  modelNameOf,
67
68
  niceTicks,
68
69
  otherDetailItems,
@@ -74,7 +75,6 @@ const {
74
75
  resolveMinuteRange,
75
76
  shortDay,
76
77
  smoothPath,
77
- statusLineActive,
78
78
  tipPlace,
79
79
  translateWith,
80
80
  trimSlots,
@@ -91,7 +91,7 @@ const NOW = new Date(2026, 2, 15, 14, 30, 45)
91
91
 
92
92
  test('day 预设映射为最近 N 天闭区间', () => {
93
93
  assert.deepEqual(resolveDayRange('7', NOW), { from: '2026-03-09', to: '2026-03-15' })
94
- assert.deepEqual(resolveDayRange('14', NOW), { from: '2026-03-02', to: '2026-03-15' })
94
+ assert.deepEqual(resolveDayRange('30', NOW), { from: '2026-02-14', to: '2026-03-15' })
95
95
  })
96
96
 
97
97
  test('day 预设跨年与跨月边界', () => {
@@ -105,7 +105,7 @@ test('day 90 天预设起点回卷上年', () => {
105
105
 
106
106
  test('hour 预设 now-N 舍入到整点桶起点', () => {
107
107
  assert.deepEqual(resolveHourRange('24h', NOW), { from: '2026-03-14T14', to: '2026-03-15T14' })
108
- assert.deepEqual(resolveHourRange('72h', NOW), { from: '2026-03-12T14', to: '2026-03-15T14' })
108
+ assert.deepEqual(resolveHourRange('3d', NOW), { from: '2026-03-12T14', to: '2026-03-15T14' })
109
109
  })
110
110
 
111
111
  test('hour 预设跨日且窗口起点落整点', () => {
@@ -113,30 +113,53 @@ test('hour 预设跨日且窗口起点落整点', () => {
113
113
  })
114
114
 
115
115
  test('minute 预设起点对齐 10 分钟桶,to 保持原始分钟', () => {
116
- assert.deepEqual(resolveMinuteRange('60m', NOW), { from: '2026-03-15T13:30', to: '2026-03-15T14:30' })
117
- assert.deepEqual(resolveMinuteRange('360m', NOW), { from: '2026-03-15T08:30', to: '2026-03-15T14:30' })
116
+ assert.deepEqual(resolveMinuteRange('3h', NOW), { from: '2026-03-15T11:30', to: '2026-03-15T14:30' })
117
+ assert.deepEqual(resolveMinuteRange('24h', NOW), { from: '2026-03-14T14:30', to: '2026-03-15T14:30' })
118
118
  const offGrid = new Date(2026, 2, 15, 14, 37, 20)
119
- assert.deepEqual(resolveMinuteRange('60m', offGrid), { from: '2026-03-15T13:30', to: '2026-03-15T14:37' })
119
+ assert.deepEqual(resolveMinuteRange('3h', offGrid), { from: '2026-03-15T11:30', to: '2026-03-15T14:37' })
120
120
  })
121
121
 
122
122
  test('minute 预设整桶边界不再回退', () => {
123
- assert.deepEqual(resolveMinuteRange('60m', new Date(2026, 2, 15, 14, 30, 0)), { from: '2026-03-15T13:30', to: '2026-03-15T14:30' })
123
+ assert.deepEqual(resolveMinuteRange('3h', new Date(2026, 2, 15, 14, 30, 0)), { from: '2026-03-15T11:30', to: '2026-03-15T14:30' })
124
124
  })
125
125
 
126
126
  test('预设定义表覆盖三视图', () => {
127
- assert.deepEqual(DAY_PRESETS, ['7', '14', '30', '90'])
128
- assert.deepEqual(HOUR_PRESETS, ['24h', '48h', '72h'])
129
- assert.deepEqual(MINUTE_PRESETS, ['60m', '180m', '360m'])
127
+ assert.deepEqual(DAY_PRESETS, ['7', '30', '90'])
128
+ assert.deepEqual(HOUR_PRESETS, ['24h', '3d', '7d', '15d'])
129
+ assert.deepEqual(MINUTE_PRESETS, ['3h', '24h', '3d', '7d'])
130
+ })
131
+
132
+ test('时/分挡位文案键逐挡齐备,含跨表同 id 挡位', () => {
133
+ // Given 时/分挡位表存在同 id('24h') When 逐挡查词典 Then 两语言均有对应键,漏配即回退键名
134
+ for (const id of HOUR_PRESETS) {
135
+ assert.notEqual(MESSAGES_ZH[`hourPreset.${id}`], undefined, `zh hourPreset.${id}`)
136
+ assert.notEqual(MESSAGES_EN[`hourPreset.${id}`], undefined, `en hourPreset.${id}`)
137
+ }
138
+ for (const id of MINUTE_PRESETS) {
139
+ assert.notEqual(MESSAGES_ZH[`minutePreset.${id}`], undefined, `zh minutePreset.${id}`)
140
+ assert.notEqual(MESSAGES_EN[`minutePreset.${id}`], undefined, `en minutePreset.${id}`)
141
+ }
142
+ })
143
+
144
+ test('pointStats 缓存命中须视图与挡位双匹配', () => {
145
+ // Given 同挡位 id 跨视图('24h') When 判定缓存命中 Then 视图不同即不命中,防误用他端点数据
146
+ const cached = { view: 'hour', preset: '24h', value: { daily: [] } }
147
+ assert.equal(pointStatsMatches(cached, 'hour', '24h'), true)
148
+ assert.equal(pointStatsMatches(cached, 'minute', '24h'), false)
149
+ assert.equal(pointStatsMatches(cached, 'hour', '3d'), false)
150
+ assert.equal(pointStatsMatches(null, 'hour', '24h'), false)
130
151
  })
131
152
 
132
153
  test('每视图渲染上限等于闭区间桶数', () => {
133
154
  assert.equal(maxSlotsFor('day', '90'), DAY_MAX_SLOTS)
134
155
  assert.equal(maxSlotsFor('hour', '24h'), 25)
135
- assert.equal(maxSlotsFor('hour', '48h'), 49)
136
- assert.equal(maxSlotsFor('hour', '72h'), 73)
137
- assert.equal(maxSlotsFor('minute', '60m'), 7)
138
- assert.equal(maxSlotsFor('minute', '180m'), 19)
139
- assert.equal(maxSlotsFor('minute', '360m'), 37)
156
+ assert.equal(maxSlotsFor('hour', '3d'), 73)
157
+ assert.equal(maxSlotsFor('hour', '7d'), 169)
158
+ assert.equal(maxSlotsFor('hour', '15d'), 361)
159
+ assert.equal(maxSlotsFor('minute', '3h'), 19)
160
+ assert.equal(maxSlotsFor('minute', '24h'), 145)
161
+ assert.equal(maxSlotsFor('minute', '3d'), 433)
162
+ assert.equal(maxSlotsFor('minute', '7d'), 1009)
140
163
  })
141
164
 
142
165
  test('trim 超上限裁最旧且恰好达上限不裁', () => {
@@ -145,6 +168,11 @@ test('trim 超上限裁最旧且恰好达上限不裁', () => {
145
168
  assert.equal(trimSlots(slots, 6), slots)
146
169
  })
147
170
 
171
+ test('日志条目时刻补零为 HH:mm:ss', () => {
172
+ assert.equal(logTimeOf(new Date(2026, 8, 8, 9, 3, 7).getTime()), '09:03:07')
173
+ assert.equal(logTimeOf(new Date(2026, 8, 8, 19, 13, 47).getTime()), '19:13:47')
174
+ })
175
+
148
176
  test('信封解析成功透传 value', () => {
149
177
  assert.deepEqual(parseEnvelope({ ok: true, value: { a: 1 } }), { ok: true, value: { a: 1 } })
150
178
  })
@@ -177,19 +205,11 @@ test('translateWith zh/en 占位替换与缺键回退键名', () => {
177
205
  test('createTranslator 与纯查表同构', () => {
178
206
  const enT = createTranslator(MESSAGES_EN)
179
207
  assert.equal(enT('rangeCustom'), 'Custom')
180
- assert.equal(enT('hourPreset', { n: 48 }), 'Last 48 hours')
208
+ assert.equal(enT('hourPreset.15d'), '15 days')
209
+ assert.equal(enT('minutePreset.7d'), '7 days')
181
210
  assert.equal(enT('missing.key'), 'missing.key')
182
211
  })
183
212
 
184
- test('状态行仅在回扫进行或异常存在时可见', () => {
185
- assert.equal(statusLineActive(null), false)
186
- assert.equal(statusLineActive({ running: false, skippedSessions: 0, recordFailures: 0 }), false)
187
- assert.equal(statusLineActive({ running: true, total: 3, done: 1 }), true)
188
- assert.equal(statusLineActive({ running: false, error: 'boom' }), true)
189
- assert.equal(statusLineActive({ running: false, skippedSessions: 2 }), true)
190
- assert.equal(statusLineActive({ running: false, recordFailures: 1 }), true)
191
- })
192
-
193
213
  test('zh/en 词典键集完全一致', () => {
194
214
  assert.deepEqual(Object.keys(MESSAGES_ZH).sort(), Object.keys(MESSAGES_EN).sort())
195
215
  })
@@ -536,6 +556,13 @@ test('donut 分段可访问标签格式化名称数值与占比', () => {
536
556
  assert.equal(modelSegmentLabel('p/m', 1234, 12.34), 'p/m: 1,234 (12.3%)')
537
557
  })
538
558
 
559
+ test('模型速度文本:官方吞吐口径格式化,无速度为空串', () => {
560
+ assert.equal(modelSpeedText(5.5556), '5.6 tok/s')
561
+ assert.equal(modelSpeedText(12.34), '12 tok/s')
562
+ assert.equal(modelSpeedText(0), '0 tok/s')
563
+ assert.equal(modelSpeedText(undefined), '')
564
+ })
565
+
539
566
  // —— S14 费用格式化与展示辅助(镜像函数核心语义见 pricing-parity.test.mjs) ——
540
567
 
541
568
  test('formatCost 千分位与两位小数', () => {
@@ -551,10 +578,10 @@ test('formatCost 微观值四位小数', () => {
551
578
  })
552
579
 
553
580
  test('镜像函数基本行为:非法输入 null 与命中计价', () => {
554
- assert.equal(matchPrice(null, 'm', NOW), null)
555
- assert.equal(matchPrice([], 'm', NOW), null)
581
+ assert.equal(matchPrice(null, 'x/m', NOW), null)
582
+ assert.equal(matchPrice([], 'x/m', NOW), null)
556
583
  assert.deepEqual(
557
- matchPrice([{ model: 'm', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [] }], 'm', NOW),
584
+ matchPrice([{ model: 'x/m', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [] }], 'x/m', NOW),
558
585
  { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 },
559
586
  )
560
587
  assert.equal(costOf({ input: 2, output: 0, cacheRead: 0, cacheWrite: 0 }, { inputTokens: 1000 * 1000 }), 2)
@@ -594,17 +621,23 @@ test('defaultPricingRule 承接给定货币,缺省回落首档', () => {
594
621
  assert.deepEqual(defaultPricingRule('$').conditions, [])
595
622
  })
596
623
 
597
- test('validatePricingRules 就地校验 model 必填与价格非空非负', () => {
624
+ test('validatePricingRules 就地校验 model 必填两段式与价格非空非负', () => {
598
625
  const valid = [{ model: 'p/m', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [] }]
599
626
  assert.equal(validatePricingRules(valid).size, 0)
600
627
  const broken = [
601
628
  { model: ' ', currency: '¥', price: { input: '', output: -1, cacheRead: 0, cacheWrite: 0 }, conditions: [] },
629
+ { model: 'solo', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [] },
630
+ { model: '*', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [] },
631
+ { model: '/x', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [] },
602
632
  ]
603
633
  const errors = validatePricingRules(broken)
604
634
  assert.equal(errors.get('0.model'), 'required')
605
635
  assert.equal(errors.get('0.price.input'), 'required')
606
636
  assert.equal(errors.get('0.price.output'), 'priceInvalid')
607
637
  assert.equal(errors.has('0.price.cacheRead'), false)
638
+ assert.equal(errors.get('1.model'), 'modelFormat')
639
+ assert.equal(errors.get('2.model'), 'modelFormat')
640
+ assert.equal(errors.get('3.model'), 'modelFormat')
608
641
  })
609
642
 
610
643
  test('costTitleText 未计价计数后缀', () => {
@@ -612,3 +645,78 @@ test('costTitleText 未计价计数后缀', () => {
612
645
  assert.equal(costTitleText(zhT, 0), '按当前费率对历史用量估算,精度为小时级')
613
646
  assert.equal(costTitleText(zhT, 3), '按当前费率对历史用量估算,精度为小时级,3 个小时桶未计价')
614
647
  })
648
+
649
+ test('defaultCondition 各类型默认值即填即用', () => {
650
+ // Given 四种条件类型 When 取默认 Then 时段全天/周几空/号段全月/日期段当天
651
+ assert.deepEqual(defaultCondition('dailyWindow'), { kind: 'dailyWindow', from: '00:00', to: '00:00' })
652
+ assert.deepEqual(defaultCondition('weekdays'), { kind: 'weekdays', days: [] })
653
+ assert.deepEqual(defaultCondition('monthDays'), { kind: 'monthDays', from: 1, to: 31 })
654
+ assert.deepEqual(defaultCondition('dateRange', new Date(2026, 2, 15)), { kind: 'dateRange', from: '2026-03-15', to: '2026-03-15' })
655
+ })
656
+
657
+ test('validatePricingRules 拒绝非法时刻与时刻字段缺失', () => {
658
+ // Given dailyWindow 时刻超界或缺失 When 校验 Then 标 condTime/required
659
+ const rules = [
660
+ { model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'dailyWindow', from: '99:99', to: '08:00' }] },
661
+ { model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'dailyWindow', from: '', to: '08:00' }] },
662
+ { model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'dailyWindow', from: '23:59', to: '24:00' }] },
663
+ ]
664
+ const errors = validatePricingRules(rules)
665
+ assert.equal(errors.get('0.conditions.0.from'), 'condTime')
666
+ assert.equal(errors.get('1.conditions.0.from'), 'required')
667
+ assert.equal(errors.get('2.conditions.0.to'), 'condTime')
668
+ })
669
+
670
+ test('validatePricingRules 校验周几与月号段边界', () => {
671
+ // Given weekdays 含 7、monthDays 含 0 或 32 When 校验 Then 标 condWeekday/condMonthDay
672
+ const rules = [
673
+ { model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'weekdays', days: [0, 7] }] },
674
+ { model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'monthDays', from: 0, to: 31 }] },
675
+ { model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'monthDays', from: 1, to: 32 }] },
676
+ ]
677
+ const errors = validatePricingRules(rules)
678
+ assert.equal(errors.get('0.conditions.0.days'), 'condWeekday')
679
+ assert.equal(errors.get('1.conditions.0.from'), 'condMonthDay')
680
+ assert.equal(errors.get('2.conditions.0.to'), 'condMonthDay')
681
+ })
682
+
683
+ test('validatePricingRules 校验日期段格式与倒序', () => {
684
+ // Given dateRange 非规范日期或倒序 When 校验 Then 标 condDate/condRange
685
+ const rules = [
686
+ { model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'dateRange', from: '2026-3-5', to: '2026-03-08' }] },
687
+ { model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'dateRange', from: '2026-03-08', to: '2026-03-05' }] },
688
+ { model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'dateRange', from: '2026-03-05', to: '2026-03-08' }] },
689
+ ]
690
+ const errors = validatePricingRules(rules)
691
+ assert.equal(errors.get('0.conditions.0.from'), 'condDate')
692
+ assert.equal(errors.get('1.conditions.0'), 'condRange')
693
+ assert.equal(errors.has('2.conditions.0'), false)
694
+ assert.equal(errors.has('2.conditions.0.from'), false)
695
+ })
696
+
697
+ test('coerceConditions 规整数字字段且不改其余字段', () => {
698
+ // Given 输入框字符串形态 When 规整 Then weekdays days 与 monthDays 号段转数字,时段日期原样
699
+ const conditions = [
700
+ { kind: 'weekdays', days: ['1', '5'] },
701
+ { kind: 'monthDays', from: '26', to: '5' },
702
+ { kind: 'dailyWindow', from: '09:00', to: '18:00' },
703
+ { kind: 'dateRange', from: '2026-03-01', to: '2026-03-15' },
704
+ ]
705
+ const coerced = coerceConditions(conditions)
706
+ assert.deepEqual(coerced[0], { kind: 'weekdays', days: [1, 5] })
707
+ assert.deepEqual(coerced[1], { kind: 'monthDays', from: 26, to: 5 })
708
+ assert.deepEqual(coerced[2], conditions[2])
709
+ assert.deepEqual(coerced[3], conditions[3])
710
+ })
711
+
712
+ test('coercePricingRules 连带规整条件', () => {
713
+ // Given 含条件规则 When POST 前规整 Then 价格与条件数字字段同时规整
714
+ const rules = [{
715
+ model: 'a/b', currency: '¥',
716
+ price: { input: '1', output: '0', cacheRead: '0', cacheWrite: '0' },
717
+ conditions: [{ kind: 'weekdays', days: ['1'] }, { kind: 'monthDays', from: '1', to: '31' }],
718
+ }]
719
+ const coerced = coercePricingRules(rules)
720
+ assert.deepEqual(coerced[0].conditions, [{ kind: 'weekdays', days: [1] }, { kind: 'monthDays', from: 1, to: 31 }])
721
+ assert.deepEqual(coerced[0].price, { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 })
722
+ })
@@ -139,6 +139,22 @@ test('step/start 与 llm/retry-started 产生 request 标记样本', async () =>
139
139
  }
140
140
  })
141
141
 
142
+ test('step/start 观测起点:首个 usage 样本附 durationMs(start 到样本时刻)', async () => {
143
+ const { ctx, store } = liveCollector()
144
+ const start = local(2026, 8, 2, 10, 0)
145
+ const sampleAt = start + 6 * 1000 + 500
146
+ ctx.emit('session/event', session('s1'), ev('step/start', 1, start, { turn: 0, step: 0 }))
147
+ ctx.emit('session/event', session('s1'), ev('assistant/message', 2, sampleAt, {
148
+ turn: 0,
149
+ step: 0,
150
+ usage: usage(),
151
+ message: {},
152
+ }))
153
+ await tick()
154
+ const usageSample = store.samples.find((sample) => !sample.request)
155
+ assert.equal(usageSample.durationMs, 6500)
156
+ })
157
+
142
158
  test('(session,turn,step) 去重:同键先到样本生效,重复报告吞掉', async () => {
143
159
  const { ctx, store } = liveCollector()
144
160
  const t = local(2026, 8, 2, 10, 0)
@@ -158,6 +174,55 @@ test('(session,turn,step) 去重:同键先到样本生效,重复报告吞掉', a
158
174
  assert.equal(store.samples[0].inputTokens, 100)
159
175
  })
160
176
 
177
+ test('无 step/start 起点的 usage 样本不带 durationMs', async () => {
178
+ const { ctx, store } = liveCollector()
179
+ const t = local(2026, 8, 2, 10, 0)
180
+ ctx.emit('session/event', session('s1'), ev('assistant/message', 1, t, {
181
+ turn: 0,
182
+ step: 0,
183
+ usage: usage(),
184
+ message: {},
185
+ }))
186
+ await tick()
187
+ assert.equal(store.samples.length, 1)
188
+ assert.equal(store.samples[0].durationMs, undefined)
189
+ })
190
+
191
+ test('retry-started 重置起点:时长只含最终尝试', async () => {
192
+ const { ctx, store } = liveCollector()
193
+ const start = local(2026, 8, 2, 10, 0)
194
+ const retryAt = start + 60 * 1000
195
+ const sampleAt = retryAt + 5 * 1000
196
+ ctx.emit('session/event', session('s1'), ev('step/start', 1, start, { turn: 0, step: 0 }))
197
+ ctx.emit('session/event', session('s1'), ev('llm/retry-started', 2, retryAt, { turn: 0, step: 0 }))
198
+ ctx.emit('session/event', session('s1'), ev('assistant/message', 3, sampleAt, {
199
+ turn: 0,
200
+ step: 0,
201
+ usage: usage(),
202
+ message: {},
203
+ }))
204
+ await tick()
205
+ const usageSample = store.samples.find((sample) => !sample.request)
206
+ assert.equal(usageSample.durationMs, 5000)
207
+ })
208
+
209
+ test('同时刻零时长不附 durationMs,request 标记样本不带时长', async () => {
210
+ const { ctx, store } = liveCollector()
211
+ const t = local(2026, 8, 2, 10, 0)
212
+ ctx.emit('session/event', session('s1'), ev('step/start', 1, t, { turn: 0, step: 0 }))
213
+ ctx.emit('session/event', session('s1'), ev('assistant/message', 2, t, {
214
+ turn: 0,
215
+ step: 0,
216
+ usage: usage(),
217
+ message: {},
218
+ }))
219
+ await tick()
220
+ assert.equal(store.samples.length, 2)
221
+ for (const sample of store.samples) {
222
+ assert.equal(sample.durationMs, undefined)
223
+ }
224
+ })
225
+
161
226
  test('不同 (turn,step) 各自成立,跨会话同键互不影响', async () => {
162
227
  const { ctx, store } = liveCollector()
163
228
  const t = local(2026, 8, 2, 10, 0)
@@ -313,6 +378,8 @@ test('采集侧 store 失败计入 recordFailures 且不抛断采集', async ()
313
378
  await tick()
314
379
  await tick()
315
380
  assert.equal(collector.status().recordFailures, 2)
381
+ assert.equal(collector.status().log.length, 2)
382
+ assert.ok(collector.status().log.every((entry) => entry.kind === 'record' && typeof entry.time === 'number'))
316
383
  assert.equal(store.samples.length, 0)
317
384
  })
318
385
 
@@ -362,6 +429,28 @@ test('回扫:无边界会话全量重放,归因与游标写回', async () => {
362
429
  assert.equal(collector.status().scannedSessions, 1)
363
430
  })
364
431
 
432
+ test('回扫:重放路径同样附加 durationMs', async () => {
433
+ const start = local(2026, 8, 2, 14, 0)
434
+ const persistence = fakePersistence([{
435
+ id: 's1',
436
+ events: [
437
+ ev('request/context', 0, start, { provider: 'deepseek', model: 'chat' }),
438
+ ev('step/start', 1, start, { turn: 0, step: 0 }),
439
+ ev('assistant/message', 2, start + 7 * 1000, {
440
+ turn: 0,
441
+ step: 0,
442
+ usage: usage(),
443
+ message: { source: { provider: 'deepseek', model: 'chat' } },
444
+ }),
445
+ ],
446
+ }])
447
+ const store = fakeStore()
448
+ const collector = new UsageCollector(fakeCtx({ persistence }), store)
449
+ await collector.backfill(persistence, fakeSessions())
450
+ const usageSample = store.samples.find((sample) => !sample.request)
451
+ assert.equal(usageSample.durationMs, 7000)
452
+ })
453
+
365
454
  test('回扫:已入游标会话不再重扫', async () => {
366
455
  const t = local(2026, 8, 2, 14, 0)
367
456
  const persistence = fakePersistence([{ id: 's1', events: [ev('turn/end', 0, t)] }])
@@ -473,6 +562,9 @@ test('回扫:单会话失败不中断整轮且不进游标', async () => {
473
562
  assert.equal(collector.status().error, undefined)
474
563
  assert.equal(collector.status().skippedSessions, 1)
475
564
  assert.equal(collector.status().done, 2)
565
+ assert.equal(collector.status().log.length, 1)
566
+ assert.equal(collector.status().log[0].kind, 'skipped')
567
+ assert.ok(collector.status().log[0].detail.startsWith('s2'))
476
568
  })
477
569
 
478
570
  test('回扫:record 失败使会话失败并保留游标重试机会', async () => {
@@ -590,6 +682,7 @@ test('status() 返回快照,外部修改不影响内部状态', async () => {
590
682
  error: undefined,
591
683
  recordFailures: 0,
592
684
  skippedSessions: 0,
685
+ log: [],
593
686
  })
594
687
  await collector.backfill(persistence, fakeSessions())
595
688
  const snapshot = collector.status()
@@ -1,34 +1,28 @@
1
1
  // 双实现同源 parity 测试:client.js 顶层定价镜像函数与宿主 src/pricing.js 同输入同输出
2
2
  // 向量表驱动:同一批向量喂双侧,deepEqual 双侧结果并对期望值断言
3
- // client.js 为非模块 script(bundle 求值形态,禁 import/export),整源求值后按顶层声明名收集
3
+ // client.js 为经典 script bundle( import/export),整文件 IIFE 书挡;经 client-eval 剥壳后整源求值,按顶层声明名收集
4
4
  import { test } from 'node:test'
5
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'
6
+ import { CLIENT_BODY, DECLARATION_NAMES } from './client-eval.mjs'
9
7
 
10
8
  import {
11
9
  matchPrice as hostMatchPrice,
12
10
  costOf as hostCostOf,
13
11
  conditionMatches as hostConditionMatches,
12
+ isTwoSegmentModel as hostIsTwoSegmentModel,
14
13
  UNIT_PER_MILLION as hostUnitPerMillion,
15
14
  CURRENCIES as hostCurrencies,
16
15
  CONDITION_KINDS as hostConditionKinds,
17
16
  TOKENS_PER_MILLION as hostTokensPerMillion,
18
17
  } from '../src/pricing.js'
19
18
 
20
- const CLIENT_SOURCE = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'client.js'), 'utf8')
21
- const DECLARATION_NAMES = [
22
- ...new Set(
23
- [...CLIENT_SOURCE.matchAll(/^(?:const|function|let) ([A-Za-z_$][\w$]*)/gm)].map((match) => match[1])
24
- ),
25
- ]
26
- const core = new Function(`${CLIENT_SOURCE}\nreturn { ${DECLARATION_NAMES.join(', ')} }`)()
19
+ const core = new Function(`${CLIENT_BODY}\nreturn { ${DECLARATION_NAMES.join(', ')} }`)()
27
20
 
28
21
  const {
29
22
  matchPrice: clientMatchPrice,
30
23
  costOf: clientCostOf,
31
24
  conditionMatches: clientConditionMatches,
25
+ isTwoSegmentModel: clientIsTwoSegmentModel,
32
26
  UNIT_PER_MILLION: clientUnitPerMillion,
33
27
  CURRENCIES: clientCurrencies,
34
28
  CONDITION_KINDS: clientConditionKinds,
@@ -83,67 +77,112 @@ const MATCH_SUNDAY_1000 = [2026, 2, 15, 10, 0]
83
77
 
84
78
  const MATCH_VECTORS = [
85
79
  {
86
- name: '精确命中按数组序取首个',
87
- rules: [ruleOf('a', 1), ruleOf('b', 2)], model: 'b', date: MATCH_SUNDAY_1000, expected: priceOf(2),
80
+ name: '全名精确命中按数组序取首个',
81
+ rules: [ruleOf('x/a', 1), ruleOf('x/b', 2)], model: 'x/b', date: MATCH_SUNDAY_1000, expected: priceOf(2),
82
+ },
83
+ {
84
+ name: '同全名多条取数组序第一条',
85
+ rules: [ruleOf('x/a', 1), ruleOf('x/a', 3)], model: 'x/a', date: MATCH_SUNDAY_1000, expected: priceOf(1),
86
+ },
87
+ {
88
+ name: '模型名通配跨供应商命中',
89
+ rules: [ruleOf('*/a', 5)], model: 'zzz/a', date: MATCH_SUNDAY_1000, expected: priceOf(5),
90
+ },
91
+ {
92
+ name: '供应商通配命中同供应商模型',
93
+ rules: [ruleOf('x/*', 5)], model: 'x/zzz', date: MATCH_SUNDAY_1000, expected: priceOf(5),
94
+ },
95
+ {
96
+ name: '无精确回落全通配',
97
+ rules: [ruleOf('*/*', 5)], model: 'zzz/yyy', date: MATCH_SUNDAY_1000, expected: priceOf(5),
98
+ },
99
+ {
100
+ name: '全名精确优先于全通配',
101
+ rules: [ruleOf('*/*', 5), ruleOf('x/a', 1)], model: 'x/a', date: MATCH_SUNDAY_1000, expected: priceOf(1),
102
+ },
103
+ {
104
+ name: '匹配链:模型名通配优先于供应商通配',
105
+ rules: [ruleOf('x/*', 3), ruleOf('*/a', 2)], model: 'x/a', date: MATCH_SUNDAY_1000, expected: priceOf(2),
106
+ },
107
+ {
108
+ name: '匹配链:供应商通配优先于全通配',
109
+ rules: [ruleOf('*/*', 4), ruleOf('x/*', 3)], model: 'x/a', date: MATCH_SUNDAY_1000, expected: priceOf(3),
88
110
  },
89
111
  {
90
- name: '同 model 多条取数组序第一条',
91
- rules: [ruleOf('a', 1), ruleOf('a', 3)], model: 'a', date: MATCH_SUNDAY_1000, expected: priceOf(1),
112
+ name: '模型名段允许含斜杠',
113
+ rules: [ruleOf('x/*', 1)], model: 'x/deepseek/deepseek-chat', date: MATCH_SUNDAY_1000, expected: priceOf(1),
92
114
  },
93
115
  {
94
- name: '无精确回落通配',
95
- rules: [ruleOf('*', 5)], model: 'zzz', date: MATCH_SUNDAY_1000, expected: priceOf(5),
116
+ name: '请求裸名归 default 供应商',
117
+ rules: [ruleOf('default/a', 1), ruleOf('*/a', 2)], model: 'a', date: MATCH_SUNDAY_1000, expected: priceOf(1),
96
118
  },
97
119
  {
98
- name: '精确优先于通配',
99
- rules: [ruleOf('*', 5), ruleOf('a', 1)], model: 'a', date: MATCH_SUNDAY_1000, expected: priceOf(1),
120
+ name: '请求首位斜杠不拆段归 default 供应商',
121
+ rules: [ruleOf('default//x', 1), ruleOf('*/x', 2)], model: '/x', date: MATCH_SUNDAY_1000, expected: priceOf(1),
122
+ },
123
+ {
124
+ name: '单段旧形态规则失效',
125
+ rules: [ruleOf('*', 5), ruleOf('a', 1)], model: 'x/a', date: MATCH_SUNDAY_1000, expected: null,
126
+ },
127
+ {
128
+ name: '供应商段不同不命中',
129
+ rules: [ruleOf('x/*', 1)], model: 'y/a', date: MATCH_SUNDAY_1000, expected: null,
130
+ },
131
+ {
132
+ name: '模型段不同不命中',
133
+ rules: [ruleOf('*/a', 1)], model: 'x/b', date: MATCH_SUNDAY_1000, expected: null,
100
134
  },
101
135
  {
102
136
  name: '多条件 AND 全过命中',
103
- rules: [ruleOf('a', 1, [{ kind: 'weekdays', days: [0] }, { kind: 'dailyWindow', from: '00:00', to: '23:59' }])],
104
- model: 'a', date: MATCH_SUNDAY_1000, expected: priceOf(1),
137
+ rules: [ruleOf('x/a', 1, [{ kind: 'weekdays', days: [0] }, { kind: 'dailyWindow', from: '00:00', to: '23:59' }])],
138
+ model: 'x/a', date: MATCH_SUNDAY_1000, expected: priceOf(1),
105
139
  },
106
140
  {
107
141
  name: '一条件不过跳至下一条规则',
108
- rules: [ruleOf('a', 1, [{ kind: 'weekdays', days: [1] }]), ruleOf('a', 2, [])],
109
- model: 'a', date: MATCH_SUNDAY_1000, expected: priceOf(2),
142
+ rules: [ruleOf('x/a', 1, [{ kind: 'weekdays', days: [1] }]), ruleOf('x/a', 2, [])],
143
+ model: 'x/a', date: MATCH_SUNDAY_1000, expected: priceOf(2),
144
+ },
145
+ {
146
+ name: '高档条件不过逐层落低档',
147
+ rules: [ruleOf('x/a', 1, [{ kind: 'weekdays', days: [1] }]), ruleOf('*/a', 5)],
148
+ model: 'x/a', date: MATCH_SUNDAY_1000, expected: priceOf(5),
110
149
  },
111
150
  {
112
151
  name: '缺 conditions 形状残缺跳过',
113
- rules: [{ model: 'a', currency: '¥', price: priceOf(1) }],
114
- model: 'a', date: MATCH_SUNDAY_1000, expected: null,
152
+ rules: [{ model: 'x/a', currency: '¥', price: priceOf(1) }],
153
+ model: 'x/a', date: MATCH_SUNDAY_1000, expected: null,
115
154
  },
116
155
  {
117
156
  name: '缺 price 形状残缺跳过',
118
- rules: [{ model: 'a', currency: '¥', conditions: [] }],
119
- model: 'a', date: MATCH_SUNDAY_1000, expected: null,
157
+ rules: [{ model: 'x/a', currency: '¥', conditions: [] }],
158
+ model: 'x/a', date: MATCH_SUNDAY_1000, expected: null,
120
159
  },
121
160
  {
122
161
  name: 'unit 非 perMillion 跳过',
123
- rules: [{ ...ruleOf('a', 1), unit: 'perThousand' }],
124
- model: 'a', date: MATCH_SUNDAY_1000, expected: null,
162
+ rules: [{ ...ruleOf('x/a', 1), unit: 'perThousand' }],
163
+ model: 'x/a', date: MATCH_SUNDAY_1000, expected: null,
125
164
  },
126
165
  {
127
166
  name: 'unit 缺省视为 perMillion 命中',
128
- rules: [ruleOf('a', 1)],
129
- model: 'a', date: MATCH_SUNDAY_1000, expected: priceOf(1),
167
+ rules: [ruleOf('x/a', 1)],
168
+ model: 'x/a', date: MATCH_SUNDAY_1000, expected: priceOf(1),
130
169
  },
131
170
  {
132
171
  name: 'rules 非数组为 null',
133
- rules: 'nope', model: 'a', date: MATCH_SUNDAY_1000, expected: null,
172
+ rules: 'nope', model: 'x/a', date: MATCH_SUNDAY_1000, expected: null,
134
173
  },
135
174
  {
136
175
  name: 'model 非字符串为 null',
137
- rules: [ruleOf('a', 1)], model: 7, date: MATCH_SUNDAY_1000, expected: null,
176
+ rules: [ruleOf('x/a', 1)], model: 7, date: MATCH_SUNDAY_1000, expected: null,
138
177
  },
139
178
  {
140
179
  name: '非法时间戳为 null',
141
- rules: [ruleOf('a', 1)], model: 'a', date: null, expected: null,
180
+ rules: [ruleOf('x/a', 1)], model: 'x/a', date: null, expected: null,
142
181
  },
143
182
  {
144
183
  name: '条件不满足且无通配为 null',
145
- rules: [ruleOf('a', 1, [{ kind: 'weekdays', days: [1] }])],
146
- model: 'a', date: MATCH_SUNDAY_1000, expected: null,
184
+ rules: [ruleOf('x/a', 1, [{ kind: 'weekdays', days: [1] }])],
185
+ model: 'x/a', date: MATCH_SUNDAY_1000, expected: null,
147
186
  },
148
187
  ]
149
188
 
@@ -221,6 +260,30 @@ test(`matchPrice 双侧一致(${MATCH_VECTORS.length} 向量)`, () => {
221
260
  }
222
261
  })
223
262
 
263
+ const SEGMENT_VECTORS = [
264
+ { name: '两段合法', model: 'a/b', expected: true },
265
+ { name: '通配段合法', model: '*/*', expected: true },
266
+ { name: '多级模型名合法', model: 'a/b/c', expected: true },
267
+ { name: '单段非法', model: 'a', expected: false },
268
+ { name: '通配单段非法', model: '*', expected: false },
269
+ { name: '首位斜杠非法', model: '/b', expected: false },
270
+ { name: '空串非法', model: '', expected: false },
271
+ { name: '模型段空非法', model: 'a/', expected: false },
272
+ { name: '段含前导空白非法', model: ' /b', expected: false },
273
+ { name: '段含尾随空白非法', model: 'a/ ', expected: false },
274
+ { name: '段内含空白非法', model: 'a /b', expected: false },
275
+ { name: '通配段含空白非法', model: '* /b', expected: false },
276
+ ]
277
+
278
+ test(`isTwoSegmentModel 双侧一致(${SEGMENT_VECTORS.length} 向量)`, () => {
279
+ for (const vector of SEGMENT_VECTORS) {
280
+ const host = hostIsTwoSegmentModel(vector.model)
281
+ const client = clientIsTwoSegmentModel(vector.model)
282
+ assert.deepEqual(client, host, vector.name)
283
+ assert.equal(host, vector.expected, vector.name)
284
+ }
285
+ })
286
+
224
287
  test(`costOf 双侧一致(${COST_VECTORS.length} 向量)`, () => {
225
288
  for (const vector of COST_VECTORS) {
226
289
  const host = hostCostOf(vector.price, vector.buckets)