@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.
- package/README.md +52 -11
- package/package.json +2 -2
- package/src/archive-reader.js +138 -0
- package/src/client.js +400 -112
- package/src/collector.js +58 -12
- package/src/direct-log-reader.js +126 -0
- package/src/pricing.js +5 -6
- 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 +153 -12
- 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 +2 -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
|
@@ -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
|
+
})
|
package/test/client.test.mjs
CHANGED
|
@@ -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:
|
|
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: '
|
|
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: '
|
|
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.
|
|
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 含 0
|
|
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:
|
|
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.
|
|
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 非规范日期或倒序
|
|
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('
|
|
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
|
+
})
|