Files
cowork-local/tests/unit/test_schedule_calculator.py
T
vudt15andClaude Sonnet 5 69ab8e125b feat(R07): task repository, schedule calculator, Qt clock adapter, task/AI-planner services
Team Hoa, EPIC R07 (Scheduling & Workflow Runtime) - Team Hoa scope only
(R07-T01 -> T05; R07-T06 Co4EWorkflowService is Team Nam's).

- R07-T01: infrastructure/persistence/json/task_repository_impl.py wraps
  core/tasks.py's CRUD; core/tasks.py::save_task now writes through
  atomic_write.write_json (same durability fix as R06-T02, save_task was
  still doing a plain write_text).
- R07-T02: domain/tasks/schedule_calculator.py::ScheduleCalculator - the
  cron/interval/daily/weekly/monthly due-time math extracted from
  core/tasks.py, pure Python with is_holiday/make_cron injected so domain/
  never imports core (ADR-001 I2). core/tasks.py keeps its old function
  names as thin wrappers so every existing caller is unchanged. This was
  previously untested; now has its own unit suite.
- R07-T03: infrastructure/qt/qt_scheduler_clock.py::QtSchedulerClock wraps
  the QTimer TaskScheduler used to own directly, injected via a new
  `clock=` constructor param (defaults to a real one). Originally planned
  at platform/qt/... ; moved after confirming that name shadows the
  stdlib platform module (used by core/windows_sandbox_vm.py,
  core/appcontainer_sandbox.py) whenever the repo root is on sys.path.
  tests/fakes/fake_clock.py lets scheduler dispatch be tested tick-by-tick
  with no Qt event loop.
- R07-T04: application/scheduling/task_application_service.py centralizes
  run_now/duplicate/pause/delete/bulk_delete and the Kanban drag-drop
  business rules (move_to_status), currently only reachable by driving
  the real ui/schedule_task_tab.py widget.
- R07-T05: application/scheduling/ai_task_planner_service.py wraps
  core/ai_task_planner.py::plan_tasks and core/task_import.py::import_tasks
  as a seam, plus the attachment-stamping step that used to only exist
  inside the AI-create dialog's worker closure.

pytest: 328 pass (same 4 pre-existing failures as the R05/R06 baseline,
unrelated to this work - see docs/refactor/BaoCao_TeamHoa_R05_R06.md).
scripts/check_imports.py: PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:27:53 +09:00

165 lines
6.0 KiB
Python

"""EPIC R07-T02: ScheduleCalculator — pure due-time/cron math.
This is the one piece of scheduling logic core/tasks.py's own docstring
claimed was "Qt-free so it can be unit-tested headlessly" but had NO unit
test at all before this task (confirmed by grepping tests/ for
"schedule_calculator"/"compute_next_run"/"cron" — nothing matched). These
tests exercise domain/tasks/schedule_calculator.py directly, with no Qt, no
filesystem, and fake holiday/cron callables so the module stays provably
zero-I/O.
"""
from __future__ import annotations
from datetime import datetime
import pytest
from cowork_local.domain.tasks.schedule_calculator import ScheduleCalculator
def _sched(**overrides):
base = {
"enabled": True,
"run_at": "2026-08-24 09:00", # a Monday
"repeat_type": "none",
"cron_expression": None,
"working_days_only": False,
"skip_holidays": False,
"holiday_country": "VN",
}
base.update(overrides)
return base
def _task(**sched_overrides):
return {"status": "scheduled", "schedule": _sched(**sched_overrides)}
class _FakeCron:
"""A cron stub that fires every day at a fixed hour:minute — enough to
exercise the cron branch without depending on core/cron.py::Cron."""
def __init__(self, expression: str):
if expression == "bad":
raise ValueError("bad cron expression")
self.hour, self.minute = 10, 0
def next_after(self, after: datetime) -> datetime:
candidate = after.replace(hour=self.hour, minute=self.minute, second=0, microsecond=0)
if candidate <= after:
from datetime import timedelta
candidate += timedelta(days=1)
return candidate
def _is_weekend_holiday(date_, country):
# A deterministic fake: only 2026-08-29 (a Saturday) counts as a holiday.
return date_.isoformat() == "2026-08-29"
def test_daily_advances_by_one_day():
calc = ScheduleCalculator()
task = _task(repeat_type="daily")
nxt = calc.compute_next_run(task, datetime(2026, 8, 24, 9, 0))
assert nxt == datetime(2026, 8, 25, 9, 0)
def test_weekly_advances_by_seven_days():
calc = ScheduleCalculator()
task = _task(repeat_type="weekly")
nxt = calc.compute_next_run(task, datetime(2026, 8, 24, 9, 0))
assert nxt == datetime(2026, 8, 31, 9, 0)
def test_monthly_clamps_day_to_shorter_month():
calc = ScheduleCalculator()
task = _task(run_at="2026-01-31 09:00", repeat_type="monthly")
nxt = calc.compute_next_run(task, datetime(2026, 1, 31, 9, 0))
assert nxt == datetime(2026, 2, 28, 9, 0) # Feb 2026 has 28 days
def test_one_shot_repeat_type_none_has_no_next_run():
calc = ScheduleCalculator()
task = _task(repeat_type="none")
assert calc.compute_next_run(task, datetime(2026, 8, 24, 9, 0)) is None
def test_cron_without_injected_factory_returns_none():
"""No make_cron wired in -> a cron schedule simply never produces a next
run, instead of crashing the caller."""
calc = ScheduleCalculator()
task = _task(repeat_type="cron", cron_expression="0 10 * * *")
assert calc.compute_next_run(task, datetime(2026, 8, 24, 9, 0)) is None
def test_cron_uses_injected_factory():
calc = ScheduleCalculator(make_cron=_FakeCron)
task = _task(repeat_type="cron", cron_expression="0 10 * * *")
nxt = calc.compute_next_run(task, datetime(2026, 8, 24, 9, 0))
assert nxt == datetime(2026, 8, 24, 10, 0)
def test_malformed_cron_expression_returns_none_not_raise():
calc = ScheduleCalculator(make_cron=_FakeCron)
task = _task(repeat_type="cron", cron_expression="bad")
assert calc.compute_next_run(task, datetime(2026, 8, 24, 9, 0)) is None
def test_working_days_only_skips_weekend():
calc = ScheduleCalculator()
# 2026-08-28 is a Friday; +1 day (daily) would land on Saturday 08-29.
task = _task(run_at="2026-08-28 09:00", repeat_type="daily", working_days_only=True)
nxt = calc.compute_next_run(task, datetime(2026, 8, 28, 9, 0))
assert nxt.weekday() < 5 # Monday 08-31, not the weekend
def test_skip_holidays_uses_injected_is_holiday():
calc = ScheduleCalculator(is_holiday=_is_weekend_holiday)
# 2026-08-28 (Fri) + 1 day = 2026-08-29, which the fake marks a holiday.
task = _task(run_at="2026-08-28 09:00", repeat_type="daily", skip_holidays=True)
nxt = calc.compute_next_run(task, datetime(2026, 8, 28, 9, 0))
assert nxt == datetime(2026, 8, 30, 9, 0) # skipped past the holiday
def test_skip_holidays_without_injected_is_holiday_degrades_gracefully():
calc = ScheduleCalculator() # no is_holiday wired in
task = _task(run_at="2026-08-28 09:00", repeat_type="daily", skip_holidays=True)
nxt = calc.compute_next_run(task, datetime(2026, 8, 28, 9, 0))
assert nxt == datetime(2026, 8, 29, 9, 0) # no holiday check applied at all
def test_shift_off_excluded_days_moves_weekend_forward():
calc = ScheduleCalculator()
sched = _sched(working_days_only=True)
saturday = datetime(2026, 8, 29, 9, 0)
shifted = calc.shift_off_excluded_days(saturday, sched)
assert shifted.weekday() < 5
assert shifted >= saturday
def test_add_month_end_of_year_rolls_to_january():
calc = ScheduleCalculator()
assert calc.add_month(datetime(2026, 12, 15, 9, 0)) == datetime(2027, 1, 15, 9, 0)
def test_due_tasks_filters_by_status_enabled_and_run_at():
calc = ScheduleCalculator()
now = datetime(2026, 8, 27, 12, 0)
due_now = _task(run_at="2026-08-27 09:00")
due_now["title"] = "due"
future = _task(run_at="2026-08-28 09:00")
future["title"] = "future"
disabled = _task(run_at="2026-08-27 09:00", enabled=False)
disabled["title"] = "disabled"
not_scheduled = _task(run_at="2026-08-27 09:00")
not_scheduled["title"] = "backlog"
not_scheduled["status"] = "backlog"
result = calc.due_tasks([due_now, future, disabled, not_scheduled], now)
assert [t["title"] for t in result] == ["due"]
def test_due_tasks_empty_when_no_tasks():
calc = ScheduleCalculator()
assert calc.due_tasks([], datetime(2026, 8, 27, 12, 0)) == []