@mzzsfy/dsh-usage-dash 0.3.0 → 0.4.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 +52 -11
- package/package.json +2 -2
- package/src/archive-reader.js +138 -0
- package/src/client.js +400 -112
- package/src/collector.js +58 -12
- package/src/direct-log-reader.js +126 -0
- package/src/pricing.js +5 -6
- package/src/routes.js +14 -1
- package/src/store.js +70 -2
- package/test/archive-reader.test.mjs +365 -0
- package/test/client.test.mjs +153 -12
- package/test/collector.test.mjs +152 -8
- package/test/direct-log-reader.test.mjs +104 -0
- package/test/pricing-parity.test.mjs +38 -4
- package/test/pricing.test.mjs +50 -18
- package/test/query.test.mjs +2 -1
- package/test/routes.test.mjs +9 -2
- package/test/stats-line.test.mjs +6 -6
- package/test/store.test.mjs +58 -1
- package/test/turn-tail.test.mjs +95 -41
package/test/collector.test.mjs
CHANGED
|
@@ -72,17 +72,46 @@ function fakeSessions(handles = []) {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
// definitions: [{ id, events?, inheritedEventCount?, fail? }]
|
|
75
|
+
// 内部统一契约形状(list → [{id}],readLog → {inheritedEventCount, events}),
|
|
76
|
+
// 宿主 persistence 的多版本映射由 archive-reader 测试单独覆盖
|
|
75
77
|
function fakePersistence(definitions = []) {
|
|
76
78
|
const byId = new Map(definitions.map((def) => [def.id, def]))
|
|
77
79
|
return {
|
|
78
80
|
async list() {
|
|
79
81
|
return definitions.map((def) => ({ id: def.id }))
|
|
80
82
|
},
|
|
81
|
-
async
|
|
83
|
+
async readLog(id) {
|
|
82
84
|
const def = byId.get(id)
|
|
83
85
|
if (!def) throw new Error(`session "${id}" not found`)
|
|
84
86
|
if (def.fail) throw new Error(def.fail)
|
|
85
|
-
return {
|
|
87
|
+
return { inheritedEventCount: def.inheritedEventCount ?? 0, events: def.events ?? [] }
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 宿主 handle 代形状(list 返回 snapshot,open/read/close 读体),供走
|
|
93
|
+
// rescan → 工厂分派 → backfill 的装配路径测试使用
|
|
94
|
+
function fakeHostPersistence(definitions = []) {
|
|
95
|
+
const byId = new Map(definitions.map((def) => [def.id, def]))
|
|
96
|
+
return {
|
|
97
|
+
async list() {
|
|
98
|
+
return definitions.map((def) => ({ header: { id: def.id }, revision: {} }))
|
|
99
|
+
},
|
|
100
|
+
async open(id, access) {
|
|
101
|
+
const def = byId.get(id)
|
|
102
|
+
if (!def) throw new Error(`session "${id}" not found`)
|
|
103
|
+
return {
|
|
104
|
+
header: { id },
|
|
105
|
+
inheritedEventCount: def.inheritedEventCount ?? 0,
|
|
106
|
+
access,
|
|
107
|
+
async read(offset, length) {
|
|
108
|
+
if (def.fail) throw new Error(def.fail)
|
|
109
|
+
const all = def.events ?? []
|
|
110
|
+
const start = typeof offset === 'number' && offset > 0 ? offset : 0
|
|
111
|
+
return { eventState: 'detached', events: all.slice(start, typeof length === 'number' ? start + length : undefined) }
|
|
112
|
+
},
|
|
113
|
+
async close() {},
|
|
114
|
+
}
|
|
86
115
|
},
|
|
87
116
|
}
|
|
88
117
|
}
|
|
@@ -250,6 +279,67 @@ test('四桶全零 usage 为噪声不产样本', async () => {
|
|
|
250
279
|
assert.equal(store.samples.length, 0)
|
|
251
280
|
})
|
|
252
281
|
|
|
282
|
+
test('V3 日志兼容:V3 专有事件与新字段被折叠忽略不炸', async () => {
|
|
283
|
+
const { ctx, store } = liveCollector()
|
|
284
|
+
const t = local(2026, 8, 2, 10, 0)
|
|
285
|
+
// V3 新增事件(SESSION_FORMAT_VERSION 3):统计不消费,折叠必须安全忽略
|
|
286
|
+
ctx.emit('session/event', session('s1'), ev('system/message', 1, t, { turn: 0, step: 0, message: { role: 'system', content: 'sys' } }))
|
|
287
|
+
ctx.emit('session/event', session('s1'), ev('assistant/attempt', 2, t, { turn: 0, step: 0, stream: [] }))
|
|
288
|
+
ctx.emit('session/event', session('s1'), ev('request/header', 3, t, { header: { config: {} }, reason: 'initial' }))
|
|
289
|
+
ctx.emit('session/event', session('s1'), ev('user/message', 4, t, { role: 'user', content: 'hi' }, ))
|
|
290
|
+
ctx.emit('session/event', session('s1'), ev('tool/call', 5, t, { turn: 0, step: 0, callId: 'c1', name: 'n', arguments: '{}' }))
|
|
291
|
+
ctx.emit('session/event', session('s1'), ev('tool/result', 6, t, { turn: 0, step: 0, message: { role: 'toolResult', content: 'r' } }))
|
|
292
|
+
ctx.emit('session/event', session('s1'), ev('session/end-seed', 7, t, {}))
|
|
293
|
+
ctx.emit('session/event', session('s1'), ev('step/end', 8, t, { turn: 0, step: 0 }))
|
|
294
|
+
ctx.emit('session/event', session('s1'), ev('turn/start', 9, t, { turn: 1 }))
|
|
295
|
+
// V3 assistant/message 携带 stream/surfaceOp/interrupted 等新字段:usage 折叠不受影响
|
|
296
|
+
ctx.emit('session/event', session('s1'), ev('assistant/message', 10, t, {
|
|
297
|
+
turn: 1,
|
|
298
|
+
step: 0,
|
|
299
|
+
usage: usage({ inputTokens: 5 }),
|
|
300
|
+
message: { role: 'assistant', source: { provider: 'p', model: 'm' } },
|
|
301
|
+
stream: [{ type: 'text-delta', text: 'x' }],
|
|
302
|
+
surfaceOp: 'append',
|
|
303
|
+
interrupted: true,
|
|
304
|
+
}))
|
|
305
|
+
await tick()
|
|
306
|
+
assert.equal(store.samples.length, 1)
|
|
307
|
+
assert.equal(store.samples[0].inputTokens, 5)
|
|
308
|
+
assert.equal(store.samples[0].model, 'p/m')
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
test('V3 日志兼容:回扫重放 V3 事件流,样本与游标正确', async () => {
|
|
312
|
+
const t = local(2026, 8, 2, 14, 0)
|
|
313
|
+
const persistence = fakePersistence([{
|
|
314
|
+
id: 'v3session',
|
|
315
|
+
events: [
|
|
316
|
+
ev('request/header', 0, t, { header: { config: {} }, reason: 'resume' }),
|
|
317
|
+
ev('system/message', 1, t, { turn: 0, step: 0, message: { role: 'system', content: 'sys' } }),
|
|
318
|
+
ev('user/message', 2, t, { role: 'user', content: 'q' }),
|
|
319
|
+
ev('step/start', 3, t, { turn: 0, step: 0 }),
|
|
320
|
+
ev('assistant/attempt', 4, t, { turn: 0, step: 0, stream: [] }),
|
|
321
|
+
ev('assistant/message', 5, t, {
|
|
322
|
+
turn: 0,
|
|
323
|
+
step: 0,
|
|
324
|
+
usage: usage({ inputTokens: 7 }),
|
|
325
|
+
message: { role: 'assistant', source: { provider: 'p', model: 'm' } },
|
|
326
|
+
stream: [],
|
|
327
|
+
}),
|
|
328
|
+
ev('step/end', 6, t, { turn: 0, step: 0 }),
|
|
329
|
+
ev('turn/end', 7, t, { turn: 0, reason: { kind: 'completed' } }),
|
|
330
|
+
],
|
|
331
|
+
}])
|
|
332
|
+
const store = fakeStore()
|
|
333
|
+
const collector = new UsageCollector(fakeCtx({ persistence }), store)
|
|
334
|
+
await collector.backfill(persistence, fakeSessions())
|
|
335
|
+
assert.deepEqual(store.samples.map((sample) => [sample.request ?? sample.turn ?? false, sample.inputTokens]), [
|
|
336
|
+
[true, 0],
|
|
337
|
+
[false, 7],
|
|
338
|
+
[true, 0],
|
|
339
|
+
])
|
|
340
|
+
assert.deepEqual([...store.state.backfilledSessions], ['v3session'])
|
|
341
|
+
})
|
|
342
|
+
|
|
253
343
|
test('纯缓存调用(仅缓存桶非零)仍产样本', async () => {
|
|
254
344
|
const { ctx, store } = liveCollector()
|
|
255
345
|
const t = local(2026, 8, 2, 10, 0)
|
|
@@ -578,6 +668,56 @@ test('回扫:record 失败使会话失败并保留游标重试机会', async ()
|
|
|
578
668
|
assert.equal(collector.status().done, 1)
|
|
579
669
|
})
|
|
580
670
|
|
|
671
|
+
test('回扫:skipped 计数独立累加,超过日志上限不漂移', async () => {
|
|
672
|
+
const t = local(2026, 8, 2, 14, 0)
|
|
673
|
+
const defs = Array.from({ length: 210 }, (_, i) => ({ id: `bad${i}`, fail: 'uses unsupported descriptor version 2' }))
|
|
674
|
+
const persistence = fakePersistence(defs)
|
|
675
|
+
const store = fakeStore()
|
|
676
|
+
const collector = new UsageCollector(fakeCtx({ persistence }), store)
|
|
677
|
+
await collector.backfill(persistence, fakeSessions())
|
|
678
|
+
assert.equal(collector.status().skippedSessions, 210)
|
|
679
|
+
assert.equal(collector.status().log.length, 200)
|
|
680
|
+
assert.deepEqual(collector.status().skippedBreakdown, { descriptor: 210, corrupt: 0, legacy: 0, other: 0 })
|
|
681
|
+
})
|
|
682
|
+
|
|
683
|
+
test('回扫:skipped 按 detail 归因分类,次轮扫描计数复位', async () => {
|
|
684
|
+
const t = local(2026, 8, 2, 14, 0)
|
|
685
|
+
const persistence = fakePersistence([
|
|
686
|
+
{ id: 'a', fail: 'uses unsupported descriptor version 2; source v0 artifact remains unchanged' },
|
|
687
|
+
{ id: 'b', fail: 'stored log is corrupt: SessionFormatError: seq gap' },
|
|
688
|
+
{ id: 'c', fail: 'format v0 contains unknown member "editor"' },
|
|
689
|
+
{ id: 'd', fail: 'mystery failure' },
|
|
690
|
+
{ id: 'ok', events: [ev('turn/end', 0, t)] },
|
|
691
|
+
])
|
|
692
|
+
const store = fakeStore()
|
|
693
|
+
const collector = new UsageCollector(fakeCtx({ persistence }), store)
|
|
694
|
+
await collector.backfill(persistence, fakeSessions())
|
|
695
|
+
assert.deepEqual(collector.status().skippedBreakdown, { descriptor: 1, corrupt: 1, legacy: 1, other: 1 })
|
|
696
|
+
assert.equal(collector.status().skippedSessions, 4)
|
|
697
|
+
// 次轮:失败档不进游标会重现,但本轮从空游标重扫,计数从头累计且包含上一轮成功档
|
|
698
|
+
await collector.backfill(persistence, fakeSessions())
|
|
699
|
+
assert.equal(collector.status().skippedSessions, 4)
|
|
700
|
+
assert.deepEqual(collector.status().skippedBreakdown, { descriptor: 1, corrupt: 1, legacy: 1, other: 1 })
|
|
701
|
+
})
|
|
702
|
+
|
|
703
|
+
test('回扫:list 失败记入 error 并向上传播,running 复位', async () => {
|
|
704
|
+
const failure = new Error('sessionPersistence API 未识别')
|
|
705
|
+
const persistence = {
|
|
706
|
+
async list() {
|
|
707
|
+
throw failure
|
|
708
|
+
},
|
|
709
|
+
async readLog() {
|
|
710
|
+
throw new Error('not reached')
|
|
711
|
+
},
|
|
712
|
+
}
|
|
713
|
+
const store = fakeStore()
|
|
714
|
+
const collector = new UsageCollector(fakeCtx({ persistence }), store)
|
|
715
|
+
await assert.rejects(collector.backfill(persistence, fakeSessions()), (error) => error === failure)
|
|
716
|
+
assert.equal(collector.status().error, failure.message)
|
|
717
|
+
assert.equal(collector.status().running, false)
|
|
718
|
+
assert.equal(store.samples.length, 0)
|
|
719
|
+
})
|
|
720
|
+
|
|
581
721
|
test('abort:已中止 signal 直接返回不进入扫描', async () => {
|
|
582
722
|
const persistence = fakePersistence([{ id: 's1', events: [] }])
|
|
583
723
|
const store = fakeStore()
|
|
@@ -597,9 +737,9 @@ test('abort:扫描中中断停止处理且不写游标', async () => {
|
|
|
597
737
|
async list() {
|
|
598
738
|
return [{ id: 's1' }]
|
|
599
739
|
},
|
|
600
|
-
async
|
|
740
|
+
async readLog() {
|
|
601
741
|
controller.abort()
|
|
602
|
-
return {
|
|
742
|
+
return { inheritedEventCount: 0, events: [ev('turn/end', 0, t)] }
|
|
603
743
|
},
|
|
604
744
|
}
|
|
605
745
|
const store = fakeStore()
|
|
@@ -625,14 +765,14 @@ test('回扫并发受默认并发 4 约束', async () => {
|
|
|
625
765
|
async list() {
|
|
626
766
|
return defs.map((def) => ({ id: def.id }))
|
|
627
767
|
},
|
|
628
|
-
async
|
|
768
|
+
async readLog(id) {
|
|
629
769
|
inFlight += 1
|
|
630
770
|
peak = Math.max(peak, inFlight)
|
|
631
771
|
if (inFlight >= EXPECTED_CONCURRENCY) release()
|
|
632
772
|
await Promise.race([gate, new Promise((resolve) => setTimeout(resolve, GATE_TIMEOUT_MS))])
|
|
633
773
|
inFlight -= 1
|
|
634
774
|
const def = defs.find((candidate) => candidate.id === id)
|
|
635
|
-
return {
|
|
775
|
+
return { inheritedEventCount: 0, events: def.events }
|
|
636
776
|
},
|
|
637
777
|
}
|
|
638
778
|
const store = fakeStore()
|
|
@@ -645,7 +785,7 @@ test('回扫并发受默认并发 4 约束', async () => {
|
|
|
645
785
|
test('resetAndRescan 以 wipe 时刻日志长度为活跃会话边界并重扫', async () => {
|
|
646
786
|
const t = local(2026, 8, 2, 9, 0)
|
|
647
787
|
const events = Array.from({ length: 13 }, (_, seq) => ev('turn/end', seq, t))
|
|
648
|
-
const persistence =
|
|
788
|
+
const persistence = fakeHostPersistence([{ id: 'live-a', events }])
|
|
649
789
|
const sessions = fakeSessions([{ id: 'live-a', seq: 10 }, { id: 'live-b' }])
|
|
650
790
|
const store = fakeStore()
|
|
651
791
|
store.state.liveFirstSeq.set('live-a', 3)
|
|
@@ -661,7 +801,7 @@ test('resetAndRescan 以 wipe 时刻日志长度为活跃会话边界并重扫',
|
|
|
661
801
|
|
|
662
802
|
test('并发 resetAndRescan 合并为一次重建', async () => {
|
|
663
803
|
const store = fakeStore()
|
|
664
|
-
const collector = new UsageCollector(fakeCtx(), store)
|
|
804
|
+
const collector = new UsageCollector(fakeCtx({ persistence: fakeHostPersistence() }), store)
|
|
665
805
|
const first = collector.resetAndRescan()
|
|
666
806
|
const second = collector.resetAndRescan()
|
|
667
807
|
await Promise.all([first, second])
|
|
@@ -682,8 +822,12 @@ test('status() 返回快照,外部修改不影响内部状态', async () => {
|
|
|
682
822
|
error: undefined,
|
|
683
823
|
recordFailures: 0,
|
|
684
824
|
skippedSessions: 0,
|
|
825
|
+
skippedBreakdown: { descriptor: 0, corrupt: 0, legacy: 0, other: 0 },
|
|
685
826
|
log: [],
|
|
686
827
|
})
|
|
828
|
+
const before = collector.status().skippedBreakdown
|
|
829
|
+
before.descriptor = 99
|
|
830
|
+
assert.equal(collector.status().skippedBreakdown.descriptor, 0)
|
|
687
831
|
await collector.backfill(persistence, fakeSessions())
|
|
688
832
|
const snapshot = collector.status()
|
|
689
833
|
assert.equal(snapshot.running, false)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// direct-log-reader 测试:多帧 zstd 解压、JSONL 宽松解析、目录定位与 id 枚举。
|
|
2
|
+
// 全部经由 root 参数注入临时目录,不依赖真实 DSH_HOME
|
|
3
|
+
|
|
4
|
+
import test from 'node:test'
|
|
5
|
+
import assert from 'node:assert/strict'
|
|
6
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
|
|
7
|
+
import { tmpdir } from 'node:os'
|
|
8
|
+
import { join } from 'node:path'
|
|
9
|
+
import { zstdCompressSync } from 'node:zlib'
|
|
10
|
+
|
|
11
|
+
import { decodeZstdFile, parseJsonlEvents, findSessionDir, listSessionIdsDirect, readSessionLogDirect } from '../src/direct-log-reader.js'
|
|
12
|
+
|
|
13
|
+
function tempRoot() {
|
|
14
|
+
return mkdtempSync(join(tmpdir(), 'ud-direct-'))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])
|
|
18
|
+
|
|
19
|
+
test('decodeZstdFile:多帧顺序解压拼接', () => {
|
|
20
|
+
const a = zstdCompressSync(Buffer.from('hello\n'))
|
|
21
|
+
const b = zstdCompressSync(Buffer.from('world\n'))
|
|
22
|
+
const bytes = Buffer.concat([MAGIC.slice(0, 3), a.subarray(3), MAGIC.slice(0, 3), b.subarray(3)])
|
|
23
|
+
assert.equal(decodeZstdFile(bytes), 'hello\nworld\n')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('decodeZstdFile:无帧抛错', () => {
|
|
27
|
+
assert.throws(() => decodeZstdFile(Buffer.from('plain text')), /no zstd frame/)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('parseJsonlEvents:header 行剔除,坏行跳过,事件保留', () => {
|
|
31
|
+
const text = [
|
|
32
|
+
JSON.stringify({ type: 'session/header', id: 's1', version: 1 }),
|
|
33
|
+
JSON.stringify({ type: 'assistant/message', seq: 1, data: { turn: 0, step: 0, usage: { inputTokens: 5 } } }),
|
|
34
|
+
'not json',
|
|
35
|
+
'',
|
|
36
|
+
JSON.stringify({ type: 'request/context', seq: 2, data: { provider: 'p', model: 'm' } }),
|
|
37
|
+
].join('\n')
|
|
38
|
+
const events = parseJsonlEvents(text)
|
|
39
|
+
assert.equal(events.length, 2)
|
|
40
|
+
assert.equal(events[0].type, 'assistant/message')
|
|
41
|
+
assert.equal(events[1].type, 'request/context')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('findSessionDir:跨 cwd 桶定位 id 目录', () => {
|
|
45
|
+
const root = tempRoot()
|
|
46
|
+
try {
|
|
47
|
+
const dir = join(root, '--C-users-proj--', 'sid-1')
|
|
48
|
+
mkdirSync(dir, { recursive: true })
|
|
49
|
+
writeFileSync(join(dir, 'session.jsonl.zstd'), zstdCompressSync(Buffer.from('{}\n')))
|
|
50
|
+
assert.equal(findSessionDir('sid-1', root), dir)
|
|
51
|
+
assert.equal(findSessionDir('missing', root), undefined)
|
|
52
|
+
assert.equal(findSessionDir('sid-1', join(root, 'nope')), undefined)
|
|
53
|
+
} finally {
|
|
54
|
+
rmSync(root, { recursive: true, force: true })
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('listSessionIdsDirect:只收含日志文件的会话目录', () => {
|
|
59
|
+
const root = tempRoot()
|
|
60
|
+
try {
|
|
61
|
+
const withLog = join(root, 'bucket-a', 'id-1')
|
|
62
|
+
mkdirSync(withLog, { recursive: true })
|
|
63
|
+
writeFileSync(join(withLog, 'session.jsonl.zstd'), '')
|
|
64
|
+
const withV3 = join(root, 'bucket-a', 'id-2')
|
|
65
|
+
mkdirSync(withV3, { recursive: true })
|
|
66
|
+
writeFileSync(join(withV3, 'session.v3.jsonl.zstd'), '')
|
|
67
|
+
const empty = join(root, 'bucket-b', 'id-3')
|
|
68
|
+
mkdirSync(empty, { recursive: true })
|
|
69
|
+
assert.deepEqual(listSessionIdsDirect(root).sort(), ['id-1', 'id-2'])
|
|
70
|
+
} finally {
|
|
71
|
+
rmSync(root, { recursive: true, force: true })
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('readSessionLogDirect:压缩档案解出事件流', () => {
|
|
76
|
+
const root = tempRoot()
|
|
77
|
+
try {
|
|
78
|
+
const dir = join(root, 'bucket', 'sid-9')
|
|
79
|
+
mkdirSync(dir, { recursive: true })
|
|
80
|
+
const text = [
|
|
81
|
+
JSON.stringify({ type: 'session/header', id: 'sid-9' }),
|
|
82
|
+
JSON.stringify({ type: 'assistant/message', seq: 0, data: { turn: 0, step: 0, usage: { inputTokens: 7 } } }),
|
|
83
|
+
].join('\n')
|
|
84
|
+
writeFileSync(join(dir, 'session.v3.jsonl.zstd'), zstdCompressSync(Buffer.from(text)))
|
|
85
|
+
const events = readSessionLogDirect('sid-9', root)
|
|
86
|
+
assert.equal(events.length, 1)
|
|
87
|
+
assert.equal(events[0].data.usage.inputTokens, 7)
|
|
88
|
+
} finally {
|
|
89
|
+
rmSync(root, { recursive: true, force: true })
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('readSessionLogDirect:明文 jsonl 档案同样可读', () => {
|
|
94
|
+
const root = tempRoot()
|
|
95
|
+
try {
|
|
96
|
+
const dir = join(root, 'bucket', 'sid-plain')
|
|
97
|
+
mkdirSync(dir, { recursive: true })
|
|
98
|
+
writeFileSync(join(dir, 'session.jsonl'), JSON.stringify({ type: 'turn/end', seq: 0 }))
|
|
99
|
+
const events = readSessionLogDirect('sid-plain', root)
|
|
100
|
+
assert.equal(events.length, 1)
|
|
101
|
+
} finally {
|
|
102
|
+
rmSync(root, { recursive: true, force: true })
|
|
103
|
+
}
|
|
104
|
+
})
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
CONDITION_KINDS as hostConditionKinds,
|
|
16
16
|
TOKENS_PER_MILLION as hostTokensPerMillion,
|
|
17
17
|
} from '../src/pricing.js'
|
|
18
|
+
import { refOf as hostRefOf } from '../src/collector.js'
|
|
18
19
|
|
|
19
20
|
const core = new Function(`${CLIENT_BODY}\nreturn { ${DECLARATION_NAMES.join(', ')} }`)()
|
|
20
21
|
|
|
@@ -27,6 +28,8 @@ const {
|
|
|
27
28
|
CURRENCIES: clientCurrencies,
|
|
28
29
|
CONDITION_KINDS: clientConditionKinds,
|
|
29
30
|
TOKENS_PER_MILLION: clientTokensPerMillion,
|
|
31
|
+
MODEL_UNROUTED: clientModelUnrouted,
|
|
32
|
+
turnModelOf: clientTurnModelOf,
|
|
30
33
|
} = core
|
|
31
34
|
|
|
32
35
|
// 本地时区固定时刻(2026-03-15 为周日):同一 Date 实例喂双侧
|
|
@@ -36,32 +39,40 @@ const SUNDAY_0900 = [2026, 2, 15, 9, 0]
|
|
|
36
39
|
const SUNDAY_2330 = [2026, 2, 15, 23, 30]
|
|
37
40
|
const SUNDAY_0300 = [2026, 2, 15, 3, 0]
|
|
38
41
|
const SUNDAY_1200 = [2026, 2, 15, 12, 0]
|
|
42
|
+
const MON_JAN01 = [2026, 0, 1, 12, 0]
|
|
39
43
|
const MON_JAN15 = [2026, 0, 15, 12, 0]
|
|
40
44
|
const MON_JAN16 = [2026, 0, 16, 12, 0]
|
|
41
45
|
const MON_JAN26 = [2026, 0, 26, 12, 0]
|
|
46
|
+
const MON_JAN31 = [2026, 0, 31, 12, 0]
|
|
42
47
|
const MON_JAN03 = [2026, 0, 3, 12, 0]
|
|
43
48
|
const FEB_30_ROLLS_TO_MAR02 = [2026, 1, 30, 12, 0]
|
|
44
49
|
|
|
45
50
|
const CONDITION_VECTORS = [
|
|
46
51
|
{ name: 'dailyWindow from<to 命中', condition: { kind: 'dailyWindow', from: '09:00', to: '18:00' }, date: SUNDAY_0930, expected: true },
|
|
47
|
-
{ name: 'dailyWindow
|
|
52
|
+
{ name: 'dailyWindow 双闭含 to 端点', condition: { kind: 'dailyWindow', from: '09:00', to: '18:00' }, date: SUNDAY_1800, expected: true },
|
|
48
53
|
{ name: 'dailyWindow 含头', condition: { kind: 'dailyWindow', from: '09:00', to: '18:00' }, date: SUNDAY_0900, expected: true },
|
|
49
54
|
{ name: 'dailyWindow 跨午夜夜段命中', condition: { kind: 'dailyWindow', from: '22:00', to: '06:00' }, date: SUNDAY_2330, expected: true },
|
|
50
55
|
{ name: 'dailyWindow 跨午夜凌晨命中', condition: { kind: 'dailyWindow', from: '22:00', to: '06:00' }, date: SUNDAY_0300, expected: true },
|
|
51
56
|
{ name: 'dailyWindow 跨午夜日间不命中', condition: { kind: 'dailyWindow', from: '22:00', to: '06:00' }, date: SUNDAY_1200, expected: false },
|
|
52
|
-
{ name: 'dailyWindow from===to
|
|
57
|
+
{ name: 'dailyWindow from===to 单点命中', condition: { kind: 'dailyWindow', from: '12:00', to: '12:00' }, date: SUNDAY_1200, expected: true },
|
|
58
|
+
{ name: 'dailyWindow 00:00~23:59 全天含尾', condition: { kind: 'dailyWindow', from: '00:00', to: '23:59' }, date: SUNDAY_2330, expected: true },
|
|
53
59
|
{ name: 'dailyWindow 非法格式', condition: { kind: 'dailyWindow', from: 'abc', to: '06:00' }, date: SUNDAY_1200, expected: false },
|
|
54
60
|
{ name: 'weekdays 命中周日', condition: { kind: 'weekdays', days: [0] }, date: SUNDAY_0930, expected: true },
|
|
55
61
|
{ name: 'weekdays 空 days 不成立', condition: { kind: 'weekdays', days: [] }, date: SUNDAY_0930, expected: false },
|
|
56
62
|
{ name: 'weekdays 未命中', condition: { kind: 'weekdays', days: [1] }, date: SUNDAY_0930, expected: false },
|
|
57
63
|
{ name: 'weekdays days 非数组', condition: { kind: 'weekdays', days: 'x' }, date: SUNDAY_0930, expected: false },
|
|
58
|
-
{ name: 'monthDays
|
|
64
|
+
{ name: 'monthDays 双闭含 to 端点', condition: { kind: 'monthDays', from: 1, to: 15 }, date: MON_JAN15, expected: true },
|
|
65
|
+
{ name: 'monthDays 含首日', condition: { kind: 'monthDays', from: 1, to: 15 }, date: MON_JAN01, expected: true },
|
|
59
66
|
{ name: 'monthDays 界外', condition: { kind: 'monthDays', from: 1, to: 15 }, date: MON_JAN16, expected: false },
|
|
67
|
+
{ name: 'monthDays 1~31 全月', condition: { kind: 'monthDays', from: 1, to: 31 }, date: MON_JAN31, expected: true },
|
|
68
|
+
{ name: 'monthDays from===to 单日命中', condition: { kind: 'monthDays', from: 15, to: 15 }, date: MON_JAN15, expected: true },
|
|
60
69
|
{ name: 'monthDays 环绕起点命中', condition: { kind: 'monthDays', from: 26, to: 5 }, date: MON_JAN26, expected: true },
|
|
61
70
|
{ name: 'monthDays 环绕中段不命中', condition: { kind: 'monthDays', from: 26, to: 5 }, date: MON_JAN15, expected: false },
|
|
62
|
-
{ name: 'monthDays
|
|
71
|
+
{ name: 'monthDays 环绕终点含端点', condition: { kind: 'monthDays', from: 26, to: 5 }, date: MON_JAN03, expected: true },
|
|
63
72
|
{ name: 'monthDays 非整数不成立', condition: { kind: 'monthDays', from: 1.5, to: 5 }, date: MON_JAN15, expected: false },
|
|
64
73
|
{ name: 'dateRange 命中', condition: { kind: 'dateRange', from: '2026-01-01', to: '2026-01-31' }, date: MON_JAN15, expected: true },
|
|
74
|
+
{ name: 'dateRange to 端点命中', condition: { kind: 'dateRange', from: '2026-01-01', to: '2026-01-31' }, date: MON_JAN31, expected: true },
|
|
75
|
+
{ name: 'dateRange from===to 单日命中', condition: { kind: 'dateRange', from: '2026-01-15', to: '2026-01-15' }, date: MON_JAN15, expected: true },
|
|
65
76
|
{ name: 'dateRange 倒序不成立', condition: { kind: 'dateRange', from: '2026-01-31', to: '2026-01-01' }, date: MON_JAN15, expected: false },
|
|
66
77
|
{ name: 'dateRange 非零填充不成立', condition: { kind: 'dateRange', from: '2026-1-1', to: '2026-1-31' }, date: MON_JAN15, expected: false },
|
|
67
78
|
{ name: 'dateRange 2 月 30 号滚月后不命中', condition: { kind: 'dateRange', from: '2026-02-01', to: '2026-02-28' }, date: FEB_30_ROLLS_TO_MAR02, expected: false },
|
|
@@ -292,3 +303,26 @@ test(`costOf 双侧一致(${COST_VECTORS.length} 向量)`, () => {
|
|
|
292
303
|
assert.deepEqual(host, vector.expected, vector.name)
|
|
293
304
|
}
|
|
294
305
|
})
|
|
306
|
+
|
|
307
|
+
// 路由引用键双实现同源:采集器 refOf(样本 model 键)与 client 注入点B turnModelOf(回合计价键)
|
|
308
|
+
// 双全拼两段、仅 model 用裸名、多 route 取首条;键不成立时两侧各按自身约定降级
|
|
309
|
+
// (refOf→undefined,turnModelOf→全通配键),该场景单独断言各自约定值
|
|
310
|
+
const REF_VECTORS = [
|
|
311
|
+
{ name: '双全拼两段', route: { provider: 'deepseek', model: 'deepseek-chat' }, expected: 'deepseek/deepseek-chat' },
|
|
312
|
+
{ name: '仅 model 用裸名', route: { model: 'm' }, expected: 'm' },
|
|
313
|
+
{ name: 'provider 空串按裸名', route: { provider: '', model: 'm' }, expected: 'm' },
|
|
314
|
+
{ name: 'provider 非空 model 空串不成立', route: { provider: 'p', model: '' }, expected: undefined },
|
|
315
|
+
{ name: '空 route 对象不成立', route: {}, expected: undefined },
|
|
316
|
+
{ name: '多 route 取首条', route: { provider: 'a', model: 'x' }, tail: { provider: 'b', model: 'y' }, expected: 'a/x' },
|
|
317
|
+
]
|
|
318
|
+
|
|
319
|
+
test(`路由引用键双侧一致(${REF_VECTORS.length} 向量)`, () => {
|
|
320
|
+
for (const vector of REF_VECTORS) {
|
|
321
|
+
const routes = vector.tail ? [vector.route, vector.tail] : [vector.route]
|
|
322
|
+
assert.equal(hostRefOf(vector.route), vector.expected, vector.name)
|
|
323
|
+
assert.equal(clientTurnModelOf({ routes }), vector.expected ?? clientModelUnrouted, vector.name)
|
|
324
|
+
}
|
|
325
|
+
assert.equal(hostRefOf(null), undefined)
|
|
326
|
+
assert.equal(clientTurnModelOf({ routes: [] }), clientModelUnrouted)
|
|
327
|
+
assert.equal(clientTurnModelOf(null), clientModelUnrouted)
|
|
328
|
+
})
|
package/test/pricing.test.mjs
CHANGED
|
@@ -228,37 +228,47 @@ test('rules 非数组或 timestamp 非法返回 null', () => {
|
|
|
228
228
|
assert.equal(matchPrice([makeRule()], 'a/b', Number.NaN), null)
|
|
229
229
|
})
|
|
230
230
|
|
|
231
|
-
test('dailyWindow from<to
|
|
232
|
-
// Given 10:00~12:00 When 逐时刻判定 Then 10:00 与
|
|
231
|
+
test('dailyWindow from<to 双闭含两端', () => {
|
|
232
|
+
// Given 10:00~12:00 When 逐时刻判定 Then 10:00 与 12:00 命中,09:59 与 12:01 不命中
|
|
233
233
|
const condition = { kind: 'dailyWindow', from: '10:00', to: '12:00' }
|
|
234
234
|
assert.equal(conditionMatches(condition, localTime(6, 10)), true)
|
|
235
235
|
assert.equal(conditionMatches(condition, localTime(6, 11, 59)), true)
|
|
236
|
+
assert.equal(conditionMatches(condition, localTime(6, 12)), true)
|
|
236
237
|
assert.equal(conditionMatches(condition, localTime(6, 9, 59)), false)
|
|
237
|
-
assert.equal(conditionMatches(condition, localTime(6, 12)), false)
|
|
238
|
+
assert.equal(conditionMatches(condition, localTime(6, 12, 1)), false)
|
|
238
239
|
})
|
|
239
240
|
|
|
240
241
|
test('dailyWindow 跨午夜窗口按本地分量命中', () => {
|
|
241
|
-
// Given 22:00~02:00 When 本地 23:30 与次日 01:00 Then 命中;本地正午不命中
|
|
242
|
+
// Given 22:00~02:00 When 本地 23:30 与次日 01:00/02:00 Then 命中;本地正午不命中
|
|
242
243
|
const condition = { kind: 'dailyWindow', from: '22:00', to: '02:00' }
|
|
243
244
|
assert.equal(conditionMatches(condition, localTime(6, 23, 30)), true)
|
|
244
245
|
assert.equal(conditionMatches(condition, new Date(2026, 8, 7, 1)), true)
|
|
246
|
+
assert.equal(conditionMatches(condition, new Date(2026, 8, 7, 2)), true)
|
|
245
247
|
assert.equal(conditionMatches(condition, localTime(6, 12)), false)
|
|
246
248
|
})
|
|
247
249
|
|
|
248
|
-
test('dailyWindow from===to
|
|
249
|
-
// Given 10:00~10:00 When 判定
|
|
250
|
+
test('dailyWindow from===to 为单点仅命中该时刻', () => {
|
|
251
|
+
// Given 10:00~10:00 双闭单点 When 判定 10:00 与其他时刻 Then 仅 10:00 命中
|
|
250
252
|
const condition = { kind: 'dailyWindow', from: '10:00', to: '10:00' }
|
|
251
|
-
assert.equal(conditionMatches(condition, localTime(6, 0)), true)
|
|
252
253
|
assert.equal(conditionMatches(condition, localTime(6, 10)), true)
|
|
254
|
+
assert.equal(conditionMatches(condition, localTime(6, 10, 1)), false)
|
|
255
|
+
assert.equal(conditionMatches(condition, localTime(6, 23, 59)), false)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
test('dailyWindow 全天用 00:00~23:59 表达', () => {
|
|
259
|
+
// Given 00:00~23:59 When 判定 00:00 与 23:59 Then 全部命中
|
|
260
|
+
const condition = { kind: 'dailyWindow', from: '00:00', to: '23:59' }
|
|
261
|
+
assert.equal(conditionMatches(condition, localTime(6, 0)), true)
|
|
253
262
|
assert.equal(conditionMatches(condition, localTime(6, 23, 59)), true)
|
|
254
263
|
})
|
|
255
264
|
|
|
256
265
|
test('dailyWindow 分钟级评估', () => {
|
|
257
|
-
// Given 10:05~10:10 When 判定 10:05/10:09/10:10 Then
|
|
266
|
+
// Given 10:05~10:10 When 判定 10:05/10:09/10:10/10:11 Then 前三者命中
|
|
258
267
|
const condition = { kind: 'dailyWindow', from: '10:05', to: '10:10' }
|
|
259
268
|
assert.equal(conditionMatches(condition, localTime(6, 10, 5)), true)
|
|
260
269
|
assert.equal(conditionMatches(condition, localTime(6, 10, 9)), true)
|
|
261
|
-
assert.equal(conditionMatches(condition, localTime(6, 10, 10)),
|
|
270
|
+
assert.equal(conditionMatches(condition, localTime(6, 10, 10)), true)
|
|
271
|
+
assert.equal(conditionMatches(condition, localTime(6, 10, 11)), false)
|
|
262
272
|
})
|
|
263
273
|
|
|
264
274
|
test('weekdays 集合按 getDay 命中', () => {
|
|
@@ -273,10 +283,11 @@ test('weekdays 空数组条件不成立', () => {
|
|
|
273
283
|
assert.equal(conditionMatches({ kind: 'weekdays', days: [] }, SUNDAY), false)
|
|
274
284
|
})
|
|
275
285
|
|
|
276
|
-
test('monthDays
|
|
277
|
-
// Given 1~15 When 判定 1/15/16 号 Then
|
|
286
|
+
test('monthDays 双闭含两端,from===to 为单日', () => {
|
|
287
|
+
// Given 1~15 When 判定 1/14/15/16 号 Then 含 1 与 15 不含 16;Given 5~5 单日 When 判定 5/6 号 Then 仅 5 命中
|
|
278
288
|
const span = { kind: 'monthDays', from: 1, to: 15 }
|
|
279
289
|
assert.equal(conditionMatches(span, new Date(2026, 8, 1)), true)
|
|
290
|
+
assert.equal(conditionMatches(span, new Date(2026, 8, 14)), true)
|
|
280
291
|
assert.equal(conditionMatches(span, new Date(2026, 8, 15)), true)
|
|
281
292
|
assert.equal(conditionMatches(span, new Date(2026, 8, 16)), false)
|
|
282
293
|
const single = { kind: 'monthDays', from: 5, to: 5 }
|
|
@@ -284,44 +295,65 @@ test('monthDays 正向段双闭含单日', () => {
|
|
|
284
295
|
assert.equal(conditionMatches(single, new Date(2026, 8, 6)), false)
|
|
285
296
|
})
|
|
286
297
|
|
|
287
|
-
test('monthDays
|
|
288
|
-
// Given
|
|
298
|
+
test('monthDays 全月用 1~31 表达,31 号仅大月命中', () => {
|
|
299
|
+
// Given 1~31 When 判定 1/30/31 号 Then 全月命中;2 月无 31 号自然不触发
|
|
300
|
+
const month = { kind: 'monthDays', from: 1, to: 31 }
|
|
301
|
+
assert.equal(conditionMatches(month, new Date(2026, 8, 1)), true)
|
|
302
|
+
assert.equal(conditionMatches(month, new Date(2026, 8, 30)), true)
|
|
303
|
+
assert.equal(conditionMatches(month, new Date(2026, 0, 31)), true)
|
|
304
|
+
assert.equal(conditionMatches(month, new Date(2026, 1, 28)), true)
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
test('monthDays 单日 31 号用 31~31 表达', () => {
|
|
308
|
+
// Given 31~31 When 判定 1 月 31 号与 2 月末 Then 仅大月 31 号命中,2 月无 31 号自然不触发
|
|
289
309
|
const condition = { kind: 'monthDays', from: 31, to: 31 }
|
|
290
310
|
assert.equal(conditionMatches(condition, new Date(2026, 0, 31)), true)
|
|
291
311
|
assert.equal(conditionMatches(condition, new Date(2026, 1, 28)), false)
|
|
292
312
|
})
|
|
293
313
|
|
|
294
|
-
test('monthDays 26~25
|
|
295
|
-
// Given 26~25
|
|
314
|
+
test('monthDays 26~25 跨月环绕为 26 起至次月 25', () => {
|
|
315
|
+
// Given 26~25 账单周期双闭 When 判定 26/31/1/24/25/15 号 Then 26..31 并 1..25 命中
|
|
296
316
|
const condition = { kind: 'monthDays', from: 26, to: 25 }
|
|
297
317
|
assert.equal(conditionMatches(condition, new Date(2026, 0, 26)), true)
|
|
298
318
|
assert.equal(conditionMatches(condition, new Date(2026, 0, 31)), true)
|
|
299
319
|
assert.equal(conditionMatches(condition, new Date(2026, 1, 1)), true)
|
|
320
|
+
assert.equal(conditionMatches(condition, new Date(2026, 1, 24)), true)
|
|
300
321
|
assert.equal(conditionMatches(condition, new Date(2026, 1, 25)), true)
|
|
301
322
|
assert.equal(conditionMatches(condition, new Date(2026, 0, 15)), true)
|
|
302
323
|
})
|
|
303
324
|
|
|
304
325
|
test('monthDays 27~10 环绕存在不命中间隙', () => {
|
|
305
|
-
// Given 27~10 When 判定 27/31/1/10 与 15/26 号 Then
|
|
326
|
+
// Given 27~10 双闭 When 判定 27/31/1/10 与 9/15/26 号 Then 27..31 并 1..10 命中,间隙不命中
|
|
306
327
|
const condition = { kind: 'monthDays', from: 27, to: 10 }
|
|
307
328
|
assert.equal(conditionMatches(condition, new Date(2026, 0, 27)), true)
|
|
308
329
|
assert.equal(conditionMatches(condition, new Date(2026, 0, 31)), true)
|
|
309
330
|
assert.equal(conditionMatches(condition, new Date(2026, 1, 1)), true)
|
|
310
331
|
assert.equal(conditionMatches(condition, new Date(2026, 1, 10)), true)
|
|
332
|
+
assert.equal(conditionMatches(condition, new Date(2026, 1, 9)), true)
|
|
333
|
+
assert.equal(conditionMatches(condition, new Date(2026, 1, 11)), false)
|
|
311
334
|
assert.equal(conditionMatches(condition, new Date(2026, 0, 15)), false)
|
|
312
335
|
assert.equal(conditionMatches(condition, new Date(2026, 0, 26)), false)
|
|
313
336
|
})
|
|
314
337
|
|
|
315
|
-
test('dateRange
|
|
316
|
-
// Given 2026-01-01~2026-01-31 When
|
|
338
|
+
test('dateRange 双闭含两端', () => {
|
|
339
|
+
// Given 2026-01-01~2026-01-31 When 判定首日/中间/末日/界外 Then 首日中间末日命中,两侧不命中
|
|
317
340
|
const condition = { kind: 'dateRange', from: '2026-01-01', to: '2026-01-31' }
|
|
318
341
|
assert.equal(conditionMatches(condition, new Date(2026, 0, 1)), true)
|
|
319
342
|
assert.equal(conditionMatches(condition, new Date(2026, 0, 15)), true)
|
|
343
|
+
assert.equal(conditionMatches(condition, new Date(2026, 0, 30)), true)
|
|
320
344
|
assert.equal(conditionMatches(condition, new Date(2026, 0, 31)), true)
|
|
321
345
|
assert.equal(conditionMatches(condition, new Date(2025, 11, 31)), false)
|
|
322
346
|
assert.equal(conditionMatches(condition, new Date(2026, 1, 1)), false)
|
|
323
347
|
})
|
|
324
348
|
|
|
349
|
+
test('dateRange from===to 为单日命中', () => {
|
|
350
|
+
// Given 同日区间双闭 When 判定该日与前后日 Then 仅该日命中
|
|
351
|
+
const single = { kind: 'dateRange', from: '2026-01-15', to: '2026-01-15' }
|
|
352
|
+
assert.equal(conditionMatches(single, new Date(2026, 0, 15)), true)
|
|
353
|
+
assert.equal(conditionMatches(single, new Date(2026, 0, 14)), false)
|
|
354
|
+
assert.equal(conditionMatches(single, new Date(2026, 0, 16)), false)
|
|
355
|
+
})
|
|
356
|
+
|
|
325
357
|
test('dateRange 倒序属配置错误不成立', () => {
|
|
326
358
|
const condition = { kind: 'dateRange', from: '2026-02-01', to: '2026-01-01' }
|
|
327
359
|
assert.equal(conditionMatches(condition, new Date(2026, 0, 15)), false)
|
package/test/query.test.mjs
CHANGED
|
@@ -393,6 +393,7 @@ test('attachCosts M 行 unpriced 按父 H 桶去重', () => {
|
|
|
393
393
|
})
|
|
394
394
|
|
|
395
395
|
test('attachCosts D 端点按 H 行桶起点计价折叠到日槽', () => {
|
|
396
|
+
// 双闭:两日期段不相邻不重叠,22/23 桶起点 01-01 落段一,00 桶起点 01-02 落段二
|
|
396
397
|
const costRows = [
|
|
397
398
|
makeRow({ bucket: '2020-01-01T22', model: 'm1', inputTokens: 1000000 }),
|
|
398
399
|
makeRow({ bucket: '2020-01-01T23', model: 'm1', inputTokens: 1000000 }),
|
|
@@ -404,7 +405,7 @@ test('attachCosts D 端点按 H 行桶起点计价折叠到日槽', () => {
|
|
|
404
405
|
]
|
|
405
406
|
const rules = [
|
|
406
407
|
ruleOf({ price: inputPrice(1), conditions: [{ kind: 'dateRange', from: '2020-01-01', to: '2020-01-01' }] }),
|
|
407
|
-
ruleOf({ price: inputPrice(3), conditions: [{ kind: 'dateRange', from: '2020-01-02', to: '2020-01-
|
|
408
|
+
ruleOf({ price: inputPrice(3), conditions: [{ kind: 'dateRange', from: '2020-01-02', to: '2020-01-03' }] }),
|
|
408
409
|
]
|
|
409
410
|
const result = aggregateRange(aggregateRows, 'D', '2020-01-01', '2020-01-02')
|
|
410
411
|
const out = attachCosts(result, costRows, 'D', rules)
|
package/test/routes.test.mjs
CHANGED
|
@@ -69,6 +69,13 @@ function makeStore({ rows = [], fail } = {}) {
|
|
|
69
69
|
const calls = { rangeRows: 0 }
|
|
70
70
|
return {
|
|
71
71
|
calls,
|
|
72
|
+
backupInfo() {
|
|
73
|
+
return { available: false }
|
|
74
|
+
},
|
|
75
|
+
async restoreFromBackup() {
|
|
76
|
+
calls.restoreFromBackup = (calls.restoreFromBackup ?? 0) + 1
|
|
77
|
+
return { takenAt: 0, rows: 0 }
|
|
78
|
+
},
|
|
72
79
|
async rangeRows(g, from, to) {
|
|
73
80
|
calls.rangeRows += 1
|
|
74
81
|
if (fail) throw fail
|
|
@@ -301,7 +308,7 @@ test('range 合法请求回 UsageStatsRange 信封', async () => {
|
|
|
301
308
|
assert.equal(store.calls.rangeRows, 1)
|
|
302
309
|
})
|
|
303
310
|
|
|
304
|
-
test('status
|
|
311
|
+
test('status 回采集快照并附带回退点元信息', async () => {
|
|
305
312
|
const collector = makeCollector({
|
|
306
313
|
stateOverrides: { running: true, total: 5, done: 2, scannedSessions: 7, lastSessionId: 's1', error: 'boom', recordFailures: 1 },
|
|
307
314
|
})
|
|
@@ -310,7 +317,7 @@ test('status 回采集快照', async () => {
|
|
|
310
317
|
const parsed = JSON.parse(res.body)
|
|
311
318
|
assert.equal(res.statusCode, 200)
|
|
312
319
|
assert.equal(parsed.ok, true)
|
|
313
|
-
assert.deepEqual(parsed.value, collector.status())
|
|
320
|
+
assert.deepEqual(parsed.value, { ...collector.status(), backup: { available: false } })
|
|
314
321
|
})
|
|
315
322
|
|
|
316
323
|
test('minutes 超出保留窗口标注 coveredFrom/coveredTo', async () => {
|
package/test/stats-line.test.mjs
CHANGED
|
@@ -35,12 +35,12 @@ const PREFS_COST_ON = { cachePrecision: false, tokenDetail: false, costDisplay:
|
|
|
35
35
|
const zhT = createTranslator(MESSAGES_ZH)
|
|
36
36
|
const enT = createTranslator(MESSAGES_EN)
|
|
37
37
|
|
|
38
|
-
//
|
|
38
|
+
// 费用组装配输入:精确规则在前、全通配规则兜底,通配仅全天时段(00:00~23:59 双闭)生效
|
|
39
39
|
const COST_RULES = [
|
|
40
40
|
{ model: 'p/m', currency: '¥', price: { input: 2, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [] },
|
|
41
41
|
{
|
|
42
42
|
model: '*/*', currency: '', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
43
|
-
conditions: [{ kind: 'dailyWindow', from: '00:00', to: '
|
|
43
|
+
conditions: [{ kind: 'dailyWindow', from: '00:00', to: '23:59' }],
|
|
44
44
|
},
|
|
45
45
|
]
|
|
46
46
|
const COST_USAGE = { uncachedInputTokens: 500000, cacheReadTokens: 0, cacheWriteTokens: 0, outputTokens: 0 }
|
|
@@ -358,11 +358,11 @@ test('buildCostItem routes 首个 model 精确规则优先于通配', () => {
|
|
|
358
358
|
assert.equal(buildCostItem(routed, wildcardFirst, PREFS_COST_ON, zhT, COST_NOW), '费用 ≈ ¥1.00')
|
|
359
359
|
})
|
|
360
360
|
|
|
361
|
-
test('buildCostItem
|
|
362
|
-
//
|
|
363
|
-
const
|
|
361
|
+
test('buildCostItem 时间条件按传入时刻评估:窗口外均不生效', () => {
|
|
362
|
+
// 双闭下 00:00~00:00 单点仅命中零点整,COST_NOW 10:00 不命中;08:00~09:00 与固定时刻不交
|
|
363
|
+
const singlePoint = [{ ...COST_RULES[1], conditions: [{ kind: 'dailyWindow', from: '00:00', to: '00:00' }] }]
|
|
364
364
|
const daytime = [{ ...COST_RULES[1], conditions: [{ kind: 'dailyWindow', from: '08:00', to: '09:00' }] }]
|
|
365
|
-
assert.equal(buildCostItem(COST_USAGE,
|
|
365
|
+
assert.equal(buildCostItem(COST_USAGE, singlePoint, PREFS_COST_ON, zhT, COST_NOW), '—')
|
|
366
366
|
assert.equal(buildCostItem(COST_USAGE, daytime, PREFS_COST_ON, zhT, COST_NOW), '—')
|
|
367
367
|
})
|
|
368
368
|
|