Skip to content

Commit

Permalink
sleep - refactor, add pause processor, non-flaky tests
Browse files Browse the repository at this point in the history
  • Loading branch information
IGalat committed Nov 11, 2024
1 parent b9cf0e2 commit c04fc91
Show file tree
Hide file tree
Showing 2 changed files with 143 additions and 84 deletions.
46 changes: 33 additions & 13 deletions src/tapper/controller/sleep_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ class StopTapperActionException(Exception):
"""Normal way to interrupt tapper's action. Will not cause error logs etc."""


def parse_sleep_time(length_of_time: float | str) -> float:
if isinstance(length_of_time, str):
sleep_time = common.parse_sleep_time(length_of_time)
elif isinstance(length_of_time, (float, int)):
sleep_time = length_of_time
else:
raise ValueError(f"tapper.sleep length_of_time {length_of_time} is invalid.")
if sleep_time < 0:
raise ValueError(f"tapper.sleep time {sleep_time} should not be negative.")
return sleep_time


@dataclass
class SleepCommandProcessor:
"""Implementation of tapper's "sleep" command."""
Expand All @@ -17,7 +29,10 @@ class SleepCommandProcessor:
"""How often the check is made for whether to continue sleeping."""
kill_check_fn: Callable[[], bool]
"""Checks whether action should be killed."""
_actual_sleep_fn: Callable[[float], None] = time.sleep
pause_check_fn: Callable[[], bool]
"""Checks whether action should be paused."""
actual_sleep_fn: Callable[[float], None] = time.sleep
get_time_fn: Callable[[], float] = time.perf_counter

def __post_init__(self) -> None:
if self.check_interval <= 0:
Expand All @@ -27,26 +42,31 @@ def __post_init__(self) -> None:

def sleep(self, length_of_time: float | str) -> None:
"""
Suspend execution for a length of time.
Ultimate function of this module and class.
Suspends execution for a length of time.
This is functionally identical to `time.sleep`, but tapper
has control needed to interrupt or pause this.
:param length_of_time: Either number (seconds),
or str seconds/millis like: "1s", "50ms".
"""
self.kill_if_required()
started_at = time.perf_counter()
time_s = (
common.parse_sleep_time(length_of_time)
if isinstance(length_of_time, str)
else length_of_time
)
if time_s is None or time_s < 0:
raise ValueError(f"sleep {length_of_time} is invalid")
finish_time = started_at + time_s
while (now := time.perf_counter()) < finish_time:
self._actual_sleep_fn(min(self.check_interval, finish_time - now))
self.pause_if_required()
sleep_time = parse_sleep_time(length_of_time)
while round(sleep_time, 3) > 0:
to_sleep = min(self.check_interval, sleep_time)
self.actual_sleep_fn(to_sleep)
sleep_time = sleep_time - to_sleep

operational_start_time = self.get_time_fn()
self.kill_if_required()
self.pause_if_required()
operational_total_time = self.get_time_fn() - operational_start_time
sleep_time = sleep_time - operational_total_time

def pause_if_required(self) -> None:
while self.pause_check_fn():
self.actual_sleep_fn(self.check_interval)

def kill_if_required(self) -> None:
if self.kill_check_fn():
Expand Down
181 changes: 110 additions & 71 deletions tests/tapper/controller/test_sleep_processor.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,42 @@
import time
from dataclasses import dataclass
from dataclasses import field
from typing import Any
from typing import Callable
from unittest.mock import call
from unittest.mock import MagicMock

import pytest
from tapper.controller.sleep_processor import SleepCommandProcessor
from tapper.controller.sleep_processor import StopTapperActionException


@dataclass
class SleepFixture:
sleep: Callable[[Any], None]
processor: SleepCommandProcessor
mock_actual_sleep: MagicMock | Callable[[Any], None]

def get_time_slept(self) -> float:
return sum(call.args[0] for call in self.mock_actual_sleep.call_args_list)


@pytest.fixture
def sleep_fixture() -> SleepFixture:
processor = SleepCommandProcessor(
check_interval=1,
kill_check_fn=MagicMock(return_value=False),
pause_check_fn=MagicMock(return_value=False),
actual_sleep_fn=MagicMock(),
get_time_fn=MagicMock(return_value=0),
)
return SleepFixture(
processor=processor,
sleep=processor.sleep,
mock_actual_sleep=processor.actual_sleep_fn,
)


@dataclass
class Counter:
count: int = 0
Expand All @@ -22,97 +51,107 @@ def tick(self) -> bool:
return self.result


def assert_time_equals(time_1: float, time_2: float) -> None:
assert abs(time_1 - time_2) < 0.05
class TestSleep:
def test_simplest(self, sleep_fixture: SleepFixture) -> None:
sleep_fixture.sleep(123)
assert sleep_fixture.get_time_slept() == 123
assert sleep_fixture.processor.pause_check_fn.call_args_list != []
assert sleep_fixture.processor.kill_check_fn.call_args_list != []

def test_zero_time(self, sleep_fixture: SleepFixture) -> None:
sleep_fixture.sleep(0)
assert sleep_fixture.get_time_slept() == 0
assert sleep_fixture.processor.pause_check_fn.call_args_list != []
assert sleep_fixture.processor.kill_check_fn.call_args_list != []

class TestSleepProcessor:
def test_simplest(self) -> None:
processor = SleepCommandProcessor(
check_interval=1,
kill_check_fn=lambda: False,
)

time_start = time.perf_counter()
processor.sleep(0)
assert_time_equals(time_start, time.perf_counter())

