Skip to content

Commit a9912a6

Browse files
committed
Add more tests to the PeriodicTimer
We use the hypothesis library to test policies more thoroughly and add some more tests for timer construction and most notably a test for a timer using the `SkipMissedAndResync` policy. This commit also include some minor naming and documention improvements in existing tests. Signed-off-by: Leandro Lucarella <[email protected]>
1 parent 1371f52 commit a9912a6

File tree

2 files changed

+282
-8
lines changed

2 files changed

+282
-8
lines changed

noxfile.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ def pylint(session: nox.Session) -> None:
3535
"pytest",
3636
"nox",
3737
"async-solipsism",
38+
"hypothesis",
3839
)
3940
session.run("pylint", *check_dirs, *check_files)
4041

@@ -49,6 +50,7 @@ def mypy(session: nox.Session) -> None:
4950
"nox",
5051
"mypy",
5152
"async-solipsism",
53+
"hypothesis",
5254
)
5355

5456
common_args = [
@@ -92,6 +94,7 @@ def pytest(session: nox.Session) -> None:
9294
"pytest-mock",
9395
"pytest-asyncio",
9496
"async-solipsism",
97+
"hypothesis",
9598
)
9699
session.install("-e", ".")
97100
session.run(

tests/utils/test_periodic_timer.py

Lines changed: 279 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,16 @@
1111
from datetime import timedelta
1212

1313
import async_solipsism
14+
import hypothesis
1415
import 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

Comments
 (0)