@mzzsfy/dsh-usage-dash 0.1.0 → 0.3.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,25 +1,23 @@
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,
20
15
  CHART_PAD,
21
16
  DAY_MAX_SLOTS,
22
17
  DAY_PRESETS,
18
+ DEFAULT_HOUR_PRESET,
19
+ DEFAULT_MINUTE_PRESET,
20
+ DEFAULT_RANGE,
23
21
  DONUT_CENTER_XY,
24
22
  DONUT_CIRCUMFERENCE,
25
23
  DONUT_OUTER_RADIUS,
@@ -39,12 +37,16 @@ const {
39
37
  aggregateCurrencyOf,
40
38
  applyCurrencyToRules,
41
39
  cacheRateText,
40
+ coerceConditions,
41
+ coercePricingRules,
42
42
  costOf,
43
43
  costTitleText,
44
44
  createTranslator,
45
45
  daysInRange,
46
+ defaultCondition,
46
47
  defaultPricingRule,
47
48
  donutSegments,
49
+ pointStatsMatches,
48
50
  formatCompact,
49
51
  formatCost,
50
52
  formatDuration,
@@ -59,10 +61,12 @@ const {
59
61
  hourTickLabel,
60
62
  indexOfDay,
61
63
  isEmptyRange,
64
+ logTimeOf,
62
65
  matchPrice,
63
66
  maxSlotsFor,
64
67
  minuteTickLabel,
65
68
  modelSegmentLabel,
69
+ modelSpeedText,
66
70
  modelNameOf,
67
71
  niceTicks,
68
72
  otherDetailItems,
@@ -74,12 +78,18 @@ const {
74
78
  resolveMinuteRange,
75
79
  shortDay,
76
80
  smoothPath,
77
- statusLineActive,
78
81
  tipPlace,
79
82
  translateWith,
80
83
  trimSlots,
81
84
  trendLayout,
82
85
  trendRatePoints,
86
+ trendSpeedPoints,
87
+ speedTipText,
88
+ speedScaleMax,
89
+ legendToggle,
90
+ leftAxisTicks,
91
+ hourSlotLabel,
92
+ minuteSlotLabel,
83
93
  validatePricingRules,
84
94
  } = core
85
95
 
@@ -91,7 +101,7 @@ const NOW = new Date(2026, 2, 15, 14, 30, 45)
91
101
 
92
102
  test('day 预设映射为最近 N 天闭区间', () => {
93
103
  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' })
104
+ assert.deepEqual(resolveDayRange('30', NOW), { from: '2026-02-14', to: '2026-03-15' })
95
105
  })
96
106
 
97
107
  test('day 预设跨年与跨月边界', () => {
@@ -105,7 +115,7 @@ test('day 90 天预设起点回卷上年', () => {
105
115
 
106
116
  test('hour 预设 now-N 舍入到整点桶起点', () => {
107
117
  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' })
118
+ assert.deepEqual(resolveHourRange('3d', NOW), { from: '2026-03-12T14', to: '2026-03-15T14' })
109
119
  })
110
120
 
111
121
  test('hour 预设跨日且窗口起点落整点', () => {
@@ -113,30 +123,60 @@ test('hour 预设跨日且窗口起点落整点', () => {
113
123
  })
114
124
 
115
125
  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' })
126
+ assert.deepEqual(resolveMinuteRange('3h', NOW), { from: '2026-03-15T11:30', to: '2026-03-15T14:30' })
127
+ assert.deepEqual(resolveMinuteRange('24h', NOW), { from: '2026-03-14T14:30', to: '2026-03-15T14:30' })
118
128
  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' })
129
+ assert.deepEqual(resolveMinuteRange('3h', offGrid), { from: '2026-03-15T11:30', to: '2026-03-15T14:37' })
120
130
  })
121
131
 
122
132
  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' })
133
+ assert.deepEqual(resolveMinuteRange('3h', new Date(2026, 2, 15, 14, 30, 0)), { from: '2026-03-15T11:30', to: '2026-03-15T14:30' })
124
134
  })
125
135
 
126
136
  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'])
137
+ assert.deepEqual(DAY_PRESETS, ['7', '30', '90'])
138
+ assert.deepEqual(HOUR_PRESETS, ['24h', '3d', '7d', '15d'])
139
+ assert.deepEqual(MINUTE_PRESETS, ['3h', '24h', '3d', '7d'])
140
+ })
141
+
142
+ test('默认挡位均在其挡位列表内,分钟窗口可解析', () => {
143
+ assert.ok(DAY_PRESETS.includes(DEFAULT_RANGE))
144
+ assert.ok(HOUR_PRESETS.includes(DEFAULT_HOUR_PRESET))
145
+ assert.ok(MINUTE_PRESETS.includes(DEFAULT_MINUTE_PRESET))
146
+ assert.notEqual(resolveMinuteRange(DEFAULT_MINUTE_PRESET, NOW), null)
147
+ })
148
+
149
+ test('时/分挡位文案键逐挡齐备,含跨表同 id 挡位', () => {
150
+ // Given 时/分挡位表存在同 id('24h') When 逐挡查词典 Then 两语言均有对应键,漏配即回退键名
151
+ for (const id of HOUR_PRESETS) {
152
+ assert.notEqual(MESSAGES_ZH[`hourPreset.${id}`], undefined, `zh hourPreset.${id}`)
153
+ assert.notEqual(MESSAGES_EN[`hourPreset.${id}`], undefined, `en hourPreset.${id}`)
154
+ }
155
+ for (const id of MINUTE_PRESETS) {
156
+ assert.notEqual(MESSAGES_ZH[`minutePreset.${id}`], undefined, `zh minutePreset.${id}`)
157
+ assert.notEqual(MESSAGES_EN[`minutePreset.${id}`], undefined, `en minutePreset.${id}`)
158
+ }
159
+ })
160
+
161
+ test('pointStats 缓存命中须视图与挡位双匹配', () => {
162
+ // Given 同挡位 id 跨视图('24h') When 判定缓存命中 Then 视图不同即不命中,防误用他端点数据
163
+ const cached = { view: 'hour', preset: '24h', value: { daily: [] } }
164
+ assert.equal(pointStatsMatches(cached, 'hour', '24h'), true)
165
+ assert.equal(pointStatsMatches(cached, 'minute', '24h'), false)
166
+ assert.equal(pointStatsMatches(cached, 'hour', '3d'), false)
167
+ assert.equal(pointStatsMatches(null, 'hour', '24h'), false)
130
168
  })
131
169
 
132
170
  test('每视图渲染上限等于闭区间桶数', () => {
133
171
  assert.equal(maxSlotsFor('day', '90'), DAY_MAX_SLOTS)
134
172
  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)
173
+ assert.equal(maxSlotsFor('hour', '3d'), 73)
174
+ assert.equal(maxSlotsFor('hour', '7d'), 169)
175
+ assert.equal(maxSlotsFor('hour', '15d'), 361)
176
+ assert.equal(maxSlotsFor('minute', '3h'), 19)
177
+ assert.equal(maxSlotsFor('minute', '24h'), 145)
178
+ assert.equal(maxSlotsFor('minute', '3d'), 433)
179
+ assert.equal(maxSlotsFor('minute', '7d'), 1009)
140
180
  })
141
181
 
142
182
  test('trim 超上限裁最旧且恰好达上限不裁', () => {
@@ -145,6 +185,11 @@ test('trim 超上限裁最旧且恰好达上限不裁', () => {
145
185
  assert.equal(trimSlots(slots, 6), slots)
146
186
  })
147
187
 
188
+ test('日志条目时刻补零为 HH:mm:ss', () => {
189
+ assert.equal(logTimeOf(new Date(2026, 8, 8, 9, 3, 7).getTime()), '09:03:07')
190
+ assert.equal(logTimeOf(new Date(2026, 8, 8, 19, 13, 47).getTime()), '19:13:47')
191
+ })
192
+
148
193
  test('信封解析成功透传 value', () => {
149
194
  assert.deepEqual(parseEnvelope({ ok: true, value: { a: 1 } }), { ok: true, value: { a: 1 } })
150
195
  })
@@ -177,19 +222,11 @@ test('translateWith zh/en 占位替换与缺键回退键名', () => {
177
222
  test('createTranslator 与纯查表同构', () => {
178
223
  const enT = createTranslator(MESSAGES_EN)
179
224
  assert.equal(enT('rangeCustom'), 'Custom')
180
- assert.equal(enT('hourPreset', { n: 48 }), 'Last 48 hours')
225
+ assert.equal(enT('hourPreset.15d'), '15 days')
226
+ assert.equal(enT('minutePreset.7d'), '7 days')
181
227
  assert.equal(enT('missing.key'), 'missing.key')
182
228
  })
183
229
 
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
230
  test('zh/en 词典键集完全一致', () => {
194
231
  assert.deepEqual(Object.keys(MESSAGES_ZH).sort(), Object.keys(MESSAGES_EN).sort())
195
232
  })
@@ -300,23 +337,53 @@ test('trendLayout 单槽列距铺满且柱宽取上限', () => {
300
337
  assert.equal(layout.maxTotal, 50)
301
338
  })
302
339
 
303
- test('trendLayout 零值槽无分段但保留刻度', () => {
340
+ test('trendLayout 零值槽无分段,无可见数据时刻度为空表', () => {
304
341
  const layout = trendLayout([{ day: '2026-03-15', total: 0, byModel: {} }], [], 720, 46)
305
342
  assert.deepEqual(layout.bars[0].segments, [])
306
- assert.deepEqual(layout.ticks, [0.5, 1])
343
+ assert.deepEqual(layout.ticks, [])
307
344
  })
308
345
 
309
- test('trendLayout 堆叠分段自底向上且高度求和等于绘图高', () => {
346
+ test('trendLayout 堆叠分段自底向上,轴上限为数据峰乘留白系数', () => {
310
347
  const layout = trendLayout([{ day: 'd', total: 50, byModel: { a: 30, b: 20 } }], ['a', 'b'], 720, 46)
311
348
  const [segA, segB] = layout.bars[0].segments
312
349
  assert.deepEqual(segA.model, 'a')
313
350
  assert.deepEqual(segB.model, 'b')
314
- approx(segA.height, (30 / 50) * 184)
315
- approx(segB.height, (20 / 50) * 184)
316
- approx(segA.height + segB.height, 184)
351
+ approx(segA.height, (30 / (50 * 1.1)) * 184)
352
+ approx(segB.height, (20 / (50 * 1.1)) * 184)
353
+ approx(segA.height + segB.height, (50 / (50 * 1.1)) * 184)
317
354
  approx(segB.y + segB.height, segA.y)
318
355
  })
319
356
 
357
+ test('trendLayout 数据峰不顶满绘图区,留白系数 1.1', () => {
358
+ const layout = trendLayout([{ day: 'd', total: 30, byModel: { a: 30 } }], ['a'], 720, 46)
359
+ const [segA] = layout.bars[0].segments
360
+ approx(segA.height + CHART_PAD.top, CHART_PAD.top + (1 / 1.1) * 184)
361
+ })
362
+
363
+ test('trendLayout 可见模型无数据时左轴刻度为空表', () => {
364
+ const slots = [{ day: 'd', total: 50, byModel: { a: 50 } }]
365
+ assert.deepEqual(trendLayout(slots, [], 720, 46).ticks, [])
366
+ assert.deepEqual(trendLayout(slots, ['b'], 720, 46).ticks, [])
367
+ assert.ok(trendLayout(slots, ['a'], 720, 46).ticks.length > 0)
368
+ })
369
+
370
+ test('leftAxisTicks 速度模式标定速度刻度,否则标定 token 刻度', () => {
371
+ const tokenTicks = leftAxisTicks([100, 200], 200, [], 1)
372
+ assert.deepEqual(tokenTicks.map((tick) => tick.label), ['100', '200'])
373
+ approx(tokenTicks[0].ratio, 0.5)
374
+ const speedTicks = leftAxisTicks([], 1, [0.5, 1], 1)
375
+ assert.deepEqual(speedTicks.map((tick) => tick.label), ['0.5', '1'])
376
+ approx(speedTicks[0].ratio, 0.5)
377
+ approx(speedTicks[1].ratio, 1)
378
+ })
379
+
380
+ test('hour/minute 悬浮窗槽标签:T 换空格,小时带 h 后缀,分钟保 HH:MM', () => {
381
+ assert.equal(hourSlotLabel('2026-03-15T14'), '2026-03-15 14h')
382
+ assert.equal(hourSlotLabel('2026-03-15T00'), '2026-03-15 00h')
383
+ assert.equal(minuteSlotLabel('2026-03-15T14:30'), '2026-03-15 14:30')
384
+ assert.equal(minuteSlotLabel('2026-03-15T00:00'), '2026-03-15 00:00')
385
+ })
386
+
320
387
  test('trendLayout 标签密度随列距收敛', () => {
321
388
  const slots = Array.from({ length: 100 }, (_, i) => ({ day: `d${i}`, total: 0, byModel: {} }))
322
389
  const layout = trendLayout(slots, [], 720, 46)
@@ -453,6 +520,61 @@ test('trendRatePoints 点映射列中心与百分比高度且零数据日跳过'
453
520
  approx(points[1].y, CHART_PAD.top + plotHeight - 0.5 * plotHeight)
454
521
  })
455
522
 
523
+ test('trendSpeedPoints 点映射列中心与刻度上限比例高度且无速度槽跳过', () => {
524
+ const slots = [
525
+ { day: 'd0', speed: 50 },
526
+ { day: 'd1', total: 10 },
527
+ { day: 'd2', speed: 25 },
528
+ ]
529
+ const bars = [{ x: 10 }, { x: 20 }, { x: 30 }]
530
+ const plotHeight = CHART_HEIGHT - CHART_PAD.top - CHART_PAD.bottom
531
+ const points = trendSpeedPoints(slots, bars, plotHeight, 100)
532
+ assert.equal(points.length, 2)
533
+ assert.equal(points[0].day, 'd0')
534
+ assert.equal(points[0].x, 10)
535
+ approx(points[0].y, CHART_PAD.top + plotHeight - 0.5 * plotHeight)
536
+ assert.equal(points[1].day, 'd2')
537
+ assert.equal(points[1].x, 30)
538
+ approx(points[1].y, CHART_PAD.top + plotHeight - 0.25 * plotHeight)
539
+ })
540
+
541
+ test('speedTipText 无速度为占位符有速度为官方吞吐口径', () => {
542
+ assert.equal(speedTipText(undefined), '—')
543
+ assert.equal(speedTipText(5.5556), '5.6 tok/s')
544
+ assert.equal(speedTipText(12.34), '12 tok/s')
545
+ })
546
+
547
+ test('legendToggle 普通点击单选,再点恢复全部,ctrl 多选且禁全藏', () => {
548
+ // null 表示全部可见
549
+ assert.deepEqual(legendToggle(null, 'a', false), new Set(['a']))
550
+ // 单选态再点同项恢复全部(null)
551
+ assert.equal(legendToggle(new Set(['a']), 'a', false), null)
552
+ // 单选态点他项 → 切换单选目标
553
+ assert.deepEqual(legendToggle(new Set(['a']), 'b', false), new Set(['b']))
554
+ // ctrl 切换单项:加入与移除
555
+ assert.deepEqual(legendToggle(new Set(['a']), 'b', true), new Set(['a', 'b']))
556
+ assert.deepEqual(legendToggle(new Set(['a', 'b']), 'b', true), new Set(['a']))
557
+ // ctrl 隐藏最后一项:无操作,保持原态
558
+ const solo = new Set(['a'])
559
+ assert.equal(legendToggle(solo, 'a', true), solo)
560
+ })
561
+
562
+ test('trendLayout 刻度跟随可见模型:单模型刻度按该槽内合计归一', () => {
563
+ const slots = [
564
+ { day: 'd0', total: 50, byModel: { a: 30, b: 20 } },
565
+ { day: 'd1', total: 90, byModel: { a: 40, b: 50 } },
566
+ ]
567
+ assert.equal(trendLayout(slots, ['a', 'b'], 720, 46).maxTotal, 90)
568
+ assert.equal(trendLayout(slots, ['a'], 720, 46).maxTotal, 40)
569
+ assert.equal(trendLayout(slots, [], 720, 46).maxTotal, 1)
570
+ })
571
+
572
+ test('speedScaleMax 全零或空槽钳底防除零,混合取最大速度', () => {
573
+ assert.equal(speedScaleMax([]), 1)
574
+ assert.equal(speedScaleMax([{ day: 'd0' }, { day: 'd1', speed: 0 }]), 1)
575
+ assert.equal(speedScaleMax([{ day: 'd0', speed: 30 }, { day: 'd1', speed: 12.5 }]), 30)
576
+ })
577
+
456
578
  test('smoothPath 空点集为空串单点为移动命令', () => {
457
579
  assert.equal(smoothPath([]), '')
458
580
  assert.equal(smoothPath([{ x: 1, y: 2 }]), 'M 1 2')
@@ -536,6 +658,13 @@ test('donut 分段可访问标签格式化名称数值与占比', () => {
536
658
  assert.equal(modelSegmentLabel('p/m', 1234, 12.34), 'p/m: 1,234 (12.3%)')
537
659
  })
538
660
 
661
+ test('模型速度文本:官方吞吐口径格式化,无速度为空串', () => {
662
+ assert.equal(modelSpeedText(5.5556), '5.6 tok/s')
663
+ assert.equal(modelSpeedText(12.34), '12 tok/s')
664
+ assert.equal(modelSpeedText(0), '0 tok/s')
665
+ assert.equal(modelSpeedText(undefined), '')
666
+ })
667
+
539
668
  // —— S14 费用格式化与展示辅助(镜像函数核心语义见 pricing-parity.test.mjs) ——
540
669
 
541
670
  test('formatCost 千分位与两位小数', () => {
@@ -551,10 +680,10 @@ test('formatCost 微观值四位小数', () => {
551
680
  })
552
681
 
553
682
  test('镜像函数基本行为:非法输入 null 与命中计价', () => {
554
- assert.equal(matchPrice(null, 'm', NOW), null)
555
- assert.equal(matchPrice([], 'm', NOW), null)
683
+ assert.equal(matchPrice(null, 'x/m', NOW), null)
684
+ assert.equal(matchPrice([], 'x/m', NOW), null)
556
685
  assert.deepEqual(
557
- matchPrice([{ model: 'm', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [] }], 'm', NOW),
686
+ matchPrice([{ model: 'x/m', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [] }], 'x/m', NOW),
558
687
  { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 },
559
688
  )
560
689
  assert.equal(costOf({ input: 2, output: 0, cacheRead: 0, cacheWrite: 0 }, { inputTokens: 1000 * 1000 }), 2)
@@ -594,17 +723,23 @@ test('defaultPricingRule 承接给定货币,缺省回落首档', () => {
594
723
  assert.deepEqual(defaultPricingRule('$').conditions, [])
595
724
  })
596
725
 
597
- test('validatePricingRules 就地校验 model 必填与价格非空非负', () => {
726
+ test('validatePricingRules 就地校验 model 必填两段式与价格非空非负', () => {
598
727
  const valid = [{ model: 'p/m', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [] }]
599
728
  assert.equal(validatePricingRules(valid).size, 0)
600
729
  const broken = [
601
730
  { model: ' ', currency: '¥', price: { input: '', output: -1, cacheRead: 0, cacheWrite: 0 }, conditions: [] },
731
+ { model: 'solo', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [] },
732
+ { model: '*', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [] },
733
+ { model: '/x', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [] },
602
734
  ]
603
735
  const errors = validatePricingRules(broken)
604
736
  assert.equal(errors.get('0.model'), 'required')
605
737
  assert.equal(errors.get('0.price.input'), 'required')
606
738
  assert.equal(errors.get('0.price.output'), 'priceInvalid')
607
739
  assert.equal(errors.has('0.price.cacheRead'), false)
740
+ assert.equal(errors.get('1.model'), 'modelFormat')
741
+ assert.equal(errors.get('2.model'), 'modelFormat')
742
+ assert.equal(errors.get('3.model'), 'modelFormat')
608
743
  })
609
744
 
610
745
  test('costTitleText 未计价计数后缀', () => {
@@ -612,3 +747,78 @@ test('costTitleText 未计价计数后缀', () => {
612
747
  assert.equal(costTitleText(zhT, 0), '按当前费率对历史用量估算,精度为小时级')
613
748
  assert.equal(costTitleText(zhT, 3), '按当前费率对历史用量估算,精度为小时级,3 个小时桶未计价')
614
749
  })
750
+
751
+ test('defaultCondition 各类型默认值即填即用', () => {
752
+ // Given 四种条件类型 When 取默认 Then 时段全天/周几空/号段全月/日期段当天
753
+ assert.deepEqual(defaultCondition('dailyWindow'), { kind: 'dailyWindow', from: '00:00', to: '00:00' })
754
+ assert.deepEqual(defaultCondition('weekdays'), { kind: 'weekdays', days: [] })
755
+ assert.deepEqual(defaultCondition('monthDays'), { kind: 'monthDays', from: 1, to: 31 })
756
+ assert.deepEqual(defaultCondition('dateRange', new Date(2026, 2, 15)), { kind: 'dateRange', from: '2026-03-15', to: '2026-03-15' })
757
+ })
758
+
759
+ test('validatePricingRules 拒绝非法时刻与时刻字段缺失', () => {
760
+ // Given dailyWindow 时刻超界或缺失 When 校验 Then 标 condTime/required
761
+ const rules = [
762
+ { model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'dailyWindow', from: '99:99', to: '08:00' }] },
763
+ { model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'dailyWindow', from: '', to: '08:00' }] },
764
+ { model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'dailyWindow', from: '23:59', to: '24:00' }] },
765
+ ]
766
+ const errors = validatePricingRules(rules)
767
+ assert.equal(errors.get('0.conditions.0.from'), 'condTime')
768
+ assert.equal(errors.get('1.conditions.0.from'), 'required')
769
+ assert.equal(errors.get('2.conditions.0.to'), 'condTime')
770
+ })
771
+
772
+ test('validatePricingRules 校验周几与月号段边界', () => {
773
+ // Given weekdays 含 7、monthDays 含 0 或 32 When 校验 Then 标 condWeekday/condMonthDay
774
+ const rules = [
775
+ { model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'weekdays', days: [0, 7] }] },
776
+ { model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'monthDays', from: 0, to: 31 }] },
777
+ { model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'monthDays', from: 1, to: 32 }] },
778
+ ]
779
+ const errors = validatePricingRules(rules)
780
+ assert.equal(errors.get('0.conditions.0.days'), 'condWeekday')
781
+ assert.equal(errors.get('1.conditions.0.from'), 'condMonthDay')
782
+ assert.equal(errors.get('2.conditions.0.to'), 'condMonthDay')
783
+ })
784
+
785
+ test('validatePricingRules 校验日期段格式与倒序', () => {
786
+ // Given dateRange 非规范日期或倒序 When 校验 Then 标 condDate/condRange
787
+ const rules = [
788
+ { model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'dateRange', from: '2026-3-5', to: '2026-03-08' }] },
789
+ { model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'dateRange', from: '2026-03-08', to: '2026-03-05' }] },
790
+ { model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'dateRange', from: '2026-03-05', to: '2026-03-08' }] },
791
+ ]
792
+ const errors = validatePricingRules(rules)
793
+ assert.equal(errors.get('0.conditions.0.from'), 'condDate')
794
+ assert.equal(errors.get('1.conditions.0'), 'condRange')
795
+ assert.equal(errors.has('2.conditions.0'), false)
796
+ assert.equal(errors.has('2.conditions.0.from'), false)
797
+ })
798
+
799
+ test('coerceConditions 规整数字字段且不改其余字段', () => {
800
+ // Given 输入框字符串形态 When 规整 Then weekdays days 与 monthDays 号段转数字,时段日期原样
801
+ const conditions = [
802
+ { kind: 'weekdays', days: ['1', '5'] },
803
+ { kind: 'monthDays', from: '26', to: '5' },
804
+ { kind: 'dailyWindow', from: '09:00', to: '18:00' },
805
+ { kind: 'dateRange', from: '2026-03-01', to: '2026-03-15' },
806
+ ]
807
+ const coerced = coerceConditions(conditions)
808
+ assert.deepEqual(coerced[0], { kind: 'weekdays', days: [1, 5] })
809
+ assert.deepEqual(coerced[1], { kind: 'monthDays', from: 26, to: 5 })
810
+ assert.deepEqual(coerced[2], conditions[2])
811
+ assert.deepEqual(coerced[3], conditions[3])
812
+ })
813
+
814
+ test('coercePricingRules 连带规整条件', () => {
815
+ // Given 含条件规则 When POST 前规整 Then 价格与条件数字字段同时规整
816
+ const rules = [{
817
+ model: 'a/b', currency: '¥',
818
+ price: { input: '1', output: '0', cacheRead: '0', cacheWrite: '0' },
819
+ conditions: [{ kind: 'weekdays', days: ['1'] }, { kind: 'monthDays', from: '1', to: '31' }],
820
+ }]
821
+ const coerced = coercePricingRules(rules)
822
+ assert.deepEqual(coerced[0].conditions, [{ kind: 'weekdays', days: [1] }, { kind: 'monthDays', from: 1, to: 31 }])
823
+ assert.deepEqual(coerced[0].price, { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 })
824
+ })
@@ -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()