@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.
@@ -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 inspect(id) {
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 { meta: { id }, inheritedEventCount: def.inheritedEventCount ?? 0, events: def.events ?? [] }
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 inspect(id) {
740
+ async readLog() {
601
741
  controller.abort()
602
- return { meta: { id }, inheritedEventCount: 0, events: [ev('turn/end', 0, t)] }
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 inspect(id) {
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 { meta: { id }, inheritedEventCount: 0, events: def.events }
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 = fakePersistence([{ id: 'live-a', events }])
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 含头不含尾', condition: { kind: 'dailyWindow', from: '09:00', to: '18:00' }, date: SUNDAY_1800, expected: false },
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 全天', condition: { kind: 'dailyWindow', from: '00:00', to: '00:00' }, date: SUNDAY_1200, expected: true },
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 双闭含端', condition: { kind: 'monthDays', from: 1, to: 15 }, date: MON_JAN15, expected: true },
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 环绕终点命中', condition: { kind: 'monthDays', from: 26, to: 5 }, date: MON_JAN03, expected: true },
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
+ })
@@ -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 与 11:59 命中,09:59 与 12: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 判定 00:00/10:00/23:59 Then 全部命中
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)), false)
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 双闭;Given 5~5 When 判定 5/6 号 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 单日 31 号在 2 月自然不触发', () => {
288
- // Given 31~31 When 判定 131 号与 2 月末 Then 前者命中;2 月无 31 号,该月任意日不命中
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 账单周期 When 判定 26/31/1/25/15 号 Then 26..31 并 1..25 覆盖全月,含 15
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 判定首末日与界外 Then 端点命中,两侧不命中
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)
@@ -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-02' }] }),
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)
@@ -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 回采集快照', async () => {
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 () => {
@@ -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: '00:00' }],
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
- // 通配规则 00:00~00:00 from===to 全天生效;08:00~09:00 与固定时刻不交
363
- const midnight = [{ ...COST_RULES[1], conditions: [{ kind: 'dailyWindow', from: '00:00', to: '00:00' }] }]
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, midnight, PREFS_COST_ON, zhT, COST_NOW), '费用 ≈ 0.50')
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