1111from datetime import timedelta
1212
1313import async_solipsism
14+ import hypothesis
1415import pytest
16+ from hypothesis import strategies as st
1517
16- from frequenz .channels .util import PeriodicTimer , SkipMissedAndDrift , TriggerAllMissed
18+ from frequenz .channels .util import (
19+ PeriodicTimer ,
20+ SkipMissedAndDrift ,
21+ SkipMissedAndResync ,
22+ TriggerAllMissed ,
23+ )
1724
1825
1926# Setting 'autouse' has no effect as this method replaces the event loop for all tests in the file.
@@ -25,7 +32,187 @@ def event_loop() -> Iterator[async_solipsism.EventLoop]:
2532 loop .close ()
2633
2734
28- async def test_contruction () -> None :
35+ _max_timedelta_microseconds = (
36+ int (
37+ timedelta .max .total_seconds () * 1_000_000 ,
38+ )
39+ - 1
40+ )
41+
42+ _min_timedelta_microseconds = (
43+ int (
44+ timedelta .min .total_seconds () * 1_000_000 ,
45+ )
46+ + 1
47+ )
48+
49+ _calculate_next_tick_time_args = {
50+ "now" : st .integers (),
51+ "scheduled_tick_time" : st .integers (),
52+ "interval" : st .integers (min_value = 1 , max_value = _max_timedelta_microseconds ),
53+ }
54+
55+
56+ def _assert_tick_is_aligned (
57+ next_tick_time : int , now : int , scheduled_tick_time : int , interval : int
58+ ) -> None :
59+ # Can be equals if scheduled_tick_time == now
60+ assert next_tick_time <= now + interval
61+ assert (next_tick_time - scheduled_tick_time ) % interval == pytest .approx (0.0 )
62+
63+
64+ @hypothesis .given (** _calculate_next_tick_time_args )
65+ def test_policy_trigger_all_missed (
66+ now : int , scheduled_tick_time : int , interval : int
67+ ) -> None :
68+ """Test the TriggerAllMissed policy."""
69+ hypothesis .assume (now >= scheduled_tick_time )
70+ assert (
71+ TriggerAllMissed ().calculate_next_tick_time (
72+ now = now , interval = interval , scheduled_tick_time = scheduled_tick_time
73+ )
74+ == scheduled_tick_time + interval
75+ )
76+
77+
78+ def test_policy_trigger_all_missed_examples () -> None :
79+ """Test the TriggerAllMissed policy with minimal examples.
80+
81+ This is just a sanity check to make sure we are not missing to test any important
82+ properties with the hypothesis tests.
83+ """
84+ policy = TriggerAllMissed ()
85+ assert (
86+ policy .calculate_next_tick_time (
87+ now = 10_200_000 , scheduled_tick_time = 9_000_000 , interval = 1_000_000
88+ )
89+ == 10_000_000
90+ )
91+ assert (
92+ policy .calculate_next_tick_time (
93+ now = 10_000_000 , scheduled_tick_time = 9_000_000 , interval = 1_000_000
94+ )
95+ == 10_000_000
96+ )
97+ assert (
98+ policy .calculate_next_tick_time (
99+ now = 10_500_000 , scheduled_tick_time = 1_000_000 , interval = 1_000_000
100+ )
101+ == 2_000_000
102+ )
103+
104+
105+ @hypothesis .given (** _calculate_next_tick_time_args )
106+ def test_policy_skip_missed_and_resync (
107+ now : int , scheduled_tick_time : int , interval : int
108+ ) -> None :
109+ """Test the SkipMissedAndResync policy."""
110+ hypothesis .assume (now >= scheduled_tick_time )
111+
112+ next_tick_time = SkipMissedAndResync ().calculate_next_tick_time (
113+ now = now , interval = interval , scheduled_tick_time = scheduled_tick_time
114+ )
115+ assert next_tick_time > now
116+ _assert_tick_is_aligned (next_tick_time , now , scheduled_tick_time , interval )
117+
118+
119+ def test_policy_skip_missed_and_resync_examples () -> None :
120+ """Test the SkipMissedAndResync policy with minimal examples.
121+
122+ This is just a sanity check to make sure we are not missing to test any important
123+ properties with the hypothesis tests.
124+ """
125+ policy = SkipMissedAndResync ()
126+ assert (
127+ policy .calculate_next_tick_time (
128+ now = 10_200_000 , scheduled_tick_time = 9_000_000 , interval = 1_000_000
129+ )
130+ == 11_000_000
131+ )
132+ assert (
133+ policy .calculate_next_tick_time (
134+ now = 10_000_000 , scheduled_tick_time = 9_000_000 , interval = 1_000_000
135+ )
136+ == 11_000_000
137+ )
138+ assert (
139+ policy .calculate_next_tick_time (
140+ now = 10_500_000 , scheduled_tick_time = 1_000_000 , interval = 1_000_000
141+ )
142+ == 11_000_000
143+ )
144+
145+
146+ @hypothesis .given (
147+ tolerance = st .integers (min_value = _min_timedelta_microseconds , max_value = - 1 )
148+ )
149+ def test_policy_skip_missed_and_drift_invalid_tolerance (tolerance : int ) -> None :
150+ """Test the SkipMissedAndDrift policy raises an error for invalid tolerances."""
151+ with pytest .raises (ValueError , match = "delay_tolerance must be positive" ):
152+ SkipMissedAndDrift (delay_tolerance = timedelta (microseconds = tolerance ))
153+
154+
155+ @hypothesis .given (
156+ tolerance = st .integers (min_value = 0 , max_value = _max_timedelta_microseconds ),
157+ ** _calculate_next_tick_time_args ,
158+ )
159+ def test_policy_skip_missed_and_drift (
160+ tolerance : int , now : int , scheduled_tick_time : int , interval : int
161+ ) -> None :
162+ """Test the SkipMissedAndDrift policy."""
163+ hypothesis .assume (now >= scheduled_tick_time )
164+
165+ next_tick_time = SkipMissedAndDrift (
166+ delay_tolerance = timedelta (microseconds = tolerance )
167+ ).calculate_next_tick_time (
168+ now = now , interval = interval , scheduled_tick_time = scheduled_tick_time
169+ )
170+ if tolerance <= interval :
171+ assert next_tick_time > now
172+ drift = now - scheduled_tick_time
173+ if drift > tolerance :
174+ assert next_tick_time == now + interval
175+ else :
176+ _assert_tick_is_aligned (next_tick_time , now , scheduled_tick_time , interval )
177+
178+
179+ def test_policy_skip_missed_and_drift_examples () -> None :
180+ """Test the SkipMissedAndDrift policy with minimal examples.
181+
182+ This is just a sanity check to make sure we are not missing to test any important
183+ properties with the hypothesis tests.
184+ """
185+ tolerance = 100_000
186+ policy = SkipMissedAndDrift (delay_tolerance = timedelta (microseconds = tolerance ))
187+ assert (
188+ policy .calculate_next_tick_time (
189+ now = 10_200_000 , scheduled_tick_time = 9_000_000 , interval = 1_000_000
190+ )
191+ == 11_200_000
192+ )
193+ assert (
194+ policy .calculate_next_tick_time (
195+ now = 10_000_000 , scheduled_tick_time = 9_000_000 , interval = 1_000_000
196+ )
197+ == 11_000_000
198+ )
199+ assert (
200+ policy .calculate_next_tick_time (
201+ now = 10_500_000 , scheduled_tick_time = 1_000_000 , interval = 1_000_000
202+ )
203+ == 11_500_000
204+ )
205+ assert (
206+ policy .calculate_next_tick_time (
207+ now = 10_000_000 + tolerance ,
208+ scheduled_tick_time = 10_000_000 ,
209+ interval = 1_000_000 ,
210+ )
211+ == 11_000_000
212+ )
213+
214+
215+ async def test_timer_contruction_defaults () -> None :
29216 """Test the construction of a periodic timer with default values."""
30217 timer = PeriodicTimer (timedelta (seconds = 1.0 ))
31218 assert timer .interval == timedelta (seconds = 1.0 )
@@ -34,7 +221,23 @@ async def test_contruction() -> None:
34221 assert timer .is_running is True
35222
36223
37- async def test_contruction_auto_start () -> None :
224+ def test_timer_contruction_no_async () -> None :
225+ """Test the construction outside of async (using a custom loop)."""
226+ loop = async_solipsism .EventLoop ()
227+ timer = PeriodicTimer (timedelta (seconds = 1.0 ), loop = loop )
228+ assert timer .interval == timedelta (seconds = 1.0 )
229+ assert isinstance (timer .missed_tick_policy , TriggerAllMissed )
230+ assert timer .loop is loop
231+ assert timer .is_running is True
232+
233+
234+ def test_timer_contruction_no_event_loop () -> None :
235+ """Test the construction outside of async (without a custom loop) fails."""
236+ with pytest .raises (RuntimeError , match = "no running event loop" ):
237+ PeriodicTimer (timedelta (seconds = 1.0 ))
238+
239+
240+ async def test_timer_contruction_auto_start () -> None :
38241 """Test the construction of a periodic timer with auto_start=False."""
39242 policy = TriggerAllMissed ()
40243 timer = PeriodicTimer (
@@ -49,7 +252,22 @@ async def test_contruction_auto_start() -> None:
49252 assert timer .is_running is False
50253
51254
52- async def test_autostart (
255+ async def test_timer_contruction_custom_args () -> None :
256+ """Test the construction of a periodic timer with custom arguments."""
257+ policy = TriggerAllMissed ()
258+ timer = PeriodicTimer (
259+ timedelta (seconds = 5.0 ),
260+ auto_start = True ,
261+ missed_tick_policy = policy ,
262+ loop = None ,
263+ )
264+ assert timer .interval == timedelta (seconds = 5.0 )
265+ assert timer .missed_tick_policy is policy
266+ assert timer .loop is asyncio .get_running_loop ()
267+ assert timer .is_running is True
268+
269+
270+ async def test_timer_autostart (
53271 event_loop : async_solipsism .EventLoop , # pylint: disable=redefined-outer-name
54272) -> None :
55273 """Test the autostart of a periodic timer."""
@@ -72,7 +290,7 @@ class _StartMethod(enum.Enum):
72290
73291
74292@pytest .mark .parametrize ("start_method" , list (_StartMethod ))
75- async def test_no_autostart (
293+ async def test_timer_no_autostart (
76294 start_method : _StartMethod ,
77295 event_loop : async_solipsism .EventLoop , # pylint: disable=redefined-outer-name
78296) -> None :
@@ -106,7 +324,7 @@ async def test_no_autostart(
106324 assert event_loop .time () == pytest .approx (1.5 )
107325
108326
109- async def test_trigger_all (
327+ async def test_timer_trigger_all_missed (
110328 event_loop : async_solipsism .EventLoop , # pylint: disable=redefined-outer-name
111329) -> None :
112330 """Test the trigger all missed behavior."""
@@ -167,10 +385,63 @@ async def test_trigger_all(
167385 assert drift == pytest .approx (timedelta (seconds = 0.0 ))
168386
169387
170- async def test_skip_and_drift (
388+ async def test_timer_skip_missed_and_resync (
389+ event_loop : async_solipsism .EventLoop , # pylint: disable=redefined-outer-name
390+ ) -> None :
391+ """Test a timer using the SkipMissedAndResync policy."""
392+ interval = 1.0
393+ timer = PeriodicTimer (
394+ timedelta (seconds = interval ), missed_tick_policy = SkipMissedAndResync ()
395+ )
396+
397+ # We let the first tick be triggered on time
398+ drift = await timer .receive ()
399+ assert event_loop .time () == pytest .approx (interval )
400+ assert drift == pytest .approx (timedelta (seconds = 0.0 ))
401+
402+ # Now we let the time pass interval plus a bit more, so we should get
403+ # a drift, but the next tick should be triggered still at a multiple of the
404+ # interval because we are using TRIGGER_ALL.
405+ await asyncio .sleep (interval + 0.1 )
406+ drift = await timer .receive ()
407+ assert event_loop .time () == pytest .approx (interval * 2 + 0.1 )
408+ assert drift == pytest .approx (timedelta (seconds = 0.1 ))
409+ drift = await timer .receive ()
410+ assert event_loop .time () == pytest .approx (interval * 3 )
411+ assert drift == pytest .approx (timedelta (seconds = 0.0 ))
412+
413+ # Now we let the time pass by two times the interval, so we should get
414+ # a drift of a whole interval and then next tick should an interval later,
415+ # as the delayed tick will be skipped and the timer will resync.
416+ await asyncio .sleep (2 * interval )
417+ drift = await timer .receive ()
418+ assert event_loop .time () == pytest .approx (interval * 5 )
419+ assert drift == pytest .approx (timedelta (seconds = interval ))
420+ drift = await timer .receive ()
421+ assert event_loop .time () == pytest .approx (interval * 6 )
422+ assert drift == pytest .approx (timedelta (seconds = 0.0 ))
423+
424+ # Finally we let the time pass by 5 times the interval plus some extra
425+ # delay. The timer should fire immediately once with a drift of 4 intervals
426+ # plus the extra delay, and then it should resync and fire again with no
427+ # drift, skipping the missed ticks.
428+ extra_delay = 0.8
429+ await asyncio .sleep (5 * interval + extra_delay )
430+ drift = await timer .receive ()
431+ assert event_loop .time () == pytest .approx (interval * 11 + extra_delay )
432+ assert drift == pytest .approx (timedelta (seconds = interval * 4 + extra_delay ))
433+ drift = await timer .receive ()
434+ assert event_loop .time () == pytest .approx (interval * 12 )
435+ assert drift == pytest .approx (timedelta (seconds = 0.0 ))
436+ drift = await timer .receive ()
437+ assert event_loop .time () == pytest .approx (interval * 13 )
438+ assert drift == pytest .approx (timedelta (seconds = 0.0 ))
439+
440+
441+ async def test_timer_skip_missed_and_drift (
171442 event_loop : async_solipsism .EventLoop , # pylint: disable=redefined-outer-name
172443) -> None :
173- """Test the skip missed and drift behavior ."""
444+ """Test a timer using the SkipMissedAndDrift policy ."""
174445 interval = 1.0
175446 tolerance = 0.1
176447 timer = PeriodicTimer (
0 commit comments