@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.
- package/README.md +236 -0
- package/package.json +4 -3
- package/src/__tests__/backend.test.ts +120 -0
- package/src/__tests__/commands.test.ts +59 -60
- package/src/__tests__/cron.test.ts +1 -2
- package/src/__tests__/format.test.ts +35 -0
- package/src/__tests__/importer.test.ts +283 -0
- package/src/__tests__/parsing.test.ts +29 -0
- package/src/__tests__/property.test.ts +131 -0
- package/src/__tests__/replay.test.ts +309 -0
- package/src/__tests__/runtime.test.ts +282 -48
- package/src/__tests__/sdk-contract.test.ts +54 -19
- package/src/__tests__/service.test.ts +214 -0
- package/src/__tests__/tool.test.ts +40 -23
- package/src/backend.ts +151 -0
- package/src/commands.ts +28 -34
- package/src/format.ts +6 -4
- package/src/importer.ts +261 -0
- package/src/index.ts +62 -34
- package/src/parsing.ts +19 -1
- package/src/replay.ts +181 -0
- package/src/runtime.ts +152 -63
- package/src/service.ts +156 -0
- package/src/tool.ts +51 -75
- package/src/types.ts +43 -5
- package/src/__tests__/store.test.ts +0 -161
- package/src/store.ts +0 -117
|
@@ -58,11 +58,10 @@ describe('computeNextCronRuns', () => {
|
|
|
58
58
|
})
|
|
59
59
|
|
|
60
60
|
describe('parseSchedule (cron 分支)', () => {
|
|
61
|
-
it('5 字段 cron
|
|
61
|
+
it('5 字段 cron 表达式补秒字段(note 不再透传到 parseSchedule 结果)', async () => {
|
|
62
62
|
const result = await parseSchedule('*/10 * * * *')
|
|
63
63
|
expect(result).toEqual({
|
|
64
64
|
spec: { mode: 'cron', cronExpression: '0 */10 * * * *' },
|
|
65
|
-
note: 'Auto-prepended seconds field (0)',
|
|
66
65
|
})
|
|
67
66
|
})
|
|
68
67
|
|
|
@@ -9,6 +9,13 @@ describe('formatSchedule', () => {
|
|
|
9
9
|
expect(formatSchedule({ mode: 'interval', intervalMs: 3_600_000 })).toBe('every 1h')
|
|
10
10
|
})
|
|
11
11
|
|
|
12
|
+
it('formats once kind as "once in X" (not misleading "every X")', () => {
|
|
13
|
+
expect(formatSchedule({ mode: 'interval', intervalMs: 300_000 }, 'once')).toBe('once in 5m')
|
|
14
|
+
expect(formatSchedule({ mode: 'interval', intervalMs: 3_600_000 }, 'once')).toBe('once in 1h')
|
|
15
|
+
// recurring/缺省保持 every
|
|
16
|
+
expect(formatSchedule({ mode: 'interval', intervalMs: 300_000 }, 'recurring')).toBe('every 5m')
|
|
17
|
+
})
|
|
18
|
+
|
|
12
19
|
it('formats cron spec', () => {
|
|
13
20
|
expect(formatSchedule({ mode: 'cron', cronExpression: '*/10 * * * *' })).toBe('*/10 * * * *')
|
|
14
21
|
})
|
|
@@ -83,6 +90,34 @@ describe('formatDuration', () => {
|
|
|
83
90
|
expect(formatDuration(90_000)).toBe('90s')
|
|
84
91
|
expect(formatDuration(1500)).toBe('2s')
|
|
85
92
|
})
|
|
93
|
+
|
|
94
|
+
// TC7 边界值:最大单位整除优先(300000 → '5m' 而非 '300s')
|
|
95
|
+
it('prefers largest unit when ms is evenly divisible', () => {
|
|
96
|
+
expect(formatDuration(300_000)).toBe('5m')
|
|
97
|
+
expect(formatDuration(3_600_000)).toBe('1h')
|
|
98
|
+
expect(formatDuration(86_400_000)).toBe('1d')
|
|
99
|
+
expect(formatDuration(60_000)).toBe('1m')
|
|
100
|
+
expect(formatDuration(1000)).toBe('1s')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('handles zero and negative as 0s', () => {
|
|
104
|
+
expect(formatDuration(0)).toBe('0s')
|
|
105
|
+
expect(formatDuration(-1)).toBe('0s')
|
|
106
|
+
expect(formatDuration(-86_400_000)).toBe('0s')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
// TC7 修正(design-review R1):1500000 % 60000 === 0 → '25m'(最大单位优先),
|
|
110
|
+
// 而非 design 初稿误写的 '1500s'。非整除大单位落秒用例改用 1510000。
|
|
111
|
+
it('prefers even minute when ms is multiple of 60000 (1500000 → 25m)', () => {
|
|
112
|
+
expect(formatDuration(1_500_000)).toBe('25m')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('falls back to seconds for large non-divisible values (1510000 → 1510s)', () => {
|
|
116
|
+
// 1510000 % 60000 = 10000 ≠ 0 → 不整除 m,落秒分支
|
|
117
|
+
expect(formatDuration(1_510_000)).toBe('1510s')
|
|
118
|
+
// 1500001 % 1000 ≠ 0 → Math.round 兜底
|
|
119
|
+
expect(formatDuration(1_500_001)).toBe('1500s')
|
|
120
|
+
})
|
|
86
121
|
})
|
|
87
122
|
|
|
88
123
|
describe('generateTaskId', () => {
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import * as fs from 'node:fs'
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import { getLegacyStorePath, importLegacyStore } from '../importer.js'
|
|
6
|
+
import type { ScheduledTask } from '../types.js'
|
|
7
|
+
|
|
8
|
+
// vi.mock 必须在 import 之前提升。mock 工厂替换 node:fs 命名空间,
|
|
9
|
+
// importer.ts 内 `import * as fs` 拿到的就是这 4 个 vi.fn()。
|
|
10
|
+
vi.mock('node:fs', () => ({
|
|
11
|
+
renameSync: vi.fn(),
|
|
12
|
+
existsSync: vi.fn(),
|
|
13
|
+
readFileSync: vi.fn(),
|
|
14
|
+
unlinkSync: vi.fn(),
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
const cwd = '/fake/workspace'
|
|
18
|
+
const storePath = getLegacyStorePath(cwd)
|
|
19
|
+
const importedPath = storePath + '.imported'
|
|
20
|
+
const sessionFile = '/x/sess.json'
|
|
21
|
+
|
|
22
|
+
/** 构造合法旧 store 任务(参考 types.ts ScheduleSpec / TaskKind 默认值)。 */
|
|
23
|
+
function makeTask(overrides: Partial<ScheduledTask> = {}): ScheduledTask {
|
|
24
|
+
return {
|
|
25
|
+
id: 'task-1',
|
|
26
|
+
name: 'test task',
|
|
27
|
+
prompt: 'do something',
|
|
28
|
+
kind: 'recurring',
|
|
29
|
+
schedule: { mode: 'interval', intervalMs: 60000 },
|
|
30
|
+
enabled: true,
|
|
31
|
+
force: false,
|
|
32
|
+
createdAt: 1000,
|
|
33
|
+
nextRunAt: 2000,
|
|
34
|
+
runCount: 0,
|
|
35
|
+
history: [],
|
|
36
|
+
...overrides,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 构造带 code 属性的 ENOENT 错误(模拟 fs.renameSync 源文件不存在)。 */
|
|
41
|
+
function enoentError(): Error & { code: string } {
|
|
42
|
+
return Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('importLegacyStore', () => {
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
vi.clearAllMocks()
|
|
48
|
+
// 默认 implementation:rename 成功、existsSync false、readFileSync 空、unlink 无副作用
|
|
49
|
+
vi.mocked(fs.renameSync).mockImplementation(() => {})
|
|
50
|
+
vi.mocked(fs.existsSync).mockReturnValue(false)
|
|
51
|
+
vi.mocked(fs.readFileSync).mockReturnValue('{}')
|
|
52
|
+
vi.mocked(fs.unlinkSync).mockImplementation(() => {})
|
|
53
|
+
// 抑制 importer 的 console.log/warn 输出(断言用 spy)
|
|
54
|
+
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
55
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
vi.restoreAllMocks()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
// ── TC1:rename 原子单成功 + 逐任务 append upsert + 删 .imported ──
|
|
63
|
+
it('TC1: rename 成功独占 + 2 任务逐个 appendEntry upsert + 删 .imported', () => {
|
|
64
|
+
const taskA = makeTask({ id: 'aaaa', name: 'A' })
|
|
65
|
+
const taskB = makeTask({ id: 'bbbb', name: 'B' })
|
|
66
|
+
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
67
|
+
JSON.stringify({ version: 1, tasks: [taskA, taskB] }),
|
|
68
|
+
)
|
|
69
|
+
const appendEntry = vi.fn()
|
|
70
|
+
// resumed session:sessionFile 已存在(pi flushed=true,append 即时落盘)→ 立即 unlink 安全
|
|
71
|
+
vi.mocked(fs.existsSync).mockImplementation(p => p === sessionFile)
|
|
72
|
+
|
|
73
|
+
importLegacyStore(cwd, { appendEntry }, sessionFile)
|
|
74
|
+
// 1) rename 原子独占:scheduler.json → scheduler.json.imported
|
|
75
|
+
expect(fs.renameSync).toHaveBeenCalledTimes(1)
|
|
76
|
+
expect(fs.renameSync).toHaveBeenCalledWith(storePath, importedPath)
|
|
77
|
+
|
|
78
|
+
// 2) appendEntry 调 2 次,customType + op 形状正确
|
|
79
|
+
expect(appendEntry).toHaveBeenCalledTimes(2)
|
|
80
|
+
expect(appendEntry).toHaveBeenNthCalledWith(1, 'pi-scheduler:task', {
|
|
81
|
+
op: 'upsert',
|
|
82
|
+
taskId: 'aaaa',
|
|
83
|
+
ownerSessionFile: sessionFile,
|
|
84
|
+
task: expect.objectContaining({ id: 'aaaa', name: 'A' }),
|
|
85
|
+
})
|
|
86
|
+
expect(appendEntry).toHaveBeenNthCalledWith(2, 'pi-scheduler:task', {
|
|
87
|
+
op: 'upsert',
|
|
88
|
+
taskId: 'bbbb',
|
|
89
|
+
ownerSessionFile: sessionFile,
|
|
90
|
+
task: expect.objectContaining({ id: 'bbbb', name: 'B' }),
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
// 3) task snapshot 不含 ownerSessionFile/pending(显式剥离)
|
|
94
|
+
const firstOp = appendEntry.mock.calls[0]![1] as { task: Record<string, unknown> }
|
|
95
|
+
expect(firstOp.task).not.toHaveProperty('ownerSessionFile')
|
|
96
|
+
expect(firstOp.task).not.toHaveProperty('pending')
|
|
97
|
+
|
|
98
|
+
// 4) unlinkSync 删 .imported
|
|
99
|
+
expect(fs.unlinkSync).toHaveBeenCalledTimes(1)
|
|
100
|
+
expect(fs.unlinkSync).toHaveBeenCalledWith(importedPath)
|
|
101
|
+
|
|
102
|
+
// 5) 进度日志(console.warn,项目 convention 禁 console.log)
|
|
103
|
+
expect(console.warn).toHaveBeenCalledWith(
|
|
104
|
+
expect.stringContaining(`imported 2 legacy tasks from ${importedPath}`),
|
|
105
|
+
)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
// ── TC2:.imported 残留崩溃恢复幂等 ──
|
|
109
|
+
it('TC2: scheduler.json 不存在(rename ENOENT) + .imported 残留 → 读 .imported 导入 + 删 .imported', () => {
|
|
110
|
+
// 实现A:scheduler.json 不存在时 renameSync 抛 ENOENT → 走 handleImportedResidue
|
|
111
|
+
vi.mocked(fs.renameSync).mockImplementation(() => {
|
|
112
|
+
throw enoentError()
|
|
113
|
+
})
|
|
114
|
+
// .imported 存在 + resumed session(sessionFile 已存在 → 已落盘 → 立即删)
|
|
115
|
+
vi.mocked(fs.existsSync).mockImplementation(p => p === importedPath || p === sessionFile)
|
|
116
|
+
const taskA = makeTask({ id: 'cccc' })
|
|
117
|
+
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
118
|
+
JSON.stringify({ version: 1, tasks: [taskA] }),
|
|
119
|
+
)
|
|
120
|
+
const appendEntry = vi.fn()
|
|
121
|
+
|
|
122
|
+
importLegacyStore(cwd, { appendEntry }, sessionFile)
|
|
123
|
+
|
|
124
|
+
expect(appendEntry).toHaveBeenCalledTimes(1)
|
|
125
|
+
expect(appendEntry).toHaveBeenCalledWith('pi-scheduler:task', {
|
|
126
|
+
op: 'upsert',
|
|
127
|
+
taskId: 'cccc',
|
|
128
|
+
ownerSessionFile: sessionFile,
|
|
129
|
+
task: expect.objectContaining({ id: 'cccc' }),
|
|
130
|
+
})
|
|
131
|
+
expect(fs.unlinkSync).toHaveBeenCalledWith(importedPath)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
// ── TC3:scheduler.json 与 .imported 都不存在 → no-op ──
|
|
135
|
+
it('TC3: 双不存在(rename ENOENT + .imported 不存在)→ no-op,不抛错', () => {
|
|
136
|
+
vi.mocked(fs.renameSync).mockImplementation(() => {
|
|
137
|
+
throw enoentError()
|
|
138
|
+
})
|
|
139
|
+
vi.mocked(fs.existsSync).mockReturnValue(false) // .imported 也不存在
|
|
140
|
+
const appendEntry = vi.fn()
|
|
141
|
+
|
|
142
|
+
expect(() => importLegacyStore(cwd, { appendEntry }, sessionFile)).not.toThrow()
|
|
143
|
+
expect(appendEntry).toHaveBeenCalledTimes(0)
|
|
144
|
+
expect(fs.unlinkSync).not.toHaveBeenCalled()
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
// ── TC4:并发单成功者——rename 抛 ENOENT + .imported 也不存在 → skip(S10 并发场景)──
|
|
148
|
+
it('TC4: 并发竞争——rename 抛 ENOENT + .imported 不存在 → 幂等 skip,不抛错', () => {
|
|
149
|
+
// 两进程并发:本进程 rename 时 scheduler.json 已被另一进程 rename 走 → ENOENT
|
|
150
|
+
vi.mocked(fs.renameSync).mockImplementation(() => {
|
|
151
|
+
throw enoentError()
|
|
152
|
+
})
|
|
153
|
+
vi.mocked(fs.existsSync).mockReturnValue(false) // 本进程也没看到 .imported
|
|
154
|
+
const appendEntry = vi.fn()
|
|
155
|
+
|
|
156
|
+
expect(() => importLegacyStore(cwd, { appendEntry }, sessionFile)).not.toThrow()
|
|
157
|
+
expect(appendEntry).toHaveBeenCalledTimes(0) // 别人已导入,本进程 skip
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
// ── TC5:currentSessionFile=undefined(--no-session 模式)→ 早 return skip ──
|
|
161
|
+
it('TC5: currentSessionFile=undefined → 整个 importer skip,不碰 fs', () => {
|
|
162
|
+
const appendEntry = vi.fn()
|
|
163
|
+
|
|
164
|
+
importLegacyStore(cwd, { appendEntry }, undefined)
|
|
165
|
+
|
|
166
|
+
expect(appendEntry).toHaveBeenCalledTimes(0)
|
|
167
|
+
expect(fs.renameSync).not.toHaveBeenCalled() // 早 return,不碰 fs
|
|
168
|
+
expect(fs.readFileSync).not.toHaveBeenCalled()
|
|
169
|
+
expect(fs.unlinkSync).not.toHaveBeenCalled()
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
// ── TC6:JSON 损坏降级(warn 不 rethrow)──
|
|
173
|
+
it('TC6: readFileSync 返回损坏 JSON → console.warn + 不 rethrow,appendEntry 0 次', () => {
|
|
174
|
+
vi.mocked(fs.readFileSync).mockReturnValue('not-json{')
|
|
175
|
+
const appendEntry = vi.fn()
|
|
176
|
+
|
|
177
|
+
expect(() => importLegacyStore(cwd, { appendEntry }, sessionFile)).not.toThrow()
|
|
178
|
+
expect(console.warn).toHaveBeenCalledWith(
|
|
179
|
+
expect.stringContaining('[scheduler] import failed'),
|
|
180
|
+
)
|
|
181
|
+
expect(appendEntry).toHaveBeenCalledTimes(0) // parse 失败,无 append
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
// ── TC7:owner 归属——upsert op.ownerSessionFile === currentSessionFile(非旧 store 路径)──
|
|
185
|
+
it('TC7: op.ownerSessionFile 严格等于传入的 currentSessionFile,非 getLegacyStorePath 推导路径', () => {
|
|
186
|
+
const ownSession = '/my/session.json'
|
|
187
|
+
const taskA = makeTask({ id: 'dddd' })
|
|
188
|
+
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
189
|
+
JSON.stringify({ version: 1, tasks: [taskA] }),
|
|
190
|
+
)
|
|
191
|
+
const appendEntry = vi.fn()
|
|
192
|
+
|
|
193
|
+
importLegacyStore(cwd, { appendEntry }, ownSession)
|
|
194
|
+
|
|
195
|
+
expect(appendEntry).toHaveBeenCalledTimes(1)
|
|
196
|
+
const op = appendEntry.mock.calls[0]![1] as { ownerSessionFile: string }
|
|
197
|
+
expect(op.ownerSessionFile).toBe(ownSession)
|
|
198
|
+
expect(op.ownerSessionFile).not.toBe(storePath)
|
|
199
|
+
expect(op.ownerSessionFile).not.toBe(importedPath)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
// ── TC8:新 session 未 flush → 延迟 unlink(IMPORT-FLUSH-GUARD,MF-1)──
|
|
203
|
+
it('TC8: 新 session(sessionFile 不存在)→ 不立即 unlink,返回 cleanup;flush 后删,未 flush 保留', () => {
|
|
204
|
+
const taskA = makeTask({ id: 'eeee' })
|
|
205
|
+
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
206
|
+
JSON.stringify({ version: 1, tasks: [taskA] }),
|
|
207
|
+
)
|
|
208
|
+
vi.mocked(fs.existsSync).mockReturnValue(false) // 新 session:sessionFile 尚不存在(pi 未 flush,entries 仅内存)
|
|
209
|
+
const appendEntry = vi.fn()
|
|
210
|
+
|
|
211
|
+
const cleanup = importLegacyStore(cwd, { appendEntry }, sessionFile)
|
|
212
|
+
|
|
213
|
+
// append 已发生,但 unlink 未执行——数据可能仅内存,销毁源文件 = 永久丢失
|
|
214
|
+
expect(appendEntry).toHaveBeenCalledTimes(1)
|
|
215
|
+
expect(fs.unlinkSync).not.toHaveBeenCalled()
|
|
216
|
+
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('deferring'))
|
|
217
|
+
|
|
218
|
+
// 情形1:flush 已发生(sessionFile 出现,.imported 仍在)→ cleanup 删除 .imported
|
|
219
|
+
vi.mocked(fs.existsSync).mockImplementation(p => p === sessionFile || p === importedPath)
|
|
220
|
+
cleanup?.()
|
|
221
|
+
expect(fs.unlinkSync).toHaveBeenCalledTimes(1)
|
|
222
|
+
expect(fs.unlinkSync).toHaveBeenCalledWith(importedPath)
|
|
223
|
+
|
|
224
|
+
// 情形2:从未 flush → cleanup 保留 .imported(不 unlink、不抛错,供崩溃恢复重导入)
|
|
225
|
+
vi.mocked(fs.unlinkSync).mockClear()
|
|
226
|
+
vi.mocked(fs.existsSync).mockReturnValue(false)
|
|
227
|
+
expect(() => cleanup?.()).not.toThrow()
|
|
228
|
+
expect(fs.unlinkSync).not.toHaveBeenCalled()
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
// ── TC8b:cleanup 幂等 + unlink ENOENT 守卫(MF-2,R2 修复)──
|
|
232
|
+
it('TC8b: cleanup 幂等(已删后 no-op)+ unlink 仅静默 ENOENT,其他 fs 错误向上抛', () => {
|
|
233
|
+
const taskA = makeTask({ id: 'ffff' })
|
|
234
|
+
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
235
|
+
JSON.stringify({ version: 1, tasks: [taskA] }),
|
|
236
|
+
)
|
|
237
|
+
vi.mocked(fs.existsSync).mockReturnValue(false) // 新 session 未 flush → 延迟删除路径
|
|
238
|
+
const appendEntry = vi.fn()
|
|
239
|
+
|
|
240
|
+
const cleanup = importLegacyStore(cwd, { appendEntry }, sessionFile)!
|
|
241
|
+
expect(cleanup).toBeDefined()
|
|
242
|
+
|
|
243
|
+
// 情形1:flush 已发生 → 正常 unlink
|
|
244
|
+
vi.mocked(fs.existsSync).mockReturnValue(true)
|
|
245
|
+
cleanup()
|
|
246
|
+
expect(fs.unlinkSync).toHaveBeenCalledTimes(1)
|
|
247
|
+
expect(fs.unlinkSync).toHaveBeenCalledWith(importedPath)
|
|
248
|
+
|
|
249
|
+
// 情形2(MF-2):并发——另一进程已 unlink(unlinkSync 抛 ENOENT)→ 静默吞掉不抛
|
|
250
|
+
vi.mocked(fs.unlinkSync).mockClear()
|
|
251
|
+
vi.mocked(fs.unlinkSync).mockImplementation(() => {
|
|
252
|
+
throw enoentError()
|
|
253
|
+
})
|
|
254
|
+
expect(() => cleanup()).not.toThrow()
|
|
255
|
+
expect(fs.unlinkSync).toHaveBeenCalledTimes(1)
|
|
256
|
+
|
|
257
|
+
// 情形3(MF-2):非 ENOENT fs 错误(EACCES)→ 不吞,向上抛(index.ts try/finally 兜底复位)
|
|
258
|
+
vi.mocked(fs.unlinkSync).mockImplementation(() => {
|
|
259
|
+
throw Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' })
|
|
260
|
+
})
|
|
261
|
+
expect(() => cleanup()).toThrow(/EACCES/)
|
|
262
|
+
|
|
263
|
+
// 情形4:幂等——.imported 已不存在(本进程或并发已删)→ no-op,不抛
|
|
264
|
+
vi.mocked(fs.unlinkSync).mockClear()
|
|
265
|
+
vi.mocked(fs.existsSync).mockReturnValue(false)
|
|
266
|
+
expect(() => cleanup()).not.toThrow()
|
|
267
|
+
expect(fs.unlinkSync).not.toHaveBeenCalled()
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
// ── TC9:0 任务空 store → 无持久化依赖,仍立即 unlink ──
|
|
271
|
+
it('TC9: 空 store(tasks 空数组)→ 0 次 append,直接 unlink .imported(无数据可丢)', () => {
|
|
272
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ version: 1, tasks: [] }))
|
|
273
|
+
vi.mocked(fs.existsSync).mockReturnValue(false) // 新 session 也不影响:0 任务无持久化依赖
|
|
274
|
+
const appendEntry = vi.fn()
|
|
275
|
+
|
|
276
|
+
const cleanup = importLegacyStore(cwd, { appendEntry }, sessionFile)
|
|
277
|
+
|
|
278
|
+
expect(appendEntry).toHaveBeenCalledTimes(0)
|
|
279
|
+
expect(fs.unlinkSync).toHaveBeenCalledTimes(1)
|
|
280
|
+
expect(fs.unlinkSync).toHaveBeenCalledWith(importedPath)
|
|
281
|
+
expect(cleanup).toBeUndefined()
|
|
282
|
+
})
|
|
283
|
+
})
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
+
computeNextCronRunAt,
|
|
5
|
+
computeNextCronRuns,
|
|
6
|
+
computeNextRunAt,
|
|
4
7
|
computeNextRuns,
|
|
5
8
|
formatDuration,
|
|
6
9
|
normalizeCronExpression,
|
|
@@ -130,6 +133,32 @@ describe('parseSchedule', () => {
|
|
|
130
133
|
})
|
|
131
134
|
})
|
|
132
135
|
|
|
136
|
+
describe('computeNextRunAt', () => {
|
|
137
|
+
it('interval 模式 → from + intervalMs(精确相等)', async () => {
|
|
138
|
+
const from = Date.now()
|
|
139
|
+
const next = await computeNextRunAt({ mode: 'interval', intervalMs: 60_000 }, from)
|
|
140
|
+
expect(next).toBe(from + 60_000)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('interval 模式 from 缺省用当前时间', async () => {
|
|
144
|
+
const before = Date.now()
|
|
145
|
+
const next = await computeNextRunAt({ mode: 'interval', intervalMs: 60_000 })
|
|
146
|
+
expect(next!).toBeGreaterThanOrEqual(before + 60_000)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('cron 有效 → 返回未来时间戳(> from)', async () => {
|
|
150
|
+
const from = Date.now()
|
|
151
|
+
const next = await computeNextRunAt({ mode: 'cron', cronExpression: '*/10 * * * *' }, from)
|
|
152
|
+
expect(next).not.toBeUndefined()
|
|
153
|
+
expect(next!).toBeGreaterThan(from)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('cron 无效 → undefined', async () => {
|
|
157
|
+
const next = await computeNextRunAt({ mode: 'cron', cronExpression: 'invalid * *' }, Date.now())
|
|
158
|
+
expect(next).toBeUndefined()
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
133
162
|
describe('computeNextRuns', () => {
|
|
134
163
|
it('computes interval runs', async () => {
|
|
135
164
|
const from = Date.now()
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import fc from 'fast-check'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { computeNextRunAt, computeNextRuns, formatDuration, parseDuration, parseSchedule } from '../parsing.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* property-based 测试(fast-check):纯函数不变量回归保护。
|
|
8
|
+
*
|
|
9
|
+
* 生成器范围契约(与 m3-design C1 一致):
|
|
10
|
+
* - intervalMs ∈ [1, 86_400_000](1ms..1d,贴近 parseDuration 现实域)
|
|
11
|
+
* - from ∈ [0, 2^48](from + intervalMs*count ≈ 2.8e14 << 2^53,无 Number 溢出)
|
|
12
|
+
* - count ∈ [1, 10]
|
|
13
|
+
*
|
|
14
|
+
* 不变量边界(C3):
|
|
15
|
+
* - formatDuration↔parseDuration round-trip 恒等仅对「精确域」成立
|
|
16
|
+
* (ms % 1000 === 0 且 ms >= 1000,s 分支整除保证);
|
|
17
|
+
* - 全域只断言输出可解析且 >= 0(Math.round 兜底会破坏非整除域恒等,禁止断言恒等)。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
describe('property: computeNextRunAt interval 精确不变量', () => {
|
|
21
|
+
it('恒等于 from + intervalMs 且 > from', async () => {
|
|
22
|
+
await fc.assert(
|
|
23
|
+
fc.asyncProperty(
|
|
24
|
+
fc.integer({ min: 1, max: 86_400_000 }),
|
|
25
|
+
fc.integer({ min: 0, max: 2 ** 48 }),
|
|
26
|
+
async (intervalMs, from) => {
|
|
27
|
+
const r = await computeNextRunAt({ mode: 'interval', intervalMs }, from)
|
|
28
|
+
expect(r).toBe(from + intervalMs)
|
|
29
|
+
expect(r!).toBeGreaterThan(from)
|
|
30
|
+
},
|
|
31
|
+
),
|
|
32
|
+
)
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('property: computeNextRuns interval 等间隔递增', () => {
|
|
37
|
+
it('长度 === count、严格递增、等间隔、runs[0] 与 computeNextRunAt 一致', async () => {
|
|
38
|
+
await fc.assert(
|
|
39
|
+
fc.asyncProperty(
|
|
40
|
+
fc.integer({ min: 1, max: 86_400_000 }),
|
|
41
|
+
fc.integer({ min: 0, max: 2 ** 48 }),
|
|
42
|
+
fc.integer({ min: 1, max: 10 }),
|
|
43
|
+
async (intervalMs, from, count) => {
|
|
44
|
+
const runs = await computeNextRuns({ mode: 'interval', intervalMs }, from, count)
|
|
45
|
+
expect(runs).toHaveLength(count)
|
|
46
|
+
for (let i = 0; i < count; i++) {
|
|
47
|
+
expect(runs[i]).toBe(from + intervalMs * (i + 1))
|
|
48
|
+
if (i > 0) expect(runs[i]!).toBeGreaterThan(runs[i - 1]!)
|
|
49
|
+
}
|
|
50
|
+
// 两入口同源不变量:runs[0] 必须与单次计算入口一致
|
|
51
|
+
const single = await computeNextRunAt({ mode: 'interval', intervalMs }, from)
|
|
52
|
+
expect(runs[0]).toBe(single)
|
|
53
|
+
},
|
|
54
|
+
),
|
|
55
|
+
)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
// ── TC4 grammar 生成:duration 单位表(与 parsing.ts DURATION_MULTIPLIERS 语义一致) ──
|
|
60
|
+
|
|
61
|
+
const UNITS = ['s', 'sec', 'second', 'seconds', 'm', 'min', 'minute', 'minutes', 'h', 'hr', 'hour', 'hours', 'd', 'day', 'days']
|
|
62
|
+
const UNIT_VARIANTS = [...UNITS, ...UNITS.map(u => u.toUpperCase()), 'Sec', 'MIN', 'Hours', 'Day']
|
|
63
|
+
|
|
64
|
+
const MULTIPLIERS: Record<string, number> = {
|
|
65
|
+
s: 1000, sec: 1000, second: 1000, seconds: 1000,
|
|
66
|
+
m: 60_000, min: 60_000, minute: 60_000, minutes: 60_000,
|
|
67
|
+
h: 3_600_000, hr: 3_600_000, hour: 3_600_000, hours: 3_600_000,
|
|
68
|
+
d: 86_400_000, day: 86_400_000, days: 86_400_000,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe('property: parseDuration/parseSchedule round-trip', () => {
|
|
72
|
+
it('合法 duration(value×unit grammar 生成)恒精确解析且 parseSchedule 恒 interval mode', async () => {
|
|
73
|
+
await fc.assert(
|
|
74
|
+
fc.asyncProperty(
|
|
75
|
+
fc.record({
|
|
76
|
+
value: fc.integer({ min: 0, max: 1_000_000 }),
|
|
77
|
+
unit: fc.constantFrom(...UNIT_VARIANTS),
|
|
78
|
+
}),
|
|
79
|
+
async ({ value, unit }) => {
|
|
80
|
+
const text = `${value}${unit}`
|
|
81
|
+
const expectedMs = value * MULTIPLIERS[unit.toLowerCase()]!
|
|
82
|
+
expect(parseDuration(text)).toBe(expectedMs)
|
|
83
|
+
|
|
84
|
+
// 无空格字符串 → 恒走 duration 分支 → interval mode + 同一 intervalMs
|
|
85
|
+
const parsed = await parseSchedule(text)
|
|
86
|
+
expect(parsed).toBeDefined()
|
|
87
|
+
expect(parsed!.spec).toEqual({ mode: 'interval', intervalMs: expectedMs })
|
|
88
|
+
},
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('非法输入(裸数字/未知单位/空串/负值/小数)恒 undefined', async () => {
|
|
94
|
+
const invalid = ['123', '5x', '', '-5m', 'm', '1.5m', '5m 5s']
|
|
95
|
+
for (const input of invalid) {
|
|
96
|
+
expect(parseDuration(input)).toBeUndefined()
|
|
97
|
+
expect(await parseSchedule(input)).toBeUndefined()
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe('property: formatDuration↔parseDuration round-trip', () => {
|
|
103
|
+
// 精确域:ms 为 1000 正整数倍且 >= 1000 → 恒等(整除路径或 s 分支兜底均精确)。
|
|
104
|
+
// 用 k*1000 生成(而非 filter)避免 fc 对 1/1000 通过率的 filter 重试浪费。
|
|
105
|
+
it('精确域 round-trip 恒等', () => {
|
|
106
|
+
fc.assert(
|
|
107
|
+
fc.property(
|
|
108
|
+
fc.integer({ min: 1, max: 1_000_000 }).map(k => k * 1000),
|
|
109
|
+
ms => {
|
|
110
|
+
expect(parseDuration(formatDuration(ms))).toBe(ms)
|
|
111
|
+
},
|
|
112
|
+
),
|
|
113
|
+
)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
// 全域:Math.round 兜底会破坏非整除域恒等(如 1500 → '2s'),
|
|
117
|
+
// 只断言输出可解析且解析值 >= 0(C3 不变量边界)。
|
|
118
|
+
it('全域输出恒可解析且 >= 0(不断言恒等)', () => {
|
|
119
|
+
fc.assert(
|
|
120
|
+
fc.property(
|
|
121
|
+
fc.integer({ min: 1, max: 1_000_000_000 }),
|
|
122
|
+
ms => {
|
|
123
|
+
const out = formatDuration(ms)
|
|
124
|
+
const parsed = parseDuration(out)
|
|
125
|
+
expect(parsed).toBeDefined()
|
|
126
|
+
expect(parsed!).toBeGreaterThanOrEqual(0)
|
|
127
|
+
},
|
|
128
|
+
),
|
|
129
|
+
)
|
|
130
|
+
})
|
|
131
|
+
})
|