@mzzsfy/dsh-usage-dash 0.4.0 → 0.6.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 +4 -4
- package/package.json +1 -1
- package/src/client.js +305 -79
- package/src/collector.js +117 -17
- package/src/query.js +38 -14
- package/src/store.js +16 -2
- package/test/client.test.mjs +201 -25
- package/test/collector.test.mjs +306 -11
- package/test/query.test.mjs +48 -0
- package/test/store.test.mjs +58 -0
- package/test/stream-parity.test.mjs +75 -0
- package/test/turn-tail.test.mjs +66 -0
package/src/collector.js
CHANGED
|
@@ -35,13 +35,57 @@ function emptySkipCounts() {
|
|
|
35
35
|
return { [SKIP_KINDS.descriptor]: 0, [SKIP_KINDS.corrupt]: 0, [SKIP_KINDS.legacy]: 0, [SKIP_KINDS.other]: 0 }
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// 官方 dsh-llm 助手流读取器同构镜像(isTokenDelta/runFirstTokenTime/
|
|
39
|
+
// assistantStreamFirstTokenTime):从紧凑记录还原首个产出 token 的时刻。
|
|
40
|
+
// 语义由 test/stream-parity.test.mjs 锁定,改一侧必须同步 parity
|
|
41
|
+
function isTokenDelta(chunk) {
|
|
42
|
+
if (chunk?.type === 'text-delta' || chunk?.type === 'reasoning-delta') return chunk.text !== ''
|
|
43
|
+
return chunk?.type === 'tool-call-delta' && (chunk.argumentsDelta !== '' || chunk.name !== undefined)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function runFirstTokenTime(run) {
|
|
47
|
+
if (run.type === 'tool-call-chunks' && run.name !== undefined) return run.time0
|
|
48
|
+
const fragments = run.type === 'tool-call-chunks' ? run.args : run.texts
|
|
49
|
+
let time = run.time0
|
|
50
|
+
for (let index = 0; index < fragments.length; index += 1) {
|
|
51
|
+
if (index > 0) {
|
|
52
|
+
const gap = run.dt[index - 1]
|
|
53
|
+
// 差分短缺属存储形态损坏,按无首 token 处理,保留后续报告锁定机会
|
|
54
|
+
if (typeof gap !== 'number') return undefined
|
|
55
|
+
time += gap
|
|
56
|
+
}
|
|
57
|
+
if (fragments[index] !== '') return time
|
|
58
|
+
}
|
|
59
|
+
return undefined
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 已知 packed run 形态白名单:未知记录形态(宿主未来新字段)安全跳过,
|
|
63
|
+
// 采集是观测性的,绝不因流记录形态漂移而崩溃
|
|
64
|
+
const RUN_RECORD_TYPES = new Set(['text-chunks', 'reasoning-chunks', 'tool-call-chunks'])
|
|
65
|
+
|
|
66
|
+
export function assistantStreamFirstTokenTime(stream) {
|
|
67
|
+
if (!Array.isArray(stream)) return undefined
|
|
68
|
+
for (const record of stream) {
|
|
69
|
+
const time = record?.type === 'chunk'
|
|
70
|
+
? (isTokenDelta(record.chunk) ? record.time : undefined)
|
|
71
|
+
: RUN_RECORD_TYPES.has(record?.type) ? runFirstTokenTime(record) : undefined
|
|
72
|
+
if (time !== undefined) return time
|
|
73
|
+
}
|
|
74
|
+
return undefined
|
|
75
|
+
}
|
|
76
|
+
|
|
38
77
|
// 会话内单 pass 折叠:跟踪每个 (turn,step) 槽的最新报告,只把首次发射交 store
|
|
39
78
|
export class UsageFold {
|
|
40
79
|
constructor() {
|
|
41
80
|
this.seen = new Map()
|
|
42
|
-
// (turn,step)
|
|
43
|
-
//
|
|
81
|
+
// (turn,step) 模型启动时刻:仅 step/start 设定(官方口径 TTFT 含失败尝试,
|
|
82
|
+
// 不随 retry-started 重置);(turn,step) 首 token 时刻:由首个产出 token
|
|
83
|
+
// 的 attempt 锁定,存活于步内重试。时长均为官方 decode 口径:durationMs
|
|
84
|
+
// = 汇报 - 首 token(吞吐分母),ttftMs = 首 token - 启动
|
|
44
85
|
this.starts = new Map()
|
|
86
|
+
this.firstTokens = new Map()
|
|
87
|
+
// 已补发 timing 的键:token 先发后只补一次,重复报告不重复配对
|
|
88
|
+
this.timingDone = new Set()
|
|
45
89
|
}
|
|
46
90
|
|
|
47
91
|
keyOf(event) {
|
|
@@ -64,9 +108,9 @@ export class UsageFold {
|
|
|
64
108
|
}
|
|
65
109
|
if (event.type === 'step/start' || event.type === 'llm/retry-started') {
|
|
66
110
|
// step/start 恰开一次模型调用,retry-started 标记每次实际启动的重试;
|
|
67
|
-
// 请求只由标记计数,与 token
|
|
111
|
+
// 请求只由标记计数,与 token 样本双计;时长起点不随重试重置
|
|
68
112
|
const key = this.keyOf(event)
|
|
69
|
-
if (key !== null) this.starts.set(key, event.time)
|
|
113
|
+
if (event.type === 'step/start' && key !== null) this.starts.set(key, event.time)
|
|
70
114
|
return {
|
|
71
115
|
time: event.time,
|
|
72
116
|
inputTokens: 0,
|
|
@@ -76,6 +120,15 @@ export class UsageFold {
|
|
|
76
120
|
request: true,
|
|
77
121
|
}
|
|
78
122
|
}
|
|
123
|
+
if (event.type === 'assistant/attempt') {
|
|
124
|
+
// 首 token 锁定:仅首个产出 token 的 attempt 生效,后续 attempt 不覆盖
|
|
125
|
+
const key = this.keyOf(event)
|
|
126
|
+
if (key !== null && !this.firstTokens.has(key)) {
|
|
127
|
+
const first = assistantStreamFirstTokenTime(event.data?.stream)
|
|
128
|
+
if (typeof first === 'number') this.firstTokens.set(key, first)
|
|
129
|
+
}
|
|
130
|
+
return null
|
|
131
|
+
}
|
|
79
132
|
if (event.type === 'assistant/chunk') {
|
|
80
133
|
const usage = event.data?.chunk?.type === 'usage' ? event.data.chunk.usage : undefined
|
|
81
134
|
return usage ? this.replaceSample(event, usage) : null
|
|
@@ -87,11 +140,39 @@ export class UsageFold {
|
|
|
87
140
|
}
|
|
88
141
|
|
|
89
142
|
replaceSample(event, usage) {
|
|
90
|
-
|
|
143
|
+
const key = this.keyOf(event)
|
|
144
|
+
const first = key !== null
|
|
145
|
+
? this.firstTokens.get(key) ?? assistantStreamFirstTokenTime(event.data?.stream)
|
|
146
|
+
: assistantStreamFirstTokenTime(event.data?.stream)
|
|
147
|
+
// decode 配对有效性对齐官方 usageOutputTokens 守卫:输出 token 非有效数值
|
|
148
|
+
// 不建配对(官方同款),首字延迟不受 usage 影响照常采集;
|
|
149
|
+
// 负差值按官方 Math.max(0) 钳 0(时钟回拨计 0 延迟样本),0 时长不附配对
|
|
150
|
+
// (存储侧 0 时长配对天然惰性,防 0 分母放大)
|
|
151
|
+
const decodeable = typeof usage.outputTokens === 'number'
|
|
152
|
+
&& Number.isFinite(usage.outputTokens) && usage.outputTokens >= 0
|
|
153
|
+
const timing = typeof first === 'number'
|
|
154
|
+
? {
|
|
155
|
+
durationMs: Math.max(0, event.time - first),
|
|
156
|
+
ttftMs: key !== null && this.starts.has(key) ? Math.max(0, first - this.starts.get(key)) : undefined,
|
|
157
|
+
decodeable,
|
|
158
|
+
}
|
|
159
|
+
: undefined
|
|
160
|
+
const prev = key !== null ? this.seen.get(key) : undefined
|
|
161
|
+
// 首样本生效:内部无条件跟踪最新报告,但交 store 的只有首次发射;
|
|
162
|
+
// 首 token 时刻不在 chunk 事件上,token 先发后由后续报告补纯 timing 增量,
|
|
163
|
+
// 已补发的键不重复补(timingDone 独立集合持久跟踪,防重复报告双计);
|
|
164
|
+
// 补发判定先于四桶和门:usage 全零的报告仍补 timing(token 已由首发承载)
|
|
165
|
+
if (prev) {
|
|
166
|
+
if (timing === undefined || this.timingDone.has(key)) return null
|
|
167
|
+
this.timingDone.add(key)
|
|
168
|
+
return this.emitTimingOnly(usage, timing, event.time)
|
|
169
|
+
}
|
|
170
|
+
// 四桶全零为噪声(不占去重键),纯缓存调用(仅缓存桶非零)仍有效。
|
|
171
|
+
// retry 场景 attempt 先于 chunk 落流时,chunk 首发即带 timing,分母以
|
|
172
|
+
// chunk 时刻近似官方汇报时刻(毫秒级组装间隔),主路径仍由 message 补发
|
|
91
173
|
const sum = (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0)
|
|
92
174
|
+ (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0)
|
|
93
175
|
if (sum <= 0) return null
|
|
94
|
-
const key = this.keyOf(event)
|
|
95
176
|
const sample = {
|
|
96
177
|
time: event.time,
|
|
97
178
|
inputTokens: usage.inputTokens ?? 0,
|
|
@@ -99,18 +180,37 @@ export class UsageFold {
|
|
|
99
180
|
cacheReadTokens: usage.cacheReadTokens ?? 0,
|
|
100
181
|
cacheWriteTokens: usage.cacheWriteTokens ?? 0,
|
|
101
182
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
183
|
+
if (key !== null) this.seen.set(key, sample)
|
|
184
|
+
return this.emitSample(sample, timing, key)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
emitSample(sample, timing, key) {
|
|
188
|
+
if (timing === undefined) return { ...sample }
|
|
189
|
+
// decode 口径聚合对:分子 decodeTokens 与分母 durationMs 同源配对
|
|
190
|
+
if (timing.decodeable && timing.durationMs > 0) {
|
|
191
|
+
sample.decodeTokens = sample.outputTokens
|
|
192
|
+
sample.durationMs = timing.durationMs
|
|
193
|
+
}
|
|
194
|
+
if (timing.ttftMs !== undefined) sample.ttftMs = timing.ttftMs
|
|
195
|
+
if (key !== undefined && key !== null) this.timingDone.add(key)
|
|
196
|
+
return { ...sample }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
emitTimingOnly(usage, timing, time) {
|
|
200
|
+
// 纯 timing 增量:token 桶全零不重复计数,decodeTokens 单独承载速度分子
|
|
201
|
+
const delta = {
|
|
202
|
+
time,
|
|
203
|
+
inputTokens: 0,
|
|
204
|
+
outputTokens: 0,
|
|
205
|
+
cacheReadTokens: 0,
|
|
206
|
+
cacheWriteTokens: 0,
|
|
207
|
+
}
|
|
208
|
+
if (timing.decodeable && timing.durationMs > 0) {
|
|
209
|
+
delta.decodeTokens = usage.outputTokens
|
|
210
|
+
delta.durationMs = timing.durationMs
|
|
108
211
|
}
|
|
109
|
-
if (
|
|
110
|
-
|
|
111
|
-
this.seen.set(key, sample)
|
|
112
|
-
// 首样本生效:内部无条件跟踪最新报告,但交 store 的只有首次发射的独立拷贝
|
|
113
|
-
return prev ? null : { ...sample }
|
|
212
|
+
if (timing.ttftMs !== undefined) delta.ttftMs = timing.ttftMs
|
|
213
|
+
return delta
|
|
114
214
|
}
|
|
115
215
|
}
|
|
116
216
|
|
package/src/query.js
CHANGED
|
@@ -98,6 +98,10 @@ const percentOf = (part, total) => (total === 0 ? 0 : (part / total) * PERCENT_S
|
|
|
98
98
|
|
|
99
99
|
const rowTokens = (row) => row.inputTokens + row.outputTokens + row.cacheReadTokens + row.cacheWriteTokens
|
|
100
100
|
|
|
101
|
+
// 速度配对分子:decode 口径取 decodeTokens;存量旧格式行(带时长无 decodeTokens)
|
|
102
|
+
// 回落 outputTokens,聚合随新数据自然收敛
|
|
103
|
+
const speedTokensOf = (row) => (row.durationMs ? row.decodeTokens ?? row.outputTokens : 0)
|
|
104
|
+
|
|
101
105
|
export function aggregateRange(rows, g, from, to) {
|
|
102
106
|
const form = BUCKET_FORMS[g]
|
|
103
107
|
const slots = enumerateBucketKeys(form)(from, to).map((key) => emptySlot(key))
|
|
@@ -105,43 +109,59 @@ export function aggregateRange(rows, g, from, to) {
|
|
|
105
109
|
const modelTotals = new Map()
|
|
106
110
|
const providerTotals = new Map()
|
|
107
111
|
const activeBuckets = new Set()
|
|
108
|
-
//
|
|
112
|
+
// 槽级配对:桶串 → 速度对 {decodeTokens, durationMs} 与首字对 {ttftMs, ttftSteps},
|
|
113
|
+
// 与模型级同口径(仅带配对数据的行计入)
|
|
109
114
|
const slotSpeeds = new Map()
|
|
115
|
+
const slotTtfts = new Map()
|
|
110
116
|
for (const row of rows) {
|
|
111
117
|
const slot = slotByKey.get(row.bucket)
|
|
112
118
|
// 桶串未落在枚举序列(如改粒度前的历史残行)不可归属,跳过防崩
|
|
113
119
|
if (!slot) continue
|
|
114
120
|
const tokens = rowTokens(row)
|
|
115
121
|
addRowToSlot(slot, row, tokens)
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
122
|
+
// 纯 timing 行(零 token 桶 + decode 配对)不参与归因,但仍进配对聚合
|
|
123
|
+
if (tokens > 0) {
|
|
124
|
+
activeBuckets.add(row.bucket)
|
|
125
|
+
slot.byModel[row.model] = (slot.byModel[row.model] ?? 0) + tokens
|
|
126
|
+
slot.byProvider[row.provider] = (slot.byProvider[row.provider] ?? 0) + tokens
|
|
127
|
+
}
|
|
120
128
|
if (row.durationMs) {
|
|
121
|
-
const pair = slotSpeeds.get(row.bucket) ?? {
|
|
122
|
-
pair.
|
|
129
|
+
const pair = slotSpeeds.get(row.bucket) ?? { decodeTokens: 0, durationMs: 0 }
|
|
130
|
+
pair.decodeTokens += speedTokensOf(row)
|
|
123
131
|
pair.durationMs += row.durationMs
|
|
124
132
|
slotSpeeds.set(row.bucket, pair)
|
|
125
133
|
}
|
|
134
|
+
if (row.ttftSteps > 0) {
|
|
135
|
+
const pair = slotTtfts.get(row.bucket) ?? { ttftMs: 0, ttftSteps: 0 }
|
|
136
|
+
pair.ttftMs += row.ttftMs ?? 0
|
|
137
|
+
pair.ttftSteps += row.ttftSteps
|
|
138
|
+
slotTtfts.set(row.bucket, pair)
|
|
139
|
+
}
|
|
126
140
|
const modelTotal = modelTotals.get(row.model)
|
|
127
141
|
if (modelTotal) {
|
|
128
142
|
modelTotal.tokens += tokens
|
|
129
143
|
modelTotal.speedDurationMs += row.durationMs ?? 0
|
|
130
|
-
modelTotal.speedOutputTokens += row
|
|
144
|
+
modelTotal.speedOutputTokens += speedTokensOf(row)
|
|
145
|
+
modelTotal.ttftMs += row.ttftMs ?? 0
|
|
146
|
+
modelTotal.ttftSteps += row.ttftSteps ?? 0
|
|
131
147
|
} else {
|
|
132
148
|
modelTotals.set(row.model, {
|
|
133
149
|
provider: row.provider,
|
|
134
150
|
tokens,
|
|
135
151
|
speedDurationMs: row.durationMs ?? 0,
|
|
136
|
-
speedOutputTokens: row
|
|
152
|
+
speedOutputTokens: speedTokensOf(row),
|
|
153
|
+
ttftMs: row.ttftMs ?? 0,
|
|
154
|
+
ttftSteps: row.ttftSteps ?? 0,
|
|
137
155
|
})
|
|
138
156
|
}
|
|
139
157
|
providerTotals.set(row.provider, (providerTotals.get(row.provider) ?? 0) + tokens)
|
|
140
158
|
}
|
|
141
|
-
// 槽级 speed
|
|
159
|
+
// 槽级 speed/ttft 条件挂:无配对数据的槽不挂字段(存量槽形契约不变)
|
|
142
160
|
for (const slot of slots) {
|
|
143
|
-
const
|
|
144
|
-
if (
|
|
161
|
+
const speedPair = slotSpeeds.get(slot.day)
|
|
162
|
+
if (speedPair && speedPair.durationMs > 0) slot.speed = speedPair.decodeTokens / (speedPair.durationMs / MS_PER_SECOND)
|
|
163
|
+
const ttftPair = slotTtfts.get(slot.day)
|
|
164
|
+
if (ttftPair && ttftPair.ttftSteps > 0) slot.ttft = ttftPair.ttftMs / ttftPair.ttftSteps
|
|
145
165
|
}
|
|
146
166
|
const totals = { tokens: 0, requests: 0, turns: 0, cacheHit: 0, cacheMiss: 0 }
|
|
147
167
|
for (const slot of slots) {
|
|
@@ -151,18 +171,22 @@ export function aggregateRange(rows, g, from, to) {
|
|
|
151
171
|
totals.cacheHit += slot.cacheHit
|
|
152
172
|
totals.cacheMiss += slot.cacheMiss
|
|
153
173
|
}
|
|
154
|
-
// speed =
|
|
155
|
-
//
|
|
174
|
+
// speed = decode 配对口径(decodeTokens ÷ 时长秒);ttft = 首 token 延迟
|
|
175
|
+
// 加权平均(毫秒);仅配对数据存在的条目挂字段,无数据条目不挂;
|
|
176
|
+
// 纯 timing 行可能产生 0-token 条目,列表保持只含 token 行(存量契约)
|
|
156
177
|
const models = [...modelTotals.entries()]
|
|
178
|
+
.filter(([, agg]) => agg.tokens > 0)
|
|
157
179
|
.map(([model, agg]) => ({
|
|
158
180
|
model,
|
|
159
181
|
provider: agg.provider,
|
|
160
182
|
tokens: agg.tokens,
|
|
161
183
|
percent: percentOf(agg.tokens, totals.tokens),
|
|
162
184
|
...(agg.speedDurationMs > 0 ? { speed: agg.speedOutputTokens / (agg.speedDurationMs / MS_PER_SECOND) } : {}),
|
|
185
|
+
...(agg.ttftSteps > 0 ? { ttft: agg.ttftMs / agg.ttftSteps } : {}),
|
|
163
186
|
}))
|
|
164
187
|
.sort((a, b) => b.tokens - a.tokens)
|
|
165
188
|
const providers = [...providerTotals.entries()]
|
|
189
|
+
.filter(([, tokens]) => tokens > 0)
|
|
166
190
|
.map(([provider, tokens]) => ({ provider, tokens, percent: percentOf(tokens, totals.tokens) }))
|
|
167
191
|
.sort((a, b) => b.tokens - a.tokens)
|
|
168
192
|
const truncated = slots.length > MAX_SLOTS
|
package/src/store.js
CHANGED
|
@@ -82,8 +82,12 @@ export const usageRowSchema = z.object({
|
|
|
82
82
|
outputTokens: z.number(),
|
|
83
83
|
cacheReadTokens: z.number(),
|
|
84
84
|
cacheWriteTokens: z.number(),
|
|
85
|
-
// 模型时长累计(毫秒)
|
|
85
|
+
// 模型时长累计(毫秒)与 decode 口径速度分子:optional 兼容存量记录(域 open 逐记录 parse)
|
|
86
86
|
durationMs: z.number().optional(),
|
|
87
|
+
decodeTokens: z.number().optional(),
|
|
88
|
+
// 首 token 延迟累计(毫秒)与样本步数:optional 兼容存量记录
|
|
89
|
+
ttftMs: z.number().optional(),
|
|
90
|
+
ttftSteps: z.number().optional(),
|
|
87
91
|
requests: z.number(),
|
|
88
92
|
turns: z.number(),
|
|
89
93
|
lastSeen: z.number(),
|
|
@@ -136,13 +140,17 @@ function emptyRow(bucket, provider, model, nowMs) {
|
|
|
136
140
|
cacheReadTokens: 0,
|
|
137
141
|
cacheWriteTokens: 0,
|
|
138
142
|
durationMs: 0,
|
|
143
|
+
decodeTokens: 0,
|
|
144
|
+
ttftMs: 0,
|
|
145
|
+
ttftSteps: 0,
|
|
139
146
|
requests: 0,
|
|
140
147
|
turns: 0,
|
|
141
148
|
lastSeen: nowMs,
|
|
142
149
|
}
|
|
143
150
|
}
|
|
144
151
|
|
|
145
|
-
// 样本折叠为计数增量:turn/request 只计次,token
|
|
152
|
+
// 样本折叠为计数增量:turn/request 只计次,token 样本累加四类桶;
|
|
153
|
+
// decodeTokens 与 durationMs 同源配对构成速度,timing 缺失按零累计
|
|
146
154
|
function deltaOf(sample, nowMs) {
|
|
147
155
|
const delta = emptyRow('', '', '', nowMs)
|
|
148
156
|
delete delta.bucket
|
|
@@ -156,6 +164,9 @@ function deltaOf(sample, nowMs) {
|
|
|
156
164
|
delta.cacheReadTokens = sample.cacheReadTokens
|
|
157
165
|
delta.cacheWriteTokens = sample.cacheWriteTokens
|
|
158
166
|
delta.durationMs = sample.durationMs ?? 0
|
|
167
|
+
delta.decodeTokens = sample.decodeTokens ?? 0
|
|
168
|
+
delta.ttftMs = sample.ttftMs ?? 0
|
|
169
|
+
delta.ttftSteps = sample.ttftMs !== undefined ? 1 : 0
|
|
159
170
|
}
|
|
160
171
|
return delta
|
|
161
172
|
}
|
|
@@ -170,6 +181,9 @@ function addDelta(base, delta) {
|
|
|
170
181
|
cacheWriteTokens: base.cacheWriteTokens + delta.cacheWriteTokens,
|
|
171
182
|
// base 侧 ?? 0 容存量旧格式行(缺字段);delta 侧经 deltaOf 恒为数值
|
|
172
183
|
durationMs: (base.durationMs ?? 0) + delta.durationMs,
|
|
184
|
+
decodeTokens: (base.decodeTokens ?? 0) + delta.decodeTokens,
|
|
185
|
+
ttftMs: (base.ttftMs ?? 0) + delta.ttftMs,
|
|
186
|
+
ttftSteps: (base.ttftSteps ?? 0) + delta.ttftSteps,
|
|
173
187
|
requests: base.requests + delta.requests,
|
|
174
188
|
turns: base.turns + delta.turns,
|
|
175
189
|
lastSeen: Math.max(base.lastSeen, delta.lastSeen),
|
package/test/client.test.mjs
CHANGED
|
@@ -75,6 +75,7 @@ const {
|
|
|
75
75
|
otherDetailItems,
|
|
76
76
|
parseEnvelope,
|
|
77
77
|
partitionGroupOf,
|
|
78
|
+
pointerAt,
|
|
78
79
|
providerOf,
|
|
79
80
|
rateAxisTicks,
|
|
80
81
|
removeRulesAt,
|
|
@@ -82,6 +83,11 @@ const {
|
|
|
82
83
|
resolveDayRange,
|
|
83
84
|
resolveHourRange,
|
|
84
85
|
resolveMinuteRange,
|
|
86
|
+
resolveHourCustomRange,
|
|
87
|
+
resolveMinuteCustomRange,
|
|
88
|
+
resolvePointQuery,
|
|
89
|
+
formatDateTimeInput,
|
|
90
|
+
parseLocalDateTime,
|
|
85
91
|
shortDay,
|
|
86
92
|
smoothPath,
|
|
87
93
|
tipPlace,
|
|
@@ -90,6 +96,10 @@ const {
|
|
|
90
96
|
trendLayout,
|
|
91
97
|
trendRatePoints,
|
|
92
98
|
trendSpeedPoints,
|
|
99
|
+
trendTtftPoints,
|
|
100
|
+
ttftScaleMax,
|
|
101
|
+
ttftTipText,
|
|
102
|
+
modelTtftText,
|
|
93
103
|
speedTipText,
|
|
94
104
|
speedScaleMax,
|
|
95
105
|
legendToggle,
|
|
@@ -173,6 +183,112 @@ test('pointStats 缓存命中须视图与挡位双匹配', () => {
|
|
|
173
183
|
assert.equal(pointStatsMatches(null, 'hour', '24h'), false)
|
|
174
184
|
})
|
|
175
185
|
|
|
186
|
+
// —— 时/分自定义时间范围:归一语义与预设挡一致(小时 floor 整点桶,分钟 from 对齐 10 分钟桶、to 保原分钟) ——
|
|
187
|
+
|
|
188
|
+
const CUSTOM_FROM = '2026-03-14T09:45'
|
|
189
|
+
const CUSTOM_TO = '2026-03-15T14:10'
|
|
190
|
+
|
|
191
|
+
test('hour 自定义范围两端 floor 到所在小时桶', () => {
|
|
192
|
+
// Given datetime 输入含分钟偏移 When 解析 Then 两端均取所在小时桶起点(闭区间)
|
|
193
|
+
assert.deepEqual(resolveHourCustomRange(CUSTOM_FROM, CUSTOM_TO), { from: '2026-03-14T09', to: '2026-03-15T14' })
|
|
194
|
+
assert.deepEqual(resolveHourCustomRange('2026-03-14T09:00', '2026-03-15T14:59'), { from: '2026-03-14T09', to: '2026-03-15T14' })
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test('hour 自定义跨度超保留期钳起点,临界窗经 floor 与原窗输出等价', () => {
|
|
198
|
+
// Given 跨度 16 天 When 解析 Then 起点钳到终点前 15 天整点;15 天余零头的窗钳后 floor 输出不变
|
|
199
|
+
assert.deepEqual(
|
|
200
|
+
resolveHourCustomRange('2026-02-27T00:00', '2026-03-15T14:10'),
|
|
201
|
+
{ from: '2026-02-28T14', to: '2026-03-15T14' },
|
|
202
|
+
)
|
|
203
|
+
assert.deepEqual(
|
|
204
|
+
resolveHourCustomRange('2026-02-28T14:00', '2026-03-15T14:10'),
|
|
205
|
+
{ from: '2026-02-28T14', to: '2026-03-15T14' },
|
|
206
|
+
)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test('hour 自定义非法输入返回 null', () => {
|
|
210
|
+
// Given 空值/垃圾串/from 晚于 to When 解析 Then 恒 null,不发请求
|
|
211
|
+
assert.equal(resolveHourCustomRange('', CUSTOM_TO), null)
|
|
212
|
+
assert.equal(resolveHourCustomRange(CUSTOM_FROM, ''), null)
|
|
213
|
+
assert.equal(resolveHourCustomRange('junk', CUSTOM_TO), null)
|
|
214
|
+
assert.equal(resolveHourCustomRange('2026-13-40T09:00', CUSTOM_TO), null)
|
|
215
|
+
assert.equal(resolveHourCustomRange(CUSTOM_TO, CUSTOM_FROM), null)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
test('parseLocalDateTime 拒绝数字分量回卷的日历非法值', () => {
|
|
219
|
+
// Given 超界分量(13 月/2 月 30 日/25 时)在 Date 构造下静默回卷 When 解析 Then 逐分量回读拦截为 null
|
|
220
|
+
assert.equal(parseLocalDateTime('2026-13-01T00:00'), null)
|
|
221
|
+
assert.equal(parseLocalDateTime('2026-02-30T10:00'), null)
|
|
222
|
+
assert.equal(parseLocalDateTime('2026-03-14T25:00'), null)
|
|
223
|
+
assert.deepEqual(parseLocalDateTime('2026-03-14T09:45'), new Date(2026, 2, 14, 9, 45))
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
test('hour 自定义拒绝回卷后仍保序的非法日历输入', () => {
|
|
227
|
+
// Given 13 月回卷为次年 1 月且整体仍早于 to When 解析 Then 恒 null,不静默改写查询时段
|
|
228
|
+
assert.equal(resolveHourCustomRange('2026-13-01T00:00', '2027-06-01T00:00'), null)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test('minute 自定义 from 对齐 10 分钟桶,to 保持原始分钟', () => {
|
|
232
|
+
// Given from 含非对齐分钟 When 解析 Then from floor 到桶边界,to 原值保留(闭区间上界)
|
|
233
|
+
assert.deepEqual(resolveMinuteCustomRange(CUSTOM_FROM, CUSTOM_TO), { from: '2026-03-14T09:40', to: '2026-03-15T14:10' })
|
|
234
|
+
assert.deepEqual(resolveMinuteCustomRange('2026-03-14T09:40', '2026-03-15T14:17'), { from: '2026-03-14T09:40', to: '2026-03-15T14:17' })
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
test('minute 自定义跨度超保留期钳起点', () => {
|
|
238
|
+
// Given 跨度 8 天 When 解析 Then 起点钳到终点前 7 天的 10 分钟桶边界
|
|
239
|
+
assert.deepEqual(
|
|
240
|
+
resolveMinuteCustomRange('2026-03-06T00:00', '2026-03-15T14:10'),
|
|
241
|
+
{ from: '2026-03-08T14:10', to: '2026-03-15T14:10' },
|
|
242
|
+
)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
test('minute 自定义非法输入返回 null', () => {
|
|
246
|
+
assert.equal(resolveMinuteCustomRange('', CUSTOM_TO), null)
|
|
247
|
+
assert.equal(resolveMinuteCustomRange('junk', CUSTOM_TO), null)
|
|
248
|
+
assert.equal(resolveMinuteCustomRange(CUSTOM_TO, CUSTOM_FROM), null)
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
test('resolvePointQuery 预设挡返回挡位键与滚动窗口请求', () => {
|
|
252
|
+
// Given 时/分预设挡 When 解析查询 Then 键为挡位 id,请求与既有 resolve 同构
|
|
253
|
+
const hour = resolvePointQuery('hour', '24h', '', '', NOW)
|
|
254
|
+
assert.equal(hour.key, '24h')
|
|
255
|
+
assert.deepEqual(hour.request, resolveHourRange('24h', NOW))
|
|
256
|
+
const minute = resolvePointQuery('minute', '3h', '', '', NOW)
|
|
257
|
+
assert.equal(minute.key, '3h')
|
|
258
|
+
assert.deepEqual(minute.request, resolveMinuteRange('3h', NOW))
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
test('resolvePointQuery 自定义挡键含归一范围且随范围变化', () => {
|
|
262
|
+
// Given 同视图同挡不同范围 When 解析查询 Then 键互异;同范围键稳定(缓存可命中)
|
|
263
|
+
const first = resolvePointQuery('hour', 'custom', CUSTOM_FROM, CUSTOM_TO, NOW)
|
|
264
|
+
assert.deepEqual(first.request, { from: '2026-03-14T09', to: '2026-03-15T14' })
|
|
265
|
+
assert.equal(first.key, 'custom:2026-03-14T09:2026-03-15T14')
|
|
266
|
+
const again = resolvePointQuery('hour', 'custom', CUSTOM_FROM, CUSTOM_TO, NOW)
|
|
267
|
+
assert.equal(again.key, first.key)
|
|
268
|
+
const shifted = resolvePointQuery('hour', 'custom', '2026-03-14T10:45', CUSTOM_TO, NOW)
|
|
269
|
+
assert.notEqual(shifted.key, first.key)
|
|
270
|
+
const minute = resolvePointQuery('minute', 'custom', CUSTOM_FROM, CUSTOM_TO, NOW)
|
|
271
|
+
assert.deepEqual(minute.request, { from: '2026-03-14T09:40', to: '2026-03-15T14:10' })
|
|
272
|
+
assert.notEqual(minute.key, first.key)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
test('resolvePointQuery 自定义输入不完整返回 null', () => {
|
|
276
|
+
// Given 任一端为空 When 解析查询 Then null,面板不发请求不误清缓存
|
|
277
|
+
assert.equal(resolvePointQuery('hour', 'custom', '', CUSTOM_TO, NOW), null)
|
|
278
|
+
assert.equal(resolvePointQuery('minute', 'custom', CUSTOM_FROM, '', NOW), null)
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
test('maxSlotsFor custom 挡取保留期桶数上限', () => {
|
|
282
|
+
// Given 自定义挡受保留期钳制 When 取渲染上限 Then 小时为 15 天小时桶数,分钟为 7 天 10 分钟桶数
|
|
283
|
+
assert.equal(maxSlotsFor('hour', 'custom'), 15 * 24 + 1)
|
|
284
|
+
assert.equal(maxSlotsFor('minute', 'custom'), (7 * 24 * 60) / 10 + 1)
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
test('formatDateTimeInput 产出 datetime-local 分钟粒度值', () => {
|
|
288
|
+
assert.equal(formatDateTimeInput(new Date(2026, 2, 15, 9, 5)), '2026-03-15T09:05')
|
|
289
|
+
assert.equal(formatDateTimeInput(NOW), '2026-03-15T14:30')
|
|
290
|
+
})
|
|
291
|
+
|
|
176
292
|
test('每视图渲染上限等于闭区间桶数', () => {
|
|
177
293
|
assert.equal(maxSlotsFor('day', '90'), DAY_MAX_SLOTS)
|
|
178
294
|
assert.equal(maxSlotsFor('hour', '24h'), 25)
|
|
@@ -472,50 +588,57 @@ test('heatLevel 零值为空档其余按峰值四分位', () => {
|
|
|
472
588
|
assert.equal(heatLevel(1, 1), 5)
|
|
473
589
|
})
|
|
474
590
|
|
|
475
|
-
const TIP_ANCHOR = {
|
|
591
|
+
const TIP_ANCHOR = { x: 100, y: 100 }
|
|
476
592
|
const TIP_SIZE = { width: 80, height: 40 }
|
|
477
593
|
const TIP_BOUNDS = { left: 0, top: 0, right: 800, bottom: 600 }
|
|
478
594
|
|
|
479
|
-
test('tipPlace
|
|
480
|
-
assert.deepEqual(tipPlace(TIP_ANCHOR, TIP_SIZE, TIP_BOUNDS), { left:
|
|
595
|
+
test('tipPlace 指针右下空位放右下', () => {
|
|
596
|
+
assert.deepEqual(tipPlace(TIP_ANCHOR, TIP_SIZE, TIP_BOUNDS), { left: 108, top: 108 })
|
|
597
|
+
})
|
|
598
|
+
|
|
599
|
+
test('tipPlace 右侧越界翻左侧', () => {
|
|
600
|
+
assert.deepEqual(tipPlace({ x: 730, y: 100 }, TIP_SIZE, TIP_BOUNDS), { left: 642, top: 108 })
|
|
601
|
+
})
|
|
602
|
+
|
|
603
|
+
test('tipPlace 下方越界翻上方', () => {
|
|
604
|
+
assert.deepEqual(tipPlace({ x: 100, y: 570 }, TIP_SIZE, TIP_BOUNDS), { left: 108, top: 522 })
|
|
605
|
+
})
|
|
606
|
+
|
|
607
|
+
test('tipPlace 右下均越界双翻', () => {
|
|
608
|
+
assert.deepEqual(tipPlace({ x: 730, y: 570 }, TIP_SIZE, TIP_BOUNDS), { left: 642, top: 522 })
|
|
481
609
|
})
|
|
482
610
|
|
|
483
|
-
test('tipPlace
|
|
484
|
-
|
|
485
|
-
assert.deepEqual(tipPlace(anchor, TIP_SIZE, TIP_BOUNDS), { left: 130, top: 108 })
|
|
611
|
+
test('tipPlace 双翻后仍越界钳进边界', () => {
|
|
612
|
+
assert.deepEqual(tipPlace({ x: 798, y: 598 }, TIP_SIZE, TIP_BOUNDS), { left: 710, top: 550 })
|
|
486
613
|
})
|
|
487
614
|
|
|
488
|
-
test('tipPlace
|
|
489
|
-
|
|
490
|
-
assert.equal(tipPlace(anchor, TIP_SIZE, TIP_BOUNDS).top, 68)
|
|
615
|
+
test('tipPlace 恰贴右缘等号归属放右侧', () => {
|
|
616
|
+
assert.deepEqual(tipPlace({ x: 704, y: 100 }, TIP_SIZE, TIP_BOUNDS), { left: 712, top: 108 })
|
|
491
617
|
})
|
|
492
618
|
|
|
493
|
-
test('tipPlace
|
|
494
|
-
assert.deepEqual(tipPlace({
|
|
495
|
-
assert.equal(tipPlace({ left: 100, top: 56, right: 140, bottom: 96 }, TIP_SIZE, TIP_BOUNDS).top, 8)
|
|
496
|
-
assert.equal(tipPlace({ left: 100, top: 8, right: 140, bottom: 48 }, TIP_SIZE, { left: 0, top: 0, right: 800, bottom: 104 }).top, 56)
|
|
619
|
+
test('tipPlace 左上角指针放右下不翻转', () => {
|
|
620
|
+
assert.deepEqual(tipPlace({ x: 2, y: 2 }, TIP_SIZE, TIP_BOUNDS), { left: 10, top: 10 })
|
|
497
621
|
})
|
|
498
622
|
|
|
499
|
-
test('tipPlace
|
|
500
|
-
|
|
501
|
-
assert.equal(tipPlace(anchor, TIP_SIZE, TIP_BOUNDS).top, 492)
|
|
623
|
+
test('tipPlace 提示框宽于边界钳贴左缘', () => {
|
|
624
|
+
assert.equal(tipPlace({ x: 400, y: 100 }, { width: 900, height: 40 }, TIP_BOUNDS).left, 8)
|
|
502
625
|
})
|
|
503
626
|
|
|
504
|
-
test('
|
|
505
|
-
|
|
506
|
-
const anchor = { left: 100, top: 20, right: 140, bottom: 50 }
|
|
507
|
-
assert.equal(tipPlace(anchor, TIP_SIZE, bounds).top, 8)
|
|
627
|
+
test('pointerAt 指针事件取指针坐标', () => {
|
|
628
|
+
assert.deepEqual(pointerAt({ clientX: 12, clientY: 34 }), { x: 12, y: 34 })
|
|
508
629
|
})
|
|
509
630
|
|
|
510
|
-
test('
|
|
511
|
-
const
|
|
512
|
-
|
|
631
|
+
test('pointerAt 焦点事件回退目标矩形中心', () => {
|
|
632
|
+
const event = {
|
|
633
|
+
currentTarget: { getBoundingClientRect: () => ({ left: 10, top: 20, right: 30, bottom: 60 }) },
|
|
634
|
+
}
|
|
635
|
+
assert.deepEqual(pointerAt(event), { x: 20, y: 40 })
|
|
513
636
|
})
|
|
514
637
|
|
|
515
|
-
test('tipPlace
|
|
638
|
+
test('tipPlace 零尺寸或非法锚点返回隐藏', () => {
|
|
516
639
|
assert.equal(tipPlace(TIP_ANCHOR, { width: 0, height: 0 }, TIP_BOUNDS), null)
|
|
517
|
-
assert.equal(tipPlace({ left: 0, top: 0, right: 0, bottom: 0 }, TIP_SIZE, TIP_BOUNDS), null)
|
|
518
640
|
assert.equal(tipPlace(null, TIP_SIZE, TIP_BOUNDS), null)
|
|
641
|
+
assert.equal(tipPlace({ x: Number.NaN, y: 100 }, TIP_SIZE, TIP_BOUNDS), null)
|
|
519
642
|
})
|
|
520
643
|
|
|
521
644
|
// —— S8 命中率曲线 ——
|
|
@@ -597,6 +720,44 @@ test('speedScaleMax 全零或空槽钳底防除零,混合取最大速度', () =>
|
|
|
597
720
|
assert.equal(speedScaleMax([{ day: 'd0', speed: 30 }, { day: 'd1', speed: 12.5 }]), 30)
|
|
598
721
|
})
|
|
599
722
|
|
|
723
|
+
test('trendTtftPoints 点映射列中心与刻度上限比例高度且无 ttft 槽跳过', () => {
|
|
724
|
+
const slots = [
|
|
725
|
+
{ day: 'd0', ttft: 2000 },
|
|
726
|
+
{ day: 'd1', total: 10 },
|
|
727
|
+
{ day: 'd2', ttft: 1000 },
|
|
728
|
+
]
|
|
729
|
+
const bars = [{ x: 10 }, { x: 20 }, { x: 30 }]
|
|
730
|
+
const plotHeight = CHART_HEIGHT - CHART_PAD.top - CHART_PAD.bottom
|
|
731
|
+
const points = trendTtftPoints(slots, bars, plotHeight, 2200)
|
|
732
|
+
assert.equal(points.length, 2)
|
|
733
|
+
assert.equal(points[0].day, 'd0')
|
|
734
|
+
assert.equal(points[0].x, 10)
|
|
735
|
+
approx(points[0].y, CHART_PAD.top + plotHeight - (2000 / 2200) * plotHeight)
|
|
736
|
+
assert.equal(points[1].day, 'd2')
|
|
737
|
+
assert.equal(points[1].x, 30)
|
|
738
|
+
approx(points[1].y, CHART_PAD.top + plotHeight - (1000 / 2200) * plotHeight)
|
|
739
|
+
})
|
|
740
|
+
|
|
741
|
+
test('ttftScaleMax 全零或空槽钳底防除零,混合取最大延迟', () => {
|
|
742
|
+
assert.equal(ttftScaleMax([]), 1)
|
|
743
|
+
assert.equal(ttftScaleMax([{ day: 'd0' }, { day: 'd1', ttft: 0 }]), 1)
|
|
744
|
+
assert.equal(ttftScaleMax([{ day: 'd0', ttft: 3000 }, { day: 'd1', ttft: 1250 }]), 3000)
|
|
745
|
+
})
|
|
746
|
+
|
|
747
|
+
test('ttftTipText 无 ttft 为占位符有 ttft 为官方时长口径', () => {
|
|
748
|
+
const zhT = createTranslator(MESSAGES_ZH)
|
|
749
|
+
const enT = createTranslator(MESSAGES_EN)
|
|
750
|
+
assert.equal(ttftTipText(undefined, zhT), '—')
|
|
751
|
+
assert.equal(ttftTipText(200, zhT), '0.2秒')
|
|
752
|
+
assert.equal(ttftTipText(200, enT), '0.2s')
|
|
753
|
+
assert.equal(ttftTipText(9500, zhT), '9.5秒')
|
|
754
|
+
})
|
|
755
|
+
|
|
756
|
+
test('ttftLegend 中英文案注册', () => {
|
|
757
|
+
assert.equal(MESSAGES_ZH.ttftLegend, '首 token 延迟')
|
|
758
|
+
assert.equal(MESSAGES_EN.ttftLegend, 'First-token latency')
|
|
759
|
+
})
|
|
760
|
+
|
|
600
761
|
test('smoothPath 空点集为空串单点为移动命令', () => {
|
|
601
762
|
assert.equal(smoothPath([]), '')
|
|
602
763
|
assert.equal(smoothPath([{ x: 1, y: 2 }]), 'M 1 2')
|
|
@@ -612,6 +773,14 @@ test('smoothPath 三点后段取真实邻点', () => {
|
|
|
612
773
|
assert.equal(d, 'M 0 0 C 10 5, 40 30, 60 30 C 80 30, 110 5, 120 0')
|
|
613
774
|
})
|
|
614
775
|
|
|
776
|
+
test('smoothPath 相邻点间距超 maxGap 折线断开成新段', () => {
|
|
777
|
+
const points = [{ x: 0, y: 0 }, { x: 60, y: 0 }, { x: 300, y: 0 }, { x: 360, y: 0 }]
|
|
778
|
+
const d = smoothPath(points, 90)
|
|
779
|
+
assert.equal(d, 'M 0 0 C 10 0, 10 0, 60 0 M 300 0 C 350 0, 350 0, 360 0')
|
|
780
|
+
// 无 maxGap 时不分段,行为与旧签名一致
|
|
781
|
+
assert.equal(smoothPath(points).includes('M 300'), false)
|
|
782
|
+
})
|
|
783
|
+
|
|
615
784
|
// —— S8 模型 donut 与列表 ——
|
|
616
785
|
|
|
617
786
|
test('donut 常量锁定视口与环几何', () => {
|
|
@@ -687,6 +856,13 @@ test('模型速度文本:官方吞吐口径格式化,无速度为空串', () =>
|
|
|
687
856
|
assert.equal(modelSpeedText(undefined), '')
|
|
688
857
|
})
|
|
689
858
|
|
|
859
|
+
test('模型首字文本:语言中立短时长,无 ttft 为空串', () => {
|
|
860
|
+
assert.equal(modelTtftText(200), 'TTFT 0.2s')
|
|
861
|
+
assert.equal(modelTtftText(162000), 'TTFT 2m42s')
|
|
862
|
+
assert.equal(modelTtftText(0), 'TTFT 0s')
|
|
863
|
+
assert.equal(modelTtftText(undefined), '')
|
|
864
|
+
})
|
|
865
|
+
|
|
690
866
|
// —— S14 费用格式化与展示辅助(镜像函数核心语义见 pricing-parity.test.mjs) ——
|
|
691
867
|
|
|
692
868
|
test('formatCost 千分位与两位小数', () => {
|