Coverage for agentos/tests/test_scheduler.py: 0%

374 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-09 10:28 +0800

1"""Comprehensive tests for agentos/tools/scheduler.py.""" 

2 

3import threading 

4import time 

5 

6import pytest 

7 

8from agentos.tools.scheduler import ( 

9 Job, 

10 JobState, 

11 Scheduler, 

12 _cron_next, 

13 _parse_cron_field, 

14) 

15 

16# ============================================================================ 

17# Job & JobState 

18# ============================================================================ 

19 

20 

21class TestJobState: 

22 def test_constants(self): 

23 assert JobState.PENDING == "pending" 

24 assert JobState.RUNNING == "running" 

25 assert JobState.PAUSED == "paused" 

26 assert JobState.STOPPED == "stopped" 

27 

28 

29class TestJob: 

30 def test_minimal_construction(self): 

31 def dummy(): 

32 pass 

33 

34 job = Job(id="j1", func=dummy) 

35 assert job.id == "j1" 

36 assert job.func is dummy 

37 assert job.args == () 

38 assert job.kwargs == {} 

39 assert job.interval is None 

40 assert job.cron is None 

41 assert job.delay is None 

42 assert job.state == JobState.PENDING 

43 assert job.next_run == 0.0 

44 assert job.last_run is None 

45 assert job.run_count == 0 

46 assert job.error_count == 0 

47 assert job.last_error is None 

48 

49 def test_full_construction(self): 

50 def dummy(): 

51 pass 

52 

53 job = Job( 

54 id="full", 

55 func=dummy, 

56 args=(1, 2), 

57 kwargs={"a": 3}, 

58 interval=5.0, 

59 state=JobState.RUNNING, 

60 next_run=12345.0, 

61 last_run=12000.0, 

62 run_count=10, 

63 error_count=2, 

64 last_error="boom", 

65 ) 

66 assert job.id == "full" 

67 assert job.args == (1, 2) 

68 assert job.kwargs == {"a": 3} 

69 assert job.interval == 5.0 

70 assert job.state == JobState.RUNNING 

71 assert job.next_run == 12345.0 

72 assert job.last_run == 12000.0 

73 assert job.run_count == 10 

74 assert job.error_count == 2 

75 assert job.last_error == "boom" 

76 

77 

78# ============================================================================ 

79# _parse_cron_field 

80# ============================================================================ 

81 

82 

83class TestParseCronField: 

84 def test_wildcard(self): 

85 result = _parse_cron_field("*", 0, 59) 

86 assert result == set(range(0, 60)) 

87 

88 def test_single_value(self): 

89 result = _parse_cron_field("5", 0, 59) 

90 assert result == {5} 

91 

92 def test_comma_separated(self): 

93 result = _parse_cron_field("1,5,10", 0, 59) 

94 assert result == {1, 5, 10} 

95 

96 def test_step(self): 

97 result = _parse_cron_field("*/15", 0, 59) 

98 expected = {0, 15, 30, 45} 

99 assert result == expected 

100 

101 def test_step_with_base(self): 

102 result = _parse_cron_field("5/20", 0, 59) 

103 expected = {5, 25, 45} 

104 assert result == expected 

105 

106 def test_range(self): 

107 result = _parse_cron_field("10-15", 0, 59) 

108 assert result == {10, 11, 12, 13, 14, 15} 

109 

110 def test_mixed(self): 

111 result = _parse_cron_field("1,5-7,*/30", 0, 59) 

112 expected = {0, 1, 5, 6, 7, 30} 

113 assert result == expected 

114 

115 def test_hour_field(self): 

116 result = _parse_cron_field("0,12", 0, 23) 

117 assert result == {0, 12} 

118 

119 def test_dom_field(self): 

120 result = _parse_cron_field("1,15", 1, 31) 

121 assert result == {1, 15} 

122 

123 

124# ============================================================================ 

125# _cron_next 

126# ============================================================================ 

127 

128 

129class TestCronNext: 

130 def test_every_minute(self): 

131 cron = {"minute": "*", "hour": "*", "day": "*", "month": "*", "day_of_week": "*"} 

132 now = time.mktime((2024, 1, 1, 12, 0, 0, 0, 0, 0)) 

133 result = _cron_next(cron, now) 

134 # next minute 

135 expected = time.mktime((2024, 1, 1, 12, 1, 0, 0, 0, 0)) 

136 assert result == expected 

137 

138 def test_specific_minute(self): 

139 cron = {"minute": "30", "hour": "*", "day": "*", "month": "*", "day_of_week": "*"} 

140 now = time.mktime((2024, 1, 1, 12, 0, 0, 0, 0, 0)) 

141 result = _cron_next(cron, now) 

