From c6d6ef0e0cbb735d5f572e1bdece7aedc1a1da75 Mon Sep 17 00:00:00 2001 From: dgtlmoon Date: Tue, 17 Feb 2026 02:38:16 +0100 Subject: [PATCH] Fix time schedule off-by-one bug at exact end times for all durations and add comprehensive edge case tests Re #846 (#3890) --- .../tests/unit/test_time_handler.py | 375 ++++++++++++++++++ changedetectionio/time_handler.py | 6 +- 2 files changed, 378 insertions(+), 3 deletions(-) diff --git a/changedetectionio/tests/unit/test_time_handler.py b/changedetectionio/tests/unit/test_time_handler.py index 03fda8e8..167ca942 100644 --- a/changedetectionio/tests/unit/test_time_handler.py +++ b/changedetectionio/tests/unit/test_time_handler.py @@ -8,6 +8,7 @@ python3 -m pytest changedetectionio/tests/unit/test_time_handler.py -v """ import unittest +import unittest.mock import arrow from changedetectionio import time_handler @@ -240,6 +241,211 @@ class TestAmIInsideTime(unittest.TestCase): # Result depends on current time self.assertIsInstance(result, bool) + def test_24_hour_schedule_from_midnight(self): + """Test 24-hour schedule starting at midnight covers entire day.""" + timezone_str = 'UTC' + # Test at a specific time: Monday 00:00 + test_time = arrow.get('2024-01-01 00:00:00', 'YYYY-MM-DD HH:mm:ss').replace(tzinfo=timezone_str) + day_of_week = test_time.format('dddd') # Monday + + # Mock current time for testing + with unittest.mock.patch('arrow.now', return_value=test_time): + result = time_handler.am_i_inside_time( + day_of_week=day_of_week, + time_str="00:00", + timezone_str=timezone_str, + duration=1440 # 24 hours + ) + self.assertTrue(result, "Should be active at start of 24-hour schedule") + + def test_24_hour_schedule_at_end_of_day(self): + """Test 24-hour schedule is active at 23:59:59.""" + timezone_str = 'UTC' + # Test at Monday 23:59:59 + test_time = arrow.get('2024-01-01 23:59:59', 'YYYY-MM-DD HH:mm:ss').replace(tzinfo=timezone_str) + day_of_week = test_time.format('dddd') # Monday + + with unittest.mock.patch('arrow.now', return_value=test_time): + result = time_handler.am_i_inside_time( + day_of_week=day_of_week, + time_str="00:00", + timezone_str=timezone_str, + duration=1440 # 24 hours + ) + self.assertTrue(result, "Should be active at end of 24-hour schedule") + + def test_24_hour_schedule_at_midnight_transition(self): + """Test 24-hour schedule at exactly midnight transition.""" + timezone_str = 'UTC' + # Test at Tuesday 00:00:00 (end of Monday's 24-hour schedule) + test_time = arrow.get('2024-01-02 00:00:00', 'YYYY-MM-DD HH:mm:ss').replace(tzinfo=timezone_str) + monday = test_time.shift(days=-1).format('dddd') # Monday + + with unittest.mock.patch('arrow.now', return_value=test_time): + result = time_handler.am_i_inside_time( + day_of_week=monday, + time_str="00:00", + timezone_str=timezone_str, + duration=1440 # 24 hours + ) + self.assertTrue(result, "Should include exactly midnight at end of 24-hour schedule") + + def test_schedule_crosses_midnight_before_midnight(self): + """Test schedule crossing midnight - before midnight.""" + timezone_str = 'UTC' + # Monday 23:30 + test_time = arrow.get('2024-01-01 23:30:00', 'YYYY-MM-DD HH:mm:ss').replace(tzinfo=timezone_str) + day_of_week = test_time.format('dddd') # Monday + + with unittest.mock.patch('arrow.now', return_value=test_time): + result = time_handler.am_i_inside_time( + day_of_week=day_of_week, + time_str="23:00", + timezone_str=timezone_str, + duration=120 # 2 hours (until 01:00 next day) + ) + self.assertTrue(result, "Should be active before midnight in cross-midnight schedule") + + def test_schedule_crosses_midnight_after_midnight(self): + """Test schedule crossing midnight - after midnight.""" + timezone_str = 'UTC' + # Tuesday 00:30 + test_time = arrow.get('2024-01-02 00:30:00', 'YYYY-MM-DD HH:mm:ss').replace(tzinfo=timezone_str) + monday = test_time.shift(days=-1).format('dddd') # Monday + + with unittest.mock.patch('arrow.now', return_value=test_time): + result = time_handler.am_i_inside_time( + day_of_week=monday, + time_str="23:00", + timezone_str=timezone_str, + duration=120 # 2 hours (until 01:00 Tuesday) + ) + self.assertTrue(result, "Should be active after midnight in cross-midnight schedule") + + def test_schedule_crosses_midnight_at_exact_end(self): + """Test schedule crossing midnight at exact end time.""" + timezone_str = 'UTC' + # Tuesday 01:00 (exact end of Monday 23:00 + 120 minutes) + test_time = arrow.get('2024-01-02 01:00:00', 'YYYY-MM-DD HH:mm:ss').replace(tzinfo=timezone_str) + monday = test_time.shift(days=-1).format('dddd') # Monday + + with unittest.mock.patch('arrow.now', return_value=test_time): + result = time_handler.am_i_inside_time( + day_of_week=monday, + time_str="23:00", + timezone_str=timezone_str, + duration=120 # 2 hours + ) + self.assertTrue(result, "Should include exact end time of schedule") + + def test_duration_60_minutes(self): + """Test that duration of 60 minutes works correctly.""" + timezone_str = 'UTC' + test_time = arrow.get('2024-01-01 12:30:00', 'YYYY-MM-DD HH:mm:ss').replace(tzinfo=timezone_str) + day_of_week = test_time.format('dddd') + + with unittest.mock.patch('arrow.now', return_value=test_time): + result = time_handler.am_i_inside_time( + day_of_week=day_of_week, + time_str="12:00", + timezone_str=timezone_str, + duration=60 # Exactly 60 minutes + ) + self.assertTrue(result, "60-minute duration should work") + + def test_duration_at_exact_end_minute(self): + """Test at exact end of 60-minute window.""" + timezone_str = 'UTC' + # Exactly 13:00 (end of 12:00 + 60 minutes) + test_time = arrow.get('2024-01-01 13:00:00', 'YYYY-MM-DD HH:mm:ss').replace(tzinfo=timezone_str) + day_of_week = test_time.format('dddd') + + with unittest.mock.patch('arrow.now', return_value=test_time): + result = time_handler.am_i_inside_time( + day_of_week=day_of_week, + time_str="12:00", + timezone_str=timezone_str, + duration=60 + ) + self.assertTrue(result, "Should include exact end minute") + + def test_one_second_after_schedule_ends(self): + """Test one second after schedule should end.""" + timezone_str = 'UTC' + # 13:00:01 (one second after 12:00 + 60 minutes) + test_time = arrow.get('2024-01-01 13:00:01', 'YYYY-MM-DD HH:mm:ss').replace(tzinfo=timezone_str) + day_of_week = test_time.format('dddd') + + with unittest.mock.patch('arrow.now', return_value=test_time): + result = time_handler.am_i_inside_time( + day_of_week=day_of_week, + time_str="12:00", + timezone_str=timezone_str, + duration=60 + ) + self.assertFalse(result, "Should be False one second after schedule ends") + + def test_multi_day_schedule(self): + """Test schedule longer than 24 hours (48 hours).""" + timezone_str = 'UTC' + # Tuesday 12:00 (36 hours after Monday 00:00) + test_time = arrow.get('2024-01-02 12:00:00', 'YYYY-MM-DD HH:mm:ss').replace(tzinfo=timezone_str) + monday = test_time.shift(days=-1).format('dddd') + + with unittest.mock.patch('arrow.now', return_value=test_time): + result = time_handler.am_i_inside_time( + day_of_week=monday, + time_str="00:00", + timezone_str=timezone_str, + duration=2880 # 48 hours + ) + self.assertTrue(result, "Should support multi-day schedules") + + def test_schedule_one_minute_duration(self): + """Test very short 1-minute schedule.""" + timezone_str = 'UTC' + test_time = arrow.get('2024-01-01 12:00:30', 'YYYY-MM-DD HH:mm:ss').replace(tzinfo=timezone_str) + day_of_week = test_time.format('dddd') + + with unittest.mock.patch('arrow.now', return_value=test_time): + result = time_handler.am_i_inside_time( + day_of_week=day_of_week, + time_str="12:00", + timezone_str=timezone_str, + duration=1 # Just 1 minute + ) + self.assertTrue(result, "1-minute schedule should work") + + def test_schedule_at_exact_start_time(self): + """Test at exact start time (00:00:00.000000).""" + timezone_str = 'UTC' + test_time = arrow.get('2024-01-01 12:00:00.000000', 'YYYY-MM-DD HH:mm:ss.SSSSSS').replace(tzinfo=timezone_str) + day_of_week = test_time.format('dddd') + + with unittest.mock.patch('arrow.now', return_value=test_time): + result = time_handler.am_i_inside_time( + day_of_week=day_of_week, + time_str="12:00", + timezone_str=timezone_str, + duration=30 + ) + self.assertTrue(result, "Should include exact start time") + + def test_schedule_one_microsecond_before_start(self): + """Test one microsecond before schedule starts.""" + timezone_str = 'UTC' + test_time = arrow.get('2024-01-01 11:59:59.999999', 'YYYY-MM-DD HH:mm:ss.SSSSSS').replace(tzinfo=timezone_str) + day_of_week = test_time.format('dddd') + + with unittest.mock.patch('arrow.now', return_value=test_time): + result = time_handler.am_i_inside_time( + day_of_week=day_of_week, + time_str="12:00", + timezone_str=timezone_str, + duration=30 + ) + self.assertFalse(result, "Should not include time before start") + class TestIsWithinSchedule(unittest.TestCase): """Tests for the is_within_schedule function.""" @@ -405,6 +611,175 @@ class TestIsWithinSchedule(unittest.TestCase): result = time_handler.is_within_schedule(time_schedule_limit) self.assertTrue(result, "Should handle timezone with whitespace") + def test_schedule_with_60_minutes(self): + """Test schedule with duration of 0 hours and 60 minutes.""" + timezone_str = 'UTC' + now = arrow.now(timezone_str) + current_day = now.format('dddd').lower() + current_hour = now.format('HH:00') + + time_schedule_limit = { + 'enabled': True, + 'timezone': timezone_str, + current_day: { + 'enabled': True, + 'start_time': current_hour, + 'duration': {'hours': 0, 'minutes': 60} # 60 minutes + } + } + + result = time_handler.is_within_schedule(time_schedule_limit) + self.assertTrue(result, "Should accept 60 minutes as valid duration") + + def test_schedule_with_24_hours(self): + """Test schedule with duration of 24 hours and 0 minutes.""" + timezone_str = 'UTC' + now = arrow.now(timezone_str) + current_day = now.format('dddd').lower() + start_hour = now.format('HH:00') + + time_schedule_limit = { + 'enabled': True, + 'timezone': timezone_str, + current_day: { + 'enabled': True, + 'start_time': start_hour, + 'duration': {'hours': 24, 'minutes': 0} # Full 24 hours + } + } + + result = time_handler.is_within_schedule(time_schedule_limit) + self.assertTrue(result, "Should accept 24 hours as valid duration") + + def test_schedule_with_90_minutes(self): + """Test schedule with duration of 0 hours and 90 minutes.""" + timezone_str = 'UTC' + now = arrow.now(timezone_str) + current_day = now.format('dddd').lower() + current_hour = now.format('HH:00') + + time_schedule_limit = { + 'enabled': True, + 'timezone': timezone_str, + current_day: { + 'enabled': True, + 'start_time': current_hour, + 'duration': {'hours': 0, 'minutes': 90} # 90 minutes = 1.5 hours + } + } + + result = time_handler.is_within_schedule(time_schedule_limit) + self.assertTrue(result, "Should accept 90 minutes as valid duration") + + def test_schedule_24_hours_from_midnight(self): + """Test 24-hour schedule from midnight using is_within_schedule.""" + timezone_str = 'UTC' + test_time = arrow.get('2024-01-01 12:00:00', 'YYYY-MM-DD HH:mm:ss').replace(tzinfo=timezone_str) + current_day = test_time.format('dddd').lower() # monday + + time_schedule_limit = { + 'enabled': True, + 'timezone': timezone_str, + current_day: { + 'enabled': True, + 'start_time': '00:00', + 'duration': {'hours': 24, 'minutes': 0} + } + } + + with unittest.mock.patch('arrow.now', return_value=test_time): + result = time_handler.is_within_schedule(time_schedule_limit) + self.assertTrue(result, "24-hour schedule from midnight should cover entire day") + + def test_schedule_24_hours_at_end_of_day(self): + """Test 24-hour schedule at 23:59 using is_within_schedule.""" + timezone_str = 'UTC' + test_time = arrow.get('2024-01-01 23:59:00', 'YYYY-MM-DD HH:mm:ss').replace(tzinfo=timezone_str) + current_day = test_time.format('dddd').lower() + + time_schedule_limit = { + 'enabled': True, + 'timezone': timezone_str, + current_day: { + 'enabled': True, + 'start_time': '00:00', + 'duration': {'hours': 24, 'minutes': 0} + } + } + + with unittest.mock.patch('arrow.now', return_value=test_time): + result = time_handler.is_within_schedule(time_schedule_limit) + self.assertTrue(result, "Should be active at 23:59 in 24-hour schedule") + + def test_schedule_crosses_midnight_with_is_within_schedule(self): + """Test schedule crossing midnight using is_within_schedule.""" + timezone_str = 'UTC' + # Tuesday 00:30 + test_time = arrow.get('2024-01-02 00:30:00', 'YYYY-MM-DD HH:mm:ss').replace(tzinfo=timezone_str) + # Get Monday as that's when the schedule started + monday = test_time.shift(days=-1).format('dddd').lower() + + time_schedule_limit = { + 'enabled': True, + 'timezone': timezone_str, + 'monday': { + 'enabled': True, + 'start_time': '23:00', + 'duration': {'hours': 2, 'minutes': 0} # Until 01:00 Tuesday + }, + 'tuesday': { + 'enabled': False, + 'start_time': '09:00', + 'duration': {'hours': 8, 'minutes': 0} + } + } + + with unittest.mock.patch('arrow.now', return_value=test_time): + result = time_handler.is_within_schedule(time_schedule_limit) + # Note: This checks Tuesday's schedule, not Monday's overlap + # So it should be False because Tuesday is disabled + self.assertFalse(result, "Should check current day (Tuesday), which is disabled") + + def test_schedule_with_mixed_hours_minutes(self): + """Test schedule with both hours and minutes (23 hours 60 minutes = 24 hours).""" + timezone_str = 'UTC' + now = arrow.now(timezone_str) + current_day = now.format('dddd').lower() + current_hour = now.format('HH:00') + + time_schedule_limit = { + 'enabled': True, + 'timezone': timezone_str, + current_day: { + 'enabled': True, + 'start_time': current_hour, + 'duration': {'hours': 23, 'minutes': 60} # = 1440 minutes = 24 hours + } + } + + result = time_handler.is_within_schedule(time_schedule_limit) + self.assertTrue(result, "Should handle 23 hours + 60 minutes = 24 hours") + + def test_schedule_48_hours(self): + """Test schedule with 48-hour duration.""" + timezone_str = 'UTC' + now = arrow.now(timezone_str) + current_day = now.format('dddd').lower() + start_hour = now.format('HH:00') + + time_schedule_limit = { + 'enabled': True, + 'timezone': timezone_str, + current_day: { + 'enabled': True, + 'start_time': start_hour, + 'duration': {'hours': 48, 'minutes': 0} # 2 full days + } + } + + result = time_handler.is_within_schedule(time_schedule_limit) + self.assertTrue(result, "Should support 48-hour (multi-day) schedules") + class TestWeekdayEnum(unittest.TestCase): """Tests for the Weekday enum.""" diff --git a/changedetectionio/time_handler.py b/changedetectionio/time_handler.py index cb03f1cc..9def380a 100644 --- a/changedetectionio/time_handler.py +++ b/changedetectionio/time_handler.py @@ -62,19 +62,19 @@ def am_i_inside_time( # Calculate start and end times for the overlap from the previous day start_datetime_tz = start_datetime_tz.shift(days=-1) end_datetime_tz = start_datetime_tz.shift(minutes=duration) - if start_datetime_tz <= now_tz < end_datetime_tz: + if start_datetime_tz <= now_tz <= end_datetime_tz: return True # Handle current day's range if target_weekday == current_weekday: end_datetime_tz = start_datetime_tz.shift(minutes=duration) - if start_datetime_tz <= now_tz < end_datetime_tz: + if start_datetime_tz <= now_tz <= end_datetime_tz: return True # Handle next day's overlap if target_weekday == (current_weekday + 1) % 7: end_datetime_tz = start_datetime_tz.shift(minutes=duration) - if now_tz < start_datetime_tz and now_tz.shift(days=1) < end_datetime_tz: + if now_tz < start_datetime_tz and now_tz.shift(days=1) <= end_datetime_tz: return True return False