Tasks widgets could only ever point at one CalDAV task list (a radio- button picker, owner-only). Now they merge any number of included task lists across every linked user, same checkbox-inclusion + optional pinned-color shape a calendar widget already has for its calendars -- FrameTaskList mirrors FrameCalendar exactly, down to the same owner- adds/anyone-mutes permission split (api_widget_task_list_select/ api_widget_task_list_color). Reused calendar_render._event_colors/ _draw_color_bar as-is for the per-task color bar -- a task dict's owner_display_name/color_index is exactly that function's single- source fallback shape. Also added an opt-in "show tasks completed in the last 24 hours" toggle (TaskWidgetConfig.show_completed): caldav_client.fetch_tasks now accepts a completed_since cutoff and returns completed VTODOs (with their completion time) instead of silently dropping them, and _draw_tasks gives a completed task a filled checkbox + muted text instead of the normal empty-box/due-date row. Migration 18 splits the single-source TaskWidgetConfig columns (added by 17, splitting tasks out of the calendar widget in the first place) into frame_task_lists, carrying forward each widget's existing single source as its first included list -- same shape migration 9 used carrying forward frame_calendars' old single opt-in. Verified live in the browser (desktop + mobile): the new "Included task lists" + "Recently completed" dialog sections, the show_completed toggle actually persisting through a real HTTP round-trip, and no regression in the calendar widget's own "Included calendars" dialog. Full suite (192 tests, including new merge_tasks/config_save/migration coverage) passes.
96 lines
4.0 KiB
Python
96 lines
4.0 KiB
Python
"""caldav_client.merge_tasks -- pure-function coverage (sort, per-source
|
|
color/owner tagging, partial-failure handling), monkeypatching
|
|
fetch_tasks itself rather than the caldav package's DAVClient/Calendar
|
|
-- this project has no CalDAV test server fixture (fetch_calendar_events'
|
|
own caldav branch is likewise only exercised this way, never against a
|
|
real server -- see test_calendar_feed.py, which only covers the ICS
|
|
path). fetch_tasks' own VTODO-parsing internals are trusted to the
|
|
icalendar library, same posture calendar_feed.py already takes for
|
|
VEVENT parsing."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
from app.caldav_client import CalDavError, TaskSource, merge_tasks
|
|
|
|
|
|
def test_single_source_tasks_pass_through_tagged(monkeypatch):
|
|
monkeypatch.setattr(
|
|
"app.caldav_client.fetch_tasks",
|
|
lambda url, username, password, completed_since=None: [
|
|
{"summary": "Buy milk", "due": "2026-08-01", "completed_at": None},
|
|
],
|
|
)
|
|
sources = [TaskSource("Alice", "https://example.com/tasks", "alice", "pw", color_index=3)]
|
|
tasks, summary = merge_tasks(sources)
|
|
assert summary == ""
|
|
assert tasks == [
|
|
{"summary": "Buy milk", "due": "2026-08-01", "completed_at": None,
|
|
"owner_display_name": "Alice", "color_index": 3},
|
|
]
|
|
|
|
|
|
def test_outstanding_tasks_sort_by_due_date_none_last(monkeypatch):
|
|
def fake_fetch(url, username, password, completed_since=None):
|
|
by_url = {
|
|
"a": [{"summary": "No due date", "due": None, "completed_at": None}],
|
|
"b": [{"summary": "Due soonest", "due": "2026-08-01", "completed_at": None}],
|
|
}
|
|
return by_url[url]
|
|
|
|
monkeypatch.setattr("app.caldav_client.fetch_tasks", fake_fetch)
|
|
sources = [TaskSource("Alice", "a", "alice", "pw"), TaskSource("Bob", "b", "bob", "pw")]
|
|
tasks, _ = merge_tasks(sources)
|
|
assert [t["summary"] for t in tasks] == ["Due soonest", "No due date"]
|
|
|
|
|
|
def test_completed_tasks_sort_after_outstanding_most_recent_first(monkeypatch):
|
|
def fake_fetch(url, username, password, completed_since=None):
|
|
return [
|
|
{"summary": "Outstanding", "due": "2026-08-05", "completed_at": None},
|
|
{"summary": "Done yesterday", "due": None, "completed_at": "2026-08-01T09:00:00+00:00"},
|
|
{"summary": "Done today", "due": None, "completed_at": "2026-08-02T09:00:00+00:00"},
|
|
]
|
|
|
|
monkeypatch.setattr("app.caldav_client.fetch_tasks", fake_fetch)
|
|
tasks, _ = merge_tasks([TaskSource("Alice", "a", "alice", "pw")])
|
|
assert [t["summary"] for t in tasks] == ["Outstanding", "Done today", "Done yesterday"]
|
|
|
|
|
|
def test_one_broken_source_does_not_blank_others(monkeypatch):
|
|
def fake_fetch(url, username, password, completed_since=None):
|
|
if url == "broken":
|
|
raise CalDavError("nope")
|
|
return [{"summary": "Buy milk", "due": None, "completed_at": None}]
|
|
|
|
monkeypatch.setattr("app.caldav_client.fetch_tasks", fake_fetch)
|
|
sources = [TaskSource("Alice", "ok", "alice", "pw"), TaskSource("Bob", "broken", "bob", "pw")]
|
|
tasks, summary = merge_tasks(sources)
|
|
assert len(tasks) == 1
|
|
assert summary == "1 of 2 task lists unavailable"
|
|
|
|
|
|
def test_all_sources_unreachable(monkeypatch):
|
|
monkeypatch.setattr(
|
|
"app.caldav_client.fetch_tasks",
|
|
lambda url, username, password, completed_since=None: (_ for _ in ()).throw(CalDavError("nope")),
|
|
)
|
|
sources = [TaskSource("Alice", "a", "alice", "pw"), TaskSource("Bob", "b", "bob", "pw")]
|
|
tasks, summary = merge_tasks(sources)
|
|
assert tasks == []
|
|
assert summary == "2 of 2 task lists unavailable"
|
|
|
|
|
|
def test_completed_since_is_forwarded_to_fetch_tasks(monkeypatch):
|
|
seen = []
|
|
|
|
def fake_fetch(url, username, password, completed_since=None):
|
|
seen.append(completed_since)
|
|
return []
|
|
|
|
monkeypatch.setattr("app.caldav_client.fetch_tasks", fake_fetch)
|
|
cutoff = datetime(2026, 8, 1, tzinfo=timezone.utc)
|
|
merge_tasks([TaskSource("Alice", "a", "alice", "pw")], completed_since=cutoff)
|
|
assert seen == [cutoff]
|