@zhushanwen/pi-scheduler 0.0.2

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/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { default } from './src/index.js'
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@zhushanwen/pi-scheduler",
3
+ "version": "0.0.2",
4
+ "type": "module",
5
+ "main": "index.ts",
6
+ "pi": {
7
+ "extensions": [
8
+ "./index.ts"
9
+ ],
10
+ "skills": []
11
+ },
12
+ "keywords": [
13
+ "pi-package"
14
+ ],
15
+ "devDependencies": {
16
+ "vitest": "^4.1.8"
17
+ },
18
+ "files": [
19
+ "index.ts",
20
+ "src/**/*.ts",
21
+ "vitest.config.ts"
22
+ ],
23
+ "peerDependencies": {
24
+ "@earendil-works/pi-coding-agent": "*",
25
+ "@sinclair/typebox": "*",
26
+ "croner": "^9.0.0"
27
+ },
28
+ "peerDependenciesMeta": {
29
+ "@earendil-works/pi-coding-agent": {
30
+ "optional": true
31
+ },
32
+ "@sinclair/typebox": {
33
+ "optional": true
34
+ },
35
+ "croner": {
36
+ "optional": true
37
+ }
38
+ },
39
+ "scripts": {
40
+ "test": "vitest run",
41
+ "test:watch": "vitest"
42
+ }
43
+ }
@@ -0,0 +1,237 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ import { executeScheduleCommand, registerScheduleCommand } from '../commands.js'
4
+ import { SchedulerRuntime } from '../runtime.js'
5
+
6
+ // Mock store 避免 FS 副作用(runtime constructor 调 createStore)。
7
+ vi.mock('../store.js', () => ({
8
+ createStore: () => ({
9
+ load: () => ({ version: 1, tasks: [] }),
10
+ persist: vi.fn(),
11
+ persistSync: vi.fn(),
12
+ storePath: '/mocked.json',
13
+ }),
14
+ }))
15
+
16
+ interface CommandOpts {
17
+ description: string
18
+ handler: (args: string) => Promise<string>
19
+ getArgumentCompletions: (prefix: string) => unknown
20
+ }
21
+
22
+ describe('/schedule command', () => {
23
+ let runtime: SchedulerRuntime
24
+ let commandOpts: CommandOpts
25
+
26
+ beforeEach(() => {
27
+ vi.clearAllMocks()
28
+ // 注册命令时把 opts 截获下来,后续直接调 handler / getArgumentCompletions。
29
+ const mockPi = {
30
+ registerCommand: (_name: string, opts: CommandOpts) => {
31
+ commandOpts = opts
32
+ },
33
+ }
34
+ runtime = new SchedulerRuntime(
35
+ '/test',
36
+ { sendMessage: vi.fn() } as never,
37
+ { isIdle: () => true, hasPendingMessages: () => false } as never,
38
+ )
39
+ registerScheduleCommand(mockPi as never, () => runtime)
40
+ })
41
+
42
+ // ── 子命令路由:list ──
43
+
44
+ it('list returns empty message when no tasks', async () => {
45
+ expect(await executeScheduleCommand(runtime, 'list')).toBe('No scheduled tasks.')
46
+ })
47
+
48
+ it('list returns formatted task lines', async () => {
49
+ await runtime.addTask('check build', { mode: 'interval', intervalMs: 60000 })
50
+ const result = await executeScheduleCommand(runtime, 'list')
51
+ expect(result).toContain('check build')
52
+ expect(result).toContain('every 1m')
53
+ })
54
+
55
+ it('list marks disabled tasks with ○', async () => {
56
+ const task = await runtime.addTask('paused task', { mode: 'interval', intervalMs: 60000 })
57
+ await runtime.toggleTask(task.id, false)
58
+ const result = await executeScheduleCommand(runtime, 'list')
59
+ expect(result).toContain('○')
60
+ expect(result).toContain('paused task')
61
+ })
62
+
63
+ // ── 子命令路由:on / off ──
64
+
65
+ it('off toggles task enabled to false', async () => {
66
+ const task = await runtime.addTask('test', { mode: 'interval', intervalMs: 60000 })
67
+ const result = await executeScheduleCommand(runtime, `off ${task.id}`)
68
+ expect(result).toContain('disabled')
69
+ expect(runtime.getTask(task.id)?.enabled).toBe(false)
70
+ })
71
+
72
+ it('on toggles task enabled to true', async () => {
73
+ const task = await runtime.addTask('test', { mode: 'interval', intervalMs: 60000 })
74
+ await runtime.toggleTask(task.id, false)
75
+ const result = await executeScheduleCommand(runtime, `on ${task.id}`)
76
+ expect(result).toContain('enabled')
77
+ expect(runtime.getTask(task.id)?.enabled).toBe(true)
78
+ })
79
+
80
+ it('off with missing id returns usage', async () => {
81
+ expect(await executeScheduleCommand(runtime, 'off')).toBe('Usage: /schedule off <id>')
82
+ })
83
+
84
+ it('on with missing id returns usage', async () => {
85
+ expect(await executeScheduleCommand(runtime, 'on')).toBe('Usage: /schedule on <id>')
86
+ })
87
+
88
+ it('off with unknown id returns not found', async () => {
89
+ expect(await executeScheduleCommand(runtime, 'off deadbeef')).toBe('Task deadbeef not found.')
90
+ })
91
+
92
+ // ── 子命令路由:rm ──
93
+
94
+ it('rm deletes task', async () => {
95
+ const task = await runtime.addTask('test', { mode: 'interval', intervalMs: 60000 })
96
+ const result = await executeScheduleCommand(runtime, `rm ${task.id}`)
97
+ expect(result).toContain('deleted')
98
+ expect(runtime.getTask(task.id)).toBeUndefined()
99
+ })
100
+
101
+ it('rm with missing id returns usage', async () => {
102
+ expect(await executeScheduleCommand(runtime, 'rm')).toBe('Usage: /schedule rm <id>')
103
+ })
104
+
105
+ it('rm with unknown id returns not found', async () => {
106
+ expect(await executeScheduleCommand(runtime, 'rm deadbeef')).toBe('Task deadbeef not found.')
107
+ })
108
+
109
+ // ── 子命令路由:run ──
110
+
111
+ it('run executes task', async () => {
112
+ const task = await runtime.addTask('test', { mode: 'interval', intervalMs: 60000 })
113
+ const result = await executeScheduleCommand(runtime, `run ${task.id}`)
114
+ expect(result).toContain('executed')
115
+ // dispatchTask 更新 task 对象(同一引用),runCount 自增到 1。
116
+ expect(runtime.getTask(task.id)?.runCount).toBe(1)
117
+ })
118
+
119
+ it('run with missing id returns usage', async () => {
120
+ expect(await executeScheduleCommand(runtime, 'run')).toBe('Usage: /schedule run <id>')
121
+ })
122
+
123
+ it('run with unknown id returns not found', async () => {
124
+ expect(await executeScheduleCommand(runtime, 'run deadbeef')).toBe('Task deadbeef not found.')
125
+ })
126
+
127
+ // ── 创建任务分支 ──
128
+
129
+ it('creates interval task from /schedule 5m check build', async () => {
130
+ const result = await executeScheduleCommand(runtime, '5m check build')
131
+ expect(result).toContain('check build')
132
+ expect(result).toContain('every 5m')
133
+ expect(runtime.listTasks()).toHaveLength(1)
134
+ })
135
+
136
+ it('created interval task is recurring by default', async () => {
137
+ await executeScheduleCommand(runtime, '5m check build')
138
+ const task = runtime.listTasks()[0]!
139
+ expect(task.kind).toBe('recurring')
140
+ })
141
+
142
+ it('creates once task from /schedule once 10s remind', async () => {
143
+ const result = await executeScheduleCommand(runtime, 'once 10s remind me')
144
+ expect(result).toContain('remind me')
145
+ // once 任务 dispatch 后会被删除,但创建时尚未 dispatch
146
+ expect(runtime.listTasks()).toHaveLength(1)
147
+ const task = runtime.listTasks()[0]!
148
+ expect(task.kind).toBe('once')
149
+ })
150
+
151
+ // Quote-aware tokenizer 修复后,cron 'expr' 能正确提取整个表达式。
152
+ it('creates cron task from quoted expression', async () => {
153
+ const result = await executeScheduleCommand(runtime, "cron '*/10 * * * *' prompt")
154
+ expect(result).toContain('created')
155
+ expect(result).toContain('*/10 * * * *')
156
+ expect(runtime.listTasks()).toHaveLength(1)
157
+ })
158
+
159
+ it('creates cron task from double-quoted expression', async () => {
160
+ const result = await executeScheduleCommand(runtime, 'cron "0 9 * * 1-5" standup reminder')
161
+ expect(result).toContain('created')
162
+ expect(result).toContain('0 9 * * 1-5')
163
+ expect(runtime.listTasks()).toHaveLength(1)
164
+ })
165
+
166
+ // Unquoted multi-token cron still fails -- tokenizer cannot distinguish cron fields from prompt.
167
+ // Users should quote the cron expression or use the schedule tool (JSON params are unambiguous).
168
+ it('cron branch fails on unquoted multi-token expression (use quotes)', async () => {
169
+ const result = await executeScheduleCommand(runtime, 'cron */10 * * * * prompt')
170
+ expect(result).toMatch(/^Invalid schedule:/)
171
+ expect(result).toContain('*/10')
172
+ })
173
+
174
+ // ── 错误分支 ──
175
+
176
+ it('invalid schedule returns error message', async () => {
177
+ const result = await executeScheduleCommand(runtime, 'invalid-duration-str')
178
+ expect(result).toMatch(/invalid|usage/i)
179
+ })
180
+
181
+ it('schedule with no prompt returns usage', async () => {
182
+ const result = await executeScheduleCommand(runtime, '5m')
183
+ expect(result).toBe('Usage: /schedule <schedule> <prompt>')
184
+ })
185
+
186
+ it('no args returns TUI not-implemented message', async () => {
187
+ const result = await executeScheduleCommand(runtime, '')
188
+ expect(result).toContain('not yet implemented')
189
+ })
190
+
191
+ it('returns error when runtime is null', async () => {
192
+ expect(await executeScheduleCommand(null, 'list')).toBe('Scheduler not initialized: session not started.')
193
+ })
194
+
195
+ // ── getArgumentCompletions ──
196
+
197
+ it('completes subcommands for empty prefix', () => {
198
+ const completions = commandOpts.getArgumentCompletions('') as Array<{ label: string }>
199
+ const labels = completions.map(c => c.label)
200
+ expect(labels).toContain('list')
201
+ expect(labels).toContain('on')
202
+ expect(labels).toContain('off')
203
+ expect(labels).toContain('rm')
204
+ expect(labels).toContain('run')
205
+ expect(labels).toContain('once')
206
+ expect(labels).toContain('cron')
207
+ })
208
+
209
+ it('filters subcommands by prefix', () => {
210
+ const completions = commandOpts.getArgumentCompletions('r') as Array<{ label: string }>
211
+ const labels = completions.map(c => c.label)
212
+ expect(labels).toContain('rm')
213
+ expect(labels).toContain('run')
214
+ expect(labels).not.toContain('list')
215
+ })
216
+
217
+ it('completes task ids after on/off/rm/run', async () => {
218
+ const task = await runtime.addTask('mytask', { mode: 'interval', intervalMs: 60000 })
219
+ // 注意:路由要求 parts.length >= 2 才进 task-id 分支('on ' 单 token 进子命令分支)。
220
+ // 当前实现对部分输入的 id 不做过滤,返回所有 task id。
221
+ const completions = commandOpts.getArgumentCompletions(`on ${task.id.slice(0, 2)}`) as Array<{ label: string; description: string }>
222
+ const labels = completions.map(c => c.label)
223
+ expect(labels).toContain(task.id)
224
+ expect(completions.find(c => c.label === task.id)?.description).toContain('mytask')
225
+ })
226
+
227
+ it('returns null for completion when runtime missing and prefix has 2 tokens', () => {
228
+ const mockPi = {
229
+ registerCommand: (_name: string, opts: CommandOpts) => {
230
+ commandOpts = opts
231
+ },
232
+ }
233
+ registerScheduleCommand(mockPi as never, () => null)
234
+ // 2 个 token 才能跳过子命令分支、命中末尾 return null
235
+ expect(commandOpts.getArgumentCompletions('on abcdef12')).toBeNull()
236
+ })
237
+ })
@@ -0,0 +1,107 @@
1
+ // src/__tests__/cron.test.ts
2
+ //
3
+ // Cron 执行路径测试(M10a)。
4
+ // parsing.test.ts 现有测试只覆盖 duration/interval 路径,未覆盖 cron 分支。
5
+ // 此文件集中测 cron 相关导出:normalizeCronExpression / computeNextCronRunAt /
6
+ // computeNextCronRuns / parseSchedule(cron) / computeNextRuns(cron)。
7
+ //
8
+ // croner 包是动态 import 的,测试时已安装(package.json peerDep croner ^9.0.0),
9
+ // getCroner() 能正常返回模块,故 cron 路径在此可真实执行。
10
+
11
+ import { describe, expect, it } from 'vitest'
12
+
13
+ import {
14
+ computeNextCronRunAt,
15
+ computeNextCronRuns,
16
+ computeNextRuns,
17
+ normalizeCronExpression,
18
+ parseSchedule,
19
+ } from '../parsing.js'
20
+
21
+ describe('computeNextCronRunAt', () => {
22
+ it('返回有效时间戳(> Date.now())', async () => {
23
+ const now = Date.now()
24
+ const next = await computeNextCronRunAt('*/10 * * * *')
25
+ expect(next).not.toBeUndefined()
26
+ expect(next!).toBeGreaterThan(now)
27
+ })
28
+
29
+ it('非法表达式返回 undefined', async () => {
30
+ const next = await computeNextCronRunAt('invalid expr')
31
+ expect(next).toBeUndefined()
32
+ })
33
+ })
34
+
35
+ describe('computeNextCronRuns', () => {
36
+ it('返回 count 个递增时间戳', async () => {
37
+ const from = Date.now()
38
+ const runs = await computeNextCronRuns('*/10 * * * *', from, 3)
39
+ expect(runs).toHaveLength(3)
40
+ // 严格递增
41
+ expect(runs[0]).toBeLessThan(runs[1]!)
42
+ expect(runs[1]).toBeLessThan(runs[2]!)
43
+ // 第一个应晚于 from
44
+ expect(runs[0]).toBeGreaterThan(from)
45
+ })
46
+
47
+ it('每两次执行间隔约 10 分钟(600000ms ± 容差)', async () => {
48
+ const from = Date.now()
49
+ const runs = await computeNextCronRuns('*/10 * * * *', from, 3)
50
+ const gap1 = runs[1]! - runs[0]!
51
+ const gap2 = runs[2]! - runs[1]!
52
+ // 10 分钟 = 600000ms,允许 ±2s 容差(cron 表达式按分钟边界对齐,间隔精确 10min)
53
+ expect(gap1).toBeGreaterThanOrEqual(600000 - 2000)
54
+ expect(gap1).toBeLessThanOrEqual(600000 + 2000)
55
+ expect(gap2).toBeGreaterThanOrEqual(600000 - 2000)
56
+ expect(gap2).toBeLessThanOrEqual(600000 + 2000)
57
+ })
58
+ })
59
+
60
+ describe('parseSchedule (cron 分支)', () => {
61
+ it('5 字段 cron 表达式补秒字段并附带 note', async () => {
62
+ const result = await parseSchedule('*/10 * * * *')
63
+ expect(result).toEqual({
64
+ spec: { mode: 'cron', cronExpression: '0 */10 * * * *' },
65
+ note: 'Auto-prepended seconds field (0)',
66
+ })
67
+ })
68
+
69
+ it('工作日 9 点 cron 表达式返回 cron mode', async () => {
70
+ const result = await parseSchedule('0 9 * * 1-5')
71
+ expect(result).toBeDefined()
72
+ expect(result!.spec.mode).toBe('cron')
73
+ expect(result!.spec).toEqual({ mode: 'cron', cronExpression: '0 0 9 * * 1-5' })
74
+ })
75
+
76
+ it('含空格但非法的表达式返回 undefined', async () => {
77
+ const result = await parseSchedule('invalid cron expr here')
78
+ expect(result).toBeUndefined()
79
+ })
80
+
81
+ it('非法分钟值(99)返回 undefined(croner 拒绝)', async () => {
82
+ const result = await parseSchedule('99 * * * *')
83
+ expect(result).toBeUndefined()
84
+ })
85
+ })
86
+
87
+ describe('normalizeCronExpression', () => {
88
+ it('6 字段 cron 原样返回', () => {
89
+ const result = normalizeCronExpression('0 9 * * 1-5 0')
90
+ expect(result).toEqual({ expression: '0 9 * * 1-5 0' })
91
+ })
92
+
93
+ it('2 字段返回 undefined', () => {
94
+ expect(normalizeCronExpression('0 9')).toBeUndefined()
95
+ })
96
+ })
97
+
98
+ describe('computeNextRuns (cron mode)', () => {
99
+ it('返回 count 个递增时间戳', async () => {
100
+ const from = Date.now()
101
+ const spec = { mode: 'cron' as const, cronExpression: '*/10 * * * *' }
102
+ const runs = await computeNextRuns(spec, from, 2)
103
+ expect(runs).toHaveLength(2)
104
+ expect(runs[0]).toBeLessThan(runs[1]!)
105
+ expect(runs[0]).toBeGreaterThan(from)
106
+ })
107
+ })
@@ -0,0 +1,115 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ import { autoName,formatRelativeTime, formatSchedule, generateTaskId, truncate } from '../format.js'
4
+ import { formatDuration } from '../parsing.js'
5
+
6
+ describe('formatSchedule', () => {
7
+ it('formats interval spec', () => {
8
+ expect(formatSchedule({ mode: 'interval', intervalMs: 300_000 })).toBe('every 5m')
9
+ expect(formatSchedule({ mode: 'interval', intervalMs: 3_600_000 })).toBe('every 1h')
10
+ })
11
+
12
+ it('formats cron spec', () => {
13
+ expect(formatSchedule({ mode: 'cron', cronExpression: '*/10 * * * *' })).toBe('*/10 * * * *')
14
+ })
15
+ })
16
+
17
+ describe('formatRelativeTime', () => {
18
+ // 固定系统时间避免 clock-boundary flake:formatRelativeTime 内部读 Date.now(),
19
+ // 若与测试捕获的 now 间有毫秒级偏差,正好压在 2h/1d 边界上的断言会偶发失败。
20
+ beforeEach(() => {
21
+ vi.useFakeTimers()
22
+ vi.setSystemTime(new Date('2026-01-15T12:00:00.000Z'))
23
+ })
24
+ afterEach(() => {
25
+ vi.useRealTimers()
26
+ })
27
+
28
+ it('formats future time', () => {
29
+ const now = Date.now()
30
+ expect(formatRelativeTime(now + 300_000)).toBe('in 5m')
31
+ expect(formatRelativeTime(now + 7_200_000)).toBe('in 2h')
32
+ })
33
+
34
+ it('formats past time', () => {
35
+ const now = Date.now()
36
+ expect(formatRelativeTime(now - 300_000)).toBe('5m ago')
37
+ expect(formatRelativeTime(now - 86_400_000)).toBe('1d ago')
38
+ })
39
+
40
+ it('formats now for recent timestamps', () => {
41
+ const now = Date.now()
42
+ expect(formatRelativeTime(now)).toBe('now')
43
+ expect(formatRelativeTime(now + 2000)).toBe('now')
44
+ expect(formatRelativeTime(now - 2000)).toBe('now')
45
+ })
46
+
47
+ // 5 秒边界:源码 `< 5000` 严格小于。用显式 now 参数精确锁定,避免 fake-timer 漂移。
48
+ it('treats 4999ms as now and 5000ms as not-now (strict <)', () => {
49
+ const base = 1_700_000_000_000
50
+ expect(formatRelativeTime(base + 4999, base)).toBe('now')
51
+ expect(formatRelativeTime(base + 5000, base)).not.toBe('now')
52
+ expect(formatRelativeTime(base + 5000, base)).toBe('in 5s')
53
+ })
54
+ })
55
+
56
+ describe('truncate', () => {
57
+ it('returns original text if within limit', () => {
58
+ expect(truncate('hello', 10)).toBe('hello')
59
+ expect(truncate('hello', 5)).toBe('hello')
60
+ })
61
+
62
+ it('truncates and adds ellipsis', () => {
63
+ expect(truncate('hello world', 8)).toBe('hello...')
64
+ expect(truncate('a very long text', 10)).toBe('a very ...')
65
+ })
66
+
67
+ it('handles edge cases', () => {
68
+ expect(truncate('', 5)).toBe('')
69
+ expect(truncate('hi', 2)).toBe('hi')
70
+ expect(truncate('hi', 1)).toBe('h')
71
+ })
72
+
73
+ // maxLen<=3 分支:不加省略号,直接 slice
74
+ it('does not add ellipsis when maxLen <= 3', () => {
75
+ expect(truncate('hello', 3)).toBe('hel')
76
+ expect(truncate('hello', 0)).toBe('')
77
+ })
78
+ })
79
+
80
+ describe('formatDuration', () => {
81
+ // 秒兜底分支:>60s 但不能整除 m → 不被误判为 "1m",走 Math.round(ms/1000)
82
+ it('falls back to seconds when not evenly divisible by larger units', () => {
83
+ expect(formatDuration(90_000)).toBe('90s')
84
+ expect(formatDuration(1500)).toBe('2s')
85
+ })
86
+ })
87
+
88
+ describe('generateTaskId', () => {
89
+ it('generates 8 character hex string', () => {
90
+ const id = generateTaskId()
91
+ expect(id).toHaveLength(8)
92
+ expect(id).toMatch(/^[0-9a-f]{8}$/)
93
+ })
94
+
95
+ it('generates unique ids', () => {
96
+ const ids = new Set(Array.from({ length: 100 }, () => generateTaskId()))
97
+ expect(ids.size).toBe(100)
98
+ })
99
+ })
100
+
101
+ describe('autoName', () => {
102
+ it('returns full prompt if <= 30 chars', () => {
103
+ expect(autoName('check build status')).toBe('check build status')
104
+ expect(autoName('a'.repeat(30))).toBe('a'.repeat(30))
105
+ })
106
+
107
+ it('truncates long prompt', () => {
108
+ const long = 'a'.repeat(50)
109
+ expect(autoName(long)).toBe('a'.repeat(27) + '...')
110
+ })
111
+
112
+ it('trims whitespace', () => {
113
+ expect(autoName(' hello ')).toBe('hello')
114
+ })
115
+ })
@@ -0,0 +1,162 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import {
4
+ computeNextRuns,
5
+ formatDuration,
6
+ normalizeCronExpression,
7
+ parseDuration,
8
+ parseSchedule,
9
+ } from '../parsing.js'
10
+
11
+ describe('parseDuration', () => {
12
+ it('parses seconds', () => {
13
+ expect(parseDuration('5s')).toBe(5000)
14
+ expect(parseDuration('30sec')).toBe(30_000)
15
+ expect(parseDuration('1second')).toBe(1000)
16
+ expect(parseDuration('2seconds')).toBe(2000)
17
+ })
18
+
19
+ it('parses minutes', () => {
20
+ expect(parseDuration('5m')).toBe(300_000)
21
+ expect(parseDuration('30min')).toBe(1_800_000)
22
+ expect(parseDuration('1minute')).toBe(60_000)
23
+ expect(parseDuration('2minutes')).toBe(120_000)
24
+ })
25
+
26
+ it('parses hours', () => {
27
+ expect(parseDuration('2h')).toBe(7_200_000)
28
+ expect(parseDuration('1hr')).toBe(3_600_000)
29
+ expect(parseDuration('1hour')).toBe(3_600_000)
30
+ expect(parseDuration('3hours')).toBe(10_800_000)
31
+ })
32
+
33
+ it('parses days', () => {
34
+ expect(parseDuration('1d')).toBe(86_400_000)
35
+ expect(parseDuration('7days')).toBe(604_800_000)
36
+ })
37
+
38
+ it('returns undefined for invalid input', () => {
39
+ expect(parseDuration('invalid')).toBeUndefined()
40
+ expect(parseDuration('')).toBeUndefined()
41
+ expect(parseDuration('5x')).toBeUndefined()
42
+ expect(parseDuration('abc5m')).toBeUndefined()
43
+ })
44
+
45
+ it('accepts zero value', () => {
46
+ expect(parseDuration('0s')).toBe(0)
47
+ })
48
+
49
+ it('returns undefined for bare number without unit', () => {
50
+ expect(parseDuration('5')).toBeUndefined()
51
+ })
52
+
53
+ it('trims leading/trailing whitespace', () => {
54
+ expect(parseDuration(' 5m ')).toBe(300_000)
55
+ })
56
+
57
+ it('accepts uppercase units (case-insensitive)', () => {
58
+ expect(parseDuration('5M')).toBe(300_000)
59
+ expect(parseDuration('5H')).toBe(18_000_000)
60
+ })
61
+ })
62
+
63
+ describe('formatDuration', () => {
64
+ it('formats milliseconds to readable string', () => {
65
+ expect(formatDuration(300_000)).toBe('5m')
66
+ expect(formatDuration(7_200_000)).toBe('2h')
67
+ expect(formatDuration(86_400_000)).toBe('1d')
68
+ })
69
+
70
+ it('uses largest unit possible', () => {
71
+ expect(formatDuration(3_600_000)).toBe('1h')
72
+ expect(formatDuration(60_000)).toBe('1m')
73
+ expect(formatDuration(1000)).toBe('1s')
74
+ })
75
+
76
+ it('handles edge cases', () => {
77
+ expect(formatDuration(0)).toBe('0s')
78
+ expect(formatDuration(-1000)).toBe('0s')
79
+ })
80
+ })
81
+
82
+ describe('normalizeCronExpression', () => {
83
+ it('prepends seconds field for 5-field cron', () => {
84
+ const result = normalizeCronExpression('*/10 * * * *')
85
+ expect(result).toEqual({
86
+ expression: '0 */10 * * * *',
87
+ note: 'Auto-prepended seconds field (0)',
88
+ })
89
+ })
90
+
91
+ it('keeps 6-field cron as-is', () => {
92
+ const result = normalizeCronExpression('0 */10 * * * *')
93
+ expect(result).toEqual({ expression: '0 */10 * * * *' })
94
+ })
95
+
96
+ it('returns undefined for invalid field count', () => {
97
+ expect(normalizeCronExpression('* * *')).toBeUndefined()
98
+ expect(normalizeCronExpression('')).toBeUndefined()
99
+ })
100
+ })
101
+
102
+ describe('parseSchedule', () => {
103
+ it('parses duration to interval mode', async () => {
104
+ const result = await parseSchedule('5m')
105
+ expect(result).toEqual({
106
+ spec: { mode: 'interval', intervalMs: 300_000 },
107
+ })
108
+ })
109
+
110
+ it('returns undefined for invalid duration', async () => {
111
+ const result = await parseSchedule('invalid')
112
+ expect(result).toBeUndefined()
113
+ })
114
+
115
+ it('returns undefined for empty input', async () => {
116
+ const result = await parseSchedule('')
117
+ expect(result).toBeUndefined()
118
+ })
119
+
120
+ // cron 分支:含空格的输入走 cron 解析(computeNextCronRunAt 验证有效性)
121
+ it('parses valid cron expression to cron mode', async () => {
122
+ const result = await parseSchedule('0 9 * * 1-5')
123
+ expect(result).toBeDefined()
124
+ expect(result!.spec.mode).toBe('cron')
125
+ })
126
+
127
+ it('returns undefined for invalid cron with spaces', async () => {
128
+ const result = await parseSchedule('not a valid cron')
129
+ expect(result).toBeUndefined()
130
+ })
131
+ })
132
+
133
+ describe('computeNextRuns', () => {
134
+ it('computes interval runs', async () => {
135
+ const from = Date.now()
136
+ const spec = { mode: 'interval' as const, intervalMs: 60_000 }
137
+ const runs = await computeNextRuns(spec, from, 3)
138
+
139
+ expect(runs).toHaveLength(3)
140
+ expect(runs[0]).toBe(from + 60_000)
141
+ expect(runs[1]).toBe(from + 120_000)
142
+ expect(runs[2]).toBe(from + 180_000)
143
+ })
144
+
145
+ it('defaults to 5 runs', async () => {
146
+ const spec = { mode: 'interval' as const, intervalMs: 60_000 }
147
+ const runs = await computeNextRuns(spec)
148
+ expect(runs).toHaveLength(5)
149
+ })
150
+
151
+ // cron 分支:computeNextRuns 委托 computeNextCronRuns
152
+ it('computes cron runs (delegates to croner)', async () => {
153
+ const from = Date.now()
154
+ const spec = { mode: 'cron' as const, cronExpression: '*/10 * * * *' }
155
+ const runs = await computeNextRuns(spec, from, 3)
156
+ expect(runs).toHaveLength(3)
157
+ // cron runs 严格递增且都晚于 from
158
+ expect(runs[0]).toBeGreaterThan(from)
159
+ expect(runs[0]).toBeLessThan(runs[1]!)
160
+ expect(runs[1]).toBeLessThan(runs[2]!)
161
+ })
162
+ })