@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.
@@ -0,0 +1,365 @@
1
+ // archive-reader 行为测试:宿主 sessionPersistence API 多版本适配层的
2
+ // 工厂分派与各代映射。宿主 API 形状以各代真实签名为蓝本,collector 只见
3
+ // 内部统一契约(list → [{id}],readLog → {inheritedEventCount, events})。
4
+ // 分页读、close 吞错、畸形行丢弃对齐竞品 0.1.12 双路径语义;
5
+ // V3 日志兼容由 collector 测试的专有事件忽略用例钉住。
6
+
7
+ import test from 'node:test'
8
+ import assert from 'node:assert/strict'
9
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
10
+ import { tmpdir } from 'node:os'
11
+ import { join } from 'node:path'
12
+ import { zstdCompressSync } from 'node:zlib'
13
+
14
+ import { createArchiveReader } from '../src/archive-reader.js'
15
+
16
+ const ev = (seq) => ({ type: 'turn/end', seq, time: 0, data: { turn: 0 } })
17
+ const NAMES = {
18
+ handle: 'HandleArchiveReader',
19
+ inspect: 'InspectArchiveReader',
20
+ unknown: 'UnknownArchiveReader',
21
+ }
22
+ const nameOf = (reader) => reader.constructor.name
23
+
24
+ test('工厂:open 形宿主分派到 handle 代适配器', () => {
25
+ const persistence = { open: () => {}, list: () => {} }
26
+ assert.equal(nameOf(createArchiveReader(persistence)), NAMES.handle)
27
+ })
28
+
29
+ test('工厂:inspect 形宿主分派到 inspect 代适配器', () => {
30
+ const persistence = { inspect: () => {}, list: () => {} }
31
+ assert.equal(nameOf(createArchiveReader(persistence)), NAMES.inspect)
32
+ })
33
+
34
+ test('工厂:两代形状都不匹配分派到 unknown 恒抛适配器', async () => {
35
+ const reader = createArchiveReader({})
36
+ assert.equal(nameOf(reader), NAMES.unknown)
37
+ await assert.rejects(reader.list(), /sessionPersistence/)
38
+ await assert.rejects(reader.readLog('s1'), /sessionPersistence/)
39
+ })
40
+
41
+ test('handle 代:list 归一 snapshot 为 {id},signal 包裹透传,畸形行丢弃', () => {
42
+ return withDirectRoot(async () => {
43
+ const seen = []
44
+ const persistence = {
45
+ async list(options) {
46
+ seen.push(options)
47
+ return [
48
+ { header: { id: 'a' }, revision: 'r1', sizeBytes: 10 },
49
+ { header: { id: 'b' }, revision: 'r2' },
50
+ { revision: 'no-header' },
51
+ null,
52
+ { header: { id: '' } },
53
+ ]
54
+ },
55
+ async open() {
56
+ throw new Error('not reached')
57
+ },
58
+ }
59
+ const signal = new AbortController().signal
60
+ const reader = createArchiveReader(persistence)
61
+ assert.deepEqual(await reader.list(signal), [{ id: 'a' }, { id: 'b' }])
62
+ assert.deepEqual(seen, [{ signal }])
63
+ })
64
+ })
65
+
66
+ test('handle 代:readLog 分页循环直到空页,handle 元数据透出', async () => {
67
+ const calls = []
68
+ const events = Array.from({ length: 1250 }, (_, seq) => ev(seq))
69
+ const persistence = {
70
+ async open(id, access, options) {
71
+ calls.push(['open', id, access, options])
72
+ return {
73
+ header: { id },
74
+ inheritedEventCount: 3,
75
+ async read(offset, length, readOptions) {
76
+ calls.push(['read', offset, length])
77
+ assert.equal(typeof readOptions, 'object')
78
+ assert.equal(length, 500)
79
+ return { eventState: 'detached', events: events.slice(offset, offset + length) }
80
+ },
81
+ async close() {
82
+ calls.push(['close'])
83
+ },
84
+ }
85
+ },
86
+ }
87
+ const signal = new AbortController().signal
88
+ const reader = createArchiveReader(persistence)
89
+ const result = await reader.readLog('s1', signal)
90
+ assert.equal(result.inheritedEventCount, 3)
91
+ assert.deepEqual(result.events, events)
92
+ const reads = calls.filter(([kind]) => kind === 'read')
93
+ // 1250 条 = 500+500+250 三页取到,第 4 次读到空页才终止
94
+ assert.equal(reads.length, 4)
95
+ assert.deepEqual(reads.map(([, offset]) => offset), [0, 500, 1000, 1250])
96
+ assert.deepEqual(calls[calls.length - 1], ['close'])
97
+ })
98
+
99
+ test('handle 代:inheritedEventCount 非法值回落 0', async () => {
100
+ const persistence = {
101
+ async open() {
102
+ return {
103
+ inheritedEventCount: -5,
104
+ async read() {
105
+ return { eventState: 'detached', events: [] }
106
+ },
107
+ async close() {},
108
+ }
109
+ },
110
+ }
111
+ const reader = createArchiveReader(persistence)
112
+ const result = await reader.readLog('s1')
113
+ assert.equal(result.inheritedEventCount, 0)
114
+ assert.deepEqual(result.events, [])
115
+ })
116
+
117
+ test('handle 代:read 抛错时 close 仍被调用且原错误传播', async () => {
118
+ let closed = 0
119
+ const failure = new Error('corrupted log')
120
+ const persistence = {
121
+ async open() {
122
+ return {
123
+ inheritedEventCount: 0,
124
+ async read() {
125
+ throw failure
126
+ },
127
+ async close() {
128
+ closed += 1
129
+ },
130
+ }
131
+ },
132
+ }
133
+ const reader = createArchiveReader(persistence)
134
+ await assert.rejects(reader.readLog('s1'), (error) => error === failure)
135
+ assert.equal(closed, 1)
136
+ })
137
+
138
+ test('handle 代:read 抛错且 close 也抛错时,原错误传播不被掩盖', async () => {
139
+ const failure = new Error('corrupted log')
140
+ const persistence = {
141
+ async open() {
142
+ return {
143
+ inheritedEventCount: 0,
144
+ async read() {
145
+ throw failure
146
+ },
147
+ async close() {
148
+ throw new Error('close failure')
149
+ },
150
+ }
151
+ },
152
+ }
153
+ const reader = createArchiveReader(persistence)
154
+ await assert.rejects(reader.readLog('s1'), (error) => error === failure)
155
+ })
156
+
157
+ test('handle 代:read 成功后 close 失败被吞掉,读取结果照常返回', async () => {
158
+ let closed = 0
159
+ const events = [ev(0)]
160
+ let reads = 0
161
+ const persistence = {
162
+ async open() {
163
+ return {
164
+ inheritedEventCount: 0,
165
+ async read() {
166
+ reads += 1
167
+ // 首读返回数据页,再读返回空页终止分页循环
168
+ return { eventState: 'detached', events: reads === 1 ? events : [] }
169
+ },
170
+ async close() {
171
+ closed += 1
172
+ throw new Error('close failure')
173
+ },
174
+ }
175
+ },
176
+ }
177
+ const reader = createArchiveReader(persistence)
178
+ const result = await reader.readLog('s1')
179
+ assert.equal(closed, 1)
180
+ assert.deepEqual(result, { inheritedEventCount: 0, events })
181
+ })
182
+
183
+ test('handle 代:abort 中断分页循环,当前页已读数据保留', async () => {
184
+ const controller = new AbortController()
185
+ const events = Array.from({ length: 700 }, (_, seq) => ev(seq))
186
+ const persistence = {
187
+ async open() {
188
+ return {
189
+ inheritedEventCount: 0,
190
+ async read(offset) {
191
+ if (offset > 0) controller.abort()
192
+ return { eventState: 'detached', events: events.slice(offset, offset + 500) }
193
+ },
194
+ async close() {},
195
+ }
196
+ },
197
+ }
198
+ const reader = createArchiveReader(persistence)
199
+ // abort 在第二页读取时触发:第二页已返回故被保留,下一轮循环检测到中止退出
200
+ const result = await reader.readLog('s1', controller.signal)
201
+ assert.equal(result.events.length, 700)
202
+ })
203
+
204
+ test('handle 代:open 抛错直接传播,不触发 close', async () => {
205
+ const failure = new Error('format unsupported')
206
+ const persistence = {
207
+ async open() {
208
+ throw failure
209
+ },
210
+ }
211
+ const reader = createArchiveReader(persistence)
212
+ await assert.rejects(reader.readLog('s1'), (error) => error === failure)
213
+ })
214
+
215
+ test('inspect 代:list 归一 header 数组为 {id},signal 直传,畸形行丢弃', () => {
216
+ return withDirectRoot(async () => {
217
+ const seen = []
218
+ const persistence = {
219
+ async list(arg) {
220
+ seen.push(arg)
221
+ return [{ id: 'a', createdAt: 1 }, { id: 'b', createdAt: 2 }, null, {}]
222
+ },
223
+ async inspect() {
224
+ throw new Error('not reached')
225
+ },
226
+ }
227
+ const signal = new AbortController().signal
228
+ const reader = createArchiveReader(persistence)
229
+ assert.deepEqual(await reader.list(signal), [{ id: 'a' }, { id: 'b' }])
230
+ assert.deepEqual(seen, [signal])
231
+ })
232
+ })
233
+
234
+ test('inspect 代:readLog 经 inspect 整读,signal 直传', async () => {
235
+ const seen = []
236
+ const events = [ev(0)]
237
+ const signal = new AbortController().signal
238
+ const persistence = {
239
+ async inspect(id, arg) {
240
+ seen.push([id, arg])
241
+ return { meta: { id }, inheritedEventCount: 2, events }
242
+ },
243
+ }
244
+ const reader = createArchiveReader(persistence)
245
+ assert.deepEqual(
246
+ await reader.readLog('s1', signal),
247
+ { inheritedEventCount: 2, events },
248
+ )
249
+ assert.deepEqual(seen, [['s1', signal]])
250
+ })
251
+
252
+ // 降级直读的测试桩:DSH_HOME 指向临时根,档案落 sessions/<bucket>/<id>/
253
+ function withDirectRoot(run) {
254
+ const root = mkdtempSync(join(tmpdir(), 'ud-fallback-'))
255
+ process.env.DSH_HOME = root
256
+ return Promise.resolve(run(root)).finally(() => {
257
+ delete process.env.DSH_HOME
258
+ rmSync(root, { recursive: true, force: true })
259
+ })
260
+ }
261
+
262
+ function seedArtifact(root, id, events) {
263
+ const text = [
264
+ JSON.stringify({ type: 'session/header', id }),
265
+ ...events.map((event) => JSON.stringify(event)),
266
+ ].join('\n')
267
+ const dir = join(root, 'sessions', 'bucket-x', id)
268
+ mkdirSync(dir, { recursive: true })
269
+ writeFileSync(join(dir, 'session.v3.jsonl.zstd'), zstdCompressSync(Buffer.from(text)))
270
+ }
271
+
272
+ test('handle 代:宿主拒读(descriptor)降级文件直读', () => {
273
+ return withDirectRoot(async (root) => {
274
+ seedArtifact(root, 'refused', [ev(0), ev(1)])
275
+ const persistence = {
276
+ async open() {
277
+ return {
278
+ inheritedEventCount: 0,
279
+ async read() {
280
+ throw new Error('subagent session "refused" uses unsupported descriptor version 2')
281
+ },
282
+ async close() {},
283
+ }
284
+ },
285
+ }
286
+ const reader = createArchiveReader(persistence)
287
+ const result = await reader.readLog('refused')
288
+ assert.equal(result.inheritedEventCount, 0)
289
+ assert.equal(result.events.length, 2)
290
+ })
291
+ })
292
+
293
+ test('handle 代:open 阶段拒读(descriptor 校验)同样降级直读', () => {
294
+ return withDirectRoot(async (root) => {
295
+ seedArtifact(root, 'open-refused', [ev(3)])
296
+ const persistence = {
297
+ async open() {
298
+ throw new Error('session "open-refused" header uses unsupported descriptor version 2')
299
+ },
300
+ }
301
+ const reader = createArchiveReader(persistence)
302
+ const result = await reader.readLog('open-refused')
303
+ assert.equal(result.events.length, 1)
304
+ })
305
+ })
306
+
307
+ test('handle 代:open 拒读且直读失败时,原错误传播', () => {
308
+ return withDirectRoot(async () => {
309
+ const persistence = {
310
+ async open() {
311
+ throw new Error('session "absent" header uses unsupported descriptor version 2')
312
+ },
313
+ }
314
+ const reader = createArchiveReader(persistence)
315
+ await assert.rejects(reader.readLog('absent'), /descriptor version 2/)
316
+ })
317
+ })
318
+
319
+ test('handle 代:非拒读错误不触发直读,原错误传播', () => {
320
+ return withDirectRoot((root) => {
321
+ seedArtifact(root, 'gone', [ev(0)])
322
+ const persistence = {
323
+ async open() {
324
+ throw new Error('session "gone" not found')
325
+ },
326
+ }
327
+ const reader = createArchiveReader(persistence)
328
+ return assert.rejects(reader.readLog('gone'), /not found/)
329
+ })
330
+ })
331
+
332
+ test('handle 代:拒读且直读也失败时,原错误传播', () => {
333
+ return withDirectRoot(async (root) => {
334
+ const persistence = {
335
+ async open() {
336
+ return {
337
+ inheritedEventCount: 0,
338
+ async read() {
339
+ throw new Error('stored log is corrupt: SessionFormatError: seq gap')
340
+ },
341
+ async close() {},
342
+ }
343
+ },
344
+ }
345
+ const reader = createArchiveReader(persistence)
346
+ await assert.rejects(reader.readLog('absent'), /corrupt/)
347
+ })
348
+ })
349
+
350
+ test('handle 代:list 并集补齐宿主不可见的磁盘档案', () => {
351
+ return withDirectRoot(async (root) => {
352
+ seedArtifact(root, 'v3-only', [ev(0)])
353
+ const persistence = {
354
+ async list() {
355
+ return [{ header: { id: 'host-known' } }]
356
+ },
357
+ async open() {
358
+ throw new Error('not reached')
359
+ },
360
+ }
361
+ const reader = createArchiveReader(persistence)
362
+ const ids = (await reader.list()).map((row) => row.id).sort()
363
+ assert.deepEqual(ids, ['host-known', 'v3-only'])
364
+ })
365
+ })
@@ -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,
@@ -470,8 +476,24 @@ const TIP_ANCHOR = { left: 100, top: 100, right: 140, bottom: 114 }
470
476
  const TIP_SIZE = { width: 80, height: 40 }
