diff --git a/code_puppy/plugins/scheduler/scheduler_wizard.py b/code_puppy/plugins/scheduler/scheduler_wizard.py index 5e63c5a41..d2abcf2c1 100644 --- a/code_puppy/plugins/scheduler/scheduler_wizard.py +++ b/code_puppy/plugins/scheduler/scheduler_wizard.py @@ -6,6 +6,8 @@ from typing import List, Optional, Tuple +from code_puppy.scheduler.time_utils import parse_times_hhmm + from prompt_toolkit.application import Application from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.layout import Layout, Window @@ -216,7 +218,8 @@ def create_task_wizard() -> Optional[dict]: "Every hour", "Every 2 hours", "Every 6 hours", - "Daily", + "Daily (every 24h)", + "Daily at specific time(s)...", "Custom interval...", ], descriptions=[ @@ -225,7 +228,8 @@ def create_task_wizard() -> Optional[dict]: "Run once per hour", "Run 12 times per day", "Run 4 times per day", - "Run once per day", + "Run once per day, 24h after last run", + "Run at exact wall-clock times, e.g. 09:00 or 09:00,17:30", "Specify custom interval like 45m, 3h, 2d", ], ) @@ -234,17 +238,37 @@ def create_task_wizard() -> Optional[dict]: print("\n ❌ Cancelled.") return None - # Map choice to schedule type and value + # Map simple choices directly to (type, value) schedule_map = { "Every 15 minutes": ("interval", "15m"), "Every 30 minutes": ("interval", "30m"), "Every hour": ("hourly", "1h"), "Every 2 hours": ("interval", "2h"), "Every 6 hours": ("interval", "6h"), - "Daily": ("daily", "24h"), + "Daily (every 24h)": ("daily", "24h"), } - if schedule_choice == "Custom interval...": + if schedule_choice == "Daily at specific time(s)...": + print("\n Enter one or more 24-hour times separated by commas.") + print(" (24-hour clock: 1:00 PM = 13:00, 5:30 PM = 17:30, midnight = 00:00)") + print(" Examples: 09:00 or 09:00,13:00,17:30\n") + time_input = TextInputMenu("Time(s) (HH:MM, comma-separated)", default="09:00") + raw_times = time_input.run() + if not raw_times: + print("\n ❌ Cancelled.") + return None + + # Validate, normalise (09:00 canonical form), and deduplicate entries. + def _warn_invalid(entry: str) -> None: + print(f" ⚠️ Skipping invalid time: '{entry}' (expected HH:MM)") + + valid = parse_times_hhmm(raw_times, on_invalid=_warn_invalid) + if not valid: + print("\n ❌ No valid times provided.") + return None + schedule_type = "daily_at" + schedule_value = ",".join(valid) + elif schedule_choice == "Custom interval...": interval_input = TextInputMenu( "Enter interval (e.g., 45m, 3h, 2d)", default="1h" ) @@ -311,7 +335,11 @@ def create_task_wizard() -> Optional[dict]: print("📋 TASK SUMMARY") print("-" * 60) print(f" Name: {task_name}") - print(f" Schedule: {schedule_type} ({schedule_value})") + if schedule_type == "daily_at": + schedule_display = f"daily at {schedule_value}" + else: + schedule_display = f"{schedule_type} ({schedule_value})" + print(f" Schedule: {schedule_display}") print(f" Agent: {selected_agent}") print(f" Model: {selected_model or '(default)'}") print(f" Directory: {working_dir}") diff --git a/code_puppy/scheduler/config.py b/code_puppy/scheduler/config.py index f734cff37..e0c3c4ee9 100644 --- a/code_puppy/scheduler/config.py +++ b/code_puppy/scheduler/config.py @@ -29,7 +29,7 @@ class ScheduledTask: prompt: str = "" agent: str = "code-puppy" model: str = "" # Uses default if empty - schedule_type: str = "interval" # "interval", "cron", "daily", "hourly" + schedule_type: str = "interval" # "interval", "cron", "daily", "hourly", "daily_at" schedule_value: str = "1h" # e.g., "30m", "1h", "0 9 * * *" for cron working_directory: str = "." log_file: str = "" # Auto-generated if empty diff --git a/code_puppy/scheduler/daemon.py b/code_puppy/scheduler/daemon.py index dd55efcb9..8de679a0b 100644 --- a/code_puppy/scheduler/daemon.py +++ b/code_puppy/scheduler/daemon.py @@ -20,11 +20,34 @@ load_tasks, ) from code_puppy.scheduler.executor import execute_task +from code_puppy.scheduler.time_utils import parse_times_hhmm # Global flag for graceful shutdown _shutdown_requested = False +def parse_daily_at_times(schedule_value: str) -> list: + """Parse a comma-separated list of HH:MM times. + + Returns a list of (hour, minute) tuples for valid entries. + Invalid entries are skipped with a warning printed to stdout. + + Examples: + "09:00" -> [(9, 0)] + "09:00,17:30" -> [(9, 0), (17, 30)] + """ + + def _warn(entry: str) -> None: + print( + f"[Scheduler] Warning: Invalid time '{entry}' in daily_at schedule, skipping." + ) + + return [ + (int(hhmm[:2]), int(hhmm[3:])) + for hhmm in parse_times_hhmm(schedule_value, on_invalid=_warn) + ] + + def parse_interval(interval_str: str) -> Optional[timedelta]: """Parse interval string like '30m', '1h', '2d' into timedelta.""" match = re.match(r"^(\d+)([smhd])$", interval_str.lower()) @@ -73,6 +96,47 @@ def should_run_task(task: ScheduledTask, now: datetime) -> bool: last_run = datetime.fromisoformat(task.last_run) return (now - last_run) >= timedelta(days=1) + elif task.schedule_type == "daily_at": + # schedule_value is a comma-separated list of HH:MM wall-clock times. + # Fire if *any* target time in the past 24 hours has not been run since. + # We check candidates on both today and yesterday so that a daemon that + # was down overnight and restarts before today's first target still + # catches the missed run from the previous day. + # + # Example: target=09:00, last_run=Feb 24 09:02, now=Feb 26 07:30 + # today's candidate = Feb 26 09:00 → 07:30 < 09:00, skip + # yesterday's candidate = Feb 25 09:00 → last_run < Feb 25 09:00 ✓ fire + # + # TIMEZONE NOTE: `now` is a naive datetime (system local time via + # datetime.now()). All HH:MM targets are therefore evaluated against + # the host's local clock. If the system timezone changes (e.g. DST + # transition or a manual tzdata update) task fire times will shift + # accordingly. Full tz-aware scheduling (zoneinfo / pytz) is left + # for a future iteration. + times = parse_daily_at_times(task.schedule_value) + if not times: + print( + f"[Scheduler] Warning: No valid times in daily_at schedule for: {task.name}" + ) + return False + + last_run = datetime.fromisoformat(task.last_run) if task.last_run else None + + for hour, minute in times: + for day_offset in (timedelta(0), timedelta(days=1)): + # Only look back to yesterday for tasks that have run before. + # A brand-new task (last_run=None) should wait for the next + # scheduled occurrence, not retroactively claim missed windows + # it never actually had. + if day_offset and last_run is None: + continue + target = (now - day_offset).replace( + hour=hour, minute=minute, second=0, microsecond=0 + ) + if target <= now and (last_run is None or last_run < target): + return True + return False + elif task.schedule_type == "cron": # Cron expressions not yet supported - would need croniter library # Log warning so users know why task isn't running diff --git a/code_puppy/scheduler/time_utils.py b/code_puppy/scheduler/time_utils.py new file mode 100644 index 000000000..812edae10 --- /dev/null +++ b/code_puppy/scheduler/time_utils.py @@ -0,0 +1,51 @@ +"""Shared time-parsing utilities for the Code Puppy scheduler.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Callable, Optional + + +def parse_times_hhmm( + raw: str, + on_invalid: Optional[Callable[[str], None]] = None, +) -> list[str]: + """Parse a comma-separated string of HH:MM times into canonical form. + + Each entry is stripped of whitespace, validated with strptime, and + normalised via strftime (so ``9:00`` becomes ``09:00``). Duplicates + are removed while preserving the first-occurrence order. + + Args: + raw: Comma-separated time string, e.g. ``"09:00,17:30"``. + on_invalid: Optional callback invoked with each entry that fails + ``%H:%M`` parsing. When *None*, invalid entries are silently + dropped. + + Returns: + Ordered, deduplicated list of canonical HH:MM strings. + + Examples: + >>> parse_times_hhmm("09:00,17:30") + ['09:00', '17:30'] + >>> parse_times_hhmm("9:0,09:00,bad") # normalise + dedupe + skip + ['09:00'] + """ + seen: set[str] = set() + result: list[str] = [] + + for entry in raw.split(","): + entry = entry.strip() + if not entry: + continue + try: + t = datetime.strptime(entry, "%H:%M") # noqa: DTZ007 + normalised = t.strftime("%H:%M") + if normalised not in seen: + seen.add(normalised) + result.append(normalised) + except ValueError: + if on_invalid is not None: + on_invalid(entry) + + return result diff --git a/tests/plugins/test_scheduler_wizard.py b/tests/plugins/test_scheduler_wizard.py index 2b70410fa..f05e39579 100644 --- a/tests/plugins/test_scheduler_wizard.py +++ b/tests/plugins/test_scheduler_wizard.py @@ -563,7 +563,7 @@ def test_wizard_code_puppy_first( mock_text.side_effect = text_instances sel_instances = [MagicMock(), MagicMock(), MagicMock()] - sel_instances[0].run.return_value = "Daily" + sel_instances[0].run.return_value = "Daily (every 24h)" sel_instances[1].run.return_value = "code-puppy" sel_instances[2].run.return_value = "m1" mock_sel.side_effect = sel_instances @@ -576,6 +576,265 @@ def test_wizard_code_puppy_first( assert result is not None assert result["schedule_type"] == "daily" + # ----------------------------------------------------------------------- + # daily_at schedule type + # ----------------------------------------------------------------------- + + @patch("code_puppy.command_line.utils.safe_input", return_value="y") + @patch(f"{_MOD}.MultilineInputMenu") + @patch(f"{_MOD}.TextInputMenu") + @patch(f"{_MOD}.SelectionMenu") + @patch(f"{_MOD}.get_available_models_list", return_value=["m1"]) + @patch( + f"{_MOD}.get_available_agents_list", return_value=[("code-puppy", "Default")] + ) + def test_daily_at_normalises_single_digit_hour( + self, mock_agents, mock_models, mock_sel, mock_text, mock_multi, mock_confirm + ): + """'9:00' (no leading zero) is normalised to '09:00' in schedule_value.""" + from code_puppy.plugins.scheduler.scheduler_wizard import create_task_wizard + + text_instances = [MagicMock(), MagicMock(), MagicMock()] + text_instances[0].run.return_value = "Task" + text_instances[1].run.return_value = "9:00" + text_instances[2].run.return_value = "." + mock_text.side_effect = text_instances + + sel_instances = [MagicMock(), MagicMock(), MagicMock()] + sel_instances[0].run.return_value = "Daily at specific time(s)..." + sel_instances[1].run.return_value = "code-puppy" + sel_instances[2].run.return_value = "(use default model)" + mock_sel.side_effect = sel_instances + + multi_inst = MagicMock() + multi_inst.run.return_value = "do the thing" + mock_multi.return_value = multi_inst + + result = create_task_wizard() + assert result is not None + assert result["schedule_value"] == "09:00" + + @patch("code_puppy.command_line.utils.safe_input", return_value="y") + @patch(f"{_MOD}.MultilineInputMenu") + @patch(f"{_MOD}.TextInputMenu") + @patch(f"{_MOD}.SelectionMenu") + @patch(f"{_MOD}.get_available_models_list", return_value=["m1"]) + @patch( + f"{_MOD}.get_available_agents_list", return_value=[("code-puppy", "Default")] + ) + def test_daily_at_deduplicates_times( + self, mock_agents, mock_models, mock_sel, mock_text, mock_multi, mock_confirm + ): + """Duplicate times are removed; first occurrence wins, order preserved.""" + from code_puppy.plugins.scheduler.scheduler_wizard import create_task_wizard + + text_instances = [MagicMock(), MagicMock(), MagicMock()] + text_instances[0].run.return_value = "Task" + text_instances[1].run.return_value = "09:00,17:00,09:00,17:00" + text_instances[2].run.return_value = "." + mock_text.side_effect = text_instances + + sel_instances = [MagicMock(), MagicMock(), MagicMock()] + sel_instances[0].run.return_value = "Daily at specific time(s)..." + sel_instances[1].run.return_value = "code-puppy" + sel_instances[2].run.return_value = "(use default model)" + mock_sel.side_effect = sel_instances + + multi_inst = MagicMock() + multi_inst.run.return_value = "do the thing" + mock_multi.return_value = multi_inst + + result = create_task_wizard() + assert result is not None + assert result["schedule_value"] == "09:00,17:00" # dupes removed + + @patch("code_puppy.command_line.utils.safe_input", return_value="y") + @patch(f"{_MOD}.MultilineInputMenu") + @patch(f"{_MOD}.TextInputMenu") + @patch(f"{_MOD}.SelectionMenu") + @patch(f"{_MOD}.get_available_models_list", return_value=["m1"]) + @patch( + f"{_MOD}.get_available_agents_list", return_value=[("code-puppy", "Default")] + ) + def test_daily_at_single_time( + self, mock_agents, mock_models, mock_sel, mock_text, mock_multi, mock_confirm + ): + """Selecting 'Daily at specific time(s)...' with one valid time works.""" + from code_puppy.plugins.scheduler.scheduler_wizard import create_task_wizard + + # TextInputMenu calls: name, time(s), working_dir + text_instances = [MagicMock(), MagicMock(), MagicMock()] + text_instances[0].run.return_value = "Morning Task" + text_instances[1].run.return_value = "09:00" + text_instances[2].run.return_value = "." + mock_text.side_effect = text_instances + + sel_instances = [MagicMock(), MagicMock(), MagicMock()] + sel_instances[0].run.return_value = "Daily at specific time(s)..." + sel_instances[1].run.return_value = "code-puppy" + sel_instances[2].run.return_value = "(use default model)" + mock_sel.side_effect = sel_instances + + multi_inst = MagicMock() + multi_inst.run.return_value = "do the thing" + mock_multi.return_value = multi_inst + + result = create_task_wizard() + assert result is not None + assert result["schedule_type"] == "daily_at" + assert result["schedule_value"] == "09:00" + + @patch("code_puppy.command_line.utils.safe_input", return_value="y") + @patch(f"{_MOD}.MultilineInputMenu") + @patch(f"{_MOD}.TextInputMenu") + @patch(f"{_MOD}.SelectionMenu") + @patch(f"{_MOD}.get_available_models_list", return_value=["m1"]) + @patch( + f"{_MOD}.get_available_agents_list", return_value=[("code-puppy", "Default")] + ) + def test_daily_at_multiple_times( + self, mock_agents, mock_models, mock_sel, mock_text, mock_multi, mock_confirm + ): + """Multiple comma-separated times are all preserved in schedule_value.""" + from code_puppy.plugins.scheduler.scheduler_wizard import create_task_wizard + + text_instances = [MagicMock(), MagicMock(), MagicMock()] + text_instances[0].run.return_value = "Twice Daily" + text_instances[1].run.return_value = "09:00,17:30" + text_instances[2].run.return_value = "." + mock_text.side_effect = text_instances + + sel_instances = [MagicMock(), MagicMock(), MagicMock()] + sel_instances[0].run.return_value = "Daily at specific time(s)..." + sel_instances[1].run.return_value = "code-puppy" + sel_instances[2].run.return_value = "(use default model)" + mock_sel.side_effect = sel_instances + + multi_inst = MagicMock() + multi_inst.run.return_value = "do the thing" + mock_multi.return_value = multi_inst + + result = create_task_wizard() + assert result is not None + assert result["schedule_type"] == "daily_at" + assert result["schedule_value"] == "09:00,17:30" + + @patch(f"{_MOD}.TextInputMenu") + @patch(f"{_MOD}.SelectionMenu") + def test_daily_at_cancel_time_input(self, mock_sel, mock_text): + """Cancelling the time input (returning None) aborts the wizard.""" + from code_puppy.plugins.scheduler.scheduler_wizard import create_task_wizard + + # TextInputMenu: name returns value, time input returns None (cancelled) + text_instances = [MagicMock(), MagicMock()] + text_instances[0].run.return_value = "Task" + text_instances[1].run.return_value = None + mock_text.side_effect = text_instances + + sel_inst = MagicMock() + sel_inst.run.return_value = "Daily at specific time(s)..." + mock_sel.return_value = sel_inst + + assert create_task_wizard() is None + + @patch(f"{_MOD}.TextInputMenu") + @patch(f"{_MOD}.SelectionMenu") + def test_daily_at_all_invalid_times(self, mock_sel, mock_text): + """Providing only invalid times shows a warning and aborts.""" + from code_puppy.plugins.scheduler.scheduler_wizard import create_task_wizard + + text_instances = [MagicMock(), MagicMock()] + text_instances[0].run.return_value = "Task" + text_instances[1].run.return_value = "notaime,alsowrong" + mock_text.side_effect = text_instances + + sel_inst = MagicMock() + sel_inst.run.return_value = "Daily at specific time(s)..." + mock_sel.return_value = sel_inst + + assert create_task_wizard() is None + + @patch("code_puppy.command_line.utils.safe_input", return_value="y") + @patch(f"{_MOD}.MultilineInputMenu") + @patch(f"{_MOD}.TextInputMenu") + @patch(f"{_MOD}.SelectionMenu") + @patch(f"{_MOD}.get_available_models_list", return_value=["m1"]) + @patch( + f"{_MOD}.get_available_agents_list", return_value=[("code-puppy", "Default")] + ) + def test_daily_at_strips_invalid_times( + self, mock_agents, mock_models, mock_sel, mock_text, mock_multi, mock_confirm + ): + """Mixed valid/invalid input: invalid entries are stripped, valid ones kept.""" + from code_puppy.plugins.scheduler.scheduler_wizard import create_task_wizard + + text_instances = [MagicMock(), MagicMock(), MagicMock()] + text_instances[0].run.return_value = "Task" + text_instances[1].run.return_value = "bad,09:00,alsowrong,17:00" + text_instances[2].run.return_value = "." + mock_text.side_effect = text_instances + + sel_instances = [MagicMock(), MagicMock(), MagicMock()] + sel_instances[0].run.return_value = "Daily at specific time(s)..." + sel_instances[1].run.return_value = "code-puppy" + sel_instances[2].run.return_value = "(use default model)" + mock_sel.side_effect = sel_instances + + multi_inst = MagicMock() + multi_inst.run.return_value = "prompt" + mock_multi.return_value = multi_inst + + result = create_task_wizard() + assert result is not None + assert result["schedule_type"] == "daily_at" + assert result["schedule_value"] == "09:00,17:00" # bad entries stripped + + @patch("code_puppy.command_line.utils.safe_input", return_value="y") + @patch(f"{_MOD}.MultilineInputMenu") + @patch(f"{_MOD}.TextInputMenu") + @patch(f"{_MOD}.SelectionMenu") + @patch(f"{_MOD}.get_available_models_list", return_value=["m1"]) + @patch( + f"{_MOD}.get_available_agents_list", return_value=[("code-puppy", "Default")] + ) + def test_daily_at_summary_display( + self, + mock_agents, + mock_models, + mock_sel, + mock_text, + mock_multi, + mock_confirm, + capsys, + ): + """Summary line should read 'daily at HH:MM' not the raw type/value.""" + from code_puppy.plugins.scheduler.scheduler_wizard import create_task_wizard + + text_instances = [MagicMock(), MagicMock(), MagicMock()] + text_instances[0].run.return_value = "Task" + text_instances[1].run.return_value = "09:00" + text_instances[2].run.return_value = "." + mock_text.side_effect = text_instances + + sel_instances = [MagicMock(), MagicMock(), MagicMock()] + sel_instances[0].run.return_value = "Daily at specific time(s)..." + sel_instances[1].run.return_value = "code-puppy" + sel_instances[2].run.return_value = "(use default model)" + mock_sel.side_effect = sel_instances + + multi_inst = MagicMock() + multi_inst.run.return_value = "prompt" + mock_multi.return_value = multi_inst + + create_task_wizard() + out = capsys.readouterr().out + assert "daily at 09:00" in out + assert "daily_at" not in out # raw type should not appear in summary + + # ----------------------------------------------------------------------- + # Test all schedule_map entries + # ----------------------------------------------------------------------- + # Test all schedule_map entries @pytest.mark.parametrize( "choice,expected_type,expected_value", @@ -584,6 +843,7 @@ def test_wizard_code_puppy_first( ("Every 30 minutes", "interval", "30m"), ("Every 2 hours", "interval", "2h"), ("Every 6 hours", "interval", "6h"), + ("Daily (every 24h)", "daily", "24h"), ], ) @patch("code_puppy.command_line.utils.safe_input", return_value="y") diff --git a/tests/scheduler/test_daily_at.py b/tests/scheduler/test_daily_at.py new file mode 100644 index 000000000..43215a62a --- /dev/null +++ b/tests/scheduler/test_daily_at.py @@ -0,0 +1,246 @@ +"""Tests for the daily_at schedule type. + +Covers parse_daily_at_times() and the daily_at branch of should_run_task(). +All wall-clock comparisons use an explicit `now` so tests never depend on +the real system clock. +""" + +from datetime import datetime + +from code_puppy.scheduler.config import ScheduledTask +from code_puppy.scheduler.daemon import parse_daily_at_times, should_run_task + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +NOW = datetime(2026, 2, 27, 10, 0, 0) # Friday, 10:00:00 am — fixed reference point + + +def daily_at_task(times: str, last_run: str | None = None) -> ScheduledTask: + """Build a daily_at ScheduledTask with minimal boilerplate.""" + task = ScheduledTask( + name="test-task", + prompt="do the thing", + schedule_type="daily_at", + schedule_value=times, + ) + task.last_run = last_run + return task + + +# --------------------------------------------------------------------------- +# parse_daily_at_times +# --------------------------------------------------------------------------- + + +class TestParseDailyAtTimes: + """Unit tests for the time-string parser.""" + + def test_single_valid_time(self): + assert parse_daily_at_times("09:00") == [(9, 0)] + + def test_multiple_valid_times(self): + assert parse_daily_at_times("09:00,13:00,17:30") == [(9, 0), (13, 0), (17, 30)] + + def test_whitespace_around_commas_is_stripped(self): + assert parse_daily_at_times("09:00 , 17:00") == [(9, 0), (17, 0)] + + def test_midnight(self): + assert parse_daily_at_times("00:00") == [(0, 0)] + + def test_end_of_day(self): + assert parse_daily_at_times("23:59") == [(23, 59)] + + def test_invalid_entry_is_skipped(self): + """A bad entry should not prevent valid ones from being returned.""" + assert parse_daily_at_times("notaime,09:00") == [(9, 0)] + + def test_all_invalid_returns_empty(self): + assert parse_daily_at_times("abc,xyz,9am") == [] + + def test_empty_string_returns_empty(self): + assert parse_daily_at_times("") == [] + + def test_missing_colon_is_invalid(self): + assert parse_daily_at_times("0900") == [] + + def test_out_of_range_hour_is_invalid(self): + assert parse_daily_at_times("25:00") == [] + + def test_out_of_range_minute_is_invalid(self): + assert parse_daily_at_times("10:60") == [] + + def test_invalid_warns_to_stdout(self, capsys): + parse_daily_at_times("bad") + out = capsys.readouterr().out + assert "Warning" in out + assert "bad" in out + + def test_valid_entry_does_not_warn(self, capsys): + parse_daily_at_times("09:00") + assert capsys.readouterr().out == "" + + +# --------------------------------------------------------------------------- +# should_run_task — daily_at branch +# --------------------------------------------------------------------------- + + +class TestShouldRunTaskDailyAt: + """Behavioural tests for the daily_at scheduling logic.""" + + # -- basic fire / no-fire ------------------------------------------------- + + def test_never_run_fires_when_past_target(self): + """A task that has never run should fire once the target time passes.""" + task = daily_at_task("09:00", last_run=None) + assert should_run_task(task, NOW) is True # NOW is 10:00, target was 09:00 + + def test_never_run_does_not_fire_before_target(self): + """A task that has never run should NOT fire if the target is still in the future.""" + task = daily_at_task("14:00", last_run=None) + assert should_run_task(task, NOW) is False # NOW is 10:00, target is 14:00 + + def test_fires_exactly_at_target_time(self): + """The task should fire right at the target minute (boundary inclusive).""" + at_target = datetime(2026, 2, 27, 9, 0, 0) + task = daily_at_task("09:00", last_run=None) + assert should_run_task(task, at_target) is True + + def test_does_not_fire_one_second_before_target(self): + """The task must NOT fire before the target minute starts.""" + just_before = datetime(2026, 2, 27, 8, 59, 59) + task = daily_at_task("09:00", last_run=None) + assert should_run_task(task, just_before) is False + + # -- last_run logic ------------------------------------------------------- + + def test_does_not_refire_after_running_today(self): + """Once a task has run today after the target, it must not fire again.""" + ran_at_nine_oh_five = "2026-02-27T09:05:00" + task = daily_at_task("09:00", last_run=ran_at_nine_oh_five) + assert should_run_task(task, NOW) is False + + def test_fires_when_last_run_was_yesterday(self): + """If last_run was yesterday the task is due again today.""" + yesterday = "2026-02-26T09:05:00" + task = daily_at_task("09:00", last_run=yesterday) + assert should_run_task(task, NOW) is True + + def test_restart_safe_fires_after_missed_window(self): + """If the daemon was down at fire-time it must catch up on next wakeup. + + Scenario: target=09:00, daemon restarted at 09:47, last_run=yesterday. + """ + restarted_late = datetime(2026, 2, 27, 9, 47, 0) + yesterday = "2026-02-26T22:00:00" + task = daily_at_task("09:00", last_run=yesterday) + assert should_run_task(task, restarted_late) is True + + def test_last_run_before_target_but_same_day_fires(self): + """last_run earlier today but before the target should still trigger.""" + ran_early_morning = "2026-02-27T07:00:00" # before 09:00 target + task = daily_at_task("09:00", last_run=ran_early_morning) + assert should_run_task(task, NOW) is True + + # -- multiple times ------------------------------------------------------- + + def test_multiple_times_first_due_second_future(self): + """With two targets, only fire if at least one is due and not yet run.""" + # 09:00 passed, 17:00 is future — last_run is yesterday so 09:00 is due + yesterday = "2026-02-26T09:05:00" + task = daily_at_task("09:00,17:00", last_run=yesterday) + assert should_run_task(task, NOW) is True + + def test_multiple_times_first_already_ran_second_future(self): + """If the only due target has already been serviced, do not fire.""" + ran_after_nine = "2026-02-27T09:05:00" # ran after 09:00 target + task = daily_at_task("09:00,17:00", last_run=ran_after_nine) + # 09:00 already serviced, 17:00 not yet reached + assert should_run_task(task, NOW) is False + + def test_multiple_times_second_now_due(self): + """When a later target becomes due the task should fire.""" + ran_after_nine = "2026-02-27T09:05:00" + at_five_pm = datetime(2026, 2, 27, 17, 1, 0) + task = daily_at_task("09:00,17:00", last_run=ran_after_nine) + assert should_run_task(task, at_five_pm) is True + + def test_multiple_times_all_future(self): + """When all targets are still in the future nothing should fire.""" + task = daily_at_task("13:00,17:00", last_run=None) + assert should_run_task(task, NOW) is False # NOW is 10:00 + + def test_multiple_times_both_past_runs_on_first_match(self): + """If both targets have passed and last_run is yesterday, task fires.""" + yesterday = "2026-02-26T22:00:00" + task = daily_at_task("08:00,09:00", last_run=yesterday) + assert should_run_task(task, NOW) is True + + # -- edge cases & error handling ------------------------------------------ + + def test_disabled_task_never_fires(self): + task = daily_at_task("09:00", last_run=None) + task.enabled = False + assert should_run_task(task, NOW) is False + + def test_all_invalid_times_returns_false_and_warns(self, capsys): + task = daily_at_task("bad,worse", last_run=None) + result = should_run_task(task, NOW) + assert result is False + out = capsys.readouterr().out + assert "Warning" in out + + def test_mix_valid_and_invalid_times_uses_valid_ones(self): + """Invalid entries in schedule_value are skipped; valid ones still work.""" + yesterday = "2026-02-26T09:05:00" + task = daily_at_task("bogus,09:00", last_run=yesterday) + assert should_run_task(task, NOW) is True + + def test_midnight_target_fires_after_midnight(self): + just_after_midnight = datetime(2026, 2, 27, 0, 1, 0) + task = daily_at_task("00:00", last_run="2026-02-26T00:05:00") + assert should_run_task(task, just_after_midnight) is True + + def test_midnight_target_does_not_fire_previous_night(self): + """Ran at 00:05 today — must not fire again until tomorrow's midnight.""" + ran_this_morning = "2026-02-27T00:05:00" + task = daily_at_task("00:00", last_run=ran_this_morning) + assert should_run_task(task, NOW) is False + + # -- 24-hour lookback for daemon restarts --------------------------------- + + def test_daemon_restart_catches_missed_yesterday_target(self): + """Daemon was down for 30+ hours; restarts before today's target. + + Scenario: target=09:00, last_run=Feb 24 09:02, now=Feb 26 07:30. + Today's 09:00 hasn't arrived yet, but yesterday's (Feb 25 09:00) + was missed while the daemon was down — the task must fire. + """ + last_ran_two_days_ago = "2026-02-24T09:02:00" + restarted_before_todays_target = datetime(2026, 2, 26, 7, 30, 0) + task = daily_at_task("09:00", last_run=last_ran_two_days_ago) + assert should_run_task(task, restarted_before_todays_target) is True + + def test_new_task_does_not_claim_yesterday_missed_window(self): + """Brand-new task (last_run=None) must wait for the next scheduled + occurrence, not immediately fire for yesterday's window. + + Scenario: task created at 07:30, target=09:00, now=07:30. + Yesterday's 09:00 is in the past but the task never existed then. + """ + new_task = daily_at_task("09:00", last_run=None) + before_target = datetime(2026, 2, 27, 7, 30, 0) + assert should_run_task(new_task, before_target) is False + + def test_already_ran_yesterday_does_not_refire_for_yesterday_window(self): + """Daemon restarts before today's target; task already ran yesterday. + + Scenario: target=09:00, last_=Feb 26 09:05, now=Feb 27 07:30. + Yesterday's window was serviced — must not double-fire. + """ + ran_yesterday_on_schedule = "2026-02-26T09:05:00" + restarted_before_todays_target = datetime(2026, 2, 27, 7, 30, 0) + task = daily_at_task("09:00", last_run=ran_yesterday_on_schedule) + assert should_run_task(task, restarted_before_todays_target) is False diff --git a/tests/scheduler/test_time_utils.py b/tests/scheduler/test_time_utils.py new file mode 100644 index 000000000..bd7982c18 --- /dev/null +++ b/tests/scheduler/test_time_utils.py @@ -0,0 +1,105 @@ +"""Tests for code_puppy.scheduler.time_utils.parse_times_hhmm.""" + +from unittest.mock import MagicMock + +import pytest + +from code_puppy.scheduler.time_utils import parse_times_hhmm + + +class TestParseTimesHhmm: + # -- happy-path parsing --------------------------------------------------- + + def test_single_valid_time(self): + assert parse_times_hhmm("09:00") == ["09:00"] + + def test_multiple_valid_times(self): + assert parse_times_hhmm("09:00,17:30") == ["09:00", "17:30"] + + def test_preserves_order(self): + assert parse_times_hhmm("17:30,09:00") == ["17:30", "09:00"] + + def test_midnight(self): + assert parse_times_hhmm("00:00") == ["00:00"] + + def test_end_of_day(self): + assert parse_times_hhmm("23:59") == ["23:59"] + + # -- normalisation -------------------------------------------------------- + + def test_normalises_single_digit_hour(self): + """'9:00' should be normalised to '09:00'.""" + assert parse_times_hhmm("9:00") == ["09:00"] + + def test_normalises_single_digit_minute(self): + """strptime/strftime round-trip ensures two-digit minute.""" + assert parse_times_hhmm("09:5") == ["09:05"] + + # -- deduplication -------------------------------------------------------- + + def test_deduplicates_exact_duplicates(self): + assert parse_times_hhmm("09:00,09:00") == ["09:00"] + + def test_deduplicates_normalised_equivalents(self): + """'9:00' and '09:00' represent the same time after normalisation.""" + assert parse_times_hhmm("9:00,09:00") == ["09:00"] + + def test_deduplicate_preserves_first_occurrence(self): + result = parse_times_hhmm("09:00,17:00,09:00,17:00") + assert result == ["09:00", "17:00"] + + # -- whitespace handling -------------------------------------------------- + + def test_strips_whitespace_around_entries(self): + assert parse_times_hhmm(" 09:00 , 17:30 ") == ["09:00", "17:30"] + + def test_empty_segments_between_commas_are_ignored(self): + assert parse_times_hhmm("09:00,,17:30") == ["09:00", "17:30"] + + def test_leading_trailing_commas_are_ignored(self): + assert parse_times_hhmm(",09:00,") == ["09:00"] + + # -- invalid entries ------------------------------------------------------ + + def test_empty_string_returns_empty_list(self): + assert parse_times_hhmm("") == [] + + def test_all_invalid_returns_empty_list(self): + assert parse_times_hhmm("bad,worse,9am") == [] + + def test_mixed_valid_and_invalid_keeps_valid(self): + assert parse_times_hhmm("09:00,bad,17:30") == ["09:00", "17:30"] + + def test_missing_colon_is_invalid(self): + assert parse_times_hhmm("0900") == [] + + def test_out_of_range_hour_is_invalid(self): + assert parse_times_hhmm("25:00") == [] + + def test_out_of_range_minute_is_invalid(self): + assert parse_times_hhmm("09:60") == [] + + # -- on_invalid callback -------------------------------------------------- + + def test_on_invalid_called_for_each_bad_entry(self): + callback = MagicMock() + parse_times_hhmm("09:00,bad,worse", on_invalid=callback) + assert callback.call_count == 2 + callback.assert_any_call("bad") + callback.assert_any_call("worse") + + def test_on_invalid_not_called_for_valid_entries(self): + callback = MagicMock() + parse_times_hhmm("09:00,17:30", on_invalid=callback) + callback.assert_not_called() + + def test_on_invalid_none_silently_skips(self): + """Default (no callback) must not raise for invalid input.""" + result = parse_times_hhmm("garbage", on_invalid=None) + assert result == [] + + def test_on_invalid_not_called_for_empty_segments(self): + """Empty segments (trailing comma, double comma) are dropped silently.""" + callback = MagicMock() + parse_times_hhmm("09:00,,", on_invalid=callback) + callback.assert_not_called()