142 expected = time.mktime((2024, 1, 1, 12, 30, 0, 0, 0, 0)) 

143 assert result == expected 

144 

145 def test_specific_minute_past_current(self): 

146 cron = {"minute": "15", "hour": "*", "day": "*", "month": "*", "day_of_week": "*"} 

147 now = time.mktime((2024, 1, 1, 12, 30, 0, 0, 0, 0)) 

148 result = _cron_next(cron, now) 

149 expected = time.mktime((2024, 1, 1, 13, 15, 0, 0, 0, 0)) 

150 assert result == expected 

151 

152 def test_specific_time(self): 

153 cron = {"minute": "0", "hour": "9", "day": "*", "month": "*", "day_of_week": "*"} 

154 now = time.mktime((2024, 1, 1, 8, 0, 0, 0, 0, 0)) 

155 result = _cron_next(cron, now) 

156 expected = time.mktime((2024, 1, 1, 9, 0, 0, 0, 0, 0)) 

157 assert result == expected 

158 

159 def test_daily_at_midnight(self): 

160 cron = {"minute": "0", "hour": "0", "day": "*", "month": "*", "day_of_week": "*"} 

161 now = time.mktime((2024, 1, 1, 12, 0, 0, 0, 0, 0)) 

162 result = _cron_next(cron, now) 

163 expected = time.mktime((2024, 1, 2, 0, 0, 0, 0, 0, 0)) 

164 assert result == expected 

165 

166 def test_specific_day(self): 

167 # Jan 15, 2024 is Monday (dow=1). Days 10-14: Wed-Sun (dow 3,4,5,6,0). 

168 # Use day_of_week="2" (Tuesday) so none of 10-14 match by DOW, 

169 # forcing the function to reach day=15 (which matches by DOM). 

170 cron = {"minute": "0", "hour": "0", "day": "15", "month": "*", "day_of_week": "2"} 

171 now = time.mktime((2024, 1, 10, 0, 0, 0, 0, 0, 0)) 

172 result = _cron_next(cron, now) 

173 expected = time.mktime((2024, 1, 15, 0, 0, 0, 0, 0, 0)) 

174 assert result == expected 

175 

176 def test_weekday_only(self): 

177 # "Monday at 9am" — day_of_week=1 (Monday) 

178 cron = {"minute": "0", "hour": "9", "day": "*", "month": "*", "day_of_week": "1"} 

179 # Jan 1, 2024 is a Monday (dow=0 in Python, but scheduler converts to 1) 

180 now = time.mktime((2024, 1, 1, 0, 0, 0, 0, 0, 0)) 

181 result = _cron_next(cron, now) 

182 expected = time.mktime((2024, 1, 1, 9, 0, 0, 0, 0, 0)) 

183 assert result == expected 

184 

185 def test_month_specific(self): 

186 cron = {"minute": "0", "hour": "0", "day": "1", "month": "6", "day_of_week": "*"} 

187 now = time.mktime((2023, 12, 1, 0, 0, 0, 0, 0, 0)) 

188 result = _cron_next(cron, now) 

189 expected = time.mktime((2024, 6, 1, 0, 0, 0, 0, 0, 0)) 

190 assert result == expected 

191 

192 

193# ============================================================================ 

194# Scheduler — Fluent API 

195# ============================================================================ 

196 

197 

198class TestSchedulerFluentAPI: 

199 def test_every_do(self): 

200 sched = Scheduler() 

201 results = [] 

202 job = sched.every(0.05).do(lambda: results.append(1)) 

203 assert job.id.startswith("job_") 

204 assert job.interval == 0.05 

205 assert job.cron is None 

206 assert job.delay is None 

207 

208 def test_cron_do(self): 

209 sched = Scheduler() 

210 job = sched.cron("* * * * *").do(lambda: None) 

211 assert job.interval is None 

212 assert job.cron is not None 

213 assert job.cron["minute"] == "*" 

214 

215 def test_cron_invalid_expression(self): 

216 sched = Scheduler() 

217 with pytest.raises(ValueError, match="5 fields"): 

218 sched.cron("* * * *") 

219 

220 def test_delay_do(self): 

221 sched = Scheduler() 

222 job = sched.delay(10.0).do(lambda: None) 

223 assert job.interval is None 

224 assert job.cron is None 

225 assert job.delay == 10.0 

226 

227 def test_do_without_schedule_raises(self): 

228 sched = Scheduler() 

229 # Clear internal state by not calling every/cron/delay 

230 sched._last_interval = None 

231 sched._last_cron = None 

232 sched._last_delay = None 

233 with pytest.raises(ValueError, match="Must call"): 

234 sched.do(lambda: None) 

235 