471
477
  const TIP_BOUNDS = { left: 0, top: 0, right: 800, bottom: 600 }
472
478
 
473
- test('tipPlace 默认锚点下方水平居中', () => {
474
- 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)
475
497
  })
476
498
 
477
499
  test('tipPlace 下方放不下翻转上方', () => {
@@ -749,51 +771,70 @@ test('costTitleText 未计价计数后缀', () => {
749
771
  })
750
772
 
751
773
  test('defaultCondition 各类型默认值即填即用', () => {
752
- // Given 四种条件类型 When 取默认 Then 时段全天/周几空/号段全月/日期段当天
753
- 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' })
754
776
  assert.deepEqual(defaultCondition('weekdays'), { kind: 'weekdays', days: [] })
755
777
  assert.deepEqual(defaultCondition('monthDays'), { kind: 'monthDays', from: 1, to: 31 })
756
778
  assert.deepEqual(defaultCondition('dateRange', new Date(2026, 2, 15)), { kind: 'dateRange', from: '2026-03-15', to: '2026-03-15' })
757
779
  })
758
780
 
759
781
  test('validatePricingRules 拒绝非法时刻与时刻字段缺失', () => {
760
- // Given dailyWindow 时刻超界或缺失 When 校验 Then 标 condTime/required
782
+ // Given dailyWindow 时刻超界或缺失 When 校验 Then 标 condTime/required;双闭域 00:00~23:59,24:00 超界
761
783
  const rules = [
762
784
  { model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'dailyWindow', from: '99:99', to: '08:00' }] },
763
785
  { 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' }] },
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' }] },
765
789
  ]
766
790
  const errors = validatePricingRules(rules)
767
791
  assert.equal(errors.get('0.conditions.0.from'), 'condTime')
768
792
  assert.equal(errors.get('1.conditions.0.from'), 'required')
769
- 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)
770
807
  })
