@mzzsfy/dsh-usage-dash 0.1.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.
@@ -0,0 +1,407 @@
1
+ // 底部信息栏增强纯函数层测试:官方 StatsLine 口径(dsh-client-ui-chat 同构)+ usp 双开关状态
2
+ // Given/When/Then 场景内嵌于用例描述
3
+ // client.js 为非模块 script(bundle 求值形态,禁 import/export),整源求值后按顶层声明名收集
4
+ import { test } from 'node:test'
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'
9
+
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(', ')} }`)()
17
+
18
+ const {
19
+ STATS_LINE_STORAGE_KEY,
20
+ MESSAGES_EN,
21
+ MESSAGES_ZH,
22
+ billedInputTokens,
23
+ roundedPercentUnits,
24
+ displayPercentUnits,
25
+ formatCacheHitPercent,
26
+ cacheHitPercent,
27
+ cacheHitPercentPrecise,
28
+ formatTokensCompact,
29
+ formatDuration,
30
+ formatTokensPerSecond,
31
+ assistantStepReading,
32
+ deriveStats,
33
+ buildStatsGroups,
34
+ buildCostItem,
35
+ createStatsLineState,
36
+ createTranslator,
37
+ } = core
38
+
39
+ const USAGE = { uncachedInputTokens: 1000, cacheReadTokens: 4000, cacheWriteTokens: 0, outputTokens: 500 }
40
+ const STATS = { turns: 2, steps: 3, llmMs: 1500, toolMs: 2000, ttftMs: 200, ttftSteps: 1, decodeMs: 800, decodeTokens: 50 }
41
+ const PREFS_OFF = { cachePrecision: false, tokenDetail: false, costDisplay: false }
42
+ const PREFS_COST_ON = { cachePrecision: false, tokenDetail: false, costDisplay: true }
43
+ const zhT = createTranslator(MESSAGES_ZH)
44
+ const enT = createTranslator(MESSAGES_EN)
45
+
46
+ // 费用组装配输入:精确规则在前、通配规则兜底,通配仅全天时段生效
47
+ const COST_RULES = [
48
+ { model: 'p/m', currency: '¥', price: { input: 2, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [] },
49
+ {
50
+ model: '*', currency: '', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 },
51
+ conditions: [{ kind: 'dailyWindow', from: '00:00', to: '00:00' }],
52
+ },
53
+ ]
54
+ const COST_USAGE = { uncachedInputTokens: 500000, cacheReadTokens: 0, cacheWriteTokens: 0, outputTokens: 0 }
55
+ const COST_NOW = new Date(2026, 2, 15, 10, 0)
56
+
57
+ function fakeStorage(initial = {}) {
58
+ const map = new Map(Object.entries(initial))
59
+ return {
60
+ getItem: (key) => (map.has(key) ? map.get(key) : null),
61
+ setItem: (key, value) => { map.set(key, String(value)) },
62
+ }
63
+ }
64
+
65
+ test('stats 词典 zh 关键值逐字节对齐官方,en 族逐字节取官方 chat', () => {
66
+ assert.equal(STATS_LINE_STORAGE_KEY, 'dsh-usage-dash:stats-line')
67
+ assert.equal(MESSAGES_ZH['stats.counts'], '{turns} 轮 · {steps} 步')
68
+ assert.equal(MESSAGES_ZH['stats.llm'], 'LLM {duration}')
69
+ assert.equal(MESSAGES_ZH['stats.toolCall'], '工具调用 {duration}')
70
+ assert.equal(MESSAGES_ZH['stats.ttftAverage'], '首 token 平均 {duration}')
71
+ assert.equal(MESSAGES_ZH['stats.tokensPerSecond'], '{throughput} tok/s')
72
+ assert.equal(MESSAGES_ZH['stats.cacheHit'], '缓存命中 {percent}%')
73
+ assert.equal(MESSAGES_ZH['stats.tokens'], '输入 {input} tok · 输出 {output} tok')
74
+ assert.equal(MESSAGES_ZH['stats.tokensDetail'], '总 {total} tok · 输入 {input} tok · 命中缓存 {hit} tok · 未命中缓存 {miss} tok · 输出 {output} tok')
75
+ assert.equal(MESSAGES_ZH.cachePrecision, '精确缓存命中率')
76
+ assert.equal(MESSAGES_ZH.cachePrecisionDesc, '在会话底部信息栏以两位小数显示缓存命中率。')
77
+ assert.equal(MESSAGES_ZH.tokenDetail, '会话 Token 明细')
78
+ assert.equal(MESSAGES_ZH.tokenDetailDesc, '在会话底部信息栏显示总 Token、命中/未命中缓存与输出明细。')
79
+ assert.equal(MESSAGES_ZH['stats.cost'], '费用 ≈ {cost}')
80
+ assert.equal(MESSAGES_ZH.costDisplay, '费用显示')
81
+ assert.equal(MESSAGES_EN['stats.cost'], 'Cost ≈ {cost}')
82
+ assert.equal(MESSAGES_EN.costDisplay, 'Cost display')
83
+ assert.equal(MESSAGES_ZH['duration.compactSeconds'], '{seconds}秒')
84
+ assert.equal(MESSAGES_ZH['duration.compactMinutes'], '{minutes}分{seconds}秒')
85
+ assert.equal(MESSAGES_ZH['number.thousand'], '{value}K')
86
+ assert.equal(MESSAGES_ZH['number.million'], '{value}M')
87
+ assert.equal(MESSAGES_EN['stats.counts'], '{turns} turns · {steps} steps')
88
+ assert.equal(MESSAGES_EN['stats.llm'], 'LLM {duration}')
89
+ assert.equal(MESSAGES_EN['stats.toolCall'], 'Tool call {duration}')
90
+ assert.equal(MESSAGES_EN['stats.ttftAverage'], 'TTFT avg {duration}')
91
+ assert.equal(MESSAGES_EN['stats.tokensPerSecond'], '{throughput} tok/s')
92
+ assert.equal(MESSAGES_EN['stats.cacheHit'], 'Cache hit {percent}%')
93
+ assert.equal(MESSAGES_EN['stats.tokens'], 'Input {input} tok · Output {output} tok')
94
+ assert.equal(MESSAGES_EN['stats.tokensDetail'], 'Total {total} tok · Input {input} tok · Cache hit {hit} tok · Cache miss {miss} tok · Output {output} tok')
95
+ assert.equal(MESSAGES_EN['duration.compactSeconds'], '{seconds}s')
96
+ assert.equal(MESSAGES_EN['duration.compactMinutes'], '{minutes}m{seconds}s')
97
+ assert.equal(MESSAGES_EN['number.thousand'], '{value}K')
98
+ assert.equal(MESSAGES_EN['number.million'], '{value}M')
99
+ })
100
+
101
+ test('billedInputTokens 为三个互斥 prompt 桶之和', () => {
102
+ assert.equal(billedInputTokens(USAGE), 5000)
103
+ assert.equal(billedInputTokens({ uncachedInputTokens: 100, cacheReadTokens: 200, cacheWriteTokens: 300, outputTokens: 0 }), 600)
104
+ })
105
+
106
+ test('roundedPercentUnits 整数位 0.5 进位', () => {
107
+ // 125*100/200 = 62.5 → 63(0.5 进位,非银行家舍入)
108
+ assert.equal(roundedPercentUnits(125, 200, 0), 63)
109
+ assert.equal(roundedPercentUnits(75, 100, 0), 75)
110
+ assert.equal(roundedPercentUnits(999946, 1000000, 0), 100)
111
+ })
112
+
113
+ test('roundedPercentUnits 十分位单位 0.5 进位', () => {
114
+ // 755*1000/1000 = 755 个十分位 → 755('75.5')
115
+ assert.equal(roundedPercentUnits(755, 1000, 1), 755)
116
+ assert.equal(roundedPercentUnits(125, 200, 1), 625)
117
+ })
118
+
119
+ test('displayPercentUnits 按小数位渲染,十分位为 0 时省略小数点', () => {
120
+ assert.equal(displayPercentUnits(75, 0), '75')
121
+ assert.equal(displayPercentUnits(805, 1), '80.5')
122
+ assert.equal(displayPercentUnits(800, 1), '80')
123
+ })
124
+
125
+ test('formatCacheHitPercent 无分母为 null,无 miss 为 100', () => {
126
+ assert.equal(formatCacheHitPercent(100, 0, 0), null)
127
+ assert.equal(formatCacheHitPercent(100, 100, 0), '100')
128
+ })
129
+
130
+ test('formatCacheHitPercent 整数位四舍五入', () => {
131
+ assert.equal(formatCacheHitPercent(75, 100), '75')
132
+ assert.equal(formatCacheHitPercent(125, 200), '63')
133
+ assert.equal(formatCacheHitPercent(52, 100), '52')
134
+ })
135
+
136
+ test('formatCacheHitPercent 溢出 100 时闭式构造 99.x', () => {
137
+ assert.equal(formatCacheHitPercent(999946, 1000000), '99.99')
138
+ assert.equal(formatCacheHitPercent(99995, 100000), '99.995')
139
+ })
140
+
141
+ test('cacheHitPercent 官方整数口径与空数据 null', () => {
142
+ assert.equal(cacheHitPercent(USAGE), '80')
143
+ assert.equal(cacheHitPercent({ uncachedInputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, outputTokens: 500 }), null)
144
+ })
145
+
146
+ test('cacheHitPercentPrecise 恒两位小数且 99.99 封顶', () => {
147
+ assert.equal(cacheHitPercentPrecise({ uncachedInputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, outputTokens: 0 }), null)
148
+ assert.equal(cacheHitPercentPrecise({ uncachedInputTokens: 0, cacheReadTokens: 8000, cacheWriteTokens: 0, outputTokens: 10 }), '100.00')
149
+ assert.equal(cacheHitPercentPrecise(USAGE), '80.00')
150
+ assert.equal(cacheHitPercentPrecise({ uncachedInputTokens: 4, cacheReadTokens: 999996, cacheWriteTokens: 0, outputTokens: 1 }), '99.99')
151
+ })
152
+
153
+ test('formatTokensCompact 官方 K/M 紧凑族', () => {
154
+ assert.equal(formatTokensCompact(517, zhT), '517')
155
+ assert.equal(formatTokensCompact(12200, zhT), '12.2K')
156
+ assert.equal(formatTokensCompact(517000, zhT), '517K')
157
+ assert.equal(formatTokensCompact(1200000, zhT), '1.2M')
158
+ assert.equal(formatTokensCompact(0, zhT), '0')
159
+ })
160
+
161
+ test('formatDuration 60 秒内一位小数,以上折分秒', () => {
162
+ assert.equal(formatDuration(45200, zhT), '45.2秒')
163
+ assert.equal(formatDuration(162000, zhT), '2分42秒')
164
+ assert.equal(formatDuration(0, zhT), '0秒')
165
+ })
166
+
167
+ test('formatTokensPerSecond 钳负值且阈值上下分别取整', () => {
168
+ assert.equal(formatTokensPerSecond(-5), '0')
169
+ assert.equal(formatTokensPerSecond(9.96), '10')
170
+ assert.equal(formatTokensPerSecond(150), '150')
171
+ assert.equal(formatTokensPerSecond(3.24), '3.2')
172
+ })
173
+
174
+ test('assistantStepReading 时间戳齐全时产出 TTFT/decode/输出 token', () => {
175
+ assert.deepEqual(
176
+ assistantStepReading({ timing: { stepStartTime: 1000, firstTokenTime: 1200, completedTime: 2000 }, usage: 50 }),
177
+ { ttftMs: 200, decodeMs: 800, outputTokens: 50 },
178
+ )
179
+ })
180
+
181
+ test('assistantStepReading 负时长钳 0 且非法 usage 为 null', () => {
182
+ // completedTime 早于 firstTokenTime → decode 钳 0
183
+ assert.deepEqual(
184
+ assistantStepReading({ timing: { stepStartTime: 1000, firstTokenTime: 2000, completedTime: 1500 }, usage: 30 }),
185
+ { ttftMs: 1000, decodeMs: 0, outputTokens: 30 },
186
+ )
187
+ assert.equal(assistantStepReading({ timing: { stepStartTime: 0, firstTokenTime: 100, completedTime: 200 }, usage: 'oops' }).outputTokens, null)
188
+ assert.equal(assistantStepReading({ timing: { stepStartTime: 0, firstTokenTime: 100, completedTime: 200 }, usage: -1 }).outputTokens, null)
189
+ assert.equal(assistantStepReading({ timing: { stepStartTime: 0, firstTokenTime: 100, completedTime: 200 }, usage: Number.NaN }).outputTokens, null)
190
+ })
191
+
192
+ test('assistantStepReading 无 timing 时读数双 null', () => {
193
+ assert.deepEqual(assistantStepReading({ usage: 50 }), { ttftMs: null, decodeMs: null, outputTokens: 50 })
194
+ })
195
+
196
+ test('deriveStats 折叠窗口节点:轮次去重/负值钳制/读数按有值步聚合', () => {
197
+ // When:混合 tool-result 与 assistant(含 turn 重复、stepStart null、firstToken null、非法 usage)
198
+ const nodes = [
199
+ { kind: 'user' },
200
+ { kind: 'tool-result', time: 5000, callTime: 3000 },
201
+ { kind: 'tool-result', time: 1000, callTime: 3000 },
202
+ { kind: 'tool-result', time: 9000, callTime: null },
203
+ { kind: 'assistant', turn: 1, timing: { stepStartTime: 1000, firstTokenTime: 1200, completedTime: 2000 }, usage: 50 },
204
+ { kind: 'assistant', turn: 1, timing: { stepStartTime: null, firstTokenTime: 1300, completedTime: 2100 }, usage: 10 },
205
+ { kind: 'assistant', turn: 2, timing: { stepStartTime: 3000, firstTokenTime: null, completedTime: 3500 }, usage: 30 },
206
+ { kind: 'assistant', turn: 3, timing: { stepStartTime: 4000, firstTokenTime: 4100, completedTime: 4600 }, usage: 'oops' },
207
+ ]
208
+ // Then:llm 1000+0+500+600;ttft 分母=有值 2 步;decode 只算时间戳与 usage 双全的步
209
+ assert.deepEqual(deriveStats(nodes), {
210
+ turns: 3,
211
+ steps: 4,
212
+ llmMs: 2100,
213
+ toolMs: 2000,
214
+ ttftMs: 300,
215
+ ttftSteps: 2,
216
+ decodeMs: 1600,
217
+ decodeTokens: 60,
218
+ })
219
+ })
220
+
221
+ test('deriveStats 空节点为零值统计', () => {
222
+ assert.deepEqual(deriveStats([]), { turns: 0, steps: 0, llmMs: 0, toolMs: 0, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0 })
223
+ })
224
+
225
+ test('buildStatsGroups 双开关全关与官方行逐字节一致', () => {
226
+ assert.deepEqual(buildStatsGroups(STATS, USAGE, PREFS_OFF, zhT), [
227
+ '2 轮 · 3 步',
228
+ 'LLM 1.5秒 · 工具调用 2秒',
229
+ '首 token 平均 0.2秒 · 63 tok/s',
230
+ '缓存命中 80%',
231
+ '输入 5K tok · 输出 500 tok',
232
+ ])
233
+ })
234
+
235
+ test('buildStatsGroups 传 en 翻译器输出官方 en 口径逐字节', () => {
236
+ assert.deepEqual(buildStatsGroups(STATS, USAGE, PREFS_OFF, enT), [
237
+ '2 turns · 3 steps',
238
+ 'LLM 1.5s · Tool call 2s',
239
+ 'TTFT avg 0.2s · 63 tok/s',
240
+ 'Cache hit 80%',
241
+ 'Input 5K tok · Output 500 tok',
242
+ ])
243
+ })
244
+
245
+ test('buildStatsGroups en 零命中率呈现 Cache hit 0%', () => {
246
+ const zeroHitStats = { turns: 2, steps: 3, llmMs: 0, toolMs: 0, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0 }
247
+ const zeroHitUsage = { uncachedInputTokens: 4000, cacheReadTokens: 0, cacheWriteTokens: 1000, outputTokens: 0 }
248
+ assert.deepEqual(buildStatsGroups(zeroHitStats, zeroHitUsage, PREFS_OFF, enT), [
249
+ '2 turns · 3 steps',
250
+ 'Cache hit 0%',
251
+ 'Input 5K tok · Output 0 tok',
252
+ ])
253
+ })
254
+
255
+ test('buildStatsGroups tokenDetail 开关切换五项明细组', () => {
256
+ const groups = buildStatsGroups(STATS, USAGE, { cachePrecision: false, tokenDetail: true }, zhT)
257
+ assert.deepEqual(groups.slice(0, 4), [
258
+ '2 轮 · 3 步',
259
+ 'LLM 1.5秒 · 工具调用 2秒',
260
+ '首 token 平均 0.2秒 · 63 tok/s',
261
+ '缓存命中 80%',
262
+ ])
263
+ assert.equal(groups[4], '总 5.5K tok · 输入 5K tok · 命中缓存 4K tok · 未命中缓存 1K tok · 输出 500 tok')
264
+ })
265
+
266
+ test('buildStatsGroups cachePrecision 开关切精确命中率', () => {
267
+ const groups = buildStatsGroups(STATS, USAGE, { cachePrecision: true, tokenDetail: false }, zhT)
268
+ assert.equal(groups[3], '缓存命中 80.00%')
269
+ })
270
+
271
+ test('buildStatsGroups usage 缺席时命中与 Token 组整体缺席', () => {
272
+ assert.deepEqual(buildStatsGroups(STATS, undefined, PREFS_OFF, zhT), [
273
+ '2 轮 · 3 步',
274
+ 'LLM 1.5秒 · 工具调用 2秒',
275
+ '首 token 平均 0.2秒 · 63 tok/s',
276
+ ])
277
+ })
278
+
279
+ test('buildStatsGroups 无步进且用量空为空数组', () => {
280
+ const zeroStats = { turns: 0, steps: 0, llmMs: 0, toolMs: 0, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0 }
281
+ const zeroUsage = { uncachedInputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, outputTokens: 0 }
282
+ assert.deepEqual(buildStatsGroups(zeroStats, zeroUsage, PREFS_OFF, zhT), [])
283
+ })
284
+
285
+ test('buildStatsGroups 仅输出时命中率组因 null 丢弃', () => {
286
+ const zeroStats = { turns: 0, steps: 0, llmMs: 0, toolMs: 0, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0 }
287
+ const usage = { uncachedInputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, outputTokens: 500 }
288
+ assert.deepEqual(buildStatsGroups(zeroStats, usage, PREFS_OFF, zhT), ['输入 0 tok · 输出 500 tok'])
289
+ })
290
+
291
+ test('createStatsLineState 默认三开,非法 JSON 回落三开,显式 false 才视为关', () => {
292
+ assert.deepEqual(createStatsLineState(fakeStorage()).get(), { cachePrecision: true, tokenDetail: true, costDisplay: true })
293
+ assert.deepEqual(createStatsLineState(null).get(), { cachePrecision: true, tokenDetail: true, costDisplay: true })
294
+ const broken = fakeStorage({ [STATS_LINE_STORAGE_KEY]: '{broken json' })
295
+ assert.deepEqual(createStatsLineState(broken).get(), { cachePrecision: true, tokenDetail: true, costDisplay: true })
296
+ const junk = fakeStorage({ [STATS_LINE_STORAGE_KEY]: JSON.stringify({ cachePrecision: 'yes', tokenDetail: 1 }) })
297
+ assert.deepEqual(createStatsLineState(junk).get(), { cachePrecision: true, tokenDetail: true, costDisplay: true })
298
+ const partialOff = fakeStorage({ [STATS_LINE_STORAGE_KEY]: JSON.stringify({ cachePrecision: false, tokenDetail: true }) })
299
+ assert.deepEqual(createStatsLineState(partialOff).get(), { cachePrecision: false, tokenDetail: true, costDisplay: true })
300
+ })
301
+
302
+ test('createStatsLineState set 合并生效并持久化且通知订阅者', () => {
303
+ const storage = fakeStorage()
304
+ const state = createStatsLineState(storage)
305
+ const seen = []
306
+ state.subscribe(() => seen.push(state.get()))
307
+ state.set({ cachePrecision: false })
308
+ assert.deepEqual(state.get(), { cachePrecision: false, tokenDetail: true, costDisplay: true })
309
+ assert.deepEqual(JSON.parse(storage.getItem(STATS_LINE_STORAGE_KEY)), { cachePrecision: false, tokenDetail: true, costDisplay: true })
310
+ assert.equal(seen.length, 1)
311
+ })
312
+
313
+ test('createStatsLineState subscribe 退订后不再通知', () => {
314
+ const state = createStatsLineState(fakeStorage())
315
+ let count = 0
316
+ const unsubscribe = state.subscribe(() => { count += 1 })
317
+ state.set({ cachePrecision: false })
318
+ unsubscribe()
319
+ state.set({ tokenDetail: false })
320
+ assert.equal(count, 1)
321
+ })
322
+
323
+ test('createStatsLineState reload 吸收外部写入', () => {
324
+ const storage = fakeStorage()
325
+ const state = createStatsLineState(storage)
326
+ let notified = 0
327
+ state.subscribe(() => { notified += 1 })
328
+ storage.setItem(STATS_LINE_STORAGE_KEY, JSON.stringify({ cachePrecision: true, tokenDetail: true }))
329
+ state.reload()
330
+ assert.deepEqual(state.get(), { cachePrecision: true, tokenDetail: true, costDisplay: true })
331
+ assert.equal(notified, 1)
332
+ })
333
+
334
+ test('createStatsLineState 写入失败仅丢持久化,内存仍生效', () => {
335
+ const state = createStatsLineState({
336
+ getItem: () => null,
337
+ setItem: () => { throw new Error('quota') },
338
+ })
339
+ state.set({ tokenDetail: false })
340
+ assert.deepEqual(state.get(), { cachePrecision: true, tokenDetail: false, costDisplay: true })
341
+ })
342
+
343
+ // —— S14 费用组(buildCostItem) ——
344
+
345
+ test('buildCostItem 开关关恒为 null,组数组不受 rules 影响', () => {
346
+ assert.equal(buildCostItem(COST_USAGE, COST_RULES, PREFS_OFF, zhT, COST_NOW), null)
347
+ assert.deepEqual(buildStatsGroups(STATS, USAGE, PREFS_OFF, zhT, COST_RULES), [
348
+ '2 轮 · 3 步',
349
+ 'LLM 1.5秒 · 工具调用 2秒',
350
+ '首 token 平均 0.2秒 · 63 tok/s',
351
+ '缓存命中 80%',
352
+ '输入 5K tok · 输出 500 tok',
353
+ ])
354
+ })
355
+
356
+ test('buildCostItem routes 缺席按通配规则匹配,符号取全局口径(首个非空货币)', () => {
357
+ // 无 routes → model '*',500000 × 1 / 每百万 = 0.5;命中通配(货币空),符号取表内首个非空 ¥
358
+ assert.equal(buildCostItem(COST_USAGE, COST_RULES, PREFS_COST_ON, zhT, COST_NOW), '费用 ≈ ¥0.50')
359
+ assert.equal(buildCostItem(COST_USAGE, COST_RULES, PREFS_COST_ON, enT, COST_NOW), 'Cost ≈ ¥0.50')
360
+ })
361
+
362
+ test('buildCostItem routes 首个 model 精确规则优先于通配', () => {
363
+ const routed = { ...COST_USAGE, routes: [{ provider: 'p', model: 'p/m' }] }
364
+ const wildcardFirst = [COST_RULES[1], COST_RULES[0]]
365
+ // 500000 × 2 / 每百万 = 1,精确规则货币 ¥
366
+ assert.equal(buildCostItem(routed, wildcardFirst, PREFS_COST_ON, zhT, COST_NOW), '费用 ≈ ¥1.00')
367
+ })
368
+
369
+ test('buildCostItem 时间条件按传入时刻评估:窗口外通配不生效', () => {
370
+ // 通配规则 00:00~00:00 from===to 全天生效;08:00~09:00 与固定时刻不交
371
+ const midnight = [{ ...COST_RULES[1], conditions: [{ kind: 'dailyWindow', from: '00:00', to: '00:00' }] }]
372
+ const daytime = [{ ...COST_RULES[1], conditions: [{ kind: 'dailyWindow', from: '08:00', to: '09:00' }] }]
373
+ assert.equal(buildCostItem(COST_USAGE, midnight, PREFS_COST_ON, zhT, COST_NOW), '费用 ≈ 0.50')
374
+ assert.equal(buildCostItem(COST_USAGE, daytime, PREFS_COST_ON, zhT, COST_NOW), '—')
375
+ })
376
+
377
+ test('buildCostItem 价格未加载为 null,规则已载无命中为占位符', () => {
378
+ assert.equal(buildCostItem(COST_USAGE, null, PREFS_COST_ON, zhT, COST_NOW), null)
379
+ assert.equal(buildCostItem(COST_USAGE, [], PREFS_COST_ON, zhT, COST_NOW), '—')
380
+ assert.equal(buildCostItem(COST_USAGE, [{ model: 'other/x', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [] }], PREFS_COST_ON, zhT, COST_NOW), '—')
381
+ })
382
+
383
+ test('buildCostItem usage 缺席为 null', () => {
384
+ assert.equal(buildCostItem(undefined, COST_RULES, PREFS_COST_ON, zhT, COST_NOW), null)
385
+ })
386
+
387
+ test('buildCostItem buildStatsGroups 费用组符号同全局口径', () => {
388
+ // USAGE 无 routes → 通配价 input 1:1000 × 1 / 每百万 = 0.001 → 四位小数微观格式;符号取首个非空 ¥
389
+ assert.deepEqual(buildStatsGroups(STATS, USAGE, PREFS_COST_ON, zhT, COST_RULES), [
390
+ '2 轮 · 3 步',
391
+ 'LLM 1.5秒 · 工具调用 2秒',
392
+ '首 token 平均 0.2秒 · 63 tok/s',
393
+ '缓存命中 80%',
394
+ '输入 5K tok · 输出 500 tok',
395
+ '费用 ≈ ¥0.0010',
396
+ ])
397
+ })
398
+
399
+ test('buildStatsGroups 开+无价不追加费用组', () => {
400
+ assert.deepEqual(buildStatsGroups(STATS, USAGE, PREFS_COST_ON, zhT, null), [
401
+ '2 轮 · 3 步',
402
+ 'LLM 1.5秒 · 工具调用 2秒',
403
+ '首 token 平均 0.2秒 · 63 tok/s',
404
+ '缓存命中 80%',
405
+ '输入 5K tok · 输出 500 tok',
406
+ ])
407
+ })