@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.
@@ -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, [0.5, 1])
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: 122 })
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: '00:00' })
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: '23:59', to: '24:00' }] },
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.to'), 'condTime')
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 含 032 When 校验 Then 标 condWeekday/condMonthDay
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: 1, to: 32 }] },
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.to'), 'condMonthDay')
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 非规范日期或倒序 When 校验 Then 标 condDate/condRange
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('2.conditions.0.from'), false)
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
+ })
@@ -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)