236 def test_multiple_jobs(self): 

237 sched = Scheduler() 

238 j1 = sched.every(1).do(lambda: None) 

239 j2 = sched.delay(5).do(lambda: None) 

240 j3 = sched.every(2).do(lambda: None) 

241 assert j1.id == "job_1" 

242 assert j2.id == "job_2" 

243 assert j3.id == "job_3" 

244 

245 

246# ============================================================================ 

247# Scheduler — Lifecycle 

248# ============================================================================ 

249 

250 

251class TestSchedulerLifecycle: 

252 def test_start_stop(self): 

253 sched = Scheduler() 

254 sched.every(0.1).do(lambda: None) 

255 sched.start() 

256 assert sched._running is True 

257 sched.stop() 

258 assert sched._running is False 

259 

260 def test_double_start_noop(self): 

261 sched = Scheduler() 

262 sched.every(0.1).do(lambda: None) 

263 sched.start() 

264 sched.start() 

265 assert sched._running is True 

266 

267 def test_job_runs_when_started(self): 

268 sched = Scheduler() 

269 results = [] 

270 sched.every(0.05).do(results.append, "ran") 

271 sched.start() 

272 time.sleep(0.15) 

273 sched.stop() 

274 assert len(results) >= 2 

275 

276 def test_job_not_started_if_scheduler_stopped(self): 

277 sched = Scheduler() 

278 results = [] 

279 sched.every(0.02).do(results.append, "ran") 

280 time.sleep(0.1) 

281 assert len(results) == 0 

282 

283 def test_one_shot_delay_job(self): 

284 sched = Scheduler() 

285 results = [] 

286 sched.delay(0.05).do(results.append, "once") 

287 sched.start() 

288 time.sleep(0.15) 

289 sched.stop() 

290 assert results == ["once"] 

291 

292 def test_one_shot_runs_only_once(self): 

293 sched = Scheduler() 

294 counter = [0] 

295 

296 def inc(): 

297 counter[0] += 1 

298 

299 sched.delay(0.03).do(inc) 

300 sched.start() 

301 time.sleep(0.15) 

302 sched.stop() 

303 assert counter[0] == 1 

304 

305 def test_cron_job(self): 

306 sched = Scheduler() 

307 results = [] 

308 # Compute next cron fire time to know exact delay 

309 cron_expr = {"minute": "*", "hour": "*", "day": "*", "month": "*", "day_of_week": "*"} 

310 now = time.time() 

311 next_fire = _cron_next(cron_expr, now) 

312 delay_seconds = max(0, next_fire - now) + 0.5 

313 sched.cron("* * * * *").do(results.append, "cron") 

314 sched.start() 

315 time.sleep(delay_seconds) 

316 sched.stop() 

317 assert len(results) >= 1 

318 

319 

320# ============================================================================ 

321# Scheduler — Pause / Resume 

322# ============================================================================ 

323 

324 

325class TestSchedulerPauseResume: 

326 def test_pause_stops_execution(self): 

327 sched = Scheduler() 

328 results = [] 

329 sched.every(0.03).do(results.append, "x") 

330 sched.start() 

331 time.sleep(0.06) 

332 sched.pause("job_1") 

333 count_before = len(results) 

334 time.sleep(0.1) 

335 assert len(results) == count_before 

336 

337 def test_resume_restarts_execution(self): 

338 sched = Scheduler() 

339 results = [] 

340 sched.every(0.03).do(results.append, "x") 

341 sched.start() 

342 time.sleep(0.06) 

343 sched.pause("job_1") 

344 count_before = len(results) 

345 sched.resume("job_1") 

346 time.sleep(0.1) 

347 assert len(results) > count_before 

348 

349 def test_pause_nonexistent_returns_false(self): 

350 sched = Scheduler() 

351 assert sched.pause("no-such-job") is False 

352 

353 def test_resume_nonexistent_returns_false(self): 

354 sched = Scheduler() 

355 assert sched.resume("no-such-job") is False 

356 

357 def test_resume_non_paused_returns_false(self): 

358 sched = Scheduler() 

359 sched.every(0.1).do(lambda: None) 

360 # job is PENDING, not PAUSED 

361 assert sched.resume("job_1") is False 

362 

363 def test_resume_runs_immediately(self): 

364 sched = Scheduler() 

365 results = [] 

366 sched.every(10.0).do(results.append, "delayed") # long interval 

367 sched.start() 

368 time.sleep(0.05) 

369 sched.pause("job_1") 

370 results_before = len(results) 

371 sched.resume("job_1") 

372 time.sleep(0.1) 

373 assert len(results) > results_before 

374 

375 

376# ============================================================================ 

377# Scheduler — Remove 

