test SafeEyesCore: add handling for condvar, add explanation, check exact times
This commit is contained in:
parent
d43e8dc8bb
commit
a823848a91
|
@ -23,14 +23,58 @@ class TestSafeEyesCore:
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def sequential_threading(self, monkeypatch, time_machine):
|
def sequential_threading(self, monkeypatch, time_machine):
|
||||||
# executes instantly
|
"""This fixture allows stopping threads at any point.
|
||||||
# TODO: separate thread?
|
|
||||||
|
It is hard-coded for SafeEyesCore, the handle class returned by the fixture must be initialized
|
||||||
|
with a SafeEyesCore instance to be patched.
|
||||||
|
With this, all sleeping/blocking/thread starting calls inside SafeEyesCore are intercepted, and paused.
|
||||||
|
Additionally, all threads inside SafeEyesCore run sequentially.
|
||||||
|
The test code can use the next() method to unpause the thread,
|
||||||
|
which will run until the next sleeping/blocking/thread starting call,
|
||||||
|
after which it needs to be woken up using next() again.
|
||||||
|
The next() method blocks the test code while the thread is running.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# executes instantly, on the same thread
|
||||||
|
# no need to switch threads, as we don't use any gtk things
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
core.utility,
|
core.utility,
|
||||||
"execute_main_thread",
|
"execute_main_thread",
|
||||||
lambda target_function, *args, **kwargs: target_function(*args, **kwargs)
|
lambda target_function, *args, **kwargs: target_function(*args, **kwargs)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
handle = None
|
||||||
|
|
||||||
|
def utility_start_thread(target_function, **kwargs):
|
||||||
|
if not handle:
|
||||||
|
raise Exception("handle must be initialized before first thread")
|
||||||
|
handle.utility_start_thread(target_function, **kwargs)
|
||||||
|
|
||||||
|
def time_sleep(time):
|
||||||
|
if not handle:
|
||||||
|
raise Exception("handle must be initialized before first sleep call")
|
||||||
|
handle.sleep(time)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
core.utility,
|
||||||
|
"start_thread",
|
||||||
|
utility_start_thread
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
core.time,
|
||||||
|
"sleep",
|
||||||
|
time_sleep
|
||||||
|
)
|
||||||
|
|
||||||
|
class PatchedCondition(threading.Condition):
|
||||||
|
def __init__(self, handle):
|
||||||
|
super().__init__()
|
||||||
|
self.handle = handle
|
||||||
|
|
||||||
|
def wait(self, timeout):
|
||||||
|
self.handle.wait_condvar(timeout)
|
||||||
|
|
||||||
|
|
||||||
class Handle:
|
class Handle:
|
||||||
thread = None
|
thread = None
|
||||||
|
@ -39,8 +83,16 @@ class TestSafeEyesCore:
|
||||||
condvar_in = threading.Condition()
|
condvar_in = threading.Condition()
|
||||||
condvar_out = threading.Condition()
|
condvar_out = threading.Condition()
|
||||||
|
|
||||||
def __init__(self, time_machine):
|
def __init__(self, safe_eyes_core):
|
||||||
|
nonlocal handle
|
||||||
|
nonlocal time_machine
|
||||||
|
if handle:
|
||||||
|
raise Exception("only one handle is allowed per test call")
|
||||||
|
handle = self
|
||||||
self.time_machine = time_machine
|
self.time_machine = time_machine
|
||||||
|
self.safe_eyes_core = safe_eyes_core
|
||||||
|
self.safe_eyes_core.waiting_condition = PatchedCondition(self)
|
||||||
|
|
||||||
|
|
||||||
def background_thread(self):
|
def background_thread(self):
|
||||||
while True:
|
while True:
|
||||||
|
@ -50,11 +102,8 @@ class TestSafeEyesCore:
|
||||||
raise Exception("thread timed out")
|
raise Exception("thread timed out")
|
||||||
|
|
||||||
if not self.running:
|
if not self.running:
|
||||||
logging.debug(f"background task shutdown")
|
|
||||||
break
|
break
|
||||||
|
|
||||||
logging.debug(f"background task woken up")
|
|
||||||
|
|
||||||
if self.task_queue:
|
if self.task_queue:
|
||||||
(target_function, kwargs) = self.task_queue.popleft()
|
(target_function, kwargs) = self.task_queue.popleft()
|
||||||
logging.debug(f"thread started {target_function}")
|
logging.debug(f"thread started {target_function}")
|
||||||
|
@ -74,11 +123,23 @@ class TestSafeEyesCore:
|
||||||
if not success:
|
if not success:
|
||||||
raise Exception("thread timed out")
|
raise Exception("thread timed out")
|
||||||
|
|
||||||
|
def wait_condvar(self, time):
|
||||||
|
if self.thread is not threading.current_thread():
|
||||||
|
raise Exception("waiting on condition may only happen in thread")
|
||||||
|
|
||||||
|
with self.condvar_out:
|
||||||
|
self.condvar_out.notify()
|
||||||
|
self.time_machine.shift(delta=datetime.timedelta(seconds=time))
|
||||||
|
with self.condvar_in:
|
||||||
|
success = self.condvar_in.wait(1)
|
||||||
|
if not success:
|
||||||
|
raise Exception("thread timed out")
|
||||||
|
|
||||||
def utility_start_thread(self, target_function, **kwargs):
|
def utility_start_thread(self, target_function, **kwargs):
|
||||||
self.task_queue.append((target_function, kwargs))
|
self.task_queue.append((target_function, kwargs))
|
||||||
|
|
||||||
if self.thread is None:
|
if self.thread is None:
|
||||||
self.thread = threading.Thread(target=self.background_thread, name="WorkThread", daemon=False, kwargs=kwargs)
|
self.thread = threading.Thread(target=self.background_thread, name="SequentialThreadingRunner", daemon=False, kwargs=kwargs)
|
||||||
self.thread.start()
|
self.thread.start()
|
||||||
|
|
||||||
def next(self):
|
def next(self):
|
||||||
|
@ -87,7 +148,6 @@ class TestSafeEyesCore:
|
||||||
with self.condvar_in:
|
with self.condvar_in:
|
||||||
self.condvar_in.notify()
|
self.condvar_in.notify()
|
||||||
|
|
||||||
def wait(self):
|
|
||||||
# wait until done:
|
# wait until done:
|
||||||
with self.condvar_out:
|
with self.condvar_out:
|
||||||
success = self.condvar_out.wait(1)
|
success = self.condvar_out.wait(1)
|
||||||
|
@ -102,22 +162,9 @@ class TestSafeEyesCore:
|
||||||
if self.thread:
|
if self.thread:
|
||||||
self.thread.join(1)
|
self.thread.join(1)
|
||||||
|
|
||||||
handle = Handle(time_machine=time_machine)
|
yield Handle
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
core.utility,
|
|
||||||
"start_thread",
|
|
||||||
handle.utility_start_thread
|
|
||||||
)
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
core.time,
|
|
||||||
"sleep",
|
|
||||||
lambda time: handle.sleep(time)
|
|
||||||
)
|
|
||||||
|
|
||||||
yield handle
|
|
||||||
|
|
||||||
|
if handle:
|
||||||
handle.stop()
|
handle.stop()
|
||||||
|
|
||||||
def test_create_empty(self):
|
def test_create_empty(self):
|
||||||
|
@ -190,12 +237,13 @@ class TestSafeEyesCore:
|
||||||
|
|
||||||
safe_eyes_core.initialize(config)
|
safe_eyes_core.initialize(config)
|
||||||
|
|
||||||
|
sequential_threading_handle = sequential_threading(safe_eyes_core)
|
||||||
|
|
||||||
safe_eyes_core.start()
|
safe_eyes_core.start()
|
||||||
|
|
||||||
# start __scheduler_job
|
# start __scheduler_job
|
||||||
sequential_threading.next()
|
sequential_threading_handle.next()
|
||||||
# FIXME: sleep is needed so code reaches the waiting_condition
|
|
||||||
sleep(0.1)
|
|
||||||
assert context['state'] == model.State.WAITING
|
assert context['state'] == model.State.WAITING
|
||||||
|
|
||||||
on_update_next_break.assert_called_once()
|
on_update_next_break.assert_called_once()
|
||||||
|
@ -203,32 +251,21 @@ class TestSafeEyesCore:
|
||||||
assert on_update_next_break.call_args[0][0].name == "translated!: break 1"
|
assert on_update_next_break.call_args[0][0].name == "translated!: break 1"
|
||||||
on_update_next_break.reset_mock()
|
on_update_next_break.reset_mock()
|
||||||
|
|
||||||
with safe_eyes_core.lock:
|
# wait for end of __scheduler_job - we cannot stop while waiting on the condvar
|
||||||
time_machine.shift(delta=datetime.timedelta(minutes=15))
|
# this just moves us into waiting for __wait_until_prepare to start
|
||||||
|
sequential_threading_handle.next()
|
||||||
with safe_eyes_core.waiting_condition:
|
|
||||||
logging.debug("notify")
|
|
||||||
safe_eyes_core.waiting_condition.notify_all()
|
|
||||||
|
|
||||||
logging.debug("wait for end of __scheduler_job")
|
|
||||||
sequential_threading.wait()
|
|
||||||
logging.debug("done waiting for end of __scheduler_job")
|
|
||||||
|
|
||||||
|
|
||||||
safe_eyes_core.stop()
|
safe_eyes_core.stop()
|
||||||
assert context['state'] == model.State.STOPPED
|
assert context['state'] == model.State.STOPPED
|
||||||
|
|
||||||
logging.debug("done")
|
|
||||||
|
|
||||||
def run_next_break(
|
def run_next_break(
|
||||||
self,
|
self,
|
||||||
sequential_threading,
|
sequential_threading_handle,
|
||||||
time_machine,
|
time_machine,
|
||||||
safe_eyes_core,
|
safe_eyes_core,
|
||||||
context,
|
context,
|
||||||
break_duration,
|
break_duration,
|
||||||
break_interval,
|
|
||||||
pre_break_warning_time,
|
|
||||||
break_name_translated
|
break_name_translated
|
||||||
):
|
):
|
||||||
"""Run one entire cycle of safe_eyes_core.
|
"""Run one entire cycle of safe_eyes_core.
|
||||||
|
@ -250,9 +287,10 @@ class TestSafeEyesCore:
|
||||||
safe_eyes_core.on_count_down += on_count_down
|
safe_eyes_core.on_count_down += on_count_down
|
||||||
|
|
||||||
# start __scheduler_job
|
# start __scheduler_job
|
||||||
sequential_threading.next()
|
sequential_threading_handle.next()
|
||||||
# FIXME: sleep is needed so code reaches the waiting_condition
|
# wait until it reaches the condvar
|
||||||
sleep(0.1)
|
|
||||||
|
|
||||||
assert context['state'] == model.State.WAITING
|
assert context['state'] == model.State.WAITING
|
||||||
|
|
||||||
on_update_next_break.assert_called_once()
|
on_update_next_break.assert_called_once()
|
||||||
|
@ -260,16 +298,9 @@ class TestSafeEyesCore:
|
||||||
assert on_update_next_break.call_args[0][0].name == break_name_translated
|
assert on_update_next_break.call_args[0][0].name == break_name_translated
|
||||||
on_update_next_break.reset_mock()
|
on_update_next_break.reset_mock()
|
||||||
|
|
||||||
with safe_eyes_core.lock:
|
# continue after condvar
|
||||||
time_machine.shift(delta=datetime.timedelta(minutes=break_interval))
|
sequential_threading_handle.next()
|
||||||
|
# end of __scheduler_job
|
||||||
with safe_eyes_core.waiting_condition:
|
|
||||||
logging.debug("notify")
|
|
||||||
safe_eyes_core.waiting_condition.notify_all()
|
|
||||||
|
|
||||||
logging.debug("wait for end of __scheduler_job")
|
|
||||||
sequential_threading.wait()
|
|
||||||
logging.debug("done waiting for end of __scheduler_job")
|
|
||||||
|
|
||||||
assert context['state'] == model.State.PRE_BREAK
|
assert context['state'] == model.State.PRE_BREAK
|
||||||
|
|
||||||
|
@ -279,27 +310,18 @@ class TestSafeEyesCore:
|
||||||
on_pre_break.reset_mock()
|
on_pre_break.reset_mock()
|
||||||
|
|
||||||
# start __wait_until_prepare
|
# start __wait_until_prepare
|
||||||
sequential_threading.next()
|
sequential_threading_handle.next()
|
||||||
|
|
||||||
# FIXME: sleep is needed so code reaches the waiting_condition
|
# wait until it reaches the condvar
|
||||||
sleep(0.1)
|
# continue after condvar
|
||||||
with safe_eyes_core.lock:
|
sequential_threading_handle.next()
|
||||||
time_machine.shift(delta=datetime.timedelta(seconds=pre_break_warning_time))
|
# end of __wait_until_prepare
|
||||||
|
|
||||||
with safe_eyes_core.waiting_condition:
|
|
||||||
logging.debug("notify")
|
|
||||||
safe_eyes_core.waiting_condition.notify_all()
|
|
||||||
|
|
||||||
logging.debug("wait for end of __wait_until_prepare")
|
|
||||||
sequential_threading.wait()
|
|
||||||
logging.debug("done waiting for end of __wait_until_prepare")
|
|
||||||
|
|
||||||
# start __start_break
|
# start __start_break
|
||||||
sequential_threading.next()
|
sequential_threading_handle.next()
|
||||||
sequential_threading.wait()
|
|
||||||
|
|
||||||
# first sleep in __start_break
|
# first sleep in __start_break
|
||||||
sequential_threading.next()
|
sequential_threading_handle.next()
|
||||||
|
|
||||||
on_start_break.assert_called_once()
|
on_start_break.assert_called_once()
|
||||||
assert isinstance(on_start_break.call_args[0][0], model.Break)
|
assert isinstance(on_start_break.call_args[0][0], model.Break)
|
||||||
|
@ -314,13 +336,11 @@ class TestSafeEyesCore:
|
||||||
assert context['state'] == model.State.BREAK
|
assert context['state'] == model.State.BREAK
|
||||||
|
|
||||||
# continue sleep in __start_break
|
# continue sleep in __start_break
|
||||||
for i in range(break_duration - 1):
|
for i in range(break_duration - 2):
|
||||||
sequential_threading.wait()
|
sequential_threading_handle.next()
|
||||||
sequential_threading.next()
|
|
||||||
|
|
||||||
logging.debug("wait for end of __start_break")
|
sequential_threading_handle.next()
|
||||||
sequential_threading.wait()
|
# end of __start_break
|
||||||
logging.debug("done waiting for end of __start_break")
|
|
||||||
|
|
||||||
on_count_down.assert_called()
|
on_count_down.assert_called()
|
||||||
assert on_count_down.call_count == break_duration
|
assert on_count_down.call_count == break_duration
|
||||||
|
@ -333,7 +353,7 @@ class TestSafeEyesCore:
|
||||||
string += "+00:00"
|
string += "+00:00"
|
||||||
assert datetime.datetime.now(datetime.timezone.utc) == datetime.datetime.fromisoformat(string)
|
assert datetime.datetime.now(datetime.timezone.utc) == datetime.datetime.fromisoformat(string)
|
||||||
|
|
||||||
def test_actual(self, sequential_threading, time_machine):
|
def test_full_run_with_defaults(self, sequential_threading, time_machine):
|
||||||
context = {
|
context = {
|
||||||
"session": {},
|
"session": {},
|
||||||
}
|
}
|
||||||
|
@ -367,87 +387,86 @@ class TestSafeEyesCore:
|
||||||
|
|
||||||
safe_eyes_core = core.SafeEyesCore(context)
|
safe_eyes_core = core.SafeEyesCore(context)
|
||||||
|
|
||||||
|
sequential_threading_handle = sequential_threading(safe_eyes_core)
|
||||||
|
|
||||||
safe_eyes_core.initialize(config)
|
safe_eyes_core.initialize(config)
|
||||||
|
|
||||||
safe_eyes_core.start()
|
safe_eyes_core.start()
|
||||||
|
|
||||||
|
|
||||||
self.run_next_break(
|
self.run_next_break(
|
||||||
sequential_threading,
|
sequential_threading_handle,
|
||||||
time_machine,
|
time_machine,
|
||||||
safe_eyes_core,
|
safe_eyes_core,
|
||||||
context,
|
context,
|
||||||
short_break_duration,
|
short_break_duration,
|
||||||
short_break_interval,
|
|
||||||
pre_break_warning_time,
|
|
||||||
"translated!: break 1"
|
"translated!: break 1"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Time passed: 15min 25s
|
||||||
|
# 15min short_break_interval, 10 seconds pre_break_warning_time, 15 seconds short_break_duration
|
||||||
self.assert_datetime("2024-08-25T13:15:25")
|
self.assert_datetime("2024-08-25T13:15:25")
|
||||||
|
|
||||||
self.run_next_break(
|
self.run_next_break(
|
||||||
sequential_threading,
|
sequential_threading_handle,
|
||||||
time_machine,
|
time_machine,
|
||||||
safe_eyes_core,
|
safe_eyes_core,
|
||||||
context,
|
context,
|
||||||
short_break_duration,
|
short_break_duration,
|
||||||
short_break_interval,
|
|
||||||
pre_break_warning_time,
|
|
||||||
"translated!: break 2"
|
"translated!: break 2"
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assert_datetime("2024-08-25T13:30:50")
|
self.assert_datetime("2024-08-25T13:30:50")
|
||||||
|
|
||||||
self.run_next_break(
|
self.run_next_break(
|
||||||
sequential_threading,
|
sequential_threading_handle,
|
||||||
time_machine,
|
time_machine,
|
||||||
safe_eyes_core,
|
safe_eyes_core,
|
||||||
context,
|
context,
|
||||||
short_break_duration,
|
short_break_duration,
|
||||||
short_break_interval,
|
|
||||||
pre_break_warning_time,
|
|
||||||
"translated!: break 3"
|
"translated!: break 3"
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assert_datetime("2024-08-25T13:46:15")
|
self.assert_datetime("2024-08-25T13:46:15")
|
||||||
|
|
||||||
self.run_next_break(
|
self.run_next_break(
|
||||||
sequential_threading,
|
sequential_threading_handle,
|
||||||
time_machine,
|
time_machine,
|
||||||
safe_eyes_core,
|
safe_eyes_core,
|
||||||
context,
|
context,
|
||||||
short_break_duration,
|
short_break_duration,
|
||||||
short_break_interval,
|
|
||||||
pre_break_warning_time,
|
|
||||||
"translated!: break 4"
|
"translated!: break 4"
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assert_datetime("2024-08-25T14:01:40")
|
self.assert_datetime("2024-08-25T14:01:40")
|
||||||
|
|
||||||
self.run_next_break(
|
self.run_next_break(
|
||||||
sequential_threading,
|
sequential_threading_handle,
|
||||||
time_machine,
|
time_machine,
|
||||||
safe_eyes_core,
|
safe_eyes_core,
|
||||||
context,
|
context,
|
||||||
long_break_duration,
|
long_break_duration,
|
||||||
long_break_interval,
|
|
||||||
pre_break_warning_time,
|
|
||||||
"translated!: long break 1"
|
"translated!: long break 1"
|
||||||
)
|
)
|
||||||
|
|
||||||
#self.assert_datetime("2024-08-25T14:16:40")
|
# Time passed: 16min 10s
|
||||||
|
# 15min short_break_interval (from previous, as long_break_interval must be multiple)
|
||||||
|
# 10 seconds pre_break_warning_time, 1 minute long_break_duration
|
||||||
|
self.assert_datetime("2024-08-25T14:17:50")
|
||||||
|
|
||||||
self.run_next_break(
|
self.run_next_break(
|
||||||
sequential_threading,
|
sequential_threading_handle,
|
||||||
time_machine,
|
time_machine,
|
||||||
safe_eyes_core,
|
safe_eyes_core,
|
||||||
context,
|
context,
|
||||||
short_break_duration,
|
short_break_duration,
|
||||||
short_break_interval,
|
|
||||||
pre_break_warning_time,
|
|
||||||
"translated!: break 1"
|
"translated!: break 1"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Time passed: 15min 25s
|
||||||
|
# 15min short_break_interval, 10 seconds pre_break_warning_time, 15 seconds short_break_duration
|
||||||
|
self.assert_datetime("2024-08-25T14:33:15")
|
||||||
|
|
||||||
safe_eyes_core.stop()
|
safe_eyes_core.stop()
|
||||||
|
|
||||||
assert context['state'] == model.State.STOPPED
|
assert context['state'] == model.State.STOPPED
|
||||||
|
|
Loading…
Reference in New Issue