diff --git a/ical/todo.py b/ical/todo.py index 32d4283..a2c8395 100644 --- a/ical/todo.py +++ b/ical/todo.py @@ -29,7 +29,7 @@ Uri, RelatedTo, ) -from .util import dtstamp_factory, normalize_datetime, uid_factory +from .util import dtstamp_factory, normalize_datetime, uid_factory, local_timezone class TodoStatus(str, enum.Enum): @@ -213,16 +213,28 @@ def computed_duration(self) -> datetime.timedelta | None: @property def timespan(self) -> Timespan: """Return a timespan representing the item start and due date.""" - if not self.start: - raise ValueError("Event must have a start and due date to calculate timespan") - return Timespan.of(self.start, self.due or self.start) + return self.timespan_of(local_timezone()) def timespan_of(self, tzinfo: datetime.tzinfo) -> Timespan: """Return a timespan representing the item start and due date.""" - if not self.start: - raise ValueError("Event must have a start and due date to calculate timespan") + dtstart = self.dtstart + dtend = self.due + if dtstart is None: + if dtend is None: + # A component with the DTSTART and DUE specifies a to-do that + # will be associated with each successive calendar date, until + # it is completed. + dtstart = datetime.datetime.now(tzinfo).date() + dtend = dtstart + datetime.timedelta(days=1) + else: + # Component with a DTSTART but no DUE date will be sorted next + # to the due date. + dtstart = dtend + elif dtend is None: + # Component with a DTSTART but not DUE date will be sorted next to the start date + dtend = dtstart return Timespan.of( - normalize_datetime(self.start, tzinfo), normalize_datetime(self.due or self.start, tzinfo) + normalize_datetime(dtstart, tzinfo), normalize_datetime(dtend, tzinfo) ) @property diff --git a/tests/test_calendar_stream.py b/tests/test_calendar_stream.py index 8c2ec59..a0fc2cc 100644 --- a/tests/test_calendar_stream.py +++ b/tests/test_calendar_stream.py @@ -11,12 +11,14 @@ from ical.exceptions import CalendarParseError from ical.calendar_stream import CalendarStream, IcsCalendarStream +from ical.store import EventStore, TodoStore MAX_ITERATIONS = 30 TESTDATA_PATH = pathlib.Path("tests/testdata/") TESTDATA_FILES = list(TESTDATA_PATH.glob("*.ics")) TESTDATA_IDS = [ x.stem for x in TESTDATA_FILES ] + def test_empty_ics(mock_prodid: Generator[None, None, None]) -> None: """Test serialization of an empty ics file.""" calendar = IcsCalendarStream.calendar_from_ics("") @@ -72,7 +74,7 @@ def test_serialize(filename: pathlib.Path, snapshot: SnapshotAssertion) -> None: @pytest.mark.parametrize("filename", TESTDATA_FILES, ids=TESTDATA_IDS) -def test_iteration(filename: pathlib.Path, snapshot: SnapshotAssertion) -> None: +def test_timeline_iteration(filename: pathlib.Path) -> None: """Fixture to ensure all calendar events are valid and support iteration.""" with filename.open() as f: cal = IcsCalendarStream.from_ics(f.read()) @@ -83,6 +85,18 @@ def test_iteration(filename: pathlib.Path, snapshot: SnapshotAssertion) -> None: assert event is not None +@pytest.mark.parametrize("filename", TESTDATA_FILES, ids=TESTDATA_IDS) +def test_todo_list_iteration(filename: pathlib.Path) -> None: + """Fixture to read golden file and compare to golden output.""" + cal = CalendarStream.from_ics(filename.read_text()) + if not cal.calendars: + return + calendar = cal.calendars[0] + tl = TodoStore(calendar).todo_list() + for todo in itertools.islice(tl, MAX_ITERATIONS): + assert todo is not None + + def test_invalid_ics() -> None: """Test parsing failures for ics content.""" with pytest.raises(CalendarParseError, match="Failed to parse calendar stream"): diff --git a/tests/test_todo.py b/tests/test_todo.py index 5a95d8f..aa3c5de 100644 --- a/tests/test_todo.py +++ b/tests/test_todo.py @@ -7,6 +7,7 @@ from typing import Any from unittest.mock import patch +from freezegun import freeze_time import pytest from ical.exceptions import CalendarParseError @@ -103,3 +104,50 @@ def test_is_recurring() -> None: datetime.date(2024, 2, 3), datetime.date(2024, 2, 4), ] + + +def test_timestamp_start_due() -> None: + """Test a timespan of a Todo with a start and due date.""" + todo = Todo( + summary="Example", + dtstart=datetime.date(2022, 8, 1), + due=datetime.date(2022, 8, 7), + ) + + with patch("ical.todo.local_timezone", return_value=zoneinfo.ZoneInfo("CET")): + ts = todo.timespan + assert ts.start.isoformat() == "2022-08-01T00:00:00+02:00" + assert ts.end.isoformat() == "2022-08-07T00:00:00+02:00" + + ts = todo.timespan_of(zoneinfo.ZoneInfo("America/Regina")) + assert ts.start.isoformat() == "2022-08-01T00:00:00-06:00" + assert ts.end.isoformat() == "2022-08-07T00:00:00-06:00" + + +def test_timespan_missing_dtstart() -> None: + """Test a timespan of a Todo without a dtstart.""" + todo = Todo(summary="Example", due=datetime.date(2022, 8, 7)) + + with patch("ical.todo.local_timezone", return_value=zoneinfo.ZoneInfo("Pacific/Honolulu")): + ts = todo.timespan + assert ts.start.isoformat() == "2022-08-07T00:00:00-10:00" + assert ts.end.isoformat() == "2022-08-07T00:00:00-10:00" + + ts = todo.timespan_of(zoneinfo.ZoneInfo("America/Regina")) + assert ts.start.isoformat() == "2022-08-07T00:00:00-06:00" + assert ts.end.isoformat() == "2022-08-07T00:00:00-06:00" + + +def test_timespan_fallback() -> None: + """Test a timespan of a Todo with no explicit dtstart and due date""" + + with freeze_time("2022-09-03T09:38:05", tz_offset=10), patch("ical.todo.local_timezone", return_value=zoneinfo.ZoneInfo("Pacific/Honolulu")): + todo = Todo(summary="Example") + ts = todo.timespan + assert ts.start.isoformat() == "2022-09-03T00:00:00-10:00" + assert ts.end.isoformat() == "2022-09-04T00:00:00-10:00" + + with freeze_time("2022-09-03T09:38:05", tz_offset=10), patch("ical.todo.local_timezone", return_value=zoneinfo.ZoneInfo("Pacific/Honolulu")): + ts = todo.timespan_of(zoneinfo.ZoneInfo("America/Regina")) + assert ts.start.isoformat() == "2022-09-03T00:00:00-06:00" + assert ts.end.isoformat() == "2022-09-04T00:00:00-06:00"