@mzzsfy/dsh-usage-dash 0.3.0 → 0.5.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.
@@ -61,6 +61,8 @@ const {
61
61
  hourTickLabel,
62
62
  indexOfDay,
63
63
  isEmptyRange,
64
+ groupRulesOf,
65
+ insertRuleAt,
64
66
  logTimeOf,
65
67
  matchPrice,
66
68
  maxSlotsFor,
@@ -68,11 +70,15 @@ const {
68
70
  modelSegmentLabel,
69
71
  modelSpeedText,
70
72
  modelNameOf,
73
+ moveRuleTo,
71
74
  niceTicks,
72
75
  otherDetailItems,
73
76
  parseEnvelope,
77
+ partitionGroupOf,
74
78
  providerOf,
75
79
  rateAxisTicks,
80
+ removeRulesAt,
81
+ renameRulesAt,
76
82
  resolveDayRange,
77
83
  resolveHourRange,
78
84
  resolveMinuteRange,
@@ -84,6 +90,10 @@ const {
84
90
  trendLayout,
85
91
  trendRatePoints,
86
92
  trendSpeedPoints,
93
+ trendTtftPoints,
94
+ ttftScaleMax,
95
+ ttftTipText,
96
+ modelTtftText,
87
97
  speedTipText,
88
98
  speedScaleMax,
89
99
  legendToggle,
@@ -470,8 +480,24 @@ const TIP_ANCHOR = { left: 100, top: 100, right: 140, bottom: 114 }
470
480
  const TIP_SIZE = { width: 80, height: 40 }
471
481
  const TIP_BOUNDS = { left: 0, top: 0, right: 800, bottom: 600 }
472
482
 
473
- test('tipPlace 默认锚点下方水平居中', () => {
474
- assert.deepEqual(tipPlace(TIP_ANCHOR, TIP_SIZE, TIP_BOUNDS), { left: 80, top: 122 })
483
+ test('tipPlace 小锚点默认翻上方水平居中', () => {
484
+ assert.deepEqual(tipPlace(TIP_ANCHOR, TIP_SIZE, TIP_BOUNDS), { left: 80, top: 52 })
485
+ })
486
+
487
+ test('tipPlace 锚定区装得下提示框贴内顶', () => {
488
+ const anchor = { left: 100, top: 100, right: 240, bottom: 460 }
489
+ assert.deepEqual(tipPlace(anchor, TIP_SIZE, TIP_BOUNDS), { left: 130, top: 108 })
490
+ })
491
+
492
+ test('tipPlace 上方不足翻下方', () => {
493
+ const anchor = { left: 100, top: 20, right: 140, bottom: 60 }
494
+ assert.equal(tipPlace(anchor, TIP_SIZE, TIP_BOUNDS).top, 68)
495
+ })
496
+
497
+ test('tipPlace 候选边界等号归属', () => {
498
+ assert.deepEqual(tipPlace({ left: 100, top: 100, right: 240, bottom: 148 }, TIP_SIZE, TIP_BOUNDS), { left: 130, top: 108 })
499
+ assert.equal(tipPlace({ left: 100, top: 56, right: 140, bottom: 96 }, TIP_SIZE, TIP_BOUNDS).top, 8)
500
+ assert.equal(tipPlace({ left: 100, top: 8, right: 140, bottom: 48 }, TIP_SIZE, { left: 0, top: 0, right: 800, bottom: 104 }).top, 56)
475
501
  })
476
502
 
477
503
  test('tipPlace 下方放不下翻转上方', () => {
@@ -575,6 +601,44 @@ test('speedScaleMax 全零或空槽钳底防除零,混合取最大速度', () =>
575
601
  assert.equal(speedScaleMax([{ day: 'd0', speed: 30 }, { day: 'd1', speed: 12.5 }]), 30)
576
602
  })
577
603
 
604
+ test('trendTtftPoints 点映射列中心与刻度上限比例高度且无 ttft 槽跳过', () => {
605
+ const slots = [
606
+ { day: 'd0', ttft: 2000 },
607
+ { day: 'd1', total: 10 },
608
+ { day: 'd2', ttft: 1000 },
609
+ ]
610
+ const bars = [{ x: 10 }, { x: 20 }, { x: 30 }]
611
+ const plotHeight = CHART_HEIGHT - CHART_PAD.top - CHART_PAD.bottom
612
+ const points = trendTtftPoints(slots, bars, plotHeight, 2200)
613
+ assert.equal(points.length, 2)
614
+ assert.equal(points[0].day, 'd0')
615
+ assert.equal(points[0].x, 10)
616
+ approx(points[0].y, CHART_PAD.top + plotHeight - (2000 / 2200) * plotHeight)
617
+ assert.equal(points[1].day, 'd2')
618
+ assert.equal(points[1].x, 30)
619
+ approx(points[1].y, CHART_PAD.top + plotHeight - (1000 / 2200) * plotHeight)
620
+ })
621
+
622
+ test('ttftScaleMax 全零或空槽钳底防除零,混合取最大延迟', () => {
623
+ assert.equal(ttftScaleMax([]), 1)
624
+ assert.equal(ttftScaleMax([{ day: 'd0' }, { day: 'd1', ttft: 0 }]), 1)
625
+ assert.equal(ttftScaleMax([{ day: 'd0', ttft: 3000 }, { day: 'd1', ttft: 1250 }]), 3000)
626
+ })
627
+
628
+ test('ttftTipText 无 ttft 为占位符有 ttft 为官方时长口径', () => {
629
+ const zhT = createTranslator(MESSAGES_ZH)
630
+ const enT = createTranslator(MESSAGES_EN)
631
+ assert.equal(ttftTipText(undefined, zhT), '—')
632
+ assert.equal(ttftTipText(200, zhT), '0.2秒')
633
+ assert.equal(ttftTipText(200, enT), '0.2s')
634
+ assert.equal(ttftTipText(9500, zhT), '9.5秒')
635
+ })
636
+
637
+ test('ttftLegend 中英文案注册', () => {
638
+ assert.equal(MESSAGES_ZH.ttftLegend, '首 token 延迟')
639
+ assert.equal(MESSAGES_EN.ttftLegend, 'First-token latency')
640
+ })
641
+
578
642
  test('smoothPath 空点集为空串单点为移动命令', () => {
579
643
  assert.equal(smoothPath([]), '')
580
644
  assert.equal(smoothPath([{ x: 1, y: 2 }]), 'M 1 2')
@@ -590,6 +654,14 @@ test('smoothPath 三点后段取真实邻点', () => {
590
654
  assert.equal(d, 'M 0 0 C 10 5, 40 30, 60 30 C 80 30, 110 5, 120 0')
591
655
  })
592
656
 
657
+ test('smoothPath 相邻点间距超 maxGap 折线断开成新段', () => {
658
+ const points = [{ x: 0, y: 0 }, { x: 60, y: 0 }, { x: 300, y: 0 }, { x: 360, y: 0 }]
659
+ const d = smoothPath(points, 90)
660
+ assert.equal(d, 'M 0 0 C 10 0, 10 0, 60 0 M 300 0 C 350 0, 350 0, 360 0')
661
+ // 无 maxGap 时不分段,行为与旧签名一致
662
+ assert.equal(smoothPath(points).includes('M 300'), false)
663
+ })
664
+
593
665
  // —— S8 模型 donut 与列表 ——
594
666
 
595
667
  test('donut 常量锁定视口与环几何', () => {
@@ -665,6 +737,13 @@ test('模型速度文本:官方吞吐口径格式化,无速度为空串', () =>
665
737
  assert.equal(modelSpeedText(undefined), '')
666
738
  })
667
739
 
740
+ test('模型首字文本:语言中立短时长,无 ttft 为空串', () => {
741
+ assert.equal(modelTtftText(200), 'TTFT 0.2s')
742
+ assert.equal(modelTtftText(162000), 'TTFT 2m42s')
743
+ assert.equal(modelTtftText(0), 'TTFT 0s')
744
+ assert.equal(modelTtftText(undefined), '')
745
+ })
746
+
668
747
  // —— S14 费用格式化与展示辅助(镜像函数核心语义见 pricing-parity.test.mjs) ——
669
748
 
670
749
  test('formatCost 千分位与两位小数', () => {
@@ -749,51 +828,70 @@ test('costTitleText 未计价计数后缀', () => {
749
828
  })
750
829
 
751
830
  test('defaultCondition 各类型默认值即填即用', () => {
752
- // Given 四种条件类型 When 取默认 Then 时段全天/周几空/号段全月/日期段当天
753
- assert.deepEqual(defaultCondition('dailyWindow'), { kind: 'dailyWindow', from: '00:00', to: '00:00' })
831
+ // Given 四种条件类型 When 取默认 Then 时段全天(00:00~23:59)/周几空/号段全月(1~31)/日期段当天单日
832
+ assert.deepEqual(defaultCondition('dailyWindow'), { kind: 'dailyWindow', from: '00:00', to: '23:59' })
754
833
  assert.deepEqual(defaultCondition('weekdays'), { kind: 'weekdays', days: [] })
755
834
  assert.deepEqual(defaultCondition('monthDays'), { kind: 'monthDays', from: 1, to: 31 })
756
835
  assert.deepEqual(defaultCondition('dateRange', new Date(2026, 2, 15)), { kind: 'dateRange', from: '2026-03-15', to: '2026-03-15' })
757
836
  })
758
837
 
759
838
  test('validatePricingRules 拒绝非法时刻与时刻字段缺失', () => {
760
- // Given dailyWindow 时刻超界或缺失 When 校验 Then 标 condTime/required
839
+ // Given dailyWindow 时刻超界或缺失 When 校验 Then 标 condTime/required;双闭域 00:00~23:59,24:00 超界
761
840
  const rules = [
762
841
  { model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'dailyWindow', from: '99:99', to: '08:00' }] },
763
842
  { model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'dailyWindow', from: '', to: '08:00' }] },
764
- { model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'dailyWindow', from: '23:59', to: '24:00' }] },
843
+ { model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'dailyWindow', from: '24:00', to: '24:00' }] },
844
+ { model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'dailyWindow', from: '08:00', to: '24:30' }] },
845
+ { model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'dailyWindow', from: '00:00', to: '23:59' }] },
765
846
  ]
766
847
  const errors = validatePricingRules(rules)
767
848
  assert.equal(errors.get('0.conditions.0.from'), 'condTime')
768
849
  assert.equal(errors.get('1.conditions.0.from'), 'required')
769
- assert.equal(errors.get('2.conditions.0.to'), 'condTime')
850
+ assert.equal(errors.get('2.conditions.0.from'), 'condTime')
851
+ assert.equal(errors.get('3.conditions.0.to'), 'condTime')
852
+ assert.equal(errors.has('4.conditions.0'), false)
853
+ })
854
+
855
+ test('validatePricingRules 接受 from===to 单点与单日', () => {
856
+ // Given 双闭下时段/号段/日期段 from===to When 校验 Then 全部合法
857
+ const rules = [
858
+ { model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'dailyWindow', from: '10:00', to: '10:00' }] },
859
+ { model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'monthDays', from: 5, to: 5 }] },
860
+ { model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'dateRange', from: '2026-03-05', to: '2026-03-05' }] },
861
+ ]
862
+ const errors = validatePricingRules(rules)
863
+ assert.equal(errors.size, 0)
770
864
  })