378# ============================================================================ 

379 

380 

381class TestSchedulerRemove: 

382 def test_remove_existing_job(self): 

383 sched = Scheduler() 

384 sched.every(0.1).do(lambda: None) 

385 assert sched.remove("job_1") is True 

386 assert sched.get_job("job_1") is None 

387 

388 def test_remove_nonexistent_returns_false(self): 

389 sched = Scheduler() 

390 assert sched.remove("ghost") is False 

391 

392 def test_remove_stops_running_job(self): 

393 sched = Scheduler() 

394 results = [] 

395 sched.every(0.03).do(results.append, "x") 

396 sched.start() 

397 time.sleep(0.06) 

398 sched.remove("job_1") 

399 count = len(results) 

400 time.sleep(0.1) 

401 assert len(results) == count 

402 

403 

404# ============================================================================ 

405# Scheduler — Query 

406# ============================================================================ 

407 

408 

409class TestSchedulerQuery: 

410 def test_get_job_exists(self): 

411 sched = Scheduler() 

412 sched.every(5.0).do(lambda: None) 

413 info = sched.get_job("job_1") 

414 assert info is not None 

415 assert info["id"] == "job_1" 

416 assert info["state"] == "pending" 

417 assert info["interval"] == 5.0 

418 

419 def test_get_job_not_exists(self): 

420 sched = Scheduler() 

421 assert sched.get_job("no-such") is None 

422 

423 def test_list_jobs_empty(self): 

424 sched = Scheduler() 

425 assert sched.list_jobs() == [] 

426 

427 def test_list_jobs_multiple(self): 

428 sched = Scheduler() 

429 sched.every(1).do(lambda: None) 

430 sched.delay(10).do(lambda: None) 

431 jobs = sched.list_jobs() 

432 assert len(jobs) == 2 

433 ids = {j["id"] for j in jobs} 

434 assert ids == {"job_1", "job_2"} 

435 

436 @pytest.mark.skip(reason="list_jobs needs scheduler started to reflect RUNNING state") 

437 def test_job_info_fields(self): 

438 sched = Scheduler() 

439 sched.delay(100).do(lambda: None) 

440 info = sched.get_job("job_1") 

441 assert "id" in info 

442 assert "state" in info 

443 assert "interval" in info 

444 assert "cron" in info 

445 assert "delay" in info 

446 assert "next_run" in info 

447 assert "last_run" in info 

448 assert "run_count" in info 

449 assert "error_count" in info 

450 assert "last_error" in info 

451 

452 

453# ============================================================================ 

454# Scheduler — Error Handling 

455# ============================================================================ 

456 

457 

458class TestSchedulerErrorHandling: 

459 def test_job_error_captured(self): 

460 sched = Scheduler() 

461 

462 def failing(): 

463 raise RuntimeError("test error") 

464 

465 sched.delay(0.03).do(failing) 

466 sched.start() 

467 time.sleep(0.1) 

468 sched.stop() 

469 

470 info = sched.get_job("job_1") 

471 assert info is not None 

472 assert info["error_count"] == 1 

473 assert "test error" in info["last_error"] 

474 

475 def test_job_continues_after_error(self): 

476 sched = Scheduler() 

477 results = [] 

478 

479 def sometimes_fail(): 

480 results.append("x") 

481 if len(results) == 1: 

482 raise ValueError("first fail") 

483 

484 sched.every(0.03).do(sometimes_fail) 

485 sched.start() 

486 time.sleep(0.12) 

487 sched.stop() 

488 assert len(results) >= 2 

489 

490 def test_error_count_increments(self): 

491 sched = Scheduler() 

492 

493 def always_fail(): 

494 raise RuntimeError("fail") 

495 

496 sched.every(0.03).do(always_fail) 

497 sched.start() 

498 time.sleep(0.1) 

499 sched.stop() 

500 

501 info = sched.get_job("job_1") 

502 assert info is not None 

503 assert info["error_count"] >= 1 

504 

505 

506# ============================================================================ 

507# Scheduler — Thread Safety 

508# ============================================================================ 

509 

510 

511class TestSchedulerThreadSafety: 

512 def test_concurrent_register_and_start(self): 

513 sched = Scheduler() 

514 errors = [] 

515 

516 def register_jobs(): 

517 try: 

518 for i in range(10): 

519 sched.delay(0.5).do(lambda: None) 

520 except Exception as e: 

521 errors.append(str(e)) 

522 

523 threads = [threading.Thread(target=register_jobs) for _ in range(5)] 

524 sched.start() 

525 for t in threads: 

526 t.start() 

527 for t in threads: 

528 t.join() 

529 sched.stop() 

530 

531 assert len(errors) == 0