diff --git a/safeeyes/core.py b/safeeyes/core.py index 4b27c3cf..5e6ae0f1 100644 --- a/safeeyes/core.py +++ b/safeeyes/core.py @@ -95,16 +95,13 @@ def initialize(self, config: Config): self.postpone_duration = self.default_postpone_duration - def start(self, next_break_time=-1, reset_breaks=False) -> None: + def start(self, next_break_time=-1) -> None: """Start Safe Eyes is it is not running already.""" if self._break_queue is None: logging.info("No breaks defined, not starting the core") return if not self.running: logging.info("Start Safe Eyes core") - if reset_breaks: - logging.info("Reset breaks to start from the beginning") - self._break_queue.reset() self.running = True self.scheduled_next_break_timestamp = int(next_break_time) @@ -204,7 +201,7 @@ def __scheduler_job(self) -> None: paused_duration, ) # Skip the next long break - self._break_queue.reset() + self._break_queue.skip_long_break() if self.context["postponed"]: # Previous break was postponed diff --git a/safeeyes/model.py b/safeeyes/model.py index fd322036..5d5bbe49 100644 --- a/safeeyes/model.py +++ b/safeeyes/model.py @@ -249,14 +249,23 @@ def __set_next_break(self, break_type: typing.Optional[BreakType] = None) -> Non self.__current_break = break_obj self.context["session"]["break"] = self.__current_break.name - def reset(self) -> None: - if self.__short_queue: - for break_object in self.__short_queue: - break_object.time = self.__short_break_time - - if self.__long_queue: - for break_object in self.__long_queue: - break_object.time = self.__long_break_time + def skip_long_break(self) -> None: + if not (self.__short_queue and self.__long_queue): + return + + for break_object in self.__short_queue: + break_object.time = self.__short_break_time + + for break_object in self.__long_queue: + break_object.time = self.__long_break_time + + if self.__current_break.type == BreakType.LONG_BREAK: + # Note: this skips the long break, meaning the following long break + # won't be the current one, but the next one after + # we could decrement the __current_long counter, but then we'd need to + # handle wraparound and possibly randomizing, which seems complicated + self.__current_break = self.__next_short() + self.context["session"]["break"] = self.__current_break.name def is_empty(self, break_type: BreakType) -> bool: """Check if the given break type is empty or not.""" diff --git a/safeeyes/safeeyes.py b/safeeyes/safeeyes.py index bca9fa16..b1c67655 100644 --- a/safeeyes/safeeyes.py +++ b/safeeyes/safeeyes.py @@ -237,8 +237,8 @@ def do_startup(self): self.show_about ) self.context["api"]["enable_safeeyes"] = ( - lambda next_break_time=-1, reset_breaks=False: utility.execute_main_thread( - self.enable_safeeyes, next_break_time, reset_breaks + lambda next_break_time=-1: utility.execute_main_thread( + self.enable_safeeyes, next_break_time ) ) self.context["api"]["disable_safeeyes"] = ( @@ -494,7 +494,7 @@ def restart(self, config, set_active=False): self.safe_eyes_core.start() self.plugins_manager.start() - def enable_safeeyes(self, scheduled_next_break_time=-1, reset_breaks=False): + def enable_safeeyes(self, scheduled_next_break_time=-1): """Listen to tray icon enable action and send the signal to core.""" if ( not self.required_plugin_dialog_active @@ -502,7 +502,7 @@ def enable_safeeyes(self, scheduled_next_break_time=-1, reset_breaks=False): and self.safe_eyes_core.has_breaks() ): self.active = True - self.safe_eyes_core.start(scheduled_next_break_time, reset_breaks) + self.safe_eyes_core.start(scheduled_next_break_time) self.plugins_manager.start() def disable_safeeyes(self, status=None, is_resting=False): diff --git a/safeeyes/tests/test_core.py b/safeeyes/tests/test_core.py index 96b1a200..abea61af 100644 --- a/safeeyes/tests/test_core.py +++ b/safeeyes/tests/test_core.py @@ -45,16 +45,19 @@ def timeout_add_seconds(self, duration: int, callback: typing.Callable) -> int: if self.callback is not None: raise Exception("only one callback supported. need to make this smarter") self.callback = (callback, duration) - print(f"callback registered for {callback} and {duration}") return 1 + def source_remove(self, source_id: int) -> None: + if self.callback is None: + raise Exception("no callback registered") + self.callback = None + def next(self) -> None: assert self.callback (callback, duration) = self.callback self.callback = None self.time_machine.shift(delta=datetime.timedelta(seconds=duration)) - print(f"shift to {datetime.datetime.now()}") callback() @@ -107,7 +110,9 @@ def timeout_add_seconds(duration, callback) -> int: return handle.timeout_add_seconds(duration, callback) def source_remove(source_id: int) -> None: - pass + if not handle: + raise Exception("handle must be initialized before first call") + handle.source_remove(source_id) monkeypatch.setattr(core.GLib, "timeout_add_seconds", timeout_add_seconds) monkeypatch.setattr(core.GLib, "source_remove", source_remove) @@ -143,18 +148,8 @@ def run_next_break( was already called. """ on_update_next_break = mock.Mock() - on_pre_break = mock.Mock(return_value=True) - on_start_break = mock.Mock(return_value=True) - start_break = mock.Mock() - on_count_down = mock.Mock() - on_stop_break = mock.Mock() safe_eyes_core.on_update_next_break += on_update_next_break - safe_eyes_core.on_pre_break += on_pre_break - safe_eyes_core.on_start_break += on_start_break - safe_eyes_core.start_break += start_break - safe_eyes_core.on_count_down += on_count_down - safe_eyes_core.on_stop_break += on_stop_break if initial: safe_eyes_core.start() @@ -170,6 +165,36 @@ def run_next_break( assert on_update_next_break.call_args[0][0].name == break_name_translated on_update_next_break.reset_mock() + self.run_next_break_from_waiting_state( + sequential_threading_handle, + safe_eyes_core, + context, + break_duration, + break_name_translated, + ) + + def run_next_break_from_waiting_state( + self, + sequential_threading_handle: SafeEyesCoreHandle, + safe_eyes_core: core.SafeEyesCore, + context, + break_duration: int, + break_name_translated: str, + ) -> None: + on_pre_break = mock.Mock(return_value=True) + on_start_break = mock.Mock(return_value=True) + start_break = mock.Mock() + on_count_down = mock.Mock() + on_stop_break = mock.Mock() + + safe_eyes_core.on_pre_break += on_pre_break + safe_eyes_core.on_start_break += on_start_break + safe_eyes_core.start_break += start_break + safe_eyes_core.on_count_down += on_count_down + safe_eyes_core.on_stop_break += on_stop_break + + assert context["state"] == model.State.WAITING + # continue after condvar sequential_threading_handle.next() # end of __scheduler_job @@ -274,6 +299,7 @@ def test_start(self, sequential_threading: SequentialThreadingFixture): "short_break_duration": 15, "random_order": False, "postpone_duration": 5, + "pre_break_warning_time": 10, # seconds }, system_config={}, ) @@ -542,3 +568,476 @@ def test_long_duration_is_bigger_than_short_interval( safe_eyes_core.stop() assert context["state"] == model.State.STOPPED + + def test_idle( + self, + sequential_threading: SequentialThreadingFixture, + time_machine: TimeMachineFixture, + ): + """Test idling for short amount of time.""" + context: dict[str, typing.Any] = { + "session": {}, + } + short_break_duration = 15 # seconds + short_break_interval = 15 # minutes + pre_break_warning_time = 10 # seconds + long_break_duration = 60 # seconds + long_break_interval = 75 # minutes + config = model.Config( + user_config={ + "short_breaks": [ + {"name": "break 1"}, + {"name": "break 2"}, + {"name": "break 3"}, + {"name": "break 4"}, + ], + "long_breaks": [ + {"name": "long break 1"}, + {"name": "long break 2"}, + {"name": "long break 3"}, + ], + "short_break_interval": short_break_interval, + "long_break_interval": long_break_interval, + "long_break_duration": long_break_duration, + "short_break_duration": short_break_duration, + "pre_break_warning_time": pre_break_warning_time, + "random_order": False, + "postpone_duration": 5, + }, + system_config={}, + ) + + self.assert_datetime("2024-08-25T13:00:00") + + safe_eyes_core = core.SafeEyesCore(context) + + sequential_threading_handle = sequential_threading(safe_eyes_core) + + safe_eyes_core.initialize(config) + + self.run_next_break( + sequential_threading_handle, + time_machine, + safe_eyes_core, + context, + short_break_duration, + "translated!: break 1", + initial=True, + ) + + # 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") + + # idle, simulate behaviour of smartpause plugin + idle_seconds = 30 + idle_period = datetime.timedelta(seconds=idle_seconds) + + safe_eyes_core.stop(is_resting=True) + + assert context["state"] == model.State.RESTING + + time_machine.shift(delta=idle_period) + + assert safe_eyes_core.scheduled_next_break_time is not None + next_break = safe_eyes_core.scheduled_next_break_time + idle_period + + safe_eyes_core.start(next_break_time=next_break.timestamp()) + + self.assert_datetime("2024-08-25T13:15:55") + + self.run_next_break_from_waiting_state( + sequential_threading_handle, + safe_eyes_core, + context, + short_break_duration, + "translated!: break 2", + ) + + self.assert_datetime("2024-08-25T13:31:20") + + self.run_next_break( + sequential_threading_handle, + time_machine, + safe_eyes_core, + context, + short_break_duration, + "translated!: break 3", + ) + + self.assert_datetime("2024-08-25T13:46:45") + + self.run_next_break( + sequential_threading_handle, + time_machine, + safe_eyes_core, + context, + short_break_duration, + "translated!: break 4", + ) + + self.assert_datetime("2024-08-25T14:02:10") + + self.run_next_break( + sequential_threading_handle, + time_machine, + safe_eyes_core, + context, + long_break_duration, + "translated!: long break 1", + ) + + # 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:18:20") + + self.run_next_break( + sequential_threading_handle, + time_machine, + safe_eyes_core, + context, + short_break_duration, + "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:45") + + safe_eyes_core.stop() + + assert context["state"] == model.State.STOPPED + + def test_idle_skip_long( + self, + sequential_threading: SequentialThreadingFixture, + time_machine: TimeMachineFixture, + ): + """Test idling for longer than long break time.""" + context: dict[str, typing.Any] = { + "session": {}, + } + short_break_duration = 15 # seconds + short_break_interval = 15 # minutes + pre_break_warning_time = 10 # seconds + long_break_duration = 60 # seconds + long_break_interval = 75 # minutes + config = model.Config( + user_config={ + "short_breaks": [ + {"name": "break 1"}, + {"name": "break 2"}, + {"name": "break 3"}, + {"name": "break 4"}, + ], + "long_breaks": [ + {"name": "long break 1"}, + {"name": "long break 2"}, + {"name": "long break 3"}, + ], + "short_break_interval": short_break_interval, + "long_break_interval": long_break_interval, + "long_break_duration": long_break_duration, + "short_break_duration": short_break_duration, + "pre_break_warning_time": pre_break_warning_time, + "random_order": False, + "postpone_duration": 5, + }, + system_config={}, + ) + + self.assert_datetime("2024-08-25T13:00:00") + + safe_eyes_core = core.SafeEyesCore(context) + + sequential_threading_handle = sequential_threading(safe_eyes_core) + + safe_eyes_core.initialize(config) + + self.run_next_break( + sequential_threading_handle, + time_machine, + safe_eyes_core, + context, + short_break_duration, + "translated!: break 1", + initial=True, + ) + + # 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") + + # idle, simulate behaviour of smartpause plugin + idle_seconds = 65 + idle_period = datetime.timedelta(seconds=idle_seconds) + + safe_eyes_core.stop(is_resting=True) + + assert context["state"] == model.State.RESTING + + time_machine.shift(delta=idle_period) + + assert safe_eyes_core.scheduled_next_break_time is not None + next_break = safe_eyes_core.scheduled_next_break_time + idle_period + + safe_eyes_core.start(next_break_time=next_break.timestamp()) + + self.assert_datetime("2024-08-25T13:16:30") + + self.run_next_break_from_waiting_state( + sequential_threading_handle, + safe_eyes_core, + context, + short_break_duration, + "translated!: break 2", + ) + + self.assert_datetime("2024-08-25T13:31:55") + + self.run_next_break( + sequential_threading_handle, + time_machine, + safe_eyes_core, + context, + short_break_duration, + "translated!: break 3", + ) + + self.assert_datetime("2024-08-25T13:47:20") + + self.run_next_break( + sequential_threading_handle, + time_machine, + safe_eyes_core, + context, + short_break_duration, + "translated!: break 4", + ) + + self.assert_datetime("2024-08-25T14:02:45") + + self.run_next_break( + sequential_threading_handle, + time_machine, + safe_eyes_core, + context, + short_break_duration, + "translated!: break 1", + ) + + self.assert_datetime("2024-08-25T14:18:10") + + self.run_next_break( + sequential_threading_handle, + time_machine, + safe_eyes_core, + context, + long_break_duration, + "translated!: long break 1", + ) + + # 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:34:20") + + self.run_next_break( + sequential_threading_handle, + time_machine, + safe_eyes_core, + context, + short_break_duration, + "translated!: break 2", + ) + + # 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:49:45") + + safe_eyes_core.stop() + + assert context["state"] == model.State.STOPPED + + def test_idle_skip_long_before_long( + self, + sequential_threading: SequentialThreadingFixture, + time_machine: TimeMachineFixture, + ): + """Test idling for longer than long break time, right before the next long + break. + + This used to skip all the short breaks too. + """ + context: dict[str, typing.Any] = { + "session": {}, + } + short_break_duration = 15 # seconds + short_break_interval = 15 # minutes + pre_break_warning_time = 10 # seconds + long_break_duration = 60 # seconds + long_break_interval = 75 # minutes + config = model.Config( + user_config={ + "short_breaks": [ + {"name": "break 1"}, + {"name": "break 2"}, + {"name": "break 3"}, + {"name": "break 4"}, + ], + "long_breaks": [ + {"name": "long break 1"}, + {"name": "long break 2"}, + {"name": "long break 3"}, + ], + "short_break_interval": short_break_interval, + "long_break_interval": long_break_interval, + "long_break_duration": long_break_duration, + "short_break_duration": short_break_duration, + "pre_break_warning_time": pre_break_warning_time, + "random_order": False, + "postpone_duration": 5, + }, + system_config={}, + ) + + self.assert_datetime("2024-08-25T13:00:00") + + safe_eyes_core = core.SafeEyesCore(context) + + sequential_threading_handle = sequential_threading(safe_eyes_core) + + safe_eyes_core.initialize(config) + + self.run_next_break( + sequential_threading_handle, + time_machine, + safe_eyes_core, + context, + short_break_duration, + "translated!: break 1", + initial=True, + ) + + # 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.run_next_break( + sequential_threading_handle, + time_machine, + safe_eyes_core, + context, + short_break_duration, + "translated!: break 2", + ) + + self.assert_datetime("2024-08-25T13:30:50") + + self.run_next_break( + sequential_threading_handle, + time_machine, + safe_eyes_core, + context, + short_break_duration, + "translated!: break 3", + ) + + self.assert_datetime("2024-08-25T13:46:15") + + self.run_next_break( + sequential_threading_handle, + time_machine, + safe_eyes_core, + context, + short_break_duration, + "translated!: break 4", + ) + + self.assert_datetime("2024-08-25T14:01:40") + + # idle, simulate behaviour of smartpause plugin + idle_seconds = 65 + idle_period = datetime.timedelta(seconds=idle_seconds) + + safe_eyes_core.stop(is_resting=True) + + assert context["state"] == model.State.RESTING + + time_machine.shift(delta=idle_period) + + assert safe_eyes_core.scheduled_next_break_time is not None + next_break = safe_eyes_core.scheduled_next_break_time + idle_period + + safe_eyes_core.start(next_break_time=next_break.timestamp()) + + self.assert_datetime("2024-08-25T14:02:45") + + self.run_next_break_from_waiting_state( + sequential_threading_handle, + safe_eyes_core, + context, + short_break_duration, + "translated!: break 1", + ) + + self.assert_datetime("2024-08-25T14:18:10") + + self.run_next_break( + sequential_threading_handle, + time_machine, + safe_eyes_core, + context, + short_break_duration, + "translated!: break 2", + ) + + self.assert_datetime("2024-08-25T14:33:35") + + self.run_next_break( + sequential_threading_handle, + time_machine, + safe_eyes_core, + context, + short_break_duration, + "translated!: break 3", + ) + + self.assert_datetime("2024-08-25T14:49:00") + + self.run_next_break( + sequential_threading_handle, + time_machine, + safe_eyes_core, + context, + short_break_duration, + "translated!: break 4", + ) + + self.assert_datetime("2024-08-25T15:04:25") + + # note that long break 1 was skipped, and we went directly to long break 2 + # there's a note in BreakQueue.skip_long_break, we could fix it if needed, but + # it seems too much effort to be worth it right now + self.run_next_break( + sequential_threading_handle, + time_machine, + safe_eyes_core, + context, + long_break_duration, + "translated!: long break 2", + ) + + self.assert_datetime("2024-08-25T15:20:35") + + safe_eyes_core.stop() + + assert context["state"] == model.State.STOPPED diff --git a/safeeyes/tests/test_model.py b/safeeyes/tests/test_model.py index 52a7b9e6..f0d219db 100644 --- a/safeeyes/tests/test_model.py +++ b/safeeyes/tests/test_model.py @@ -384,6 +384,66 @@ def test_full_next_break(self, monkeypatch: pytest.MonkeyPatch) -> None: assert bq.next().name == "translated!: break 4" assert bq.next().name == "translated!: long break 1" + def test_skip_long_break(self, monkeypatch: pytest.MonkeyPatch) -> None: + bq = self.get_bq_full(monkeypatch) + + next = bq.get_break() + assert next.name == "translated!: break 1" + assert not bq.is_long_break() + + assert bq.next().name == "translated!: break 2" + assert bq.next().name == "translated!: break 3" + assert bq.next().name == "translated!: break 4" + assert bq.next().name == "translated!: long break 1" + assert bq.is_long_break() + assert bq.next().name == "translated!: break 1" + assert not bq.is_long_break() + assert bq.next().name == "translated!: break 2" + + bq.skip_long_break() + + assert bq.next().name == "translated!: break 3" + assert bq.next().name == "translated!: break 4" + assert bq.next().name == "translated!: break 1" + assert bq.next().name == "translated!: long break 2" + assert bq.next().name == "translated!: break 2" + assert bq.next().name == "translated!: break 3" + assert bq.next().name == "translated!: break 4" + assert bq.next().name == "translated!: break 1" + assert bq.next().name == "translated!: long break 3" + + def test_skip_long_break_before_long_break( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + bq = self.get_bq_full(monkeypatch) + + next = bq.get_break() + assert next.name == "translated!: break 1" + assert not bq.is_long_break() + + assert bq.next().name == "translated!: break 2" + assert bq.next().name == "translated!: break 3" + assert bq.next().name == "translated!: break 4" + assert bq.next().name == "translated!: long break 1" + assert bq.is_long_break() + assert bq.next().name == "translated!: break 1" + assert not bq.is_long_break() + assert bq.next().name == "translated!: break 2" + assert bq.next().name == "translated!: break 3" + assert bq.next().name == "translated!: break 4" + assert bq.next().name == "translated!: long break 2" + + assert bq.get_break().name == "translated!: long break 2" + + bq.skip_long_break() + + assert bq.get_break().name == "translated!: break 1" + + assert bq.next().name == "translated!: break 2" + assert bq.next().name == "translated!: break 3" + assert bq.next().name == "translated!: break 4" + assert bq.next().name == "translated!: long break 3" + def test_full_next_break_random(self, monkeypatch: pytest.MonkeyPatch) -> None: random_seed = 5 bq = self.get_bq_full(monkeypatch, random_seed)