771
865
 
772
866
  test('validatePricingRules 校验周几与月号段边界', () => {
773
- // Given weekdays 含 7、monthDays 含 032 When 校验 Then 标 condWeekday/condMonthDay
867
+ // Given weekdays 含 7、monthDays 含 0/32/33 When 校验 Then 标 condWeekday/condMonthDay;31 为合法上界
774
868
  const rules = [
775
869
  { model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'weekdays', days: [0, 7] }] },
776
870
  { model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'monthDays', from: 0, to: 31 }] },
777
- { model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'monthDays', from: 1, to: 32 }] },
871
+ { model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'monthDays', from: 32, to: 32 }] },
872
+ { model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'monthDays', from: 1, to: 33 }] },
778
873
  ]
779
874
  const errors = validatePricingRules(rules)
780
875
  assert.equal(errors.get('0.conditions.0.days'), 'condWeekday')
781
876
  assert.equal(errors.get('1.conditions.0.from'), 'condMonthDay')
782
- assert.equal(errors.get('2.conditions.0.to'), 'condMonthDay')
877
+ assert.equal(errors.get('2.conditions.0.from'), 'condMonthDay')
878
+ assert.equal(errors.get('3.conditions.0.to'), 'condMonthDay')
783
879
  })
784
880
 
785
881
  test('validatePricingRules 校验日期段格式与倒序', () => {
786
- // Given dateRange 非规范日期或倒序 When 校验 Then 标 condDate/condRange
882
+ // Given dateRange 非规范日期或倒序(双闭下同日合法)When 校验 Then 标 condDate/condRange
787
883
  const rules = [
788
884
  { model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'dateRange', from: '2026-3-5', to: '2026-03-08' }] },
789
885
  { model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'dateRange', from: '2026-03-08', to: '2026-03-05' }] },
