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
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-09 10:28 +0800
1"""Comprehensive tests for agentos/tools/scheduler.py."""
3import threading
4import time
6import pytest
8from agentos.tools.scheduler import (
9 Job,
10 JobState,
11 Scheduler,
12 _cron_next,
13 _parse_cron_field,
14)
16# ============================================================================
17# Job & JobState
18# ============================================================================
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"
29class TestJob:
30 def test_minimal_construction(self):
31 def dummy():
32 pass
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
49 def test_full_construction(self):
50 def dummy():
51 pass
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"
78# ============================================================================
79# _parse_cron_field
80# ============================================================================
83class TestParseCronField:
84 def test_wildcard(self):
85 result = _parse_cron_field("*", 0, 59)
86 assert result == set(range(0, 60))
88 def test_single_value(self):
89 result = _parse_cron_field("5", 0, 59)
90 assert result == {5}
92 def test_comma_separated(self):
93 result = _parse_cron_field("1,5,10", 0, 59)
94 assert result == {1, 5, 10}
96 def test_step(self):
97 result = _parse_cron_field("*/15", 0, 59)
98 expected = {0, 15, 30, 45}
99 assert result == expected
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
106 def test_range(self):
107 result = _parse_cron_field("10-15", 0, 59)
108 assert result == {10, 11, 12, 13, 14, 15}
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
115 def test_hour_field(self):
116 result = _parse_cron_field("0,12", 0, 23)
117 assert result == {0, 12}
119 def test_dom_field(self):
120 result = _parse_cron_field("1,15", 1, 31)
121 assert result == {1, 15}
124# ============================================================================
125# _cron_next
126# ============================================================================
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
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
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
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
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
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
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
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
193# ============================================================================
194# Scheduler — Fluent API
195# ============================================================================
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
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"] == "*"
215 def test_cron_invalid_expression(self):
216 sched = Scheduler()
217 with pytest.raises(ValueError, match="5 fields"):
218 sched.cron("* * * *")
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
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)
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"
246# ============================================================================
247# Scheduler — Lifecycle
248# ============================================================================
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
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
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
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
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"]
292 def test_one_shot_runs_only_once(self):
293 sched = Scheduler()
294 counter = [0]
296 def inc():
297 counter[0] += 1
299 sched.delay(0.03).do(inc)
300 sched.start()
301 time.sleep(0.15)
302 sched.stop()
303 assert counter[0] == 1
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
320# ============================================================================
321# Scheduler — Pause / Resume
322# ============================================================================
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
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
349 def test_pause_nonexistent_returns_false(self):
350 sched = Scheduler()
351 assert sched.pause("no-such-job") is False
353 def test_resume_nonexistent_returns_false(self):
354 sched = Scheduler()
355 assert sched.resume("no-such-job") is False
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
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
376# ============================================================================
377# Scheduler — Remove
378# ============================================================================
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
388 def test_remove_nonexistent_returns_false(self):
389 sched = Scheduler()
390 assert sched.remove("ghost") is False
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
404# ============================================================================
405# Scheduler — Query
406# ============================================================================
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
419 def test_get_job_not_exists(self):
420 sched = Scheduler()
421 assert sched.get_job("no-such") is None
423 def test_list_jobs_empty(self):
424 sched = Scheduler()
425 assert sched.list_jobs() == []
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"}
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
453# ============================================================================
454# Scheduler — Error Handling
455# ============================================================================
458class TestSchedulerErrorHandling:
459 def test_job_error_captured(self):
460 sched = Scheduler()
462 def failing():
463 raise RuntimeError("test error")
465 sched.delay(0.03).do(failing)
466 sched.start()
467 time.sleep(0.1)
468 sched.stop()
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"]
475 def test_job_continues_after_error(self):
476 sched = Scheduler()
477 results = []
479 def sometimes_fail():
480 results.append("x")
481 if len(results) == 1:
482 raise ValueError("first fail")
484 sched.every(0.03).do(sometimes_fail)
485 sched.start()
486 time.sleep(0.12)
487 sched.stop()
488 assert len(results) >= 2
490 def test_error_count_increments(self):
491 sched = Scheduler()
493 def always_fail():
494 raise RuntimeError("fail")
496 sched.every(0.03).do(always_fail)
497 sched.start()
498 time.sleep(0.1)
499 sched.stop()
501 info = sched.get_job("job_1")
502 assert info is not None
503 assert info["error_count"] >= 1
506# ============================================================================
507# Scheduler — Thread Safety
508# ============================================================================
511class TestSchedulerThreadSafety:
512 def test_concurrent_register_and_start(self):
513 sched = Scheduler()
514 errors = []
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))
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()
531 assert len(errors) == 0