@mzzsfy/dsh-usage-dash 0.2.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 +54 -13
- package/package.json +2 -2
- package/src/archive-reader.js +138 -0
- package/src/client.js +548 -141
- package/src/collector.js +58 -12
- package/src/direct-log-reader.js +126 -0
- package/src/pricing.js +5 -6
- package/src/query.js +13 -0
- 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 +261 -18
- 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 +22 -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/client.test.mjs
CHANGED
|
@@ -15,6 +15,9 @@ const {
|
|
|
15
15
|
CHART_PAD,
|
|
16
16
|
DAY_MAX_SLOTS,
|
|
17
17
|
DAY_PRESETS,
|
|
18
|
+
DEFAULT_HOUR_PRESET,
|
|
19
|
+
DEFAULT_MINUTE_PRESET,
|
|
20
|
+
DEFAULT_RANGE,
|
|
18
21
|
DONUT_CENTER_XY,
|
|
19
22
|
DONUT_CIRCUMFERENCE,
|
|
20
23
|
DONUT_OUTER_RADIUS,
|
|
@@ -58,6 +61,8 @@ const {
|
|
|
58
61
|
hourTickLabel,
|
|
59
62
|
indexOfDay,
|
|
60
63
|
isEmptyRange,
|
|
64
|
+
groupRulesOf,
|
|
65
|
+
insertRuleAt,
|
|
61
66
|
logTimeOf,
|
|
62
67
|
matchPrice,
|
|
63
68
|
maxSlotsFor,
|
|
@@ -65,11 +70,15 @@ const {
|
|
|
65
70
|
modelSegmentLabel,
|
|
66
71
|
modelSpeedText,
|
|
67
72
|
modelNameOf,
|
|
73
|
+
moveRuleTo,
|
|
68
74
|
niceTicks,
|
|
69
75
|
otherDetailItems,
|
|
70
76
|
parseEnvelope,
|
|
77
|
+
partitionGroupOf,
|
|
71
78
|
providerOf,
|
|
72
79
|
rateAxisTicks,
|
|
80
|
+
removeRulesAt,
|
|
81
|
+
renameRulesAt,
|
|
73
82
|
resolveDayRange,
|
|
74
83
|
resolveHourRange,
|
|
75
84
|
resolveMinuteRange,
|
|
@@ -80,6 +89,13 @@ const {
|
|
|
80
89
|
trimSlots,
|
|
81
90
|
trendLayout,
|
|
82
91
|
trendRatePoints,
|
|
92
|
+
trendSpeedPoints,
|
|
93
|
+
speedTipText,
|
|
94
|
+
speedScaleMax,
|
|
95
|
+
legendToggle,
|
|
96
|
+
leftAxisTicks,
|
|
97
|
+
hourSlotLabel,
|
|
98
|
+
minuteSlotLabel,
|
|
83
99
|
validatePricingRules,
|
|
84
100
|
} = core
|
|
85
101
|
|
|
@@ -129,6 +145,13 @@ test('预设定义表覆盖三视图', () => {
|
|
|
129
145
|
assert.deepEqual(MINUTE_PRESETS, ['3h', '24h', '3d', '7d'])
|
|
130
146
|
})
|
|
131
147
|
|
|
148
|
+
test('默认挡位均在其挡位列表内,分钟窗口可解析', () => {
|
|
149
|
+
assert.ok(DAY_PRESETS.includes(DEFAULT_RANGE))
|
|
150
|
+
assert.ok(HOUR_PRESETS.includes(DEFAULT_HOUR_PRESET))
|
|
151
|
+
assert.ok(MINUTE_PRESETS.includes(DEFAULT_MINUTE_PRESET))
|
|
152
|
+
assert.notEqual(resolveMinuteRange(DEFAULT_MINUTE_PRESET, NOW), null)
|
|
153
|
+
})
|
|
154
|
+
|
|
132
155
|
test('时/分挡位文案键逐挡齐备,含跨表同 id 挡位', () => {
|
|
133
156
|
// Given 时/分挡位表存在同 id('24h') When 逐挡查词典 Then 两语言均有对应键,漏配即回退键名
|
|
134
157
|
for (const id of HOUR_PRESETS) {
|
|
@@ -320,23 +343,53 @@ test('trendLayout 单槽列距铺满且柱宽取上限', () => {
|
|
|
320
343
|
assert.equal(layout.maxTotal, 50)
|
|
321
344
|
})
|
|
322
345
|
|
|
323
|
-
test('trendLayout
|
|
346
|
+
test('trendLayout 零值槽无分段,无可见数据时刻度为空表', () => {
|
|
324
347
|
const layout = trendLayout([{ day: '2026-03-15', total: 0, byModel: {} }], [], 720, 46)
|
|
325
348
|
assert.deepEqual(layout.bars[0].segments, [])
|
|
326
|
-
assert.deepEqual(layout.ticks, [
|
|
349
|
+
assert.deepEqual(layout.ticks, [])
|
|
327
350
|
})
|
|
328
351
|
|
|
329
|
-
test('trendLayout
|
|
352
|
+
test('trendLayout 堆叠分段自底向上,轴上限为数据峰乘留白系数', () => {
|
|
330
353
|
const layout = trendLayout([{ day: 'd', total: 50, byModel: { a: 30, b: 20 } }], ['a', 'b'], 720, 46)
|
|
331
354
|
const [segA, segB] = layout.bars[0].segments
|
|
332
355
|
assert.deepEqual(segA.model, 'a')
|
|
333
356
|
assert.deepEqual(segB.model, 'b')
|
|
334
|
-
approx(segA.height, (30 / 50) * 184)
|
|
335
|
-
approx(segB.height, (20 / 50) * 184)
|
|
336
|
-
approx(segA.height + segB.height, 184)
|
|
357
|
+
approx(segA.height, (30 / (50 * 1.1)) * 184)
|
|
358
|
+
approx(segB.height, (20 / (50 * 1.1)) * 184)
|
|
359
|
+
approx(segA.height + segB.height, (50 / (50 * 1.1)) * 184)
|
|
337
360
|
approx(segB.y + segB.height, segA.y)
|
|
338
361
|
})
|
|
339
362
|
|
|
363
|
+
test('trendLayout 数据峰不顶满绘图区,留白系数 1.1', () => {
|
|
364
|
+
const layout = trendLayout([{ day: 'd', total: 30, byModel: { a: 30 } }], ['a'], 720, 46)
|
|
365
|
+
const [segA] = layout.bars[0].segments
|
|
366
|
+
approx(segA.height + CHART_PAD.top, CHART_PAD.top + (1 / 1.1) * 184)
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
test('trendLayout 可见模型无数据时左轴刻度为空表', () => {
|
|
370
|
+
const slots = [{ day: 'd', total: 50, byModel: { a: 50 } }]
|
|
371
|
+
assert.deepEqual(trendLayout(slots, [], 720, 46).ticks, [])
|
|
372
|
+
assert.deepEqual(trendLayout(slots, ['b'], 720, 46).ticks, [])
|
|
373
|
+
assert.ok(trendLayout(slots, ['a'], 720, 46).ticks.length > 0)
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
test('leftAxisTicks 速度模式标定速度刻度,否则标定 token 刻度', () => {
|
|
377
|
+
const tokenTicks = leftAxisTicks([100, 200], 200, [], 1)
|
|
378
|
+
assert.deepEqual(tokenTicks.map((tick) => tick.label), ['100', '200'])
|
|
379
|
+
approx(tokenTicks[0].ratio, 0.5)
|
|
380
|
+
const speedTicks = leftAxisTicks([], 1, [0.5, 1], 1)
|
|
381
|
+
assert.deepEqual(speedTicks.map((tick) => tick.label), ['0.5', '1'])
|
|
382
|
+
approx(speedTicks[0].ratio, 0.5)
|
|
383
|
+
approx(speedTicks[1].ratio, 1)
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
test('hour/minute 悬浮窗槽标签:T 换空格,小时带 h 后缀,分钟保 HH:MM', () => {
|
|
387
|
+
assert.equal(hourSlotLabel('2026-03-15T14'), '2026-03-15 14h')
|
|
388
|
+
assert.equal(hourSlotLabel('2026-03-15T00'), '2026-03-15 00h')
|
|
389
|
+
assert.equal(minuteSlotLabel('2026-03-15T14:30'), '2026-03-15 14:30')
|
|
390
|
+
assert.equal(minuteSlotLabel('2026-03-15T00:00'), '2026-03-15 00:00')
|
|
391
|
+
})
|
|
392
|
+
|
|
340
393
|
test('trendLayout 标签密度随列距收敛', () => {
|
|
341
394
|
const slots = Array.from({ length: 100 }, (_, i) => ({ day: `d${i}`, total: 0, byModel: {} }))
|
|
342
395
|
const layout = trendLayout(slots, [], 720, 46)
|
|
@@ -423,8 +476,24 @@ const TIP_ANCHOR = { left: 100, top: 100, right: 140, bottom: 114 }
|
|
|
423
476
|
const TIP_SIZE = { width: 80, height: 40 }
|
|
424
477
|
const TIP_BOUNDS = { left: 0, top: 0, right: 800, bottom: 600 }
|
|
425
478
|
|
|
426
|
-
test('tipPlace
|
|
427
|
-
assert.deepEqual(tipPlace(TIP_ANCHOR, TIP_SIZE, TIP_BOUNDS), { left: 80, top:
|
|
479
|
+
test('tipPlace 小锚点默认翻上方水平居中', () => {
|
|
480
|
+
assert.deepEqual(tipPlace(TIP_ANCHOR, TIP_SIZE, TIP_BOUNDS), { left: 80, top: 52 })
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
test('tipPlace 锚定区装得下提示框贴内顶', () => {
|
|
484
|
+
const anchor = { left: 100, top: 100, right: 240, bottom: 460 }
|
|
485
|
+
assert.deepEqual(tipPlace(anchor, TIP_SIZE, TIP_BOUNDS), { left: 130, top: 108 })
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
test('tipPlace 上方不足翻下方', () => {
|
|
489
|
+
const anchor = { left: 100, top: 20, right: 140, bottom: 60 }
|
|
490
|
+
assert.equal(tipPlace(anchor, TIP_SIZE, TIP_BOUNDS).top, 68)
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
test('tipPlace 候选边界等号归属', () => {
|
|
494
|
+
assert.deepEqual(tipPlace({ left: 100, top: 100, right: 240, bottom: 148 }, TIP_SIZE, TIP_BOUNDS), { left: 130, top: 108 })
|
|
495
|
+
assert.equal(tipPlace({ left: 100, top: 56, right: 140, bottom: 96 }, TIP_SIZE, TIP_BOUNDS).top, 8)
|
|
496
|
+
assert.equal(tipPlace({ left: 100, top: 8, right: 140, bottom: 48 }, TIP_SIZE, { left: 0, top: 0, right: 800, bottom: 104 }).top, 56)
|
|
428
497
|
})
|
|
429
498
|
|
|
430
499
|
test('tipPlace 下方放不下翻转上方', () => {
|
|
@@ -473,6 +542,61 @@ test('trendRatePoints 点映射列中心与百分比高度且零数据日跳过'
|
|
|
473
542
|
approx(points[1].y, CHART_PAD.top + plotHeight - 0.5 * plotHeight)
|
|
474
543
|
})
|
|
475
544
|
|
|
545
|
+
test('trendSpeedPoints 点映射列中心与刻度上限比例高度且无速度槽跳过', () => {
|
|
546
|
+
const slots = [
|
|
547
|
+
{ day: 'd0', speed: 50 },
|
|
548
|
+
{ day: 'd1', total: 10 },
|
|
549
|
+
{ day: 'd2', speed: 25 },
|
|
550
|
+
]
|
|
551
|
+
const bars = [{ x: 10 }, { x: 20 }, { x: 30 }]
|
|
552
|
+
const plotHeight = CHART_HEIGHT - CHART_PAD.top - CHART_PAD.bottom
|
|
553
|
+
const points = trendSpeedPoints(slots, bars, plotHeight, 100)
|
|
554
|
+
assert.equal(points.length, 2)
|
|
555
|
+
assert.equal(points[0].day, 'd0')
|
|
556
|
+
assert.equal(points[0].x, 10)
|
|
557
|
+
approx(points[0].y, CHART_PAD.top + plotHeight - 0.5 * plotHeight)
|
|
558
|
+
assert.equal(points[1].day, 'd2')
|
|
559
|
+
assert.equal(points[1].x, 30)
|
|
560
|
+
approx(points[1].y, CHART_PAD.top + plotHeight - 0.25 * plotHeight)
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
test('speedTipText 无速度为占位符有速度为官方吞吐口径', () => {
|
|
564
|
+
assert.equal(speedTipText(undefined), '—')
|
|
565
|
+
assert.equal(speedTipText(5.5556), '5.6 tok/s')
|
|
566
|
+
assert.equal(speedTipText(12.34), '12 tok/s')
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
test('legendToggle 普通点击单选,再点恢复全部,ctrl 多选且禁全藏', () => {
|
|
570
|
+
// null 表示全部可见
|
|
571
|
+
assert.deepEqual(legendToggle(null, 'a', false), new Set(['a']))
|
|
572
|
+
// 单选态再点同项恢复全部(null)
|
|
573
|
+
assert.equal(legendToggle(new Set(['a']), 'a', false), null)
|
|
574
|
+
// 单选态点他项 → 切换单选目标
|
|
575
|
+
assert.deepEqual(legendToggle(new Set(['a']), 'b', false), new Set(['b']))
|
|
576
|
+
// ctrl 切换单项:加入与移除
|
|
577
|
+
assert.deepEqual(legendToggle(new Set(['a']), 'b', true), new Set(['a', 'b']))
|
|
578
|
+
assert.deepEqual(legendToggle(new Set(['a', 'b']), 'b', true), new Set(['a']))
|
|
579
|
+
// ctrl 隐藏最后一项:无操作,保持原态
|
|
580
|
+
const solo = new Set(['a'])
|
|
581
|
+
assert.equal(legendToggle(solo, 'a', true), solo)
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
test('trendLayout 刻度跟随可见模型:单模型刻度按该槽内合计归一', () => {
|
|
585
|
+
const slots = [
|
|
586
|
+
{ day: 'd0', total: 50, byModel: { a: 30, b: 20 } },
|
|
587
|
+
{ day: 'd1', total: 90, byModel: { a: 40, b: 50 } },
|
|
588
|
+
]
|
|
589
|
+
assert.equal(trendLayout(slots, ['a', 'b'], 720, 46).maxTotal, 90)
|
|
590
|
+
assert.equal(trendLayout(slots, ['a'], 720, 46).maxTotal, 40)
|
|
591
|
+
assert.equal(trendLayout(slots, [], 720, 46).maxTotal, 1)
|
|
592
|
+
})
|
|
593
|
+
|
|
594
|
+
test('speedScaleMax 全零或空槽钳底防除零,混合取最大速度', () => {
|
|
595
|
+
assert.equal(speedScaleMax([]), 1)
|
|
596
|
+
assert.equal(speedScaleMax([{ day: 'd0' }, { day: 'd1', speed: 0 }]), 1)
|
|
597
|
+
assert.equal(speedScaleMax([{ day: 'd0', speed: 30 }, { day: 'd1', speed: 12.5 }]), 30)
|
|
598
|
+
})
|
|
599
|
+
|
|
476
600
|
test('smoothPath 空点集为空串单点为移动命令', () => {
|
|
477
601
|
assert.equal(smoothPath([]), '')
|
|
478
602
|
assert.equal(smoothPath([{ x: 1, y: 2 }]), 'M 1 2')
|
|
@@ -647,51 +771,70 @@ test('costTitleText 未计价计数后缀', () => {
|
|
|
647
771
|
})
|
|
648
772
|
|
|
649
773
|
test('defaultCondition 各类型默认值即填即用', () => {
|
|
650
|
-
// Given 四种条件类型 When 取默认 Then
|
|
651
|
-
assert.deepEqual(defaultCondition('dailyWindow'), { kind: 'dailyWindow', from: '00:00', to: '
|
|
774
|
+
// Given 四种条件类型 When 取默认 Then 时段全天(00:00~23:59)/周几空/号段全月(1~31)/日期段当天单日
|
|
775
|
+
assert.deepEqual(defaultCondition('dailyWindow'), { kind: 'dailyWindow', from: '00:00', to: '23:59' })
|
|
652
776
|
assert.deepEqual(defaultCondition('weekdays'), { kind: 'weekdays', days: [] })
|
|
653
777
|
assert.deepEqual(defaultCondition('monthDays'), { kind: 'monthDays', from: 1, to: 31 })
|
|
654
778
|
assert.deepEqual(defaultCondition('dateRange', new Date(2026, 2, 15)), { kind: 'dateRange', from: '2026-03-15', to: '2026-03-15' })
|
|
655
779
|
})
|
|
656
780
|
|
|
657
781
|
test('validatePricingRules 拒绝非法时刻与时刻字段缺失', () => {
|
|
658
|
-
// Given dailyWindow 时刻超界或缺失 When 校验 Then 标 condTime/required
|
|
782
|
+
// Given dailyWindow 时刻超界或缺失 When 校验 Then 标 condTime/required;双闭域 00:00~23:59,24:00 超界
|
|
659
783
|
const rules = [
|
|
660
784
|
{ model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'dailyWindow', from: '99:99', to: '08:00' }] },
|
|
661
785
|
{ model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'dailyWindow', from: '', to: '08:00' }] },
|
|
662
|
-
{ model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'dailyWindow', from: '
|
|
786
|
+
{ model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'dailyWindow', from: '24:00', to: '24:00' }] },
|
|
787
|
+
{ model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'dailyWindow', from: '08:00', to: '24:30' }] },
|
|
788
|
+
{ model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'dailyWindow', from: '00:00', to: '23:59' }] },
|
|
663
789
|
]
|
|
664
790
|
const errors = validatePricingRules(rules)
|
|
665
791
|
assert.equal(errors.get('0.conditions.0.from'), 'condTime')
|
|
666
792
|
assert.equal(errors.get('1.conditions.0.from'), 'required')
|
|
667
|
-
assert.equal(errors.get('2.conditions.0.
|
|
793
|
+
assert.equal(errors.get('2.conditions.0.from'), 'condTime')
|
|
794
|
+
assert.equal(errors.get('3.conditions.0.to'), 'condTime')
|
|
795
|
+
assert.equal(errors.has('4.conditions.0'), false)
|
|
796
|
+
})
|
|
797
|
+
|
|
798
|
+
test('validatePricingRules 接受 from===to 单点与单日', () => {
|
|
799
|
+
// Given 双闭下时段/号段/日期段 from===to When 校验 Then 全部合法
|
|
800
|
+
const rules = [
|
|
801
|
+
{ model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'dailyWindow', from: '10:00', to: '10:00' }] },
|
|
802
|
+
{ model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'monthDays', from: 5, to: 5 }] },
|
|
803
|
+
{ model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'dateRange', from: '2026-03-05', to: '2026-03-05' }] },
|
|
804
|
+
]
|
|
805
|
+
const errors = validatePricingRules(rules)
|
|
806
|
+
assert.equal(errors.size, 0)
|
|
668
807
|
})
|
|
669
808
|
|
|
670
809
|
test('validatePricingRules 校验周几与月号段边界', () => {
|
|
671
|
-
// Given weekdays 含 7、monthDays 含 0
|
|
810
|
+
// Given weekdays 含 7、monthDays 含 0/32/33 When 校验 Then 标 condWeekday/condMonthDay;31 为合法上界
|
|
672
811
|
const rules = [
|
|
673
812
|
{ model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'weekdays', days: [0, 7] }] },
|
|
674
813
|
{ model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'monthDays', from: 0, to: 31 }] },
|
|
675
|
-
{ model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'monthDays', from:
|
|
814
|
+
{ model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'monthDays', from: 32, to: 32 }] },
|
|
815
|
+
{ model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'monthDays', from: 1, to: 33 }] },
|
|
676
816
|
]
|
|
677
817
|
const errors = validatePricingRules(rules)
|
|
678
818
|
assert.equal(errors.get('0.conditions.0.days'), 'condWeekday')
|
|
679
819
|
assert.equal(errors.get('1.conditions.0.from'), 'condMonthDay')
|
|
680
|
-
assert.equal(errors.get('2.conditions.0.
|
|
820
|
+
assert.equal(errors.get('2.conditions.0.from'), 'condMonthDay')
|
|
821
|
+
assert.equal(errors.get('3.conditions.0.to'), 'condMonthDay')
|
|
681
822
|
})
|
|
682
823
|
|
|
683
824
|
test('validatePricingRules 校验日期段格式与倒序', () => {
|
|
684
|
-
// Given dateRange 非规范日期或倒序
|
|
825
|
+
// Given dateRange 非规范日期或倒序(双闭下同日合法)When 校验 Then 标 condDate/condRange
|
|
685
826
|
const rules = [
|
|
686
827
|
{ model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'dateRange', from: '2026-3-5', to: '2026-03-08' }] },
|
|
687
828
|
{ model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'dateRange', from: '2026-03-08', to: '2026-03-05' }] },
|
|
829
|
+
{ model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'dateRange', from: '2026-03-05', to: '2026-03-05' }] },
|
|
688
830
|
{ model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'dateRange', from: '2026-03-05', to: '2026-03-08' }] },
|
|
689
831
|
]
|
|
690
832
|
const errors = validatePricingRules(rules)
|
|
691
833
|
assert.equal(errors.get('0.conditions.0.from'), 'condDate')
|
|
692
834
|
assert.equal(errors.get('1.conditions.0'), 'condRange')
|
|
693
835
|
assert.equal(errors.has('2.conditions.0'), false)
|
|
694
|
-
assert.equal(errors.has('
|
|
836
|
+
assert.equal(errors.has('3.conditions.0'), false)
|
|
837
|
+
assert.equal(errors.has('3.conditions.0.from'), false)
|
|
695
838
|
})
|
|
696
839
|
|
|
697
840
|
test('coerceConditions 规整数字字段且不改其余字段', () => {
|
|
@@ -720,3 +863,103 @@ test('coercePricingRules 连带规整条件', () => {
|
|
|
720
863
|
assert.deepEqual(coerced[0].conditions, [{ kind: 'weekdays', days: [1] }, { kind: 'monthDays', from: 1, to: 31 }])
|
|
721
864
|
assert.deepEqual(coerced[0].price, { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 })
|
|
722
865
|
})
|
|
866
|
+
|
|
867
|
+
// —— 定价编辑器分组视图:模型组 = 默认价(组末无条件规则投影,禁条件) + 附加计费规则(带条件) ——
|
|
868
|
+
|
|
869
|
+
test('groupRulesOf 按模型聚合,组序为首次出现序,槽位保序带平面索引', () => {
|
|
870
|
+
// Given 平面规则 [a1, b1, a2] When 分组 Then a 组槽位 [0,2]、b 组槽位 [1],组顺序按首次出现
|
|
871
|
+
const rules = [{ model: 'a/1' }, { model: 'b/1' }, { model: 'a/1' }]
|
|
872
|
+
const groups = groupRulesOf(rules)
|
|
873
|
+
assert.deepEqual(groups.map((group) => group.model), ['a/1', 'b/1'])
|
|
874
|
+
assert.deepEqual(groups[0].slots.map((slot) => slot.index), [0, 2])
|
|
875
|
+
assert.deepEqual(groups[1].slots.map((slot) => slot.index), [1])
|
|
876
|
+
groups[0].slots.forEach((slot, i) => assert.equal(slot.rule, rules[[0, 2][i]]))
|
|
877
|
+
})
|
|
878
|
+
|
|
879
|
+
test('groupRulesOf 空表返回空组列表', () => {
|
|
880
|
+
assert.deepEqual(groupRulesOf([]), [])
|
|
881
|
+
})
|
|
882
|
+
|
|
883
|
+
test('partitionGroupOf 组末无条件规则为默认价,其余为附加规则且保序', () => {
|
|
884
|
+
// Given 组槽位 [条件0, 条件1, 无条件2] When 划分 Then 默认价=索引2,附加=[0,1]
|
|
885
|
+
const cond = { kind: 'dailyWindow', from: '00:00', to: '08:00' }
|
|
886
|
+
const group = { model: 'a/1', slots: [
|
|
887
|
+
{ rule: { model: 'a/1', conditions: [cond] }, index: 0 },
|
|
888
|
+
{ rule: { model: 'a/1', conditions: [{ kind: 'weekdays', days: [1] }] }, index: 1 },
|
|
889
|
+
{ rule: { model: 'a/1', conditions: [] }, index: 2 },
|
|
890
|
+
] }
|
|
891
|
+
const { defaultSlot, extras } = partitionGroupOf(group)
|
|
892
|
+
assert.equal(defaultSlot.index, 2)
|
|
893
|
+
assert.deepEqual(extras.map((slot) => slot.index), [0, 1])
|
|
894
|
+
})
|
|
895
|
+
|
|
896
|
+
test('partitionGroupOf 全条件规则组无默认价', () => {
|
|
897
|
+
// Given 组内全部带条件 When 划分 Then defaultSlot 为 null,附加=全部槽位
|
|
898
|
+
const group = { model: 'a/1', slots: [
|
|
899
|
+
{ rule: { model: 'a/1', conditions: [{ kind: 'weekdays', days: [1] }] }, index: 0 },
|
|
900
|
+
{ rule: { model: 'a/1', conditions: [{ kind: 'weekdays', days: [2] }] }, index: 1 },
|
|
901
|
+
] }
|
|
902
|
+
const { defaultSlot, extras } = partitionGroupOf(group)
|
|
903
|
+
assert.equal(defaultSlot, null)
|
|
904
|
+
assert.deepEqual(extras.map((slot) => slot.index), [0, 1])
|
|
905
|
+
})
|
|
906
|
+
|
|
907
|
+
test('partitionGroupOf 多条无条件规则时末条为默认价,前条按附加规则显示', () => {
|
|
908
|
+
// Given 组槽位 [无条件0, 无条件1] When 划分 Then 默认价=索引1,附加=[0](存量数据自然收敛)
|
|
909
|
+
const group = { model: 'a/1', slots: [
|
|
910
|
+
{ rule: { model: 'a/1', conditions: [] }, index: 0 },
|
|
911
|
+
{ rule: { model: 'a/1', conditions: [] }, index: 1 },
|
|
912
|
+
] }
|
|
913
|
+
const { defaultSlot, extras } = partitionGroupOf(group)
|
|
914
|
+
assert.equal(defaultSlot.index, 1)
|
|
915
|
+
assert.deepEqual(extras.map((slot) => slot.index), [0])
|
|
916
|
+
})
|
|
917
|
+
|
|
918
|
+
test('moveRuleTo 跨位移动返回新数组且不动原数组', () => {
|
|
919
|
+
// Given [A,B,C] When 索引 2 移到索引 0 Then [C,A,B],原数组不变
|
|
920
|
+
const rules = [{ model: 'a/1' }, { model: 'b/1' }, { model: 'a/2' }]
|
|
921
|
+
const moved = moveRuleTo(rules, 2, 0)
|
|
922
|
+
assert.deepEqual(moved.map((rule) => rule.model), ['a/2', 'a/1', 'b/1'])
|
|
923
|
+
assert.deepEqual(rules.map((rule) => rule.model), ['a/1', 'b/1', 'a/2'])
|
|
924
|
+
assert.notEqual(moved, rules)
|
|
925
|
+
})
|
|
926
|
+
|
|
927
|
+
test('moveRuleTo 组内上移等价于移到组内前一槽位平面位', () => {
|
|
928
|
+
// Given [a1,b1,a2] When a2 移到 a1 平面位 0 Then [a2,a1,b1],a 组内序为 a2,a1
|
|
929
|
+
const rules = [{ model: 'a/1' }, { model: 'b/1' }, { model: 'a/1' }]
|
|
930
|
+
assert.deepEqual(moveRuleTo(rules, 2, 0).map((rule) => rule.model), ['a/1', 'a/1', 'b/1'])
|
|
931
|
+
})
|
|
932
|
+
|
|
933
|
+
test('moveRuleTo 同位或越界返回原数组引用', () => {
|
|
934
|
+
// Given 同源同目标/越界 When 移动 Then 恒返回原引用,UI 无操作
|
|
935
|
+
const rules = [{ model: 'a/1' }, { model: 'a/2' }]
|
|
936
|
+
assert.equal(moveRuleTo(rules, 0, 0), rules)
|
|
937
|
+
assert.equal(moveRuleTo(rules, -1, 1), rules)
|
|
938
|
+
assert.equal(moveRuleTo(rules, 0, rules.length), rules)
|
|
939
|
+
assert.equal(moveRuleTo(rules, rules.length, 0), rules)
|
|
940
|
+
})
|
|
941
|
+
|
|
942
|
+
test('renameRulesAt 批量改组内模型键,其余规则不动', () => {
|
|
943
|
+
// Given 索引 [0,2] 改名 x/y When 应用 Then 仅这两条 model 变 x/y
|
|
944
|
+
const rules = [{ model: 'a/1' }, { model: 'b/1' }, { model: 'a/1' }]
|
|
945
|
+
const renamed = renameRulesAt(rules, [0, 2], 'x/y')
|
|
946
|
+
assert.deepEqual(renamed.map((rule) => rule.model), ['x/y', 'b/1', 'x/y'])
|
|
947
|
+
assert.notEqual(renamed[0], rules[0])
|
|
948
|
+
assert.equal(renamed[1], rules[1])
|
|
949
|
+
assert.notEqual(renamed[2], rules[2])
|
|
950
|
+
})
|
|
951
|
+
test('insertRuleAt 插入到目标位之后', () => {
|
|
952
|
+
// Given 组内末槽位平面索引 2 When 插入新槽位 Then 落在索引 3,后续元素后移
|
|
953
|
+
const rules = [{ model: 'a/1' }, { model: 'b/1' }, { model: 'a/1' }]
|
|
954
|
+
const next = insertRuleAt(rules, 2, { model: 'a/1' })
|
|
955
|
+
assert.equal(next.length, 4)
|
|
956
|
+
assert.equal(next[3].model, 'a/1')
|
|
957
|
+
assert.equal(next[1].model, 'b/1')
|
|
958
|
+
})
|
|
959
|
+
|
|
960
|
+
test('removeRulesAt 批量移除组内槽位', () => {
|
|
961
|
+
// Given 索引 [0,2] When 删除 Then 仅剩 b 组
|
|
962
|
+
const rules = [{ model: 'a/1' }, { model: 'b/1' }, { model: 'a/1' }]
|
|
963
|
+
const rest = removeRulesAt(rules, [0, 2])
|
|
964
|
+
assert.deepEqual(rest.map((rule) => rule.model), ['b/1'])
|
|
965
|
+
})
|
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)
|