Let a tasks widget merge multiple task lists, checkbox+color like calendar
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.
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
"""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]
|
||||
@@ -18,6 +18,7 @@ from app.models import (
|
||||
Frame,
|
||||
FrameButtonAction,
|
||||
FrameCalendar,
|
||||
FrameTaskList,
|
||||
PhotoWidgetConfig,
|
||||
ServerSettings,
|
||||
TaskWidgetConfig,
|
||||
@@ -180,7 +181,7 @@ def test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database(db
|
||||
_ensure_frame_calendars_rekeyed has a real frame_id-shaped table to
|
||||
migrate."""
|
||||
with db_module.engine.begin() as conn:
|
||||
for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs",
|
||||
for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs", "frame_task_lists",
|
||||
"calendar_widget_configs", "photo_widget_configs", "widgets"):
|
||||
conn.execute(text(f"DROP TABLE {table}"))
|
||||
conn.execute(text("DROP TABLE frame_calendars"))
|
||||
@@ -234,16 +235,22 @@ def test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database(db
|
||||
assert config.queue == ["legacy-asset", "next-asset"]
|
||||
|
||||
|
||||
def test_migration_17_extracts_tasks_into_a_standalone_widget(db_session):
|
||||
"""Exercises _migration_17's actual data-extraction SQL (the real
|
||||
"existing widget-system database upgrading past this migration"
|
||||
scenario): a calendar_widget_configs row in its pre-17 shape (tasks_*
|
||||
columns still present, still holding a configured task source) should
|
||||
come out the other side as a sibling `tasks` widget carrying that
|
||||
source, with calendar_widget_configs no longer having tasks_*
|
||||
columns at all."""
|
||||
def test_migration_17_and_18_extract_tasks_into_a_standalone_multi_list_widget(db_session):
|
||||
"""Exercises _migration_17 and _migration_18's actual data-extraction
|
||||
SQL back to back (the real "existing widget-system database
|
||||
upgrading past both migrations" scenario, since both apply in the
|
||||
same run_migrations() call here): a calendar_widget_configs row in
|
||||
its pre-17 shape (tasks_* columns still present, still holding a
|
||||
configured task source) should come out the other side as a sibling
|
||||
`tasks` widget with that source carried forward as its first
|
||||
FrameTaskList row (migration 17's extraction, then migration 18's
|
||||
further extraction of the single-source TaskWidgetConfig it produces
|
||||
into FrameTaskList), with calendar_widget_configs no longer having
|
||||
tasks_* columns and TaskWidgetConfig no longer having user_id/
|
||||
calendar_key columns either."""
|
||||
with db_module.engine.begin() as conn:
|
||||
conn.execute(text("DROP TABLE task_widget_configs"))
|
||||
conn.execute(text("DROP TABLE frame_task_lists"))
|
||||
conn.execute(text("DROP TABLE calendar_widget_configs"))
|
||||
conn.execute(text(
|
||||
"CREATE TABLE calendar_widget_configs ("
|
||||
@@ -301,6 +308,9 @@ def test_migration_17_extracts_tasks_into_a_standalone_widget(db_session):
|
||||
|
||||
columns = {c["name"] for c in inspect(db_module.engine).get_columns("calendar_widget_configs")}
|
||||
assert not any(c.startswith("tasks_") for c in columns)
|
||||
task_cfg_columns = {c["name"] for c in inspect(db_module.engine).get_columns("task_widget_configs")}
|
||||
assert "user_id" not in task_cfg_columns and "calendar_key" not in task_cfg_columns
|
||||
assert "show_completed" in task_cfg_columns
|
||||
|
||||
task_widgets = db_session.scalars(
|
||||
select(Widget).where(Widget.frame_id == 1, Widget.widget_type == "tasks")
|
||||
@@ -309,10 +319,16 @@ def test_migration_17_extracts_tasks_into_a_standalone_widget(db_session):
|
||||
task_widget = task_widgets[0]
|
||||
|
||||
cfg = db_session.get(TaskWidgetConfig, task_widget.id)
|
||||
assert cfg.user_id == user_id
|
||||
assert cfg.calendar_key == "caldav:/some/tasks/"
|
||||
assert cfg.checked_at == 123.0
|
||||
assert cfg.cached == [{"summary": "Buy milk"}]
|
||||
assert cfg.show_completed is False
|
||||
|
||||
task_list = db_session.scalars(
|
||||
select(FrameTaskList).where(FrameTaskList.widget_id == task_widget.id)
|
||||
).one()
|
||||
assert task_list.user_id == user_id
|
||||
assert task_list.calendar_key == "caldav:/some/tasks/"
|
||||
assert task_list.included is True
|
||||
|
||||
# Auto-placed without overlapping the calendar widget it was split from.
|
||||
cal = db_session.get(Widget, calendar_widget_id)
|
||||
@@ -330,7 +346,7 @@ def test_frame_calendars_rekey_attaches_existing_rows_to_their_calendar_widget(d
|
||||
the widget backfill, not as a numbered migration racing ahead of
|
||||
it (see _ensure_frame_calendars_rekeyed's own docstring)."""
|
||||
with db_module.engine.begin() as conn:
|
||||
for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs",
|
||||
for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs", "frame_task_lists",
|
||||
"calendar_widget_configs", "photo_widget_configs", "widgets"):
|
||||
conn.execute(text(f"DROP TABLE {table}"))
|
||||
conn.execute(text("DROP TABLE frame_calendars"))
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""The "owner controls adding their own data; anyone linked can mute it"
|
||||
permission pattern, repeated across calendar-select, tasks-source, and
|
||||
whiteboard-source -- exercised at the HTTP layer (not just unit-level)
|
||||
permission pattern, repeated across calendar-select, task-list-select,
|
||||
and whiteboard-source (plus the owner-only, no-mute-split calendar-color/
|
||||
task-list-color) -- exercised at the HTTP layer (not just unit-level)
|
||||
since the whole point is verifying the *endpoint's* authorization check,
|
||||
not just a helper function's logic. All three now live under
|
||||
not just a helper function's logic. All of these live under
|
||||
/api/frames/{id}/widgets/{widget_id}/... (see routers/api_widgets.py)."""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -13,6 +14,7 @@ from app.models import (
|
||||
CalendarWidgetConfig,
|
||||
Frame,
|
||||
FrameCalendar,
|
||||
FrameTaskList,
|
||||
TaskWidgetConfig,
|
||||
User,
|
||||
Widget,
|
||||
@@ -165,53 +167,139 @@ def test_whiteboard_source_400s_when_widget_is_not_a_whiteboard(client, db_sessi
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# --- tasks-source ---
|
||||
# --- task-list-select ---
|
||||
|
||||
def test_tasks_source_set_always_targets_the_caller(client, db_session):
|
||||
def test_task_list_select_bob_cannot_add_alices_list(client, db_session):
|
||||
frame = _setup_two_linked_users(client, db_session)
|
||||
widget = _add_tasks_widget(db_session, frame)
|
||||
client.cookies.clear()
|
||||
login(client, "bob")
|
||||
|
||||
resp = client.post(f"/api/frames/1/widgets/{widget.id}/tasks-source",
|
||||
json={"calendar_key": "caldav:/some/tasks/"}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
bob_row = db_session.query(User).filter_by(username="bob").one()
|
||||
cfg = db_session.get(TaskWidgetConfig, widget.id)
|
||||
assert cfg.user_id == bob_row.id
|
||||
assert cfg.calendar_key == "caldav:/some/tasks/"
|
||||
|
||||
|
||||
def test_tasks_source_anyone_linked_can_clear(client, db_session):
|
||||
frame = _setup_two_linked_users(client, db_session)
|
||||
widget = _add_tasks_widget(db_session, frame)
|
||||
client.post(f"/api/frames/1/widgets/{widget.id}/tasks-source",
|
||||
json={"calendar_key": "caldav:/alice/tasks/"}, headers=csrf_headers(client))
|
||||
alice_id = db_session.query(User).filter_by(username="alice").one().id
|
||||
|
||||
client.cookies.clear()
|
||||
login(client, "bob")
|
||||
resp = client.post(f"/api/frames/1/widgets/{widget.id}/tasks-source", json={"calendar_key": None},
|
||||
headers=csrf_headers(client))
|
||||
resp = client.post(f"/api/frames/1/widgets/{widget.id}/task-list-select", json={
|
||||
"user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": True,
|
||||
}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_task_list_select_owner_can_add_their_own(client, db_session):
|
||||
frame = _setup_two_linked_users(client, db_session)
|
||||
widget = _add_tasks_widget(db_session, frame)
|
||||
alice_id = db_session.query(User).filter_by(username="alice").one().id
|
||||
|
||||
resp = client.post(f"/api/frames/1/widgets/{widget.id}/task-list-select", json={
|
||||
"user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": True,
|
||||
}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
cfg = db_session.get(TaskWidgetConfig, widget.id)
|
||||
assert cfg.user_id is None
|
||||
assert cfg.calendar_key is None
|
||||
row = db_session.query(FrameTaskList).filter_by(
|
||||
widget_id=widget.id, user_id=alice_id, calendar_key="caldav:/alice/tasks/"
|
||||
).one()
|
||||
assert row.included is True
|
||||
|
||||
|
||||
def test_tasks_source_404s_for_a_widget_id_that_does_not_exist(client, db_session):
|
||||
_setup_two_linked_users(client, db_session)
|
||||
resp = client.post("/api/frames/1/widgets/999999/tasks-source",
|
||||
json={"calendar_key": "caldav:/some/tasks/"}, headers=csrf_headers(client))
|
||||
def test_task_list_select_bob_can_mute_alices_list(client, db_session):
|
||||
"""Muting is a display-preference veto anyone linked gets, unlike
|
||||
adding -- the one-sided half of this endpoint's permission split."""
|
||||
frame = _setup_two_linked_users(client, db_session)
|
||||
widget = _add_tasks_widget(db_session, frame)
|
||||
alice_id = db_session.query(User).filter_by(username="alice").one().id
|
||||
client.post(f"/api/frames/1/widgets/{widget.id}/task-list-select", json={
|
||||
"user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": True,
|
||||
}, headers=csrf_headers(client))
|
||||
|
||||
client.cookies.clear()
|
||||
login(client, "bob")
|
||||
resp = client.post(f"/api/frames/1/widgets/{widget.id}/task-list-select", json={
|
||||
"user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": False,
|
||||
}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
row = db_session.query(FrameTaskList).filter_by(
|
||||
widget_id=widget.id, user_id=alice_id, calendar_key="caldav:/alice/tasks/"
|
||||
).one()
|
||||
assert row.included is False
|
||||
|
||||
|
||||
def test_task_list_select_cannot_mute_a_list_that_was_never_added(client, db_session):
|
||||
frame = _setup_two_linked_users(client, db_session)
|
||||
widget = _add_tasks_widget(db_session, frame)
|
||||
alice_id = db_session.query(User).filter_by(username="alice").one().id
|
||||
|
||||
client.cookies.clear()
|
||||
login(client, "bob")
|
||||
resp = client.post(f"/api/frames/1/widgets/{widget.id}/task-list-select", json={
|
||||
"user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": False,
|
||||
}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_tasks_source_400s_when_widget_is_not_tasks(client, db_session):
|
||||
def test_task_list_select_404s_for_a_widget_id_that_does_not_exist(client, db_session):
|
||||
_setup_two_linked_users(client, db_session)
|
||||
resp = client.post("/api/frames/1/widgets/999999/task-list-select", json={
|
||||
"user_id": 1, "calendar_key": "caldav:/some/tasks/", "included": True,
|
||||
}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_task_list_select_400s_when_widget_is_not_tasks(client, db_session):
|
||||
frame = _setup_two_linked_users(client, db_session)
|
||||
photo_widget_id = _photo_widget_id(db_session, frame)
|
||||
resp = client.post(f"/api/frames/1/widgets/{photo_widget_id}/tasks-source",
|
||||
json={"calendar_key": "caldav:/some/tasks/"}, headers=csrf_headers(client))
|
||||
alice_id = db_session.query(User).filter_by(username="alice").one().id
|
||||
resp = client.post(f"/api/frames/1/widgets/{photo_widget_id}/task-list-select", json={
|
||||
"user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": True,
|
||||
}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# --- task-list-color ---
|
||||
|
||||
def test_task_list_color_owner_can_pin_it(client, db_session):
|
||||
frame = _setup_two_linked_users(client, db_session)
|
||||
widget = _add_tasks_widget(db_session, frame)
|
||||
alice_id = db_session.query(User).filter_by(username="alice").one().id
|
||||
client.post(f"/api/frames/1/widgets/{widget.id}/task-list-select", json={
|
||||
"user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": True,
|
||||
}, headers=csrf_headers(client))
|
||||
|
||||
resp = client.post(f"/api/frames/1/widgets/{widget.id}/task-list-color", json={
|
||||
"calendar_key": "caldav:/alice/tasks/", "color_index": 3,
|
||||
}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
row = db_session.query(FrameTaskList).filter_by(
|
||||
widget_id=widget.id, user_id=alice_id, calendar_key="caldav:/alice/tasks/"
|
||||
).one()
|
||||
assert row.color_index == 3
|
||||
|
||||
|
||||
def test_task_list_color_bob_cannot_recolor_alices_list(client, db_session):
|
||||
frame = _setup_two_linked_users(client, db_session)
|
||||
widget = _add_tasks_widget(db_session, frame)
|
||||
alice_id = db_session.query(User).filter_by(username="alice").one().id
|
||||
client.post(f"/api/frames/1/widgets/{widget.id}/task-list-select", json={
|
||||
"user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": True,
|
||||
}, headers=csrf_headers(client))
|
||||
|
||||
client.cookies.clear()
|
||||
login(client, "bob")
|
||||
resp = client.post(f"/api/frames/1/widgets/{widget.id}/task-list-color", json={
|
||||
"calendar_key": "caldav:/alice/tasks/", "color_index": 3,
|
||||
}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 404 # bob has no row for alice's list to recolor
|
||||
|
||||
|
||||
def test_task_list_color_rejects_out_of_range_index(client, db_session):
|
||||
frame = _setup_two_linked_users(client, db_session)
|
||||
widget = _add_tasks_widget(db_session, frame)
|
||||
alice_id = db_session.query(User).filter_by(username="alice").one().id
|
||||
client.post(f"/api/frames/1/widgets/{widget.id}/task-list-select", json={
|
||||
"user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": True,
|
||||
}, headers=csrf_headers(client))
|
||||
|
||||
resp = client.post(f"/api/frames/1/widgets/{widget.id}/task-list-color", json={
|
||||
"calendar_key": "caldav:/alice/tasks/", "color_index": 99,
|
||||
}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ while the (unmodified, copied-over) dialog JS posts form-urlencoded data
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.models import CalendarWidgetConfig, Frame, PhotoWidgetConfig, Widget
|
||||
from app.models import CalendarWidgetConfig, Frame, PhotoWidgetConfig, TaskWidgetConfig, Widget
|
||||
|
||||
from .conftest import csrf_headers
|
||||
|
||||
@@ -32,6 +32,18 @@ def _add_calendar_widget(db_session) -> Widget:
|
||||
return widget
|
||||
|
||||
|
||||
def _add_tasks_widget(db_session) -> Widget:
|
||||
import time
|
||||
|
||||
widget = Widget(frame_id=1, widget_type="tasks", x=0, y=0, w=2, h=2,
|
||||
sort_order=1, created_at=time.time())
|
||||
db_session.add(widget)
|
||||
db_session.flush()
|
||||
db_session.add(TaskWidgetConfig(widget_id=widget.id))
|
||||
db_session.commit()
|
||||
return widget
|
||||
|
||||
|
||||
def _mock_immich(monkeypatch):
|
||||
monkeypatch.setattr("app.routers.api_widgets.immich_client_for", lambda frame: object())
|
||||
monkeypatch.setattr("app.routers.api_widgets.list_assets", lambda client, album_id: _ASSETS)
|
||||
@@ -80,6 +92,21 @@ def test_config_save_updates_a_calendar_widget(client, db_session):
|
||||
assert cfg.weather_units == "celsius"
|
||||
|
||||
|
||||
def test_config_save_updates_a_tasks_widget(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget = _add_tasks_widget(db_session)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/frames/1/widgets/{widget.id}/config",
|
||||
data={"tasks_show_completed": "true"},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
cfg = db_session.get(TaskWidgetConfig, widget.id)
|
||||
assert cfg.show_completed is True
|
||||
|
||||
|
||||
def test_config_save_only_partially_updates_provided_fields(client, db_session):
|
||||
"""Fields not present in the POST are left untouched -- the whole
|
||||
point of the partial-update convention (each dialog's own form only
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"""app.widgets.tasks -- unit-level, no HTTP: constructs Widget/
|
||||
TaskWidgetConfig rows directly and monkeypatches the underlying fetch
|
||||
call (get_or_refresh_tasks_for_widget, throttle/CalDAV-fetch logic
|
||||
covered separately). Split out of the old calendar widget's week-view-
|
||||
only task list -- see models.TaskWidgetConfig."""
|
||||
call (get_or_refresh_tasks_for_widget, throttle/multi-list-merge/CalDAV
|
||||
-fetch logic covered separately -- see test_widget_config_and_queue_
|
||||
endpoints.py and test_permission_boundaries.py for the FrameTaskList
|
||||
inclusion/color HTTP-layer coverage). Split out of the old calendar
|
||||
widget's week-view-only, single-list task list -- see
|
||||
models.TaskWidgetConfig/FrameTaskList."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -11,8 +14,6 @@ import time
|
||||
from app import widgets
|
||||
from app.models import Frame, TaskWidgetConfig, Widget
|
||||
|
||||
from .conftest import make_user
|
||||
|
||||
|
||||
def _make_widget(db_session, **cfg_kwargs) -> tuple[Frame, Widget]:
|
||||
frame = db_session.get(Frame, 1)
|
||||
@@ -25,30 +26,44 @@ def _make_widget(db_session, **cfg_kwargs) -> tuple[Frame, Widget]:
|
||||
return frame, widget
|
||||
|
||||
|
||||
def _make_configured_widget(db_session) -> tuple[Frame, Widget]:
|
||||
user = make_user(db_session, "task-owner")
|
||||
return _make_widget(db_session, user_id=user.id, calendar_key="caldav:/some/tasks/")
|
||||
|
||||
|
||||
def test_render_unconfigured_widget_returns_correctly_sized_placeholder(db_session):
|
||||
frame, widget = _make_widget(db_session) # no user_id/calendar_key set
|
||||
img = widgets.tasks.render(db_session, frame, widget, 300, 200)
|
||||
assert img.size == (300, 200)
|
||||
|
||||
|
||||
def test_render_configured_widget_produces_a_correctly_sized_image(db_session, monkeypatch):
|
||||
frame, widget = _make_configured_widget(db_session)
|
||||
monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget",
|
||||
lambda db, frame, widget: [{"summary": "Buy milk", "due": None}])
|
||||
def test_render_produces_a_correctly_sized_image_with_no_lists_included(db_session, monkeypatch):
|
||||
"""No placeholder for "nothing included yet" -- same posture as
|
||||
app/widgets/calendar.py (see tasks.py's own module docstring):
|
||||
get_or_refresh_tasks_for_widget already returns [] with nothing
|
||||
included, and render() draws straight through that."""
|
||||
frame, widget = _make_widget(db_session)
|
||||
monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget", lambda db, frame, widget: [])
|
||||
|
||||
img = widgets.tasks.render(db_session, frame, widget, 300, 200)
|
||||
assert img.size == (300, 200)
|
||||
assert img.mode == "RGB"
|
||||
|
||||
|
||||
def test_render_configured_but_empty_task_list_still_renders(db_session, monkeypatch):
|
||||
frame, widget = _make_configured_widget(db_session)
|
||||
monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget", lambda db, frame, widget: [])
|
||||
def test_render_produces_a_correctly_sized_image_with_tasks(db_session, monkeypatch):
|
||||
frame, widget = _make_widget(db_session)
|
||||
monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget",
|
||||
lambda db, frame, widget: [
|
||||
{"summary": "Buy milk", "due": None, "completed_at": None,
|
||||
"owner_display_name": "Alice", "color_index": None},
|
||||
])
|
||||
|
||||
img = widgets.tasks.render(db_session, frame, widget, 300, 200)
|
||||
assert img.size == (300, 200)
|
||||
assert img.mode == "RGB"
|
||||
|
||||
|
||||
def test_render_with_completed_tasks(db_session, monkeypatch):
|
||||
"""show_completed's merged result includes completed tasks too (see
|
||||
caldav_client.fetch_tasks' completed_since) -- render() must handle
|
||||
that shape (a non-None completed_at) without error."""
|
||||
frame, widget = _make_widget(db_session)
|
||||
monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget",
|
||||
lambda db, frame, widget: [
|
||||
{"summary": "Walk the dog", "due": "2026-08-01", "completed_at": None,
|
||||
"owner_display_name": "Alice", "color_index": 2},
|
||||
{"summary": "Buy milk", "due": None, "completed_at": "2026-08-01T10:00:00+00:00",
|
||||
"owner_display_name": "Alice", "color_index": 2},
|
||||
])
|
||||
|
||||
img = widgets.tasks.render(db_session, frame, widget, 300, 200)
|
||||
assert img.size == (300, 200)
|
||||
@@ -58,7 +73,7 @@ def test_render_at_minimum_grid_footprint(db_session, monkeypatch):
|
||||
"""grid.MIN_FOOTPRINT["tasks"] is (2, 2) cells -- on an 8x5 grid
|
||||
against a full 800x480 panel that's a 200x192 box, the smallest a
|
||||
tasks widget can actually be placed at."""
|
||||
frame, widget = _make_configured_widget(db_session)
|
||||
frame, widget = _make_widget(db_session)
|
||||
monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget", lambda db, frame, widget: [])
|
||||
|
||||
img = widgets.tasks.render(db_session, frame, widget, 200, 192)
|
||||
|
||||
Reference in New Issue
Block a user