@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,614 @@
1
+ // client 纯函数层测试:滚动窗口映射/信封解析/上限裁剪/文案/格式化/分组/柱状几何
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
+ CHART_HEIGHT,
20
+ CHART_PAD,
21
+ DAY_MAX_SLOTS,
22
+ DAY_PRESETS,
23
+ DONUT_CENTER_XY,
24
+ DONUT_CIRCUMFERENCE,
25
+ DONUT_OUTER_RADIUS,
26
+ DONUT_RADIUS,
27
+ DONUT_STROKE_WIDTH,
28
+ DONUT_VIEWBOX_SIZE,
29
+ HEAT_BASE,
30
+ HEAT_GAP,
31
+ HEAT_RX,
32
+ HEAT_WEEKS,
33
+ HEAT_WINDOW_DAYS,
34
+ HOUR_PRESETS,
35
+ MINUTE_PRESETS,
36
+ MESSAGES_EN,
37
+ MESSAGES_ZH,
38
+ OTHER_MODEL,
39
+ aggregateCurrencyOf,
40
+ applyCurrencyToRules,
41
+ cacheRateText,
42
+ costOf,
43
+ costTitleText,
44
+ createTranslator,
45
+ daysInRange,
46
+ defaultPricingRule,
47
+ donutSegments,
48
+ formatCompact,
49
+ formatCost,
50
+ formatDuration,
51
+ formatTokens,
52
+ formatTokensCompact,
53
+ groupPointSlots,
54
+ groupStats,
55
+ heatDisplayDays,
56
+ heatGrid,
57
+ heatLayout,
58
+ heatLevel,
59
+ hourTickLabel,
60
+ indexOfDay,
61
+ isEmptyRange,
62
+ matchPrice,
63
+ maxSlotsFor,
64
+ minuteTickLabel,
65
+ modelSegmentLabel,
66
+ modelNameOf,
67
+ niceTicks,
68
+ otherDetailItems,
69
+ parseEnvelope,
70
+ providerOf,
71
+ rateAxisTicks,
72
+ resolveDayRange,
73
+ resolveHourRange,
74
+ resolveMinuteRange,
75
+ shortDay,
76
+ smoothPath,
77
+ statusLineActive,
78
+ tipPlace,
79
+ translateWith,
80
+ trimSlots,
81
+ trendLayout,
82
+ trendRatePoints,
83
+ validatePricingRules,
84
+ } = core
85
+
86
+ const EPSILON = 1e-9
87
+ const approx = (actual, expected) => assert.ok(Math.abs(actual - expected) < EPSILON, `expected ${expected}, got ${actual}`)
88
+
89
+ // 本地时区固定时钟:2026-03-15 14:30:45
90
+ const NOW = new Date(2026, 2, 15, 14, 30, 45)
91
+
92
+ test('day 预设映射为最近 N 天闭区间', () => {
93
+ assert.deepEqual(resolveDayRange('7', NOW), { from: '2026-03-09', to: '2026-03-15' })
94
+ assert.deepEqual(resolveDayRange('14', NOW), { from: '2026-03-02', to: '2026-03-15' })
95
+ })
96
+
97
+ test('day 预设跨年与跨月边界', () => {
98
+ assert.deepEqual(resolveDayRange('7', new Date(2026, 0, 3)), { from: '2025-12-28', to: '2026-01-03' })
99
+ assert.deepEqual(resolveDayRange('30', new Date(2026, 2, 5)), { from: '2026-02-04', to: '2026-03-05' })
100
+ })
101
+
102
+ test('day 90 天预设起点回卷上年', () => {
103
+ assert.deepEqual(resolveDayRange('90', NOW), { from: '2025-12-16', to: '2026-03-15' })
104
+ })
105
+
106
+ test('hour 预设 now-N 舍入到整点桶起点', () => {
107
+ assert.deepEqual(resolveHourRange('24h', NOW), { from: '2026-03-14T14', to: '2026-03-15T14' })
108
+ assert.deepEqual(resolveHourRange('72h', NOW), { from: '2026-03-12T14', to: '2026-03-15T14' })
109
+ })
110
+
111
+ test('hour 预设跨日且窗口起点落整点', () => {
112
+ assert.deepEqual(resolveHourRange('24h', new Date(2026, 2, 15, 0, 30)), { from: '2026-03-14T00', to: '2026-03-15T00' })
113
+ })
114
+
115
+ test('minute 预设起点对齐 10 分钟桶,to 保持原始分钟', () => {
116
+ assert.deepEqual(resolveMinuteRange('60m', NOW), { from: '2026-03-15T13:30', to: '2026-03-15T14:30' })
117
+ assert.deepEqual(resolveMinuteRange('360m', NOW), { from: '2026-03-15T08:30', to: '2026-03-15T14:30' })
118
+ const offGrid = new Date(2026, 2, 15, 14, 37, 20)
119
+ assert.deepEqual(resolveMinuteRange('60m', offGrid), { from: '2026-03-15T13:30', to: '2026-03-15T14:37' })
120
+ })
121
+
122
+ test('minute 预设整桶边界不再回退', () => {
123
+ assert.deepEqual(resolveMinuteRange('60m', new Date(2026, 2, 15, 14, 30, 0)), { from: '2026-03-15T13:30', to: '2026-03-15T14:30' })
124
+ })
125
+
126
+ test('预设定义表覆盖三视图', () => {
127
+ assert.deepEqual(DAY_PRESETS, ['7', '14', '30', '90'])
128
+ assert.deepEqual(HOUR_PRESETS, ['24h', '48h', '72h'])
129
+ assert.deepEqual(MINUTE_PRESETS, ['60m', '180m', '360m'])
130
+ })
131
+
132
+ test('每视图渲染上限等于闭区间桶数', () => {
133
+ assert.equal(maxSlotsFor('day', '90'), DAY_MAX_SLOTS)
134
+ 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)
140
+ })
141
+
142
+ test('trim 超上限裁最旧且恰好达上限不裁', () => {
143
+ const slots = ['1', '2', '3', '4', '5', '6'].map((day) => ({ day }))
144
+ assert.deepEqual(trimSlots(slots, 3).map((slot) => slot.day), ['4', '5', '6'])
145
+ assert.equal(trimSlots(slots, 6), slots)
146
+ })
147
+
148
+ test('信封解析成功透传 value', () => {
149
+ assert.deepEqual(parseEnvelope({ ok: true, value: { a: 1 } }), { ok: true, value: { a: 1 } })
150
+ })
151
+
152
+ test('信封解析失败透传 code 与 message', () => {
153
+ assert.deepEqual(
154
+ parseEnvelope({ ok: false, error: { code: 'forbidden', message: '拒绝' } }),
155
+ { ok: false, code: 'forbidden', message: '拒绝' },
156
+ )
157
+ })
158
+
159
+ test('信封解析键缺失回退默认 code 与 message', () => {
160
+ assert.equal(parseEnvelope({ ok: false }).code, 'error')
161
+ assert.equal(parseEnvelope({ ok: false, error: {} }).message, 'usage api error')
162
+ assert.equal(parseEnvelope(null).ok, false)
163
+ assert.equal(parseEnvelope('junk').code, 'error')
164
+ })
165
+
166
+ test('translateWith zh/en 占位替换与缺键回退键名', () => {
167
+ assert.equal(translateWith(MESSAGES_ZH, 'status.running', { done: 2, total: 9 }), '回扫中 2/9')
168
+ assert.equal(translateWith(MESSAGES_ZH, 'trendLimited', { n: 45 }), '仅显示最近 45 天')
169
+ assert.equal(translateWith(MESSAGES_ZH, 'refresh'), '刷新')
170
+ assert.equal(translateWith(MESSAGES_ZH, 'no.such.key'), 'no.such.key')
171
+ assert.equal(translateWith(MESSAGES_EN, 'skippedSessions', { n: 2 }), '2 unreadable sessions skipped')
172
+ assert.equal(translateWith(MESSAGES_EN, 'trendLimited', { n: 45 }), 'Showing only the last 45 days')
173
+ assert.equal(translateWith(MESSAGES_EN, 'rebuild'), 'Rebuild')
174
+ assert.equal(translateWith(MESSAGES_EN, 'no.such.key'), 'no.such.key')
175
+ })
176
+
177
+ test('createTranslator 与纯查表同构', () => {
178
+ const enT = createTranslator(MESSAGES_EN)
179
+ assert.equal(enT('rangeCustom'), 'Custom')
180
+ assert.equal(enT('hourPreset', { n: 48 }), 'Last 48 hours')
181
+ assert.equal(enT('missing.key'), 'missing.key')
182
+ })
183
+
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
+ test('zh/en 词典键集完全一致', () => {
194
+ assert.deepEqual(Object.keys(MESSAGES_ZH).sort(), Object.keys(MESSAGES_EN).sort())
195
+ })
196
+
197
+ test('文案表覆盖 client.js 全部 t 键', () => {
198
+ const source = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../src/client.js'), 'utf8')
199
+ const keys = [...source.matchAll(/\bt\('([^']+)'\)/g)].map((match) => match[1])
200
+ assert.ok(keys.length >= 10, `应从 client.js 抽到 t 调用,实际 ${keys.length}`)
201
+ for (const key of keys) assert.ok(key in MESSAGES_ZH, `缺少文案键: ${key}`)
202
+ })
203
+
204
+ test('formatDuration/formatTokensCompact 传 en 翻译器输出官方口径', () => {
205
+ const enT = createTranslator(MESSAGES_EN)
206
+ assert.equal(formatDuration(4500, enT), '4.5s')
207
+ assert.equal(formatDuration(162000, enT), '2m42s')
208
+ assert.equal(formatTokensCompact(12200, enT), '12.2K')
209
+ assert.equal(formatTokensCompact(5000, enT), '5K')
210
+ })
211
+
212
+ test('数字格式化千分位与紧凑单位', () => {
213
+ assert.equal(formatTokens(1234567), '1,234,567')
214
+ assert.equal(formatCompact(999), '999')
215
+ assert.equal(formatCompact(1234), '1.2k')
216
+ assert.equal(formatCompact(1234567), '1.2M')
217
+ assert.equal(formatCompact(2.5e9), '2.5B')
218
+ })
219
+
220
+ test('命中率文案零数据为占位符', () => {
221
+ assert.equal(cacheRateText(0, 0), '—')
222
+ assert.equal(cacheRateText(30, 70), '30.0%')
223
+ })
224
+
225
+ test('模型 ref 拆分与缺省 provider', () => {
226
+ assert.equal(modelNameOf('openai/gpt'), 'gpt')
227
+ assert.equal(providerOf('openai/gpt'), 'openai')
228
+ assert.equal(modelNameOf('solo'), 'solo')
229
+ assert.equal(providerOf('solo'), 'default')
230
+ })
231
+
232
+ test('X 轴标签日界槽叠加日期其余纯时分', () => {
233
+ assert.equal(shortDay('2026-03-05'), '3/5')
234
+ assert.equal(hourTickLabel('2026-03-15T09'), '09:00')
235
+ assert.equal(hourTickLabel('2026-03-15T00'), '3/15 00:00')
236
+ assert.equal(minuteTickLabel('2026-03-15T09:05'), '09:05')
237
+ assert.equal(minuteTickLabel('2026-03-15T00:00'), '3/15 00:00')
238
+ })
239
+
240
+ test('空态判定仅四项全零成立', () => {
241
+ assert.equal(isEmptyRange({ tokens: 0, cacheHit: 0, requests: 0, turns: 0 }), true)
242
+ assert.equal(isEmptyRange({ tokens: 1, cacheHit: 0, requests: 0, turns: 0 }), false)
243
+ assert.equal(isEmptyRange({ tokens: 0, cacheHit: 0, requests: 0, turns: 3 }), false)
244
+ })
245
+
246
+ test('groupStats 折叠 top5 之外为其他哨兵并归并逐日', () => {
247
+ const stats = {
248
+ models: [
249
+ { model: 'p/m1', tokens: 500 },
250
+ { model: 'p/m2', tokens: 400 },
251
+ { model: 'p/m3', tokens: 300 },
252
+ { model: 'p/m4', tokens: 200 },
253
+ { model: 'p/m5', tokens: 100 },
254
+ { model: 'p/m6', tokens: 50 },
255
+ ],
256
+ daily: [
257
+ { day: '2026-03-14', total: 1000, byModel: { 'p/m1': 400, 'p/m2': 500, 'p/m6': 100 } },
258
+ { day: '2026-03-15', total: 550, byModel: { 'p/m3': 300, 'p/m6': 250 } },
259
+ ],
260
+ }
261
+ const grouped = groupStats(stats)
262
+ assert.deepEqual(grouped.models.map((m) => m.model), ['p/m1', 'p/m2', 'p/m3', 'p/m4', 'p/m5', OTHER_MODEL])
263
+ assert.equal(grouped.models[5].tokens, 50)
264
+ assert.deepEqual(grouped.daily[0].byModel, { 'p/m1': 400, 'p/m2': 500, [OTHER_MODEL]: 100 })
265
+ assert.deepEqual(grouped.daily[1].byModel, { 'p/m3': 300, [OTHER_MODEL]: 250 })
266
+ })
267
+
268
+ test('groupPointSlots 跨槽求和排名且不超上限不折叠', () => {
269
+ const slots = [
270
+ { day: '2026-03-15T10', total: 15, byModel: { a: 10, b: 5 } },
271
+ { day: '2026-03-15T11', total: 8, byModel: { a: 1, c: 7 } },
272
+ ]
273
+ const grouped = groupPointSlots(slots)
274
+ assert.deepEqual(grouped.models.map((m) => m.model), ['a', 'c', 'b'])
275
+ assert.deepEqual(grouped.models.map((m) => m.tokens), [11, 7, 5])
276
+ assert.deepEqual(grouped.daily[1].byModel, { a: 1, c: 7 })
277
+ })
278
+
279
+ test('groupPointSlots 窗口模型超上限折叠其他', () => {
280
+ const slots = [
281
+ { day: 'h0', total: 6, byModel: { m1: 1, m2: 1, m3: 1, m4: 1, m5: 1, m6: 1 } },
282
+ { day: 'h1', total: 6, byModel: { m1: 3, m6: 3 } },
283
+ ]
284
+ const grouped = groupPointSlots(slots)
285
+ assert.equal(grouped.models.at(-1).model, OTHER_MODEL)
286
+ assert.equal(grouped.models.at(-1).tokens, 1)
287
+ assert.deepEqual(grouped.daily[0].byModel, { m1: 1, m2: 1, m3: 1, m4: 1, m6: 1, [OTHER_MODEL]: 1 })
288
+ })
289
+
290
+ test('niceTicks 产生 1/2/5 序列且零上限为空', () => {
291
+ assert.deepEqual(niceTicks(0, 4), [])
292
+ assert.deepEqual(niceTicks(50, 4), [20, 40])
293
+ assert.deepEqual(niceTicks(1, 4), [0.5, 1])
294
+ })
295
+
296
+ test('trendLayout 单槽列距铺满且柱宽取上限', () => {
297
+ const layout = trendLayout([{ day: '2026-03-15', total: 50, byModel: { a: 30, b: 20 } }], ['a', 'b'], 720, 46)
298
+ assert.equal(layout.step, 720 - 46 - 65)
299
+ assert.equal(layout.barWidth, 30)
300
+ assert.equal(layout.maxTotal, 50)
301
+ })
302
+
303
+ test('trendLayout 零值槽无分段但保留刻度', () => {
304
+ const layout = trendLayout([{ day: '2026-03-15', total: 0, byModel: {} }], [], 720, 46)
305
+ assert.deepEqual(layout.bars[0].segments, [])
306
+ assert.deepEqual(layout.ticks, [0.5, 1])
307
+ })
308
+
309
+ test('trendLayout 堆叠分段自底向上且高度求和等于绘图高', () => {
310
+ const layout = trendLayout([{ day: 'd', total: 50, byModel: { a: 30, b: 20 } }], ['a', 'b'], 720, 46)
311
+ const [segA, segB] = layout.bars[0].segments
312
+ assert.deepEqual(segA.model, 'a')
313
+ 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)
317
+ approx(segB.y + segB.height, segA.y)
318
+ })
319
+
320
+ test('trendLayout 标签密度随列距收敛', () => {
321
+ const slots = Array.from({ length: 100 }, (_, i) => ({ day: `d${i}`, total: 0, byModel: {} }))
322
+ const layout = trendLayout(slots, [], 720, 46)
323
+ assert.equal(layout.labelEvery, 8)
324
+ })
325
+
326
+ test('热力图常量锁定窗口与几何基准', () => {
327
+ assert.equal(HEAT_WEEKS, 26)
328
+ assert.equal(HEAT_WINDOW_DAYS, HEAT_WEEKS * 7)
329
+ assert.equal(HEAT_BASE, 14)
330
+ assert.equal(HEAT_GAP, 3)
331
+ assert.equal(HEAT_RX, 3)
332
+ })
333
+
334
+ test('indexOfDay 周一为零周日为六', () => {
335
+ assert.equal(indexOfDay('2026-03-09'), 0)
336
+ assert.equal(indexOfDay('2026-03-11'), 2)
337
+ assert.equal(indexOfDay('2026-03-15'), 6)
338
+ })
339
+
340
+ test('daysInRange 闭区间含两端且跨月连续', () => {
341
+ assert.deepEqual(daysInRange('2026-02-27', '2026-03-02'), ['2026-02-27', '2026-02-28', '2026-03-01', '2026-03-02'])
342
+ assert.deepEqual(daysInRange('2026-03-15', '2026-03-15'), ['2026-03-15'])
343
+ })
344
+
345
+ test('daysInRange 非法日期返回空表', () => {
346
+ assert.deepEqual(daysInRange('junk', 'junk'), [])
347
+ })
348
+
349
+ test('heatLayout 过窄保基准尺寸裁到可容列数', () => {
350
+ assert.deepEqual(heatLayout(200, '2026-03-09'), { size: HEAT_BASE, cols: 11 })
351
+ })
352
+
353
+ test('heatLayout 够宽连续生长铺满窗口', () => {
354
+ const layout = heatLayout(800, '2026-03-09')
355
+ assert.equal(layout.cols, HEAT_WEEKS)
356
+ approx(layout.size, 798 / 27 - 3)
357
+ })
358
+
359
+ test('heatLayout 生长下限不小于基准尺寸', () => {
360
+ assert.deepEqual(heatLayout(460, '2026-03-09'), { size: HEAT_BASE, cols: HEAT_WEEKS })
361
+ })
362
+
363
+ test('heatLayout 极窄至少保一列', () => {
364
+ assert.deepEqual(heatLayout(0, '2026-03-09'), { size: HEAT_BASE, cols: 1 })
365
+ })
366
+
367
+ test('heatDisplayDays 裁剪取尾部保最新且整窗不裁', () => {
368
+ const all = daysInRange('2025-09-15', '2026-03-15')
369
+ assert.equal(all.length, HEAT_WINDOW_DAYS)
370
+ assert.deepEqual(heatDisplayDays(all, 11), all.slice(-11 * 7))
371
+ assert.deepEqual(heatDisplayDays(all, HEAT_WEEKS), all)
372
+ })
373
+
374
+ test('heatGrid 周首位移换行且坐标含格距', () => {
375
+ const rows = daysInRange('2026-03-09', '2026-03-15').map((day) => ({ day }))
376
+ const grid = heatGrid(rows, 14)
377
+ assert.equal(grid.weeks, 2)
378
+ assert.deepEqual(grid.cells[0], { day: '2026-03-09', x: HEAT_GAP, y: HEAT_GAP + 17 })
379
+ assert.deepEqual(grid.cells[6], { day: '2026-03-15', x: HEAT_GAP + 17, y: HEAT_GAP })
380
+ assert.equal(grid.width, 2 * 17 + HEAT_GAP)
381
+ assert.equal(grid.height, 7 * 17 + HEAT_GAP)
382
+ })
383
+
384
+ test('heatGrid 周日对齐零位移整周单列', () => {
385
+ const rows = daysInRange('2026-03-15', '2026-03-21').map((day) => ({ day }))
386
+ const grid = heatGrid(rows, 14)
387
+ assert.equal(grid.weeks, 1)
388
+ assert.deepEqual(grid.cells[6], { day: '2026-03-21', x: HEAT_GAP, y: HEAT_GAP + 6 * 17 })
389
+ assert.equal(grid.width, 17 + HEAT_GAP)
390
+ })
391
+
392
+ test('heatLevel 零值为空档其余按峰值四分位', () => {
393
+ assert.equal(heatLevel(0, 100), 0)
394
+ assert.equal(heatLevel(24, 100), 1)
395
+ assert.equal(heatLevel(25, 100), 2)
396
+ assert.equal(heatLevel(50, 100), 3)
397
+ assert.equal(heatLevel(75, 100), 4)
398
+ assert.equal(heatLevel(100, 100), 5)
399
+ assert.equal(heatLevel(1, 1), 5)
400
+ })
401
+
402
+ const TIP_ANCHOR = { left: 100, top: 100, right: 140, bottom: 114 }
403
+ const TIP_SIZE = { width: 80, height: 40 }
404
+ const TIP_BOUNDS = { left: 0, top: 0, right: 800, bottom: 600 }
405
+
406
+ test('tipPlace 默认锚点下方水平居中', () => {
407
+ assert.deepEqual(tipPlace(TIP_ANCHOR, TIP_SIZE, TIP_BOUNDS), { left: 80, top: 122 })
408
+ })
409
+
410
+ test('tipPlace 下方放不下翻转上方', () => {
411
+ const anchor = { ...TIP_ANCHOR, top: 540, bottom: 580 }
412
+ assert.equal(tipPlace(anchor, TIP_SIZE, TIP_BOUNDS).top, 492)
413
+ })
414
+
415
+ test('tipPlace 两侧均放不下钳到边界顶', () => {
416
+ const bounds = { left: 0, top: 0, right: 800, bottom: 50 }
417
+ const anchor = { left: 100, top: 20, right: 140, bottom: 50 }
418
+ assert.equal(tipPlace(anchor, TIP_SIZE, bounds).top, 8)
419
+ })
420
+
421
+ test('tipPlace 水平越界钳到边界左缘', () => {
422
+ const anchor = { left: 2, top: 100, right: 10, bottom: 114 }
423
+ assert.equal(tipPlace(anchor, TIP_SIZE, TIP_BOUNDS).left, 8)
424
+ })
425
+
426
+ test('tipPlace 零尺寸或全零锚定矩形返回隐藏', () => {
427
+ assert.equal(tipPlace(TIP_ANCHOR, { width: 0, height: 0 }, TIP_BOUNDS), null)
428
+ assert.equal(tipPlace({ left: 0, top: 0, right: 0, bottom: 0 }, TIP_SIZE, TIP_BOUNDS), null)
429
+ assert.equal(tipPlace(null, TIP_SIZE, TIP_BOUNDS), null)
430
+ })
431
+
432
+ // —— S8 命中率曲线 ——
433
+
434
+ test('命中率副轴固定零到百五等分刻度', () => {
435
+ assert.deepEqual(rateAxisTicks(), [0, 25, 50, 75, 100])
436
+ })
437
+
438
+ test('trendRatePoints 点映射列中心与百分比高度且零数据日跳过', () => {
439
+ const slots = [
440
+ { day: 'd0', cacheHit: 75, cacheMiss: 25 },
441
+ { day: 'd1', cacheHit: 0, cacheMiss: 0 },
442
+ { day: 'd2', cacheHit: 50, cacheMiss: 50 },
443
+ ]
444
+ const bars = [{ x: 10 }, { x: 20 }, { x: 30 }]
445
+ const plotHeight = CHART_HEIGHT - CHART_PAD.top - CHART_PAD.bottom
446
+ const points = trendRatePoints(slots, bars, plotHeight)
447
+ assert.equal(points.length, 2)
448
+ assert.equal(points[0].day, 'd0')
449
+ assert.equal(points[0].x, 10)
450
+ approx(points[0].y, CHART_PAD.top + plotHeight - 0.75 * plotHeight)
451
+ assert.equal(points[1].day, 'd2')
452
+ assert.equal(points[1].x, 30)
453
+ approx(points[1].y, CHART_PAD.top + plotHeight - 0.5 * plotHeight)
454
+ })
455
+
456
+ test('smoothPath 空点集为空串单点为移动命令', () => {
457
+ assert.equal(smoothPath([]), '')
458
+ assert.equal(smoothPath([{ x: 1, y: 2 }]), 'M 1 2')
459
+ })
460
+
461
+ test('smoothPath 两点控制点取邻点差六分之一端点折返', () => {
462
+ const d = smoothPath([{ x: 0, y: 0 }, { x: 60, y: 30 }])
463
+ assert.equal(d, 'M 0 0 C 10 5, 50 25, 60 30')
464
+ })
465
+
466
+ test('smoothPath 三点后段取真实邻点', () => {
467
+ const d = smoothPath([{ x: 0, y: 0 }, { x: 60, y: 30 }, { x: 120, y: 0 }])
468
+ assert.equal(d, 'M 0 0 C 10 5, 40 30, 60 30 C 80 30, 110 5, 120 0')
469
+ })
470
+
471
+ // —— S8 模型 donut 与列表 ——
472
+
473
+ test('donut 常量锁定视口与环几何', () => {
474
+ assert.equal(DONUT_VIEWBOX_SIZE, 200)
475
+ assert.equal(DONUT_CENTER_XY, 100)
476
+ assert.equal(DONUT_OUTER_RADIUS, 95)
477
+ assert.equal(DONUT_STROKE_WIDTH, 30)
478
+ assert.equal(DONUT_RADIUS, DONUT_OUTER_RADIUS - DONUT_STROKE_WIDTH / 2)
479
+ approx(DONUT_CIRCUMFERENCE, 2 * Math.PI * DONUT_RADIUS)
480
+ })
481
+
482
+ test('donutSegments 分段 dash 按 token 占比 offset 渲染序累加', () => {
483
+ const segments = donutSegments([{ model: 'a', tokens: 75 }, { model: 'b', tokens: 25 }], 100)
484
+ approx(segments[0].dash, DONUT_CIRCUMFERENCE * 0.75)
485
+ assert.equal(segments[0].offset, 0)
486
+ approx(segments[0].percent, 75)
487
+ approx(segments[1].dash, DONUT_CIRCUMFERENCE * 0.25)
488
+ approx(segments[1].offset, DONUT_CIRCUMFERENCE * 0.75)
489
+ approx(segments[0].dash + segments[1].dash, DONUT_CIRCUMFERENCE)
490
+ })
491
+
492
+ test('donutSegments 全零 total 回落下限防除零', () => {
493
+ const segments = donutSegments([{ model: 'a', tokens: 0 }], 0)
494
+ assert.equal(segments.length, 1)
495
+ assert.equal(segments[0].dash, 0)
496
+ assert.equal(segments[0].offset, 0)
497
+ assert.equal(segments[0].percent, 0)
498
+ })
499
+
500
+ test('groupStats 哨兵保留其他模型明细供展开与提示', () => {
501
+ const stats = {
502
+ models: [
503
+ { model: 'p/m1', tokens: 500 },
504
+ { model: 'p/m2', tokens: 400 },
505
+ { model: 'p/m3', tokens: 300 },
506
+ { model: 'p/m4', tokens: 200 },
507
+ { model: 'p/m5', tokens: 100 },
508
+ { model: 'p/m6', tokens: 50 },
509
+ ],
510
+ daily: [],
511
+ }
512
+ const grouped = groupStats(stats)
513
+ assert.deepEqual(grouped.models[5].items, [{ model: 'p/m6', tokens: 50 }])
514
+ })
515
+
516
+ test('groupStats 逐日保留其他明细映射供 tooltip', () => {
517
+ const stats = {
518
+ models: Array.from({ length: 6 }, (_, index) => ({ model: `m${index + 1}`, tokens: 70 - (index + 1) * 10 })),
519
+ daily: [{ day: 'd', total: 110, byModel: { m1: 50, m2: 40, m6: 20 } }],
520
+ }
521
+ const grouped = groupStats(stats)
522
+ assert.deepEqual(grouped.daily[0].byModel, { m1: 50, m2: 40, [OTHER_MODEL]: 20 })
523
+ assert.deepEqual(grouped.daily[0].otherByModel, { m6: 20 })
524
+ })
525
+
526
+ test('otherDetailItems 取哨兵明细无哨兵为空表', () => {
527
+ const withOther = [
528
+ { model: 'a', tokens: 9 },
529
+ { model: OTHER_MODEL, tokens: 3, items: [{ model: 'x', tokens: 2 }, { model: 'y', tokens: 1 }] },
530
+ ]
531
+ assert.deepEqual(otherDetailItems(withOther), [{ model: 'x', tokens: 2 }, { model: 'y', tokens: 1 }])
532
+ assert.deepEqual(otherDetailItems([{ model: 'a', tokens: 9 }]), [])
533
+ })
534
+
535
+ test('donut 分段可访问标签格式化名称数值与占比', () => {
536
+ assert.equal(modelSegmentLabel('p/m', 1234, 12.34), 'p/m: 1,234 (12.3%)')
537
+ })
538
+
539
+ // —— S14 费用格式化与展示辅助(镜像函数核心语义见 pricing-parity.test.mjs) ——
540
+
541
+ test('formatCost 千分位与两位小数', () => {
542
+ assert.equal(formatCost(1234567.891, '¥'), '¥1,234,567.89')
543
+ assert.equal(formatCost(1234.5, '¥'), '¥1,234.50')
544
+ assert.equal(formatCost(1.5, ''), '1.50')
545
+ assert.equal(formatCost(0, '$'), '$0.00')
546
+ })
547
+
548
+ test('formatCost 微观值四位小数', () => {
549
+ assert.equal(formatCost(0.001, ''), '0.0010')
550
+ assert.equal(formatCost(0.009999, '$'), '$0.0100')
551
+ })
552
+
553
+ test('镜像函数基本行为:非法输入 null 与命中计价', () => {
554
+ assert.equal(matchPrice(null, 'm', NOW), null)
555
+ assert.equal(matchPrice([], 'm', NOW), null)
556
+ assert.deepEqual(
557
+ matchPrice([{ model: 'm', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [] }], 'm', NOW),
558
+ { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 },
559
+ )
560
+ assert.equal(costOf({ input: 2, output: 0, cacheRead: 0, cacheWrite: 0 }, { inputTokens: 1000 * 1000 }), 2)
561
+ })
562
+
563
+ test('aggregateCurrencyOf 取首个非空货币,无则空串', () => {
564
+ assert.equal(aggregateCurrencyOf([{ currency: '' }, { currency: '$' }]), '$')
565
+ assert.equal(aggregateCurrencyOf([{ currency: '¥' }]), '¥')
566
+ assert.equal(aggregateCurrencyOf([{ currency: '' }]), '')
567
+ assert.equal(aggregateCurrencyOf(null), '')
568
+ })
569
+
570
+ test('applyCurrencyToRules 整表统一货币,不改其余字段且不动原数组', () => {
571
+ const rules = [
572
+ { model: 'a', currency: '', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [] },
573
+ { model: '*', currency: '¥', price: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ type: 'weekdays', days: [1] }] },
574
+ ]
575
+ const stamped = applyCurrencyToRules(rules, '$')
576
+ assert.equal(stamped.length, rules.length)
577
+ assert.equal(stamped[0].currency, '$')
578
+ assert.equal(stamped[1].currency, '$')
579
+ assert.deepEqual(stamped[1].conditions, [{ type: 'weekdays', days: [1] }])
580
+ assert.deepEqual(stamped[1].price, rules[1].price)
581
+ assert.equal(rules[0].currency, '')
582
+ assert.equal(rules[1].currency, '¥')
583
+ })
584
+
585
+ test('applyCurrencyToRules 空表恒等', () => {
586
+ assert.deepEqual(applyCurrencyToRules([], '$'), [])
587
+ })
588
+
589
+ test('defaultPricingRule 承接给定货币,缺省回落首档', () => {
590
+ assert.equal(defaultPricingRule('$').currency, '$')
591
+ assert.equal(defaultPricingRule().currency, '¥')
592
+ assert.equal(defaultPricingRule('$').model, '')
593
+ assert.deepEqual(defaultPricingRule('$').price, { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 })
594
+ assert.deepEqual(defaultPricingRule('$').conditions, [])
595
+ })
596
+
597
+ test('validatePricingRules 就地校验 model 必填与价格非空非负', () => {
598
+ const valid = [{ model: 'p/m', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [] }]
599
+ assert.equal(validatePricingRules(valid).size, 0)
600
+ const broken = [
601
+ { model: ' ', currency: '¥', price: { input: '', output: -1, cacheRead: 0, cacheWrite: 0 }, conditions: [] },
602
+ ]
603
+ const errors = validatePricingRules(broken)
604
+ assert.equal(errors.get('0.model'), 'required')
605
+ assert.equal(errors.get('0.price.input'), 'required')
606
+ assert.equal(errors.get('0.price.output'), 'priceInvalid')
607
+ assert.equal(errors.has('0.price.cacheRead'), false)
608
+ })
609
+
610
+ test('costTitleText 未计价计数后缀', () => {
611
+ const zhT = createTranslator(MESSAGES_ZH)
612
+ assert.equal(costTitleText(zhT, 0), '按当前费率对历史用量估算,精度为小时级')
613
+ assert.equal(costTitleText(zhT, 3), '按当前费率对历史用量估算,精度为小时级,3 个小时桶未计价')
614
+ })