@zhushanwen/pi-scheduler 0.1.0 → 0.2.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,309 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ import { replayFoldEntries, type SchedulerEntryLike } from '../replay.js'
4
+ import type { SchedulerEntryOp, TaskSnapshot } from '../types.js'
5
+
6
+ /** 构造 pi-scheduler:task custom entry(包装 op)。 */
7
+ function entry(op: SchedulerEntryOp): SchedulerEntryLike {
8
+ return { type: 'custom', customType: 'pi-scheduler:task', data: op }
9
+ }
10
+
11
+ /** 构造非 scheduler 的 custom entry(应被折叠忽略)。 */
12
+ function otherEntry(): SchedulerEntryLike {
13
+ return { type: 'custom', customType: 'some-other-ext', data: { foo: 1 } }
14
+ }
15
+
16
+ /** 构造 message entry(应被折叠忽略)。 */
17
+ function messageEntry(): SchedulerEntryLike {
18
+ return { type: 'message', data: {} }
19
+ }
20
+
21
+ /** 构造 base task 快照(upsert op 用)。 */
22
+ function snapshot(overrides: Partial<TaskSnapshot> = {}): TaskSnapshot {
23
+ return {
24
+ id: 'aaa',
25
+ name: 'test',
26
+ prompt: 'p',
27
+ kind: 'recurring',
28
+ schedule: { mode: 'interval', intervalMs: 60000 },
29
+ enabled: true,
30
+ force: false,
31
+ createdAt: 0,
32
+ nextRunAt: 100,
33
+ runCount: 0,
34
+ history: [],
35
+ ...overrides,
36
+ }
37
+ }
38
+
39
+ describe('replayFoldEntries', () => {
40
+ // ── TC-W-REPLAY-FOLD:折叠协议正确性(4 op 混合)──
41
+ it('TC-W-REPLAY-FOLD: 折叠 upsert/advance/toggle/delete 4 op 到正确末态', () => {
42
+ const session = '/s.json'
43
+ const entries = [
44
+ entry({ op: 'upsert', taskId: 'A', ownerSessionFile: session, task: snapshot({ id: 'A' }) }),
45
+ entry({ op: 'upsert', taskId: 'B', ownerSessionFile: session, task: snapshot({ id: 'B' }) }),
46
+ entry({
47
+ op: 'advance',
48
+ taskId: 'A',
49
+ nextRunAt: 200,
50
+ at: 100,
51
+ status: 'success',
52
+ }),
53
+ entry({ op: 'toggle', taskId: 'A', enabled: false }),
54
+ entry({ op: 'delete', taskId: 'B' }),
55
+ ]
56
+
57
+ const result = replayFoldEntries(entries, session)
58
+
59
+ expect(result.size).toBe(1)
60
+ expect(result.has('B')).toBe(false) // B 被 delete,不复活
61
+
62
+ const a = result.get('A')!
63
+ expect(a).toBeDefined()
64
+ expect(a.nextRunAt).toBe(200)
65
+ expect(a.lastRunAt).toBe(100)
66
+ expect(a.runCount).toBe(1)
67
+ expect(a.history).toEqual([{ at: 100, status: 'success' }])
68
+ expect(a.lastStatus).toBe('success') // gap2:advance 恢复 lastStatus
69
+ expect(a.enabled).toBe(false)
70
+ expect(a.ownerSessionFile).toBe(session) // ownerSessionFile 从 op 顶层恢复
71
+ })
72
+
73
+ it('折叠忽略非 pi-scheduler:task 的 custom entry 与 message entry', () => {
74
+ const session = '/s.json'
75
+ const entries = [
76
+ messageEntry(),
77
+ otherEntry(),
78
+ entry({ op: 'upsert', taskId: 'aaa', ownerSessionFile: session, task: snapshot() }),
79
+ otherEntry(),
80
+ ]
81
+ const result = replayFoldEntries(entries, session)
82
+ expect(result.size).toBe(1)
83
+ expect(result.get('aaa')).toBeDefined()
84
+ })
85
+
86
+ it('折叠忽略 data 缺失或 op 字段非法的 entry(防御损坏 entry)', () => {
87
+ const session = '/s.json'
88
+ const entries: SchedulerEntryLike[] = [
89
+ { type: 'custom', customType: 'pi-scheduler:task' }, // data undefined
90
+ { type: 'custom', customType: 'pi-scheduler:task', data: { op: 123 } }, // op 非字符串
91
+ { type: 'custom', customType: 'pi-scheduler:task', data: {} }, // 缺 op
92
+ { type: 'custom', customType: 'pi-scheduler:task', data: null },
93
+ ]
94
+ const result = replayFoldEntries(entries, session)
95
+ expect(result.size).toBe(0)
96
+ })
97
+
98
+ // ── TC-W-REPLAY-NEXTRUNAT:nextRunAt 不回退(D1 核心)──
99
+ it('TC-W-REPLAY-NEXTRUNAT: recurring advance 推进 nextRunAt,重放不回退到初值', () => {
100
+ const session = '/s.json'
101
+ const entries = [
102
+ entry({
103
+ op: 'upsert',
104
+ taskId: 'X',
105
+ ownerSessionFile: session,
106
+ task: snapshot({ id: 'X', nextRunAt: 100 }),
107
+ }),
108
+ entry({ op: 'advance', taskId: 'X', nextRunAt: 500, at: 200, status: 'success' }),
109
+ ]
110
+
111
+ const result = replayFoldEntries(entries, session)
112
+ const x = result.get('X')!
113
+ expect(x.nextRunAt).toBe(500) // 非 upsert 初值 100
114
+ expect(x.runCount).toBe(1)
115
+ expect(x.lastRunAt).toBe(200)
116
+ expect(x.lastStatus).toBe('success')
117
+ })
118
+
119
+ // ── TC-W-FORK-OWNER-FILTER:全量折叠后整体过滤(gap1)──
120
+ it('TC-W-FORK-OWNER-FILTER: fork 继承序列在非 owner session 重放后 Map 为空', () => {
121
+ // upsert owner=A + advance(advance op 无 ownerSessionFile 字段)
122
+ const entries = [
123
+ entry({
124
+ op: 'upsert',
125
+ taskId: 'X',
126
+ ownerSessionFile: '/a.json',
127
+ task: snapshot({ id: 'X' }),
128
+ }),
129
+ entry({ op: 'advance', taskId: 'X', nextRunAt: 500, at: 200, status: 'success' }),
130
+ ]
131
+
132
+ // session B 重放:步骤①全量折叠得到 task(owner=A),步骤②整体过滤移除
133
+ const inB = replayFoldEntries(entries, '/b.json')
134
+ expect(inB.size).toBe(0)
135
+ expect(inB.has('X')).toBe(false)
136
+
137
+ // 对照:session A 重放应保留 X(验证不是「永远过滤」,而是 owner 不匹配才过滤)
138
+ const inA = replayFoldEntries(entries, '/a.json')
139
+ expect(inA.size).toBe(1)
140
+ expect(inA.get('X')!.nextRunAt).toBe(500)
141
+ })
142
+
143
+ // ── TC-W-GETENTRIES-FALLBACK:getEntries 异常兜底(gap4)──
144
+ it('TC-W-GETENTRIES-FALLBACK: 迭代器抛错时 console.warn + 返回空 Map,不崩溃', () => {
145
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
146
+ // 构造迭代时抛错的 iterable(模拟 session JSONL 损坏)
147
+ const throwingIterable: Iterable<SchedulerEntryLike> = {
148
+ [Symbol.iterator]() {
149
+ let i = 0
150
+ return {
151
+ next() {
152
+ if (i++ === 0) throw new Error('JSONL corrupted')
153
+ return { done: true, value: undefined as unknown as SchedulerEntryLike }
154
+ },
155
+ }
156
+ },
157
+ }
158
+
159
+ const result = replayFoldEntries(throwingIterable, '/s.json')
160
+ expect(result.size).toBe(0)
161
+ expect(warnSpy).toHaveBeenCalled()
162
+ const msg = warnSpy.mock.calls[0]![0] as string
163
+ expect(msg).toContain('replayFoldEntries failed')
164
+ expect(msg).toContain('JSONL corrupted')
165
+ warnSpy.mockRestore()
166
+ })
167
+
168
+ // ── gap2 补强:advance 后 lastStatus/lastRunAt/runCount/history 全恢复 ──
169
+ it('gap2: 多次 advance 累积 runCount/history,history 超 20 裁剪', () => {
170
+ const session = '/s.json'
171
+ const entries = [
172
+ entry({ op: 'upsert', taskId: 'X', ownerSessionFile: session, task: snapshot({ id: 'X' }) }),
173
+ ]
174
+ for (let i = 1; i <= 25; i++) {
175
+ entries.push(entry({ op: 'advance', taskId: 'X', nextRunAt: 100 + i * 100, at: i * 10, status: 'success' }))
176
+ }
177
+
178
+ const result = replayFoldEntries(entries, session)
179
+ const x = result.get('X')!
180
+ expect(x.runCount).toBe(25)
181
+ expect(x.lastRunAt).toBe(250) // 最后一次 advance at
182
+ expect(x.nextRunAt).toBe(2600) // 最后一次 advance nextRunAt (100+25*100)
183
+ expect(x.lastStatus).toBe('success')
184
+ expect(x.history.length).toBe(20) // 裁剪到 20
185
+ // 保留最后 20 条(at=60..250),最早 5 条(at=10..50)被 shift 掉
186
+ expect(x.history[0]!.at).toBe(60)
187
+ expect(x.history[19]!.at).toBe(250)
188
+ })
189
+
190
+ // ── gap1 补强:delete 后该 taskId 不复活(后续 upsert 仍可重建)──
191
+ it('gap1 补强: delete 后同 taskId 的 upsert 仍可重建任务', () => {
192
+ const session = '/s.json'
193
+ const entries = [
194
+ entry({ op: 'upsert', taskId: 'A', ownerSessionFile: session, task: snapshot({ id: 'A', nextRunAt: 100 }) }),
195
+ entry({ op: 'delete', taskId: 'A' }),
196
+ // advance 对已删 taskId 为 no-op(安全)
197
+ entry({ op: 'advance', taskId: 'A', nextRunAt: 999, at: 999, status: 'success' }),
198
+ // upsert 重建 A
199
+ entry({ op: 'upsert', taskId: 'A', ownerSessionFile: session, task: snapshot({ id: 'A', nextRunAt: 200 }) }),
200
+ ]
201
+ const result = replayFoldEntries(entries, session)
202
+ const a = result.get('A')!
203
+ expect(a).toBeDefined()
204
+ expect(a.nextRunAt).toBe(200) // 重建后的快照值,非 advance 的 999(advance 在 delete 后 no-op)
205
+ expect(a.runCount).toBe(0) // 重建后重置
206
+ })
207
+
208
+ // ── currentSessionFile undefined:所有带 owner 的任务被过滤(--no-session 模式)──
209
+ it('currentSessionFile 为 undefined 时,带 ownerSessionFile 的任务被过滤', () => {
210
+ const entries = [
211
+ entry({ op: 'upsert', taskId: 'A', ownerSessionFile: '/a.json', task: snapshot({ id: 'A' }) }),
212
+ ]
213
+ const result = replayFoldEntries(entries, undefined)
214
+ expect(result.size).toBe(0)
215
+ })
216
+
217
+ // ── P1:toggle op 携带 nextRunAt 重放不回退到 upsert 快照旧值(跨 session 持久化)──
218
+ it('P1: toggle op 携带 nextRunAt 时,重放后 task.nextRunAt = toggle 携带值(非 upsert 快照旧过期值)', () => {
219
+ const session = '/s.json'
220
+ const entries = [
221
+ // upsert 快照 nextRunAt=100(disable 期间过期的旧值),enabled=false(disable 态)
222
+ entry({
223
+ op: 'upsert',
224
+ taskId: 'A',
225
+ ownerSessionFile: session,
226
+ task: snapshot({ id: 'A', nextRunAt: 100, enabled: false }),
227
+ }),
228
+ // toggle enable 重算到未来(500)并携带 nextRunAt
229
+ entry({ op: 'toggle', taskId: 'A', enabled: true, nextRunAt: 500 }),
230
+ ]
231
+
232
+ const result = replayFoldEntries(entries, session)
233
+ const a = result.get('A')!
234
+ expect(a).toBeDefined()
235
+ expect(a.enabled).toBe(true)
236
+ expect(a.nextRunAt).toBe(500) // 重算的未来值,非 upsert 快照的旧过期值 100
237
+ })
238
+
239
+ // 对照:toggle 不携带 nextRunAt 时,nextRunAt 保持 upsert 快照值(普通 toggle / cron 失效回退语义)
240
+ it('P1 对照: toggle op 不携带 nextRunAt 时,重放后 nextRunAt = upsert 快照值', () => {
241
+ const session = '/s.json'
242
+ const entries = [
243
+ entry({
244
+ op: 'upsert',
245
+ taskId: 'A',
246
+ ownerSessionFile: session,
247
+ task: snapshot({ id: 'A', nextRunAt: 100, enabled: false }),
248
+ }),
249
+ entry({ op: 'toggle', taskId: 'A', enabled: true }),
250
+ ]
251
+
252
+ const result = replayFoldEntries(entries, session)
253
+ const a = result.get('A')!
254
+ expect(a.enabled).toBe(true)
255
+ expect(a.nextRunAt).toBe(100) // 保持 upsert 快照值
256
+ })
257
+
258
+ // ── MF-2:守卫按变体校验必填字段——损坏 entry 只跳过该条,不清空全部任务 ──
259
+ it('MF-2: op 合法但缺必填字段的损坏 entry 被跳过,其余任务保留', () => {
260
+ const session = '/s.json'
261
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
262
+ const entries: SchedulerEntryLike[] = [
263
+ { type: 'custom', customType: 'pi-scheduler:task', data: { op: 'upsert' } }, // 缺 taskId+task
264
+ { type: 'custom', customType: 'pi-scheduler:task', data: { op: 'upsert', taskId: 'A' } }, // 缺 task
265
+ { type: 'custom', customType: 'pi-scheduler:task', data: { op: 'advance', taskId: 'A', at: 1, status: 'success' } }, // 缺 nextRunAt
266
+ { type: 'custom', customType: 'pi-scheduler:task', data: { op: 'toggle', taskId: 'A' } }, // 缺 enabled
267
+ { type: 'custom', customType: 'pi-scheduler:task', data: { op: 'delete' } }, // 缺 taskId
268
+ // 合法 entry 应保留(此前这 5 条损坏 entry 会清空全部任务)
269
+ entry({ op: 'upsert', taskId: 'A', ownerSessionFile: session, task: snapshot({ id: 'A' }) }),
270
+ ]
271
+
272
+ const result = replayFoldEntries(entries, session)
273
+ expect(result.size).toBe(1)
274
+ expect(result.get('A')).toBeDefined()
275
+ warnSpy.mockRestore()
276
+ })
277
+
278
+ it('MF-2: upsert task 嵌套数据损坏(history 非数组)→ 逐条跳过该 entry,其余任务保留', () => {
279
+ const session = '/s.json'
280
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
281
+ const entries: SchedulerEntryLike[] = [
282
+ {
283
+ type: 'custom',
284
+ customType: 'pi-scheduler:task',
285
+ data: {
286
+ op: 'upsert',
287
+ taskId: 'BAD',
288
+ ownerSessionFile: session,
289
+ task: { ...snapshot({ id: 'BAD' }), history: 'not-an-array' },
290
+ },
291
+ },
292
+ entry({ op: 'upsert', taskId: 'GOOD', ownerSessionFile: session, task: snapshot({ id: 'GOOD' }) }),
293
+ ]
294
+
295
+ const result = replayFoldEntries(entries, session)
296
+ expect(result.size).toBe(1)
297
+ expect(result.get('GOOD')).toBeDefined()
298
+ expect(result.has('BAD')).toBe(false)
299
+ // 逐条跳过 warn(非外层整体 catch 的 replayFoldEntries failed warn)
300
+ expect(warnSpy).toHaveBeenCalled()
301
+ const msg = warnSpy.mock.calls[0]![0] as string
302
+ expect(msg).toContain('skipping corrupted scheduler entry')
303
+ warnSpy.mockRestore()
304
+ })
305
+
306
+ afterEach(() => {
307
+ vi.restoreAllMocks()
308
+ })
309
+ })