@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.
- package/README.md +49 -0
- package/cordis.patch.yml +9 -0
- package/package.json +46 -0
- package/src/client.js +2416 -0
- package/src/collector.js +327 -0
- package/src/index.js +104 -0
- package/src/pricing.js +119 -0
- package/src/query.js +193 -0
- package/src/routes.js +323 -0
- package/src/store.js +333 -0
- package/test/client.test.mjs +614 -0
- package/test/collector.test.mjs +603 -0
- package/test/pricing-parity.test.mjs +231 -0
- package/test/pricing.test.mjs +285 -0
- package/test/query.test.mjs +427 -0
- package/test/routes.test.mjs +566 -0
- package/test/stats-line.test.mjs +407 -0
- package/test/store.test.mjs +397 -0
- package/test/switch-guard.test.mjs +32 -0
- package/test/turn-tail.test.mjs +120 -0
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
// store 行为测试:桶键生成、三粒度落桶、写入重试与失败聚合、清理窗口、游标链。
|
|
2
|
+
// 域句柄以内存假件注入,断言外部可观察行为(表内容与游标值),不断言内部实现。
|
|
3
|
+
|
|
4
|
+
import test from 'node:test'
|
|
5
|
+
import assert from 'node:assert/strict'
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_MINUTE_RETENTION_DAYS,
|
|
9
|
+
HOUR_RETENTION_DAYS,
|
|
10
|
+
MINUTE_RETENTION_MAX_DAYS,
|
|
11
|
+
UsageStore,
|
|
12
|
+
__resetSharedStoreForTests,
|
|
13
|
+
clampMinuteRetentionDays,
|
|
14
|
+
dayKey,
|
|
15
|
+
hourKey,
|
|
16
|
+
minuteKey,
|
|
17
|
+
sharedStore,
|
|
18
|
+
} from '../src/store.js'
|
|
19
|
+
|
|
20
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
21
|
+
|
|
22
|
+
// 本地时区构造时间戳,测试不依赖运行环境时区
|
|
23
|
+
const local = (y, mo, d, h = 0, mi = 0, s = 0, ms = 0) => new Date(y, mo - 1, d, h, mi, s, ms).getTime()
|
|
24
|
+
|
|
25
|
+
function rowOf(bucket, provider, model) {
|
|
26
|
+
return {
|
|
27
|
+
bucket,
|
|
28
|
+
provider,
|
|
29
|
+
model,
|
|
30
|
+
inputTokens: 0,
|
|
31
|
+
outputTokens: 0,
|
|
32
|
+
cacheReadTokens: 0,
|
|
33
|
+
cacheWriteTokens: 0,
|
|
34
|
+
requests: 0,
|
|
35
|
+
turns: 0,
|
|
36
|
+
lastSeen: 0,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 内存假域:表与 global 方法面同构;failUpdate 返回错误时 update 抛出该错误
|
|
41
|
+
function fakeDomain({ failUpdate = () => null } = {}) {
|
|
42
|
+
const rows = new Map()
|
|
43
|
+
const stats = { puts: 0, globalSets: 0 }
|
|
44
|
+
let globalValue = { backfilledSessions: [], liveFirstSeq: {} }
|
|
45
|
+
const table = {
|
|
46
|
+
get: (key) => rows.get(key),
|
|
47
|
+
keys: () => rows.keys(),
|
|
48
|
+
entries: () => rows.entries(),
|
|
49
|
+
get size() {
|
|
50
|
+
return rows.size
|
|
51
|
+
},
|
|
52
|
+
async put(key, value) {
|
|
53
|
+
stats.puts += 1
|
|
54
|
+
rows.set(key, value)
|
|
55
|
+
},
|
|
56
|
+
async delete(key) {
|
|
57
|
+
return rows.delete(key)
|
|
58
|
+
},
|
|
59
|
+
async update(key, fn) {
|
|
60
|
+
const failure = failUpdate(key)
|
|
61
|
+
if (failure) throw failure
|
|
62
|
+
const current = rows.get(key)
|
|
63
|
+
if (current === undefined) {
|
|
64
|
+
// 与宿主 storage-domain 缺键报错同形,匹配 /no record .* to update/
|
|
65
|
+
throw new Error(`domain 'usage_stats' table 'buckets' has no record '${key}' to update`)
|
|
66
|
+
}
|
|
67
|
+
rows.set(key, fn(current))
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
rows,
|
|
72
|
+
stats,
|
|
73
|
+
table: () => table,
|
|
74
|
+
global: {
|
|
75
|
+
get: () => globalValue,
|
|
76
|
+
set: async (value) => {
|
|
77
|
+
stats.globalSets += 1
|
|
78
|
+
globalValue = value
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const facilityOf = (domain) => ({ open: async () => domain })
|
|
85
|
+
|
|
86
|
+
const tokenSample = (time, extra = {}) => ({
|
|
87
|
+
time,
|
|
88
|
+
model: 'deepseek/deepseek-chat',
|
|
89
|
+
inputTokens: 10,
|
|
90
|
+
outputTokens: 20,
|
|
91
|
+
cacheReadTokens: 3,
|
|
92
|
+
cacheWriteTokens: 4,
|
|
93
|
+
...extra,
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('分钟保留天数默认常量与上限和计划一致', () => {
|
|
97
|
+
assert.equal(DEFAULT_MINUTE_RETENTION_DAYS, 2)
|
|
98
|
+
assert.equal(MINUTE_RETENTION_MAX_DAYS, 2)
|
|
99
|
+
assert.equal(HOUR_RETENTION_DAYS, 15)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('分钟保留值归一:非法回落默认,超上限截断,0 保留', () => {
|
|
103
|
+
assert.equal(clampMinuteRetentionDays(1), 1)
|
|
104
|
+
assert.equal(clampMinuteRetentionDays(0), 0)
|
|
105
|
+
assert.equal(clampMinuteRetentionDays(7), 2)
|
|
106
|
+
assert.equal(clampMinuteRetentionDays(-1), 2)
|
|
107
|
+
assert.equal(clampMinuteRetentionDays('x'), 2)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('桶键生成含补零,分钟桶对齐 10 分钟', () => {
|
|
111
|
+
const ts = local(2026, 1, 5, 3, 7)
|
|
112
|
+
assert.equal(dayKey(ts), '2026-01-05')
|
|
113
|
+
assert.equal(hourKey(ts), '2026-01-05T03')
|
|
114
|
+
assert.equal(minuteKey(ts), '2026-01-05T03:00')
|
|
115
|
+
const late = local(2026, 12, 31, 23, 59)
|
|
116
|
+
assert.equal(hourKey(late), '2026-12-31T23')
|
|
117
|
+
assert.equal(minuteKey(late), '2026-12-31T23:50')
|
|
118
|
+
const aligned = local(2026, 1, 5, 3, 20)
|
|
119
|
+
assert.equal(minuteKey(aligned), '2026-01-05T03:20')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('跨日跨时跨分边界:毫秒推进即换桶', () => {
|
|
123
|
+
const before = local(2026, 8, 2, 23, 59, 59, 999)
|
|
124
|
+
const after = before + 1
|
|
125
|
+
assert.equal(dayKey(before), '2026-08-02')
|
|
126
|
+
assert.equal(dayKey(after), '2026-08-03')
|
|
127
|
+
assert.equal(hourKey(before), '2026-08-02T23')
|
|
128
|
+
assert.equal(hourKey(after), '2026-08-03T00')
|
|
129
|
+
assert.equal(minuteKey(before), '2026-08-02T23:50')
|
|
130
|
+
assert.equal(minuteKey(after), '2026-08-03T00:00')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('token 样本同刻落三粒度三行,行内无 g 字段', async () => {
|
|
134
|
+
const domain = fakeDomain()
|
|
135
|
+
const store = new UsageStore(facilityOf(domain))
|
|
136
|
+
await store.record(tokenSample(local(2026, 8, 2, 14, 37)))
|
|
137
|
+
const keys = ['D|2026-08-02', 'H|2026-08-02T14', 'M|2026-08-02T14:30'].map(
|
|
138
|
+
(prefix) => `${prefix}|deepseek|deepseek/deepseek-chat`,
|
|
139
|
+
)
|
|
140
|
+
assert.equal(domain.rows.size, 3)
|
|
141
|
+
for (const key of keys) {
|
|
142
|
+
const seen = domain.rows.get(key)
|
|
143
|
+
assert.ok(seen, `缺少行 ${key}`)
|
|
144
|
+
assert.equal(seen.inputTokens, 10)
|
|
145
|
+
assert.equal(seen.outputTokens, 20)
|
|
146
|
+
assert.equal(seen.cacheReadTokens, 3)
|
|
147
|
+
assert.equal(seen.cacheWriteTokens, 4)
|
|
148
|
+
assert.equal(seen.requests, 0)
|
|
149
|
+
assert.equal(seen.turns, 0)
|
|
150
|
+
assert.equal('g' in seen, false)
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('turn 样本落合成行:provider default 与 model (turns)', async () => {
|
|
155
|
+
const domain = fakeDomain()
|
|
156
|
+
const store = new UsageStore(facilityOf(domain))
|
|
157
|
+
await store.record({ time: local(2026, 8, 2, 14, 37), turn: true })
|
|
158
|
+
const seen = domain.rows.get('D|2026-08-02|default|(turns)')
|
|
159
|
+
assert.ok(seen)
|
|
160
|
+
assert.equal(seen.turns, 1)
|
|
161
|
+
assert.equal(seen.inputTokens, 0)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test('request 样本只计请求数,无归因落 (unknown)', async () => {
|
|
165
|
+
const domain = fakeDomain()
|
|
166
|
+
const store = new UsageStore(facilityOf(domain))
|
|
167
|
+
await store.record({ time: local(2026, 8, 2, 14, 37), request: true, model: 'deepseek-chat' })
|
|
168
|
+
await store.record({ time: local(2026, 8, 2, 14, 38), request: true })
|
|
169
|
+
const labelled = domain.rows.get('D|2026-08-02|default|deepseek-chat')
|
|
170
|
+
assert.ok(labelled)
|
|
171
|
+
assert.equal(labelled.requests, 1)
|
|
172
|
+
assert.equal(labelled.inputTokens, 0)
|
|
173
|
+
const unknown = domain.rows.get('D|2026-08-02|default|(unknown)')
|
|
174
|
+
assert.ok(unknown)
|
|
175
|
+
assert.equal(unknown.requests, 1)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('missing-record 首写竞态:put 种子后重试写入真值', async () => {
|
|
179
|
+
const domain = fakeDomain()
|
|
180
|
+
const store = new UsageStore(facilityOf(domain))
|
|
181
|
+
await store.record(tokenSample(local(2026, 8, 2, 14, 37)))
|
|
182
|
+
assert.equal(domain.stats.puts, 3)
|
|
183
|
+
const seen = domain.rows.get('D|2026-08-02|deepseek|deepseek/deepseek-chat')
|
|
184
|
+
assert.equal(seen.inputTokens, 10)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
test('allSettled 聚合:单粒度失败上抛且其余粒度不丢', async () => {
|
|
188
|
+
const domain = fakeDomain({
|
|
189
|
+
failUpdate: (key) => (key.startsWith('H|') ? new Error('simulated hour failure') : null),
|
|
190
|
+
})
|
|
191
|
+
const store = new UsageStore(facilityOf(domain))
|
|
192
|
+
await assert.rejects(
|
|
193
|
+
store.record(tokenSample(local(2026, 8, 2, 14, 37))),
|
|
194
|
+
(err) => err.name === 'AggregateError' && err.errors.length === 1,
|
|
195
|
+
)
|
|
196
|
+
assert.ok(domain.rows.has('D|2026-08-02|deepseek|deepseek/deepseek-chat'))
|
|
197
|
+
assert.ok(domain.rows.has('M|2026-08-02T14:30|deepseek|deepseek/deepseek-chat'))
|
|
198
|
+
assert.equal(domain.rows.has('H|2026-08-02T14|deepseek|deepseek/deepseek-chat'), false)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
test('非 missing 错误不触发种子重试', async () => {
|
|
202
|
+
const key = 'D|2026-08-02|deepseek|deepseek/deepseek-chat'
|
|
203
|
+
const domain = fakeDomain({
|
|
204
|
+
failUpdate: (candidate) => (candidate === key ? new Error('backend write failed') : null),
|
|
205
|
+
})
|
|
206
|
+
const store = new UsageStore(facilityOf(domain))
|
|
207
|
+
await assert.rejects(
|
|
208
|
+
store.record(tokenSample(local(2026, 8, 2, 14, 37))),
|
|
209
|
+
(err) => err.name === 'AggregateError' && /backend write failed/.test(err.errors[0].message),
|
|
210
|
+
)
|
|
211
|
+
assert.equal(domain.rows.has(key), false)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
test('rangeRows 按粒度前缀与桶串闭区间扫描', async () => {
|
|
215
|
+
const domain = fakeDomain()
|
|
216
|
+
const store = new UsageStore(facilityOf(domain))
|
|
217
|
+
const table = domain.table()
|
|
218
|
+
await table.put('D|2026-07-31|a|m', rowOf('2026-07-31', 'a', 'm'))
|
|
219
|
+
await table.put('D|2026-08-01|a|m', rowOf('2026-08-01', 'a', 'm'))
|
|
220
|
+
await table.put('D|2026-08-02|a|m', rowOf('2026-08-02', 'a', 'm'))
|
|
221
|
+
await table.put('H|2026-08-01T10|a|m', rowOf('2026-08-01T10', 'a', 'm'))
|
|
222
|
+
await table.put('M|2026-08-01T10:30|a|m', rowOf('2026-08-01T10:30', 'a', 'm'))
|
|
223
|
+
const days = await store.rangeRows('D', '2026-08-01', '2026-08-02')
|
|
224
|
+
assert.deepEqual(days.map((seen) => seen.bucket).sort(), ['2026-08-01', '2026-08-02'])
|
|
225
|
+
const hours = await store.rangeRows('H', '2026-08-01T00', '2026-08-01T23')
|
|
226
|
+
assert.deepEqual(hours.map((seen) => seen.bucket), ['2026-08-01T10'])
|
|
227
|
+
const minutes = await store.rangeRows('M', '2026-08-01T10:30', '2026-08-01T10:30')
|
|
228
|
+
assert.deepEqual(minutes.map((seen) => seen.bucket), ['2026-08-01T10:30'])
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test('pruneMinutes 按保留窗口清理分钟桶且不碰其他粒度', async () => {
|
|
232
|
+
const domain = fakeDomain()
|
|
233
|
+
const store = new UsageStore(facilityOf(domain), { now: () => local(2026, 8, 10, 12, 0) })
|
|
234
|
+
const table = domain.table()
|
|
235
|
+
await table.put('M|2026-08-08T11:00|a|m', rowOf('2026-08-08T11:00', 'a', 'm'))
|
|
236
|
+
await table.put('M|2026-08-08T12:00|a|m', rowOf('2026-08-08T12:00', 'a', 'm'))
|
|
237
|
+
await table.put('M|2026-08-09T00:00|a|m', rowOf('2026-08-09T00:00', 'a', 'm'))
|
|
238
|
+
await table.put('D|2026-08-01|a|m', rowOf('2026-08-01', 'a', 'm'))
|
|
239
|
+
await table.put('H|2026-08-01T00|a|m', rowOf('2026-08-01T00', 'a', 'm'))
|
|
240
|
+
await store.pruneMinutes(DEFAULT_MINUTE_RETENTION_DAYS)
|
|
241
|
+
assert.equal(domain.rows.has('M|2026-08-08T11:00|a|m'), false)
|
|
242
|
+
assert.equal(domain.rows.has('M|2026-08-08T12:00|a|m'), true)
|
|
243
|
+
assert.equal(domain.rows.has('M|2026-08-09T00:00|a|m'), true)
|
|
244
|
+
assert.equal(domain.rows.has('D|2026-08-01|a|m'), true)
|
|
245
|
+
assert.equal(domain.rows.has('H|2026-08-01T00|a|m'), true)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
test('保留窗口为 0 时分钟桶全部清理', async () => {
|
|
249
|
+
const domain = fakeDomain()
|
|
250
|
+
const store = new UsageStore(facilityOf(domain), { now: () => local(2026, 8, 10, 12, 0) })
|
|
251
|
+
const table = domain.table()
|
|
252
|
+
await table.put('M|2026-08-10T11:00|a|m', rowOf('2026-08-10T11:00', 'a', 'm'))
|
|
253
|
+
await table.put('D|2026-08-10|a|m', rowOf('2026-08-10', 'a', 'm'))
|
|
254
|
+
await store.pruneMinutes(0)
|
|
255
|
+
assert.equal(domain.rows.has('M|2026-08-10T11:00|a|m'), false)
|
|
256
|
+
assert.equal(domain.rows.has('D|2026-08-10|a|m'), true)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
test('分钟保留超上限时 clamp 到 48h 窗口', async () => {
|
|
260
|
+
const domain = fakeDomain()
|
|
261
|
+
const store = new UsageStore(facilityOf(domain), { now: () => local(2026, 8, 10, 12, 0) })
|
|
262
|
+
const table = domain.table()
|
|
263
|
+
// clamp 后窗口起点 = 08-08T12:00,02 天前的桶一律清理,即使注入 30 天
|
|
264
|
+
await table.put('M|2026-08-05T00:00|a|m', rowOf('2026-08-05T00:00', 'a', 'm'))
|
|
265
|
+
await table.put('M|2026-08-08T12:00|a|m', rowOf('2026-08-08T12:00', 'a', 'm'))
|
|
266
|
+
await store.pruneMinutes(30)
|
|
267
|
+
assert.equal(domain.rows.has('M|2026-08-05T00:00|a|m'), false)
|
|
268
|
+
assert.equal(domain.rows.has('M|2026-08-08T12:00|a|m'), true)
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
test('pruneHours 固定 15 天窗口清理小时桶且不碰其他粒度', async () => {
|
|
272
|
+
const domain = fakeDomain()
|
|
273
|
+
const store = new UsageStore(facilityOf(domain), { now: () => local(2026, 8, 10, 12, 0) })
|
|
274
|
+
const table = domain.table()
|
|
275
|
+
await table.put('H|2026-07-25T00:00|a|m', rowOf('2026-07-25T00:00', 'a', 'm'))
|
|
276
|
+
await table.put('H|2026-07-26T12:00|a|m', rowOf('2026-07-26T12:00', 'a', 'm'))
|
|
277
|
+
await table.put('D|2026-07-20|a|m', rowOf('2026-07-20', 'a', 'm'))
|
|
278
|
+
await table.put('M|2026-07-25T00:00|a|m', rowOf('2026-07-25T00:00', 'a', 'm'))
|
|
279
|
+
await store.pruneHours()
|
|
280
|
+
assert.equal(domain.rows.has('H|2026-07-25T00:00|a|m'), false)
|
|
281
|
+
assert.equal(domain.rows.has('H|2026-07-26T12:00|a|m'), true)
|
|
282
|
+
assert.equal(domain.rows.has('D|2026-07-20|a|m'), true)
|
|
283
|
+
assert.equal(domain.rows.has('M|2026-07-25T00:00|a|m'), true)
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
test('每日本地日首次写入触发清理,同日后续写入不重复触发', async () => {
|
|
287
|
+
let clock = local(2026, 8, 10, 12, 0)
|
|
288
|
+
const domain = fakeDomain()
|
|
289
|
+
const store = new UsageStore(facilityOf(domain), { now: () => clock, retentionDays: () => 0 })
|
|
290
|
+
const table = domain.table()
|
|
291
|
+
await table.put('M|2026-08-01T00:00|a|m', rowOf('2026-08-01T00:00', 'a', 'm'))
|
|
292
|
+
await store.record(tokenSample(local(2026, 8, 10, 12, 0)))
|
|
293
|
+
assert.equal(domain.rows.has('M|2026-08-01T00:00|a|m'), false)
|
|
294
|
+
await table.put('M|2026-08-01T00:00|a|m', rowOf('2026-08-01T00:00', 'a', 'm'))
|
|
295
|
+
await store.record(tokenSample(local(2026, 8, 10, 12, 1)))
|
|
296
|
+
assert.equal(domain.rows.has('M|2026-08-01T00:00|a|m'), true)
|
|
297
|
+
clock = local(2026, 8, 11, 0, 0)
|
|
298
|
+
await store.record(tokenSample(local(2026, 8, 11, 0, 0)))
|
|
299
|
+
assert.equal(domain.rows.has('M|2026-08-01T00:00|a|m'), false)
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
test('并发 markSeenSessions 不丢 id', async () => {
|
|
303
|
+
const domain = fakeDomain()
|
|
304
|
+
const store = new UsageStore(facilityOf(domain))
|
|
305
|
+
await Promise.all([
|
|
306
|
+
store.markSeenSessions(['a']),
|
|
307
|
+
store.markSeenSessions(['b']),
|
|
308
|
+
store.markSeenSessions(['c']),
|
|
309
|
+
])
|
|
310
|
+
assert.deepEqual([...(await store.seenSessions())].sort(), ['a', 'b', 'c'])
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
test('游标链串行:reset 排在先前 markSeen 之后,行与游标一致重置', async () => {
|
|
314
|
+
const domain = fakeDomain()
|
|
315
|
+
const store = new UsageStore(facilityOf(domain))
|
|
316
|
+
const pending = store.markSeenSessions(['a'])
|
|
317
|
+
await store.record(tokenSample(local(2026, 8, 2, 14, 37)))
|
|
318
|
+
await store.reset(new Map([['live-a', 7]]))
|
|
319
|
+
await pending
|
|
320
|
+
assert.equal(domain.rows.size, 0)
|
|
321
|
+
assert.deepEqual(domain.global.get(), { backfilledSessions: [], liveFirstSeq: { 'live-a': 7 } })
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
test('reset 不带边界时游标清空', async () => {
|
|
325
|
+
const domain = fakeDomain()
|
|
326
|
+
const store = new UsageStore(facilityOf(domain))
|
|
327
|
+
await store.markLiveSequences([['s', 5]])
|
|
328
|
+
await store.reset()
|
|
329
|
+
assert.deepEqual(domain.global.get(), { backfilledSessions: [], liveFirstSeq: {} })
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
test('markLiveSequences 保留最小 seq 且无变化不写 global', async () => {
|
|
333
|
+
const domain = fakeDomain()
|
|
334
|
+
const store = new UsageStore(facilityOf(domain))
|
|
335
|
+
await store.markLiveSequences([['s', 5]])
|
|
336
|
+
assert.deepEqual(await store.liveSequences(), new Map([['s', 5]]))
|
|
337
|
+
await store.markLiveSequences([['s', 3], ['t', 9]])
|
|
338
|
+
assert.deepEqual(await store.liveSequences(), new Map([['s', 3], ['t', 9]]))
|
|
339
|
+
const setsAfterChange = domain.stats.globalSets
|
|
340
|
+
await store.markLiveSequences([['s', 4]])
|
|
341
|
+
assert.equal(domain.stats.globalSets, setsAfterChange)
|
|
342
|
+
assert.deepEqual(await store.liveSequences(), new Map([['s', 3], ['t', 9]]))
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
test('游标写单次失败不断链', async () => {
|
|
346
|
+
const domain = fakeDomain()
|
|
347
|
+
const originalSet = domain.global.set
|
|
348
|
+
let failed = false
|
|
349
|
+
domain.global.set = async (value) => {
|
|
350
|
+
if (!failed) {
|
|
351
|
+
failed = true
|
|
352
|
+
throw new Error('simulated set failure')
|
|
353
|
+
}
|
|
354
|
+
return originalSet(value)
|
|
355
|
+
}
|
|
356
|
+
const store = new UsageStore(facilityOf(domain))
|
|
357
|
+
await assert.rejects(store.markSeenSessions(['a']))
|
|
358
|
+
await store.markSeenSessions(['b'])
|
|
359
|
+
assert.deepEqual([...(await store.seenSessions())].sort(), ['b'])
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
test('启动时游标为空且已有行则清空待重建', async () => {
|
|
363
|
+
const domain = fakeDomain()
|
|
364
|
+
await domain.table().put('D|2026-08-02|a|m', rowOf('2026-08-02', 'a', 'm'))
|
|
365
|
+
const store = new UsageStore(facilityOf(domain))
|
|
366
|
+
await store.readyPromise()
|
|
367
|
+
assert.equal(domain.rows.size, 0)
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
test('启动时游标非空则既有行保留', async () => {
|
|
371
|
+
const domain = fakeDomain()
|
|
372
|
+
await domain.table().put('D|2026-08-02|a|m', rowOf('2026-08-02', 'a', 'm'))
|
|
373
|
+
await domain.global.set({ backfilledSessions: ['old'], liveFirstSeq: {} })
|
|
374
|
+
const store = new UsageStore(facilityOf(domain))
|
|
375
|
+
await store.readyPromise()
|
|
376
|
+
assert.equal(domain.rows.size, 1)
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
test('域打开失败进入降级:ready 可等待,操作按调用失败', async () => {
|
|
380
|
+
const store = new UsageStore({ open: () => Promise.reject(new Error('domain already open')) })
|
|
381
|
+
await store.readyPromise()
|
|
382
|
+
assert.ok(store.degradation)
|
|
383
|
+
await assert.rejects(store.record(tokenSample(local(2026, 8, 2, 14, 37))), /usage store degraded/)
|
|
384
|
+
await assert.rejects(store.markSeenSessions(['a']), /usage store degraded/)
|
|
385
|
+
await assert.rejects(store.rangeRows('D', '2026-08-01', '2026-08-02'), /usage store degraded/)
|
|
386
|
+
assert.deepEqual(await store.seenSessions(), new Set())
|
|
387
|
+
assert.deepEqual(await store.liveSequences(), new Map())
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
test('globalThis 单例复用同实例,重置缝生效', () => {
|
|
391
|
+
__resetSharedStoreForTests()
|
|
392
|
+
const first = sharedStore(facilityOf(fakeDomain()))
|
|
393
|
+
assert.equal(sharedStore(facilityOf(fakeDomain())), first)
|
|
394
|
+
__resetSharedStoreForTests()
|
|
395
|
+
assert.notEqual(sharedStore(facilityOf(fakeDomain())), first)
|
|
396
|
+
__resetSharedStoreForTests()
|
|
397
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// 开关样式守卫 BDD:状态选择器回退为裸 input 会静默隐藏 label 内其他 input,
|
|
2
|
+
// 属无报错的 UI 损坏,故对源文本做静态断言锁死结构(turn-notify 同款守卫)
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
import test from 'node:test'
|
|
7
|
+
import assert from 'node:assert/strict'
|
|
8
|
+
|
|
9
|
+
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
10
|
+
const source = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
|
|
11
|
+
|
|
12
|
+
test('switch 隐藏规则以 input[type="checkbox"] 精确匹配', () => {
|
|
13
|
+
assert.match(source, /\.ud-switch input\[type="checkbox"\] \{ position:absolute/)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('switch 状态选择器禁止裸 input 锚定(防误伤 label 内其他 input)', () => {
|
|
17
|
+
const bare = source.match(/\.ud-switch input:(?!\[type)[a-z-]+/g)
|
|
18
|
+
assert.equal(bare, null, `裸 input 状态选择器: ${bare}`)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('checkbox 仅允许出现在 Switch 组件内,禁止裸 checkbox 直出', () => {
|
|
22
|
+
const occurrences = [...source.matchAll(/type: 'checkbox'/g)].map((match) => match.index)
|
|
23
|
+
assert.equal(occurrences.length, 1, `checkbox 字面量出现 ${occurrences.length} 次`)
|
|
24
|
+
const factoryStart = source.indexOf('function Switch(')
|
|
25
|
+
const factoryEnd = source.indexOf('__thumb', factoryStart)
|
|
26
|
+
assert.ok(occurrences[0] > factoryStart && occurrences[0] < factoryEnd, 'checkbox 字面量不在 Switch 组件内')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('Switch 产出顺序为 input 在前 track 在后', () => {
|
|
30
|
+
const factory = source.slice(source.indexOf('function Switch('))
|
|
31
|
+
assert.ok(factory.indexOf("h('input'") < factory.indexOf('ud-switch__track'), '组件内 input 必须先于 track')
|
|
32
|
+
})
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// 注入点B(S15)纯函数层测试:select 矩阵/模型键回退/单轮行文本/title 口径/文案键/显隐 CSS 镜像
|
|
2
|
+
// client.js 为非模块 script(bundle 求值形态,禁 import/export),整源求值后按顶层声明名收集
|
|
3
|
+
import { test } from 'node:test'
|
|
4
|
+
import assert from 'node:assert/strict'
|
|
5
|
+
import { readFileSync } from 'node:fs'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { dirname, join } from 'node:path'
|
|
8
|
+
|
|
9
|
+
const CLIENT_SOURCE = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'client.js'), 'utf8')
|
|
10
|
+
const DECLARATION_NAMES = [
|
|
11
|
+
...new Set(
|
|
12
|
+
[...CLIENT_SOURCE.matchAll(/^(?:const|function|let) ([A-Za-z_$][\w$]*)/gm)].map((match) => match[1])
|
|
13
|
+
),
|
|
14
|
+
]
|
|
15
|
+
const core = new Function(`${CLIENT_SOURCE}\nreturn { ${DECLARATION_NAMES.join(', ')} }`)()
|
|
16
|
+
|
|
17
|
+
const {
|
|
18
|
+
MESSAGES_EN,
|
|
19
|
+
MESSAGES_ZH,
|
|
20
|
+
MODEL_WILDCARD,
|
|
21
|
+
TURN_TAIL_DATA_KEY,
|
|
22
|
+
TURN_TAIL_PRIORITY,
|
|
23
|
+
buildTurnCostLine,
|
|
24
|
+
createTranslator,
|
|
25
|
+
matchPrice,
|
|
26
|
+
selectTurnTokenUsage,
|
|
27
|
+
turnCostTitleText,
|
|
28
|
+
turnModelOf,
|
|
29
|
+
} = core
|
|
30
|
+
|
|
31
|
+
const zhT = createTranslator(MESSAGES_ZH)
|
|
32
|
+
const enT = createTranslator(MESSAGES_EN)
|
|
33
|
+
|
|
34
|
+
// 本地时区固定时钟,与 client.test.mjs 同一刻
|
|
35
|
+
const NOW = new Date(2026, 2, 15, 14, 30, 45)
|
|
36
|
+
|
|
37
|
+
test('chain 常量锁定:数据键与尝试顺序后试于 deliverables', () => {
|
|
38
|
+
assert.equal(TURN_TAIL_DATA_KEY, 'turn-tail')
|
|
39
|
+
assert.equal(TURN_TAIL_PRIORITY, 1)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('select 从 Turn 位置数据返回 tokenUsage 本体', () => {
|
|
43
|
+
const tokenUsage = { uncachedInputTokens: 10, outputTokens: 5, totalTokens: 15 }
|
|
44
|
+
const owner = { turn: { data: new Map([[TURN_TAIL_DATA_KEY, { tokenUsage }]]) } }
|
|
45
|
+
assert.equal(selectTurnTokenUsage(owner), tokenUsage)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('select 数据键缺席或 tokenUsage 缺失返回 null', () => {
|
|
49
|
+
assert.equal(selectTurnTokenUsage({ turn: { data: new Map([[TURN_TAIL_DATA_KEY, {}]]) } }), null)
|
|
50
|
+
assert.equal(selectTurnTokenUsage({ turn: { data: new Map() } }), null)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('select owner 形状残缺全部 null 不抛', () => {
|
|
54
|
+
assert.equal(selectTurnTokenUsage({ turn: { data: {} } }), null)
|
|
55
|
+
assert.equal(selectTurnTokenUsage({ turn: {} }), null)
|
|
56
|
+
assert.equal(selectTurnTokenUsage({}), null)
|
|
57
|
+
assert.equal(selectTurnTokenUsage(null), null)
|
|
58
|
+
assert.equal(selectTurnTokenUsage(undefined), null)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('计价模型键取 routes 首个,缺失回退通配', () => {
|
|
62
|
+
assert.equal(turnModelOf({ routes: [{ provider: 'p', model: 'p/m' }] }), 'p/m')
|
|
63
|
+
assert.equal(turnModelOf({ routes: [] }), MODEL_WILDCARD)
|
|
64
|
+
assert.equal(turnModelOf({}), MODEL_WILDCARD)
|
|
65
|
+
assert.equal(turnModelOf(null), MODEL_WILDCARD)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('routes 缺失模型键命中通配规则', () => {
|
|
69
|
+
const rules = [
|
|
70
|
+
{ model: 'p/m', currency: '¥', price: { input: 9, output: 9, cacheRead: 9, cacheWrite: 9 }, conditions: [] },
|
|
71
|
+
{ model: MODEL_WILDCARD, currency: '$', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [] },
|
|
72
|
+
]
|
|
73
|
+
const tokenUsage = { uncachedInputTokens: 1000, outputTokens: 0, totalTokens: 1000 }
|
|
74
|
+
assert.equal(turnModelOf(tokenUsage), MODEL_WILDCARD)
|
|
75
|
+
assert.deepEqual(matchPrice(rules, turnModelOf(tokenUsage), NOW), { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 })
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('单轮行四桶全带:摘要含 prompt 三桶费用按价计', () => {
|
|
79
|
+
const tokenUsage = {
|
|
80
|
+
uncachedInputTokens: 8000, outputTokens: 1000, totalTokens: 10000,
|
|
81
|
+
cacheReadTokens: 1000, cacheWriteTokens: 0, routes: [{ provider: 'p', model: 'p/m' }],
|
|
82
|
+
}
|
|
83
|
+
const price = { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }
|
|
84
|
+
assert.equal(buildTurnCostLine(zhT, tokenUsage, price, '¥'), '输入 9K tok · 输出 1K tok · 费用 ≈ ¥0.01')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('单轮行可选桶缺失按 0 计入摘要与费用', () => {
|
|
88
|
+
const tokenUsage = { uncachedInputTokens: 8000, outputTokens: 1000, totalTokens: 9000 }
|
|
89
|
+
const price = { input: 1, output: 2, cacheRead: 5, cacheWrite: 5 }
|
|
90
|
+
assert.equal(buildTurnCostLine(zhT, tokenUsage, price, '¥'), '输入 8K tok · 输出 1K tok · 费用 ≈ ¥0.01')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('单轮行取不到价费用为占位符', () => {
|
|
94
|
+
const tokenUsage = { uncachedInputTokens: 8000, outputTokens: 1000, totalTokens: 9000 }
|
|
95
|
+
assert.equal(buildTurnCostLine(zhT, tokenUsage, null, ''), '输入 8K tok · 输出 1K tok · 费用 ≈ —')
|
|
96
|
+
assert.equal(buildTurnCostLine(enT, tokenUsage, null, ''), 'Input 8K tok · Output 1K tok · Cost ≈ —')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('title 估算口径,可选桶未上报追加标注', () => {
|
|
100
|
+
const full = { uncachedInputTokens: 1, outputTokens: 1, totalTokens: 2, cacheReadTokens: 0, cacheWriteTokens: 0 }
|
|
101
|
+
assert.equal(turnCostTitleText(zhT, full), '单轮用量按当前费率估算')
|
|
102
|
+
const missing = { uncachedInputTokens: 1, outputTokens: 1, totalTokens: 2 }
|
|
103
|
+
assert.equal(turnCostTitleText(zhT, missing), '单轮用量按当前费率估算,该提供商未上报此桶')
|
|
104
|
+
assert.equal(turnCostTitleText(enT, missing), 'Per-turn usage estimated at current rates,Not reported by this provider')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('注入点B 文案键 zh/en 值锁定', () => {
|
|
108
|
+
assert.equal(MESSAGES_ZH['stats.turnCost'], '{summary} · 费用 ≈ {cost}')
|
|
109
|
+
assert.equal(MESSAGES_EN['stats.turnCost'], '{summary} · Cost ≈ {cost}')
|
|
110
|
+
assert.equal(MESSAGES_ZH.turnCostTitle, '单轮用量按当前费率估算')
|
|
111
|
+
assert.equal(MESSAGES_EN.turnCostTitle, 'Per-turn usage estimated at current rates')
|
|
112
|
+
assert.equal(MESSAGES_ZH.turnTokensUnreported, '该提供商未上报此桶')
|
|
113
|
+
assert.equal(MESSAGES_EN.turnTokensUnreported, 'Not reported by this provider')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('显隐 CSS 镜像官方动作行:历史悬停显现与焦点恢复', () => {
|
|
117
|
+
assert.ok(CLIENT_SOURCE.includes('.ud-turn-cost{'))
|
|
118
|
+
assert.ok(CLIENT_SOURCE.includes('@media (hover:hover){[data-actions-reveal=hover] .ud-turn-cost{opacity:0'))
|
|
119
|
+
assert.ok(CLIENT_SOURCE.includes('[data-actions-reveal=hover]:hover .ud-turn-cost,[data-actions-reveal=hover]:focus-within .ud-turn-cost{opacity:1}'))
|
|
120
|
+
})
|