def test_immediate_kill(self) -> None:
counter = Counter(result=True)
processor = SleepCommandProcessor(check_interval=1, kill_check_fn=counter.tick)
def test_negative_time(self, sleep_fixture: SleepFixture) -> None:
with pytest.raises(ValueError):
sleep_fixture.sleep(-1)

def test_correct_number_of_sleeps(self, sleep_fixture: SleepFixture) -> None:
sleep_fixture.processor.check_interval = 0.05
sleep_fixture.sleep(0.4)
assert sleep_fixture.get_time_slept() == 0.4
assert sleep_fixture.mock_actual_sleep.call_args_list == [call(0.05)] * 8

def test_check_interval_bigger_than_sleep_time(
self, sleep_fixture: SleepFixture
) -> None:
sleep_fixture.processor.check_interval = 1
sleep_fixture.sleep(0.02)
assert sleep_fixture.get_time_slept() == 0.02
assert sleep_fixture.processor.pause_check_fn.call_args_list != []
assert sleep_fixture.processor.kill_check_fn.call_args_list != []

def test_time_str_seconds(self, sleep_fixture: SleepFixture) -> None:
sleep_fixture.sleep("2.1s")
assert sleep_fixture.get_time_slept() == 2.1

def test_time_str_millis(self, sleep_fixture: SleepFixture) -> None:
sleep_fixture.sleep("20ms")
assert sleep_fixture.get_time_slept() == 0.02

def test_repeat_sleep(self, sleep_fixture: SleepFixture) -> None:
sleep_fixture.sleep(123)
sleep_fixture.sleep(".123s")
assert sleep_fixture.get_time_slept() == 123.123


class TestSleepKill:
def test_immediate_kill(self, sleep_fixture: SleepFixture) -> None:
sleep_fixture.processor.kill_check_fn = lambda: True
with pytest.raises(StopTapperActionException):
processor.sleep(20)
sleep_fixture.sleep(20)

def test_zero_time(self) -> None:
counter = Counter()
processor = SleepCommandProcessor(
check_interval=1,
kill_check_fn=counter.tick,
def test_killed_after_some_time_interval(self, sleep_fixture: SleepFixture) -> None:
sleep_fixture.processor.check_interval = 0.01
sleep_fixture.processor.kill_check_fn.side_effect = lambda: bool(
sleep_fixture.get_time_slept() >= 1.234
)
processor.sleep(0)
assert counter.count == 1
with pytest.raises(StopTapperActionException):
sleep_fixture.sleep(10)
assert sleep_fixture.get_time_slept() == pytest.approx(1.234, abs=0.01)
assert len(sleep_fixture.processor.kill_check_fn.call_args_list) >= 123

def test_negative_time(self) -> None:
processor = SleepCommandProcessor(
check_interval=1,
kill_check_fn=lambda: False,
)
with pytest.raises(ValueError):
processor.sleep(-1)

def test_correct_time_and_number_of_checks(self) -> None:
counter = Counter()
processor = SleepCommandProcessor(
check_interval=0.05,
kill_check_fn=counter.tick,
class TestSleepPause:
def test_time_slept_extends_with_pause(self, sleep_fixture: SleepFixture) -> None:
sleep_fixture.processor.check_interval = 0.1
sleep_fixture.processor.pause_check_fn.side_effect = lambda: bool(
1 <= sleep_fixture.get_time_slept() <= 8
)
sleep_fixture.sleep(10)
assert sleep_fixture.get_time_slept() == pytest.approx(17, abs=0.2)

time_start = time.perf_counter()
processor.sleep(0.1)
assert counter.count == 3 # 1 check at the start and 2 intervals
assert_time_equals(time_start + 0.1, time.perf_counter())

def test_check_interval_bigger_than_sleep_time(self) -> None:
counter = Counter()
processor = SleepCommandProcessor(
check_interval=1,
kill_check_fn=counter.tick,
)
def assert_time_equals(time_1: float, time_2: float) -> None:
assert abs(time_1 - time_2) < 0.05

time_start = time.perf_counter()
processor.sleep(0.02)
assert counter.count == 2
assert_time_equals(time_start + 0.02, time.perf_counter())

def test_time_str_seconds(self) -> None:
class TestRealSleep:
def test_zero_time(self) -> None:
processor = SleepCommandProcessor(
check_interval=0.01,
check_interval=1,
kill_check_fn=lambda: False,
pause_check_fn=lambda: False,
)

time_start = time.perf_counter()
processor.sleep("0.02s")
assert_time_equals(time_start + 0.02, time.perf_counter())
processor.sleep(0)
assert_time_equals(time_start, time.perf_counter())

def test_time_str_millis(self) -> None:
def test_correct_time_slept(self) -> None:
kill_c = Counter()
pause_c = Counter()
processor = SleepCommandProcessor(
check_interval=0.01,
kill_check_fn=lambda: False,
check_interval=0.05,
kill_check_fn=kill_c.tick,
pause_check_fn=pause_c.tick,
)

time_start = time.perf_counter()
processor.sleep("20ms")
assert_time_equals(time_start + 0.02, time.perf_counter())

def test_killed_after_3_interval(self) -> None:
counter = Counter(at_3=lambda: True)
processor = SleepCommandProcessor(
check_interval=0.01,
kill_check_fn=counter.tick,
)
processor.sleep(0.1)

time_start = time.perf_counter()
with pytest.raises(StopTapperActionException):
processor.sleep(1)
assert counter.count == 3 # 1 initial and 2 sleeps
assert_time_equals(time_start + 0.02, time.perf_counter())
assert_time_equals(time_start + 0.1, time.perf_counter())
assert kill_c.count >= 2
assert pause_c.count >= 2

0 comments on commit c04fc91

Please sign in to comment.