@mzzsfy/dsh-usage-stats 0.2.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,278 @@
1
+ // v2 纯逻辑层测试:账本 v2 会话索引、币种折算、价格匹配链、汇率、CSV、仪表盘点位、JS 费用条沙箱。
2
+ import { test } from 'node:test'
3
+ import assert from 'node:assert/strict'
4
+
5
+ import {
6
+ createLedger,
7
+ dayKeyOf,
8
+ recordCall,
9
+ pruneLedger,
10
+ ensureSessionsIndex,
11
+ sessionTotals,
12
+ } from '../src/ledger.mjs'
13
+ import {
14
+ normalizeCustomPrices,
15
+ matchPrice,
16
+ nativeCostOfCall,
17
+ toUsd,
18
+ catalogOrFallback,
19
+ } from '../src/pricing.mjs'
20
+ import { DEFAULT_USD_CNY, RATE_TTL_MS, isRateStale, parseTencentRate, parseErApiRate, resolveRate } from '../src/rates.mjs'
21
+ import { csvCell, csvRow, toCsv } from '../src/csv.mjs'
22
+ import { monthOverview, dayTrend, monthHeatmap, sessionsView, buildDashboard } from '../src/dashboard.mjs'
23
+ import { compileFeeBar, renderFeeBar, turnCostOf, FEE_BAR_SAMPLE } from '../src/feebar.mjs'
24
+
25
+ const DAY_MS = 24 * 60 * 60 * 1000
26
+ const PER_MILLION = 1000000
27
+
28
+ function callAt(ts, overrides) {
29
+ return Object.assign({
30
+ at: ts,
31
+ sessionId: 'sess-1',
32
+ model: 'deepseek-chat',
33
+ provider: 'deepseek',
34
+ inputTokens: 1000,
35
+ cacheReadTokens: 500,
36
+ outputTokens: 200,
37
+ }, overrides)
38
+ }
39
+
40
+ // ---- 账本 v2:顶层会话索引 ----
41
+
42
+ test('账本 v2:recordCall 同步累加顶层会话索引,firstAt/lastAt 取 call.at', () => {
43
+ const t0 = new Date(2026, 1, 26, 10).getTime()
44
+ const ledger = createLedger()
45
+ recordCall(ledger, callAt(t0), 0.01)
46
+ recordCall(ledger, callAt(t0 + DAY_MS, { inputTokens: 2000 }), 0.02)
47
+ const row = ledger.sessions['sess-1']
48
+ assert.equal(row.firstAt, t0)
49
+ assert.equal(row.lastAt, t0 + DAY_MS)
50
+ assert.equal(row.calls, 2)
51
+ assert.equal(row.inputTokens, 3000)
52
+ assert.ok(Math.abs(row.cost - 0.03) < 1e-9)
53
+ // 按日聚合并存
54
+ assert.equal(Object.keys(ledger.days).length, 2)
55
+ })
56
+
57
+ test('账本 v2:旧结构载入视为空索引,增量写入不回填,版本号置位', () => {
58
+ const t0 = new Date(2026, 1, 26, 10).getTime()
59
+ const legacy = { version: 1, days: {} }
60
+ const ledger = ensureSessionsIndex(legacy)
61
+ assert.equal(ledger.version, 2, '旧账本载入后版本号升级为当前版本')
62
+ assert.deepEqual(Object.keys(ledger.sessions), [])
63
+ recordCall(ledger, callAt(t0, { sessionId: 'sess-new' }), 0.02)
64
+ assert.equal(ledger.sessions['sess-new'].calls, 1)
65
+ assert.equal(ledger.sessions['sess-1'], undefined)
66
+ })
67
+
68
+ test('账本 v2:修剪按 lastAt 清理超期会话索引', () => {
69
+ const base = new Date(2026, 1, 26, 10).getTime()
70
+ const ledger = createLedger()
71
+ recordCall(ledger, callAt(base), 0.01)
72
+ recordCall(ledger, callAt(base - 30 * DAY_MS, { sessionId: 'old' }), 0.01)
73
+ pruneLedger(ledger, dayKeyOf(base), 3)
74
+ assert.equal(ledger.sessions['old'], undefined)
75
+ assert.ok(ledger.sessions['sess-1'])
76
+ })
77
+
78
+ test('账本 v2:sessionTotals 跨日合并读数', () => {
79
+ const t0 = new Date(2026, 1, 26, 10).getTime()
80
+ const ledger = createLedger()
81
+ recordCall(ledger, callAt(t0), 0.01)
82
+ recordCall(ledger, callAt(t0 + DAY_MS, { sessionId: 'sess-2' }), 0.02)
83
+ const totals = sessionTotals(ledger, 'sess-1')
84
+ assert.equal(totals.calls, 1)
85
+ assert.equal(sessionTotals(ledger, 'missing'), null)
86
+ })
87
+
88
+ // ---- 币种折算与价格匹配链 ----
89
+
90
+ test('计价:CNY 原生直算,USD 按汇率折算入账本 USD 口径', () => {
91
+ const cny = { input: 2, output: 4, cacheRead: 0.2, currency: 'CNY' }
92
+ const native = nativeCostOfCall({ inputTokens: PER_MILLION, cacheReadTokens: 0, outputTokens: 0 }, cny)
93
+ assert.equal(native.currency, 'CNY')
94
+ assert.ok(Math.abs(native.amount - 2) < 1e-9)
95
+ // 汇率 8 CNY/USD,2 CNY = 0.25 USD
96
+ assert.ok(Math.abs(toUsd(native.amount, 'CNY', 8) - 0.25) < 1e-9)
97
+ const usd = nativeCostOfCall({ inputTokens: PER_MILLION, cacheReadTokens: 0, outputTokens: 0 }, { input: 2, output: 4, cacheRead: null, currency: 'USD' })
98
+ assert.ok(Math.abs(usd.amount - 2) < 1e-9)
99
+ assert.ok(Math.abs(toUsd(2, 'USD', 8) - 2) < 1e-9)
100
+ })
101
+
102
+ test('自定义单价:归一化支持精确与前缀键,大小写不敏感', () => {
103
+ const table = normalizeCustomPrices([
104
+ { model: 'DeepSeek/DeepSeek-Chat', input: 2, output: 4, cacheRead: 0.2, currency: 'CNY' },
105
+ { model: 'deepseek-r', input: 1, output: 2, cacheRead: null, currency: 'CNY' },
106
+ ])
107
+ assert.equal(table.length, 2)
108
+ assert.deepEqual(table[0].keys, ['deepseek/deepseek-chat', 'deepseek-chat'])
109
+ assert.deepEqual(table[1].keys, ['deepseek-r'])
110
+ })
111
+
112
+ test('匹配链:自定义 provider/model 精确 -> 自定义精确 -> 自定义最长前缀 -> 目录价', () => {
113
+ const custom = normalizeCustomPrices([
114
+ { model: 'deepseek/deepseek-chat', input: 2, output: 4, cacheRead: null, currency: 'CNY' },
115
+ { model: 'deepseek-chat', input: 8, output: 8, cacheRead: null, currency: 'CNY' },
116
+ { model: 'gpt', input: 1, output: 1, cacheRead: null, currency: 'USD' },
117
+ ])
118
+ const catalog = normalizeCustomPrices([
119
+ { model: 'openai/gpt-4', input: 10, output: 20, cacheRead: null, currency: 'USD' },
120
+ ])
121
+ assert.equal(matchPrice(custom, catalog, 'deepseek', 'deepseek-chat').tier, 'custom-full')
122
+ assert.equal(matchPrice(custom, catalog, 'other', 'deepseek-chat').tier, 'custom-name')
123
+ const prefix = matchPrice(custom, catalog, 'other', 'gpt-4o-mini')
124
+ assert.equal(prefix.tier, 'custom-prefix')
125
+ assert.equal(prefix.entry.currency, 'USD')
126
+ // 设计文档顺序:自定义前缀先于目录价,即使目录价精确
127
+ assert.equal(matchPrice(custom, catalog, 'openai', 'gpt-4').tier, 'custom-prefix')
128
+ const onlyCatalog = normalizeCustomPrices([
129
+ { model: 'openai/gpt-4', input: 10, output: 20, cacheRead: null, currency: 'USD' },
130
+ ])
131
+ assert.equal(matchPrice([], onlyCatalog, 'openai', 'gpt-4').tier, 'catalog')
132
+ assert.equal(matchPrice(custom, catalog, 'x', 'none'), null)
133
+ })
134
+
135
+ // ---- 汇率 ----
136
+
137
+ test('汇率:解析腾讯财经与 er-api,超时判定,全失败沿用上次并标注非实时', () => {
138
+ assert.ok(Math.abs(parseTencentRate('v_whUSDCNY="100~USDCNY~7.2531~..."') - 7.2531) < 1e-9)
139
+ assert.equal(parseTencentRate('garbage'), null)
140
+ assert.ok(Math.abs(parseErApiRate({ rates: { CNY: 7.1 } }) - 7.1) < 1e-9)
141
+ assert.equal(parseErApiRate({}), null)
142
+ const now = 1000 * RATE_TTL_MS
143
+ assert.equal(isRateStale(now - RATE_TTL_MS + 1, now), false)
144
+ assert.equal(isRateStale(now - RATE_TTL_MS - 1, now), true)
145
+ const fresh = resolveRate({ rate: 7, fetchedAt: now - RATE_TTL_MS - 1, stale: true }, { ok: true, rate: 7.3 }, now)
146
+ assert.ok(Math.abs(fresh.rate - 7.3) < 1e-9)
147
+ assert.equal(fresh.stale, false)
148
+ const stale = resolveRate({ rate: 7, fetchedAt: now - RATE_TTL_MS - 1, stale: false }, { ok: false }, now)
149
+ assert.ok(Math.abs(stale.rate - 7) < 1e-9)
150
+ assert.equal(stale.stale, true)
151
+ const none = resolveRate(null, { ok: false }, now)
152
+ assert.ok(Math.abs(none.rate - DEFAULT_USD_CNY) < 1e-9)
153
+ assert.equal(none.fetchedAt, 0)
154
+ assert.equal(none.stale, true)
155
+ })
156
+
157
+ // ---- CSV ----
158
+
159
+ test('CSV:公式注入防护与全字符集转义', () => {
160
+ assert.equal(csvCell('=cmd'), "'=cmd")
161
+ assert.equal(csvCell('+1'), "'+1")
162
+ assert.equal(csvCell('-1'), "'-1")
163
+ assert.equal(csvCell('@x'), "'@x")
164
+ assert.equal(csvCell('a,b'), '"a,b"')
165
+ assert.equal(csvCell('say "hi"'), '"say ""hi"""')
166
+ assert.equal(csvCell('行\n换'), '"行\n换"')
167
+ assert.equal(csvCell('中文🚀'), '中文🚀')
168
+ assert.equal(csvRow(['a', 'b']), 'a,b')
169
+ const csv = toCsv([['h1', 'h2'], ['=x', 'y']])
170
+ assert.ok(csv.startsWith(''))
171
+ assert.equal(csv.split('\r\n').length, 2)
172
+ })
173
+
174
+ // ---- 仪表盘聚合 ----
175
+
176
+ function monthLedger() {
177
+ const ledger = createLedger()
178
+ const mk = (y, m, d, cost) => recordCall(ledger, callAt(new Date(y, m, d, 10).getTime(), { sessionId: 's' + d }), cost)
179
+ // 本月 3 天 + 上月 2 天(以 2026-03-15 为今天)
180
+ mk(2026, 2, 15, 3)
181
+ mk(2026, 2, 14, 1)
182
+ mk(2026, 2, 13, 2)
183
+ mk(2026, 1, 20, 5)
184
+ mk(2026, 1, 21, 7)
185
+ return ledger
186
+ }
187
+
188
+ test('概览:本月 Hero、按日均外推预计、今日与本周环比', () => {
189
+ const todayKey = dayKeyOf(new Date(2026, 2, 15, 10).getTime())
190
+ const overview = monthOverview(monthLedger(), todayKey)
191
+ assert.ok(Math.abs(overview.month.cost - 6) < 1e-9)
192
+ // 3 月 31 天,已过 15 天,6/15*31
193
+ assert.ok(Math.abs(overview.projection - (6 / 15) * 31) < 1e-9)
194
+ assert.ok(Math.abs(overview.todayRatio - 3) < 1e-9)
195
+ assert.equal(overview.weekRatio, null)
196
+ assert.equal(overview.month.calls, 3)
197
+ })
198
+
199
+ test('趋势:范围与视角切换,无数据日期为 null 不伪造', () => {
200
+ const todayKey = dayKeyOf(new Date(2026, 2, 15, 10).getTime())
201
+ const points = dayTrend(monthLedger(), todayKey, 5)
202
+ assert.equal(points.length, 5)
203
+ assert.ok(Math.abs(points[4].cost - 3) < 1e-9)
204
+ assert.equal(points[3].date, '2026-03-14')
205
+ assert.ok(Math.abs(points[3].cost - 1) < 1e-9)
206
+ assert.equal(points[1].cost, null)
207
+ assert.equal(points[1].tokens, 0)
208
+ })
209
+
210
+ test('热力图:当月逐日分档着色,悬停数据齐备', () => {
211
+ const todayKey = dayKeyOf(new Date(2026, 2, 15, 10).getTime())
212
+ const grid = monthHeatmap(monthLedger(), todayKey)
213
+ assert.equal(grid.month, '2026-03')
214
+ assert.equal(grid.days.length, 31)
215
+ const d15 = grid.days.find((d) => d.date === '2026-03-15')
216
+ assert.ok(Math.abs(d15.cost - 3) < 1e-9)
217
+ assert.equal(d15.calls, 1)
218
+ assert.equal(d15.tier > 0, true)
219
+ const d1 = grid.days.find((d) => d.date === '2026-03-01')
220
+ assert.equal(d1.cost, null)
221
+ assert.equal(d1.tier, 0)
222
+ })
223
+
224
+ test('明细:读顶层索引按费用倒序,标题缺失由调用方以 ID 前缀兜底', () => {
225
+ const ledger = monthLedger()
226
+ recordCall(ledger, callAt(new Date(2026, 2, 15, 11).getTime(), { sessionId: 'big' }), 9)
227
+ const rows = sessionsView(ledger, 10)
228
+ assert.equal(rows[0].sessionId, 'big')
229
+ assert.ok(rows[0].cost > rows[1].cost)
230
+ assert.ok(rows[0].lastAt > 0)
231
+ })
232
+
233
+ test('仪表盘聚合:一次产出概览/趋势/热力图/明细,含 CNY 折算', () => {
234
+ const todayKey = dayKeyOf(new Date(2026, 2, 15, 10).getTime())
235
+ const dash = buildDashboard(monthLedger(), todayKey, { rate: 8, stale: false })
236
+ assert.ok(dash.overview.monthCostCny > 0)
237
+ assert.equal(dash.trend7.length, 7)
238
+ assert.equal(dash.trend30.length, 30)
239
+ assert.equal(dash.heatmap.month, '2026-03')
240
+ assert.ok(dash.sessions.length > 0)
241
+ assert.equal(dash.rate.stale, false)
242
+ })
243
+
244
+ // ---- 自定义 JS 费用条 ----
245
+
246
+ test('计价:目录价未就绪时回落兜底价,计费不静默丢失', () => {
247
+ const table = catalogOrFallback(null)
248
+ const matched = matchPrice([], table, 'deepseek', 'deepseek-chat')
249
+ assert.ok(matched, '未就绪时仍能命中兜底价')
250
+ const native = nativeCostOfCall({ inputTokens: PER_MILLION, cacheReadTokens: 0, outputTokens: PER_MILLION }, matched.entry)
251
+ assert.ok(native.amount > 0, '未就绪时计费仍有值')
252
+ const live = [{ keys: ['x/y', 'y'], input: 1, output: 2, cacheRead: null }]
253
+ assert.equal(catalogOrFallback(live), live, '已就绪目录价原样返回')
254
+ })
255
+
256
+ test('费用条沙箱:合法函数返回字符串,异常与非字符串回退并带错误', () => {
257
+ const ok = renderFeeBar('(data) => "费用 " + data.sessionCost', FEE_BAR_SAMPLE)
258
+ assert.equal(ok.fallback, false)
259
+ assert.ok(ok.text.indexOf('费用') === 0)
260
+ const bad = renderFeeBar('(data) => { throw new Error("boom") }', FEE_BAR_SAMPLE)
261
+ assert.equal(bad.fallback, true)
262
+ assert.ok(bad.error.length > 0)
263
+ const nonstr = renderFeeBar('(data) => 42', FEE_BAR_SAMPLE)
264
+ assert.equal(nonstr.fallback, true)
265
+ assert.equal(compileFeeBar('not a fn'), null)
266
+ })
267
+
268
+ test('费用条本轮费用:首样本置 0,后续差分且负值钳为 0', () => {
269
+ assert.equal(turnCostOf(0.5, null), 0, '首样本无前值,无差分')
270
+ assert.ok(Math.abs(turnCostOf(0.6, 0.5) - 0.1) < 1e-9)
271
+ assert.equal(turnCostOf(0.4, 0.5), 0, '账本清零等负差分钳为 0')
272
+ })
273
+
274
+ test('CSV:制表符与回车前缀同样触发公式注入防护', () => {
275
+ assert.equal(csvCell('\t=1'), "'\t=1")
276
+ assert.equal(csvCell('\r=1'), '"\'\r=1"', '前缀单引号后含回车再走引号包裹')
277
+ assert.equal(csvCell('a\tb'), 'a\tb', '非前缀制表符不改写(引号规则处理)')
278
+ })