771
808
 
772
809
  test('validatePricingRules 校验周几与月号段边界', () => {
773
- // Given weekdays 含 7、monthDays 含 032 When 校验 Then 标 condWeekday/condMonthDay
810
+ // Given weekdays 含 7、monthDays 含 0/32/33 When 校验 Then 标 condWeekday/condMonthDay;31 为合法上界
774
811
  const rules = [
775
812
  { model: 'a/b', currency: '¥', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [{ kind: 'weekdays', days: [0, 7] }] },
776
813
  { 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 }] },
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 }] },
778
816
  ]
779
817
  const errors = validatePricingRules(rules)
780
818
  assert.equal(errors.get('0.conditions.0.days'), 'condWeekday')
781
819
  assert.equal(errors.get('1.conditions.0.from'), 'condMonthDay')
782
- 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')
783
822
  })
784
823
 
785
824
  test('validatePricingRules 校验日期段格式与倒序', () => {
786
- // Given dateRange 非规范日期或倒序 When 校验 Then 标 condDate/condRange
825
+ // Given dateRange 非规范日期或倒序(双闭下同日合法)When 校验 Then 标 condDate/condRange
787
826
  const rules = [
788
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' }] },
789
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' }] },
790
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' }] },
791
831
  ]
792
832
  const errors = validatePricingRules(rules)
793
833
  assert.equal(errors.get('0.conditions.0.from'), 'condDate')
794
834
  assert.equal(errors.get('1.conditions.0'), 'condRange')
795
835
  assert.equal(errors.has('2.conditions.0'), false)
796
- 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)
797
838
  })
798
839
 
799
840
  test('coerceConditions 规整数字字段且不改其余字段', () => {
@@ -822,3 +863,103 @@ test('coercePricingRules 连带规整条件', () => {
822
863
  assert.deepEqual(coerced[0].conditions, [{ kind: 'weekdays', days: [1] }, { kind: 'monthDays', from: 1, to: 31 }])
823
864
  assert.deepEqual(coerced[0].price, { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 })
824
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
+ })