886
+ { model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'dateRange', from: '2026-03-05', to: '2026-03-05' }] },
790
887
  { model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'dateRange', from: '2026-03-05', to: '2026-03-08' }] },
791
888
  ]
792
889
  const errors = validatePricingRules(rules)
793
890
  assert.equal(errors.get('0.conditions.0.from'), 'condDate')
794
891
  assert.equal(errors.get('1.conditions.0'), 'condRange')
795
892
  assert.equal(errors.has('2.conditions.0'), false)
796
- assert.equal(errors.has('2.conditions.0.from'), false)
893
+ assert.equal(errors.has('3.conditions.0'), false)
894
+ assert.equal(errors.has('3.conditions.0.from'), false)
797
895
  })
798
896
 
799
897
  test('coerceConditions 规整数字字段且不改其余字段', () => {
@@ -822,3 +920,103 @@ test('coercePricingRules 连带规整条件', () => {
822
920
  assert.deepEqual(coerced[0].conditions, [{ kind: 'weekdays', days: [1] }, { kind: 'monthDays', from: 1, to: 31 }])
823
921
  assert.deepEqual(coerced[0].price, { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 })
824
922
  })
923
+
924
+ // —— 定价编辑器分组视图:模型组 = 默认价(组末无条件规则投影,禁条件) + 附加计费规则(带条件) ——
925
+
926
+ test('groupRulesOf 按模型聚合,组序为首次出现序,槽位保序带平面索引', () => {
927
+ // Given 平面规则 [a1, b1, a2] When 分组 Then a 组槽位 [0,2]、b 组槽位 [1],组顺序按首次出现
928
+ const rules = [{ model: 'a/1' }, { model: 'b/1' }, { model: 'a/1' }]
929
+ const groups = groupRulesOf(rules)
930
+ assert.deepEqual(groups.map((group) => group.model), ['a/1', 'b/1'])
931
+ assert.deepEqual(groups[0].slots.map((slot) => slot.index), [0, 2])
932
+ assert.deepEqual(groups[1].slots.map((slot) => slot.index), [1])
933
+ groups[0].slots.forEach((slot, i) => assert.equal(slot.rule, rules[[0, 2][i]]))
934
+ })
935
+
936
+ test('groupRulesOf 空表返回空组列表', () => {
937
+ assert.deepEqual(groupRulesOf([]), [])
938
+ })
939
+
940
+ test('partitionGroupOf 组末无条件规则为默认价,其余为附加规则且保序', () => {
941
+ // Given 组槽位 [条件0, 条件1, 无条件2] When 划分 Then 默认价=索引2,附加=[0,1]
942
+ const cond = { kind: 'dailyWindow', from: '00:00', to: '08:00' }
943
+ const group = { model: 'a/1', slots: [
944
+ { rule: { model: 'a/1', conditions: [cond] }, index: 0 },
945
+ { rule: { model: 'a/1', conditions: [{ kind: 'weekdays', days: [1] }] }, index: 1 },
946
+ { rule: { model: 'a/1', conditions: [] }, index: 2 },
947
+ ] }
948
+ const { defaultSlot, extras } = partitionGroupOf(group)
949
+ assert.equal(defaultSlot.index, 2)
950
+ assert.deepEqual(extras.map((slot) => slot.index), [0, 1])
951
+ })
952
+
953
+ test('partitionGroupOf 全条件规则组无默认价', () => {
954
+ // Given 组内全部带条件 When 划分 Then defaultSlot 为 null,附加=全部槽位
955
+ const group = { model: 'a/1', slots: [
956
+ { rule: { model: 'a/1', conditions: [{ kind: 'weekdays', days: [1] }] }, index: 0 },
957
+ { rule: { model: 'a/1', conditions: [{ kind: 'weekdays', days: [2] }] }, index: 1 },
958
+ ] }
959
+ const { defaultSlot, extras } = partitionGroupOf(group)
960
+ assert.equal(defaultSlot, null)
961
+ assert.deepEqual(extras.map((slot) => slot.index), [0, 1])
962
+ })
963
+
964
+ test('partitionGroupOf 多条无条件规则时末条为默认价,前条按附加规则显示', () => {
965
+ // Given 组槽位 [无条件0, 无条件1] When 划分 Then 默认价=索引1,附加=[0](存量数据自然收敛)
966
+ const group = { model: 'a/1', slots: [
967
+ { rule: { model: 'a/1', conditions: [] }, index: 0 },
968
+ { rule: { model: 'a/1', conditions: [] }, index: 1 },
969
+ ] }
970
+ const { defaultSlot, extras } = partitionGroupOf(group)
971
+ assert.equal(defaultSlot.index, 1)
972
+ assert.deepEqual(extras.map((slot) => slot.index), [0])
973
+ })
974
+
975
+ test('moveRuleTo 跨位移动返回新数组且不动原数组', () => {
976
+ // Given [A,B,C] When 索引 2 移到索引 0 Then [C,A,B],原数组不变
977
+ const rules = [{ model: 'a/1' }, { model: 'b/1' }, { model: 'a/2' }]
978
+ const moved = moveRuleTo(rules, 2, 0)
979
+ assert.deepEqual(moved.map((rule) => rule.model), ['a/2', 'a/1', 'b/1'])
980
+ assert.deepEqual(rules.map((rule) => rule.model), ['a/1', 'b/1', 'a/2'])
981
+ assert.notEqual(moved, rules)
982
+ })
983
+
984
+ test('moveRuleTo 组内上移等价于移到组内前一槽位平面位', () => {
985
+ // Given [a1,b1,a2] When a2 移到 a1 平面位 0 Then [a2,a1,b1],a 组内序为 a2,a1
986
+ const rules = [{ model: 'a/1' }, { model: 'b/1' }, { model: 'a/1' }]
987
+ assert.deepEqual(moveRuleTo(rules, 2, 0).map((rule) => rule.model), ['a/1', 'a/1', 'b/1'])
988
+ })
989
+
990
+ test('moveRuleTo 同位或越界返回原数组引用', () => {
991
+ // Given 同源同目标/越界 When 移动 Then 恒返回原引用,UI 无操作
992
+ const rules = [{ model: 'a/1' }, { model: 'a/2' }]
993
+ assert.equal(moveRuleTo(rules, 0, 0), rules)
994
+ assert.equal(moveRuleTo(rules, -1, 1), rules)
995
+ assert.equal(moveRuleTo(rules, 0, rules.length), rules)
996
+ assert.equal(moveRuleTo(rules, rules.length, 0), rules)
997
+ })
998
+
999
+ test('renameRulesAt 批量改组内模型键,其余规则不动', () => {
1000
+ // Given 索引 [0,2] 改名 x/y When 应用 Then 仅这两条 model 变 x/y
1001
+ const rules = [{ model: 'a/1' }, { model: 'b/1' }, { model: 'a/1' }]
1002
+ const renamed = renameRulesAt(rules, [0, 2], 'x/y')
1003
+ assert.deepEqual(renamed.map((rule) => rule.model), ['x/y', 'b/1', 'x/y'])
1004
+ assert.notEqual(renamed[0], rules[0])
1005
+ assert.equal(renamed[1], rules[1])
1006
+ assert.notEqual(renamed[2], rules[2])
1007
+ })
1008
+ test('insertRuleAt 插入到目标位之后', () => {
1009
+ // Given 组内末槽位平面索引 2 When 插入新槽位 Then 落在索引 3,后续元素后移
1010
+ const rules = [{ model: 'a/1' }, { model: 'b/1' }, { model: 'a/1' }]
1011
+ const next = insertRuleAt(rules, 2, { model: 'a/1' })
1012
+ assert.equal(next.length, 4)
1013
+ assert.equal(next[3].model, 'a/1')
1014
+ assert.equal(next[1].model, 'b/1')
1015
+ })
1016
+
1017
+ test('removeRulesAt 批量移除组内槽位', () => {
1018
+ // Given 索引 [0,2] When 删除 Then 仅剩 b 组
1019
+ const rules = [{ model: 'a/1' }, { model: 'b/1' }, { model: 'a/1' }]
1020
+ const rest = removeRulesAt(rules, [0, 2])
1021
+ assert.deepEqual(rest.map((rule) => rule.model), ['b/1'])
1022
+ })