@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.
- package/README.md +13 -11
- package/package.json +1 -1
- package/src/client.js +588 -160
- package/src/collector.js +46 -12
- package/src/index.js +2 -2
- package/src/pricing.js +47 -9
- package/src/query.js +35 -3
- package/src/routes.js +3 -2
- package/src/store.js +123 -43
- package/test/client-eval.mjs +23 -0
- package/test/client-scope.test.mjs +60 -0
- package/test/client.test.mjs +253 -43
- package/test/collector.test.mjs +93 -0
- package/test/pricing-parity.test.mjs +99 -36
- package/test/pricing.test.mjs +112 -10
- package/test/query.test.mjs +53 -3
- package/test/routes.test.mjs +11 -7
- package/test/stats-line.test.mjs +8 -16
- package/test/store.test.mjs +145 -22
- package/test/turn-tail.test.mjs +12 -20
package/test/client.test.mjs
CHANGED
|
@@ -1,25 +1,23 @@
|
|
|
1
1
|
// client 纯函数层测试:滚动窗口映射/信封解析/上限裁剪/文案/格式化/分组/柱状几何
|
|
2
2
|
// Given/When/Then 场景内嵌于用例描述
|
|
3
|
-
// client.js
|
|
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
|
|
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('
|
|
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('
|
|
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('
|
|
117
|
-
assert.deepEqual(resolveMinuteRange('
|
|
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('
|
|
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('
|
|
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', '
|
|
128
|
-
assert.deepEqual(HOUR_PRESETS, ['24h', '
|
|
129
|
-
assert.deepEqual(MINUTE_PRESETS, ['
|
|
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', '
|
|
136
|
-
assert.equal(maxSlotsFor('hour', '
|
|
137
|
-
assert.equal(maxSlotsFor('
|
|
138
|
-
assert.equal(maxSlotsFor('minute', '
|
|
139
|
-
assert.equal(maxSlotsFor('minute', '
|
|
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'
|
|
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, [
|
|
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
|
+
})
|
package/test/collector.test.mjs
CHANGED
|
@@ -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()
|