taskcheck 1.0.0.post2__tar.gz → 1.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {taskcheck-1.0.0.post2 → taskcheck-1.1.0}/PKG-INFO +6 -2
- taskcheck-1.0.0.post2/taskcheck.egg-info/PKG-INFO → taskcheck-1.1.0/README.md +5 -13
- {taskcheck-1.0.0.post2 → taskcheck-1.1.0}/pyproject.toml +1 -1
- {taskcheck-1.0.0.post2 → taskcheck-1.1.0}/taskcheck/__main__.py +5 -5
- {taskcheck-1.0.0.post2 → taskcheck-1.1.0}/taskcheck/ical.py +7 -6
- {taskcheck-1.0.0.post2 → taskcheck-1.1.0}/taskcheck/install.py +11 -1
- {taskcheck-1.0.0.post2 → taskcheck-1.1.0}/taskcheck/report.py +15 -8
- taskcheck-1.0.0.post2/README.md → taskcheck-1.1.0/taskcheck.egg-info/PKG-INFO +17 -1
- {taskcheck-1.0.0.post2 → taskcheck-1.1.0}/tests/test_common.py +11 -0
- taskcheck-1.1.0/tests/test_ical.py +189 -0
- {taskcheck-1.0.0.post2 → taskcheck-1.1.0}/tests/test_parallel.py +88 -83
- {taskcheck-1.0.0.post2 → taskcheck-1.1.0}/tests/test_report.py +37 -1
- taskcheck-1.0.0.post2/tests/test_ical.py +0 -171
- {taskcheck-1.0.0.post2 → taskcheck-1.1.0}/LICENSE +0 -0
- {taskcheck-1.0.0.post2 → taskcheck-1.1.0}/setup.cfg +0 -0
- {taskcheck-1.0.0.post2 → taskcheck-1.1.0}/taskcheck/common.py +0 -0
- {taskcheck-1.0.0.post2 → taskcheck-1.1.0}/taskcheck/parallel.py +0 -0
- {taskcheck-1.0.0.post2 → taskcheck-1.1.0}/taskcheck.egg-info/SOURCES.txt +0 -0
- {taskcheck-1.0.0.post2 → taskcheck-1.1.0}/taskcheck.egg-info/dependency_links.txt +0 -0
- {taskcheck-1.0.0.post2 → taskcheck-1.1.0}/taskcheck.egg-info/entry_points.txt +0 -0
- {taskcheck-1.0.0.post2 → taskcheck-1.1.0}/taskcheck.egg-info/requires.txt +0 -0
- {taskcheck-1.0.0.post2 → taskcheck-1.1.0}/taskcheck.egg-info/top_level.txt +0 -0
- {taskcheck-1.0.0.post2 → taskcheck-1.1.0}/tests/test_install.py +0 -0
- {taskcheck-1.0.0.post2 → taskcheck-1.1.0}/tests/test_main.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: taskcheck
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: Automatic scheduler for taskwarrior
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -32,6 +32,9 @@ This is a taskwarrior extension that automatically schedules your tasks based on
|
|
|
32
32
|
estimated time, and calendar events, finding an optimal time to work on each task and match all your
|
|
33
33
|
deadlines.
|
|
34
34
|
|
|
35
|
+
> [!IMPORTANT]
|
|
36
|
+
> This repo is actively maintained again
|
|
37
|
+
|
|
35
38
|
## Features
|
|
36
39
|
|
|
37
40
|
- [x] **Use arbitrarily complex time maps for working hours**
|
|
@@ -44,7 +47,6 @@ deadlines.
|
|
|
44
47
|
- [x] Simple, customizable reports for planned and unplanned tasks
|
|
45
48
|
- [x] Emoji and attribute customization in reports
|
|
46
49
|
- [ ] Use Google API to access calendars
|
|
47
|
-
- [ ] Export tasks to iCal calendar and API calendars
|
|
48
50
|
|
|
49
51
|
## Install
|
|
50
52
|
|
|
@@ -138,6 +140,7 @@ expiration = 720 # In hours (720 hours = 30 days)
|
|
|
138
140
|
include_unplanned = true
|
|
139
141
|
additional_attributes = ["estimated", "due", "urgency"] # Extra attributes to show in the report
|
|
140
142
|
additional_attributes_unplanned = ["due", "urgency"] # Extra attributes for unplanned tasks
|
|
143
|
+
unplanned_max_due = "30d" # Only show unplanned tasks due within this Taskwarrior-relative horizon; tasks without a due date are always shown
|
|
141
144
|
emoji_keywords = {"meet"=":busts_in_silhouette:", "review"=":mag_right:"} # Map keywords to emoji
|
|
142
145
|
```
|
|
143
146
|
|
|
@@ -155,6 +158,7 @@ emoji_keywords = {"meet"=":busts_in_silhouette:", "review"=":mag_right:"} # Map
|
|
|
155
158
|
- `include_unplanned`: Show unplanned tasks in a separate section.
|
|
156
159
|
- `additional_attributes`: Extra columns to show in the report.
|
|
157
160
|
- `additional_attributes_unplanned`: Extra columns for unplanned tasks.
|
|
161
|
+
- `unplanned_max_due`: Optional Taskwarrior-relative horizon (for example `30d`) for dated unplanned tasks. Tasks without a due date are always shown; omitting this setting applies no limit.
|
|
158
162
|
- `emoji_keywords`: Map keywords in task descriptions to emoji.
|
|
159
163
|
|
|
160
164
|
## Algorithm
|
|
@@ -1,15 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: taskcheck
|
|
3
|
-
Version: 1.0.0.post2
|
|
4
|
-
Summary: Automatic scheduler for taskwarrior
|
|
5
|
-
Requires-Python: >=3.11
|
|
6
|
-
Description-Content-Type: text/markdown
|
|
7
|
-
Requires-Dist: appdirs>=1.4.4
|
|
8
|
-
Requires-Dist: icalendar>=6.0.1
|
|
9
|
-
Requires-Dist: random-unicode-emoji>=2.8
|
|
10
|
-
Requires-Dist: requests>=2.32.3
|
|
11
|
-
Requires-Dist: rich>=13.9.4
|
|
12
|
-
|
|
13
1
|
<div align="center">
|
|
14
2
|
|
|
15
3
|
```
|
|
@@ -32,6 +20,9 @@ This is a taskwarrior extension that automatically schedules your tasks based on
|
|
|
32
20
|
estimated time, and calendar events, finding an optimal time to work on each task and match all your
|
|
33
21
|
deadlines.
|
|
34
22
|
|
|
23
|
+
> [!IMPORTANT]
|
|
24
|
+
> This repo is actively maintained again
|
|
25
|
+
|
|
35
26
|
## Features
|
|
36
27
|
|
|
37
28
|
- [x] **Use arbitrarily complex time maps for working hours**
|
|
@@ -44,7 +35,6 @@ deadlines.
|
|
|
44
35
|
- [x] Simple, customizable reports for planned and unplanned tasks
|
|
45
36
|
- [x] Emoji and attribute customization in reports
|
|
46
37
|
- [ ] Use Google API to access calendars
|
|
47
|
-
- [ ] Export tasks to iCal calendar and API calendars
|
|
48
38
|
|
|
49
39
|
## Install
|
|
50
40
|
|
|
@@ -138,6 +128,7 @@ expiration = 720 # In hours (720 hours = 30 days)
|
|
|
138
128
|
include_unplanned = true
|
|
139
129
|
additional_attributes = ["estimated", "due", "urgency"] # Extra attributes to show in the report
|
|
140
130
|
additional_attributes_unplanned = ["due", "urgency"] # Extra attributes for unplanned tasks
|
|
131
|
+
unplanned_max_due = "30d" # Only show unplanned tasks due within this Taskwarrior-relative horizon; tasks without a due date are always shown
|
|
141
132
|
emoji_keywords = {"meet"=":busts_in_silhouette:", "review"=":mag_right:"} # Map keywords to emoji
|
|
142
133
|
```
|
|
143
134
|
|
|
@@ -155,6 +146,7 @@ emoji_keywords = {"meet"=":busts_in_silhouette:", "review"=":mag_right:"} # Map
|
|
|
155
146
|
- `include_unplanned`: Show unplanned tasks in a separate section.
|
|
156
147
|
- `additional_attributes`: Extra columns to show in the report.
|
|
157
148
|
- `additional_attributes_unplanned`: Extra columns for unplanned tasks.
|
|
149
|
+
- `unplanned_max_due`: Optional Taskwarrior-relative horizon (for example `30d`) for dated unplanned tasks. Tasks without a due date are always shown; omitting this setting applies no limit.
|
|
158
150
|
- `emoji_keywords`: Map keywords in task descriptions to emoji.
|
|
159
151
|
|
|
160
152
|
## Algorithm
|
|
@@ -24,18 +24,18 @@ arg_parser.add_argument(
|
|
|
24
24
|
"-s",
|
|
25
25
|
"--schedule",
|
|
26
26
|
action="store_true",
|
|
27
|
-
help="Perform the scheduling algorithm, giving a schedule and a scheduling UDA and alerting for not completable tasks",
|
|
27
|
+
help="Perform the scheduling algorithm, giving a schedule and a scheduling UDA and alerting for not completable tasks.",
|
|
28
28
|
)
|
|
29
29
|
arg_parser.add_argument(
|
|
30
30
|
"-f",
|
|
31
31
|
"--force-update",
|
|
32
32
|
action="store_true",
|
|
33
|
-
help="Force update of all ical calendars by ignoring cache expiration",
|
|
33
|
+
help="Force update of all ical calendars by ignoring cache expiration.",
|
|
34
34
|
)
|
|
35
35
|
arg_parser.add_argument(
|
|
36
36
|
"--taskrc",
|
|
37
37
|
action="store",
|
|
38
|
-
help="Set custom TASKRC directory for debugging purposes",
|
|
38
|
+
help="Set custom TASKRC directory for debugging purposes.",
|
|
39
39
|
)
|
|
40
40
|
arg_parser.add_argument(
|
|
41
41
|
"--urgency-weight",
|
|
@@ -45,14 +45,14 @@ arg_parser.add_argument(
|
|
|
45
45
|
arg_parser.add_argument(
|
|
46
46
|
"--dry-run",
|
|
47
47
|
action="store_true",
|
|
48
|
-
help="Perform scheduling without modifying the Taskwarrior database, useful for testing",
|
|
48
|
+
help="Perform scheduling without modifying the Taskwarrior database, useful for testing.",
|
|
49
49
|
)
|
|
50
50
|
arg_parser.add_argument(
|
|
51
51
|
"--no-auto-adjust-urgency",
|
|
52
52
|
dest="auto_adjust_urgency",
|
|
53
53
|
action="store_false",
|
|
54
54
|
default=True,
|
|
55
|
-
help="Disable automatic reduction of urgency weight
|
|
55
|
+
help="Disable automatic reduction of urgency weight when tasks cannot be completed on time. (Enabled by default, meaning automatic reduction will occur.)",
|
|
56
56
|
)
|
|
57
57
|
|
|
58
58
|
|
|
@@ -40,17 +40,18 @@ def parse_ical_events(ical_text, days_ahead, all_day, tz_name=None, verbose=Fals
|
|
|
40
40
|
if not all_day:
|
|
41
41
|
continue
|
|
42
42
|
else:
|
|
43
|
-
# all-day events should block since 00:00 and end at 23:59:59
|
|
44
43
|
event_start = datetime(
|
|
45
44
|
event_start.year,
|
|
46
45
|
event_start.month,
|
|
47
46
|
event_start.day,
|
|
48
|
-
0,
|
|
49
|
-
0,
|
|
50
|
-
0,
|
|
51
|
-
0,
|
|
52
47
|
)
|
|
53
|
-
event_end =
|
|
48
|
+
event_end = datetime(
|
|
49
|
+
event_end.year,
|
|
50
|
+
event_end.month,
|
|
51
|
+
event_end.day,
|
|
52
|
+
)
|
|
53
|
+
if event_end <= event_start:
|
|
54
|
+
event_end = event_start + timedelta(days=1)
|
|
54
55
|
if recurrence_id:
|
|
55
56
|
if end_date >= recurrence_id.dt.date() >= today:
|
|
56
57
|
# this was an occurrence of a recurring event
|
|
@@ -19,12 +19,22 @@ weight_urgency = 1.0
|
|
|
19
19
|
include_unplanned = true
|
|
20
20
|
additional_attributes = []
|
|
21
21
|
additional_attributes_unplanned = []
|
|
22
|
+
# unplanned_max_due = "30d"
|
|
22
23
|
emoji_keywords = {}
|
|
23
24
|
"""
|
|
24
25
|
|
|
25
26
|
|
|
26
27
|
def apply_config(key, value):
|
|
27
|
-
subprocess.run(
|
|
28
|
+
subprocess.run(
|
|
29
|
+
[
|
|
30
|
+
"task",
|
|
31
|
+
"rc.confirmation=0",
|
|
32
|
+
"config",
|
|
33
|
+
key,
|
|
34
|
+
value,
|
|
35
|
+
],
|
|
36
|
+
stdout=subprocess.DEVNULL,
|
|
37
|
+
)
|
|
28
38
|
|
|
29
39
|
|
|
30
40
|
def install():
|
|
@@ -194,6 +194,7 @@ def get_tasks(config, tasks, year, month, day):
|
|
|
194
194
|
"id": task["id"],
|
|
195
195
|
"project": task.get("project", ""),
|
|
196
196
|
"description": task["description"],
|
|
197
|
+
"urgency": task["urgency"],
|
|
197
198
|
"scheduling_day": f"{year}-{month}-{day}",
|
|
198
199
|
"scheduling_hours": m.group(1),
|
|
199
200
|
**{
|
|
@@ -265,22 +266,28 @@ def get_unplanned_tasks(config, tasks, taskrc=None):
|
|
|
265
266
|
env=env,
|
|
266
267
|
)
|
|
267
268
|
tasks = json.loads(tasks.stdout)
|
|
268
|
-
|
|
269
|
+
max_due = config.get("unplanned_max_due")
|
|
270
|
+
if not max_due:
|
|
271
|
+
return tasks
|
|
272
|
+
|
|
273
|
+
due_limit = get_taskwarrior_date(max_due, taskrc=taskrc)
|
|
274
|
+
return [
|
|
275
|
+
task
|
|
276
|
+
for task in tasks
|
|
277
|
+
if not task.get("due")
|
|
278
|
+
or datetime.strptime(task["due"], "%Y%m%dT%H%M%SZ") <= due_limit
|
|
279
|
+
]
|
|
269
280
|
|
|
270
281
|
|
|
271
282
|
def generate_report(config, constraint, verbose=False, force_update=False, taskrc=None, scheduling_results=None):
|
|
272
283
|
config = config["report"]
|
|
273
284
|
console = Console()
|
|
274
285
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
tasks = scheduling_results
|
|
278
|
-
else:
|
|
279
|
-
# Fetch tasks from Taskwarrior normally
|
|
280
|
-
tasks = fetch_tasks(taskrc)
|
|
286
|
+
original_tasks = fetch_tasks(taskrc)
|
|
287
|
+
tasks = scheduling_results if scheduling_results is not None else original_tasks
|
|
281
288
|
|
|
282
289
|
if config.get("include_unplanned"):
|
|
283
|
-
unplanned_tasks = get_unplanned_tasks(config,
|
|
290
|
+
unplanned_tasks = get_unplanned_tasks(config, original_tasks, taskrc=taskrc)
|
|
284
291
|
display_unplanned_tasks(console, config, unplanned_tasks)
|
|
285
292
|
|
|
286
293
|
for year, month, day in get_days_in_constraint(constraint, taskrc=taskrc):
|
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: taskcheck
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Automatic scheduler for taskwarrior
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: appdirs>=1.4.4
|
|
8
|
+
Requires-Dist: icalendar>=6.0.1
|
|
9
|
+
Requires-Dist: random-unicode-emoji>=2.8
|
|
10
|
+
Requires-Dist: requests>=2.32.3
|
|
11
|
+
Requires-Dist: rich>=13.9.4
|
|
12
|
+
|
|
1
13
|
<div align="center">
|
|
2
14
|
|
|
3
15
|
```
|
|
@@ -20,6 +32,9 @@ This is a taskwarrior extension that automatically schedules your tasks based on
|
|
|
20
32
|
estimated time, and calendar events, finding an optimal time to work on each task and match all your
|
|
21
33
|
deadlines.
|
|
22
34
|
|
|
35
|
+
> [!IMPORTANT]
|
|
36
|
+
> This repo is actively maintained again
|
|
37
|
+
|
|
23
38
|
## Features
|
|
24
39
|
|
|
25
40
|
- [x] **Use arbitrarily complex time maps for working hours**
|
|
@@ -32,7 +47,6 @@ deadlines.
|
|
|
32
47
|
- [x] Simple, customizable reports for planned and unplanned tasks
|
|
33
48
|
- [x] Emoji and attribute customization in reports
|
|
34
49
|
- [ ] Use Google API to access calendars
|
|
35
|
-
- [ ] Export tasks to iCal calendar and API calendars
|
|
36
50
|
|
|
37
51
|
## Install
|
|
38
52
|
|
|
@@ -126,6 +140,7 @@ expiration = 720 # In hours (720 hours = 30 days)
|
|
|
126
140
|
include_unplanned = true
|
|
127
141
|
additional_attributes = ["estimated", "due", "urgency"] # Extra attributes to show in the report
|
|
128
142
|
additional_attributes_unplanned = ["due", "urgency"] # Extra attributes for unplanned tasks
|
|
143
|
+
unplanned_max_due = "30d" # Only show unplanned tasks due within this Taskwarrior-relative horizon; tasks without a due date are always shown
|
|
129
144
|
emoji_keywords = {"meet"=":busts_in_silhouette:", "review"=":mag_right:"} # Map keywords to emoji
|
|
130
145
|
```
|
|
131
146
|
|
|
@@ -143,6 +158,7 @@ emoji_keywords = {"meet"=":busts_in_silhouette:", "review"=":mag_right:"} # Map
|
|
|
143
158
|
- `include_unplanned`: Show unplanned tasks in a separate section.
|
|
144
159
|
- `additional_attributes`: Extra columns to show in the report.
|
|
145
160
|
- `additional_attributes_unplanned`: Extra columns for unplanned tasks.
|
|
161
|
+
- `unplanned_max_due`: Optional Taskwarrior-relative horizon (for example `30d`) for dated unplanned tasks. Tasks without a due date are always shown; omitting this setting applies no limit.
|
|
146
162
|
- `emoji_keywords`: Map keywords in task descriptions to emoji.
|
|
147
163
|
|
|
148
164
|
## Algorithm
|
|
@@ -80,6 +80,17 @@ class TestAvailableHours:
|
|
|
80
80
|
available = get_available_hours(time_map, date, calendars)
|
|
81
81
|
assert available == 0.0
|
|
82
82
|
|
|
83
|
+
def test_get_available_hours_multi_day_all_day_event(self, sample_config):
|
|
84
|
+
time_map = sample_config["time_maps"]["work"]
|
|
85
|
+
calendars = [[{
|
|
86
|
+
"start": datetime(2023, 12, 5, 0, 0),
|
|
87
|
+
"end": datetime(2023, 12, 7, 0, 0),
|
|
88
|
+
}]]
|
|
89
|
+
|
|
90
|
+
assert get_available_hours(time_map, datetime(2023, 12, 5).date(), calendars) == 0.0
|
|
91
|
+
assert get_available_hours(time_map, datetime(2023, 12, 6).date(), calendars) == 0.0
|
|
92
|
+
assert get_available_hours(time_map, datetime(2023, 12, 7).date(), calendars) == 8.0
|
|
93
|
+
|
|
83
94
|
|
|
84
95
|
class TestLongRangeTimeMap:
|
|
85
96
|
def test_get_long_range_time_map(self, sample_config, sample_calendar_events):
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from datetime import datetime, timedelta
|
|
3
|
+
from unittest.mock import Mock, patch
|
|
4
|
+
|
|
5
|
+
from taskcheck.ical import fetch_ical_data, parse_ical_events, ical_to_dict, get_cache_filename
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestIcalFetching:
|
|
9
|
+
@patch("requests.get")
|
|
10
|
+
def test_fetch_ical_data_success(self, mock_get, mock_ical_response):
|
|
11
|
+
mock_response = Mock()
|
|
12
|
+
mock_response.text = mock_ical_response
|
|
13
|
+
mock_response.raise_for_status = Mock()
|
|
14
|
+
mock_get.return_value = mock_response
|
|
15
|
+
|
|
16
|
+
assert fetch_ical_data("https://example.com/calendar.ics") == mock_ical_response
|
|
17
|
+
|
|
18
|
+
@patch("requests.get")
|
|
19
|
+
def test_fetch_ical_data_error(self, mock_get):
|
|
20
|
+
mock_response = Mock()
|
|
21
|
+
mock_response.raise_for_status.side_effect = Exception("HTTP Error")
|
|
22
|
+
mock_get.return_value = mock_response
|
|
23
|
+
|
|
24
|
+
with pytest.raises(Exception):
|
|
25
|
+
fetch_ical_data("https://example.com/calendar.ics")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TestIcalParsing:
|
|
29
|
+
@patch("taskcheck.ical.datetime")
|
|
30
|
+
def test_parse_ical_events_simple(self, mock_datetime, mock_ical_response):
|
|
31
|
+
mock_datetime.now.return_value = datetime(2023, 12, 1, 12, 0, 0)
|
|
32
|
+
mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)
|
|
33
|
+
events = parse_ical_events(mock_ical_response, days_ahead=7, all_day=False)
|
|
34
|
+
assert len(events) == 2
|
|
35
|
+
|
|
36
|
+
@patch("taskcheck.ical.datetime")
|
|
37
|
+
def test_parse_ical_events_with_timezone(self, mock_datetime, mock_ical_response):
|
|
38
|
+
mock_datetime.now.return_value = datetime(2023, 12, 1, 12, 0, 0)
|
|
39
|
+
mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)
|
|
40
|
+
events = parse_ical_events(mock_ical_response, days_ahead=7, all_day=False, tz_name="America/New_York")
|
|
41
|
+
assert events and events[0]["start"].endswith(("-05:00", "-04:00"))
|
|
42
|
+
|
|
43
|
+
@patch("taskcheck.ical.datetime")
|
|
44
|
+
def test_parse_ical_events_recurring(self, mock_datetime):
|
|
45
|
+
mock_datetime.now.return_value = datetime(2023, 12, 1, 12, 0, 0)
|
|
46
|
+
mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)
|
|
47
|
+
ical_text = """BEGIN:VCALENDAR
|
|
48
|
+
VERSION:2.0
|
|
49
|
+
PRODID:test
|
|
50
|
+
BEGIN:VEVENT
|
|
51
|
+
UID:recurring-event
|
|
52
|
+
DTSTART:20231205T140000Z
|
|
53
|
+
DTEND:20231205T150000Z
|
|
54
|
+
SUMMARY:Weekly Meeting
|
|
55
|
+
RRULE:FREQ=WEEKLY;COUNT=3
|
|
56
|
+
END:VEVENT
|
|
57
|
+
END:VCALENDAR"""
|
|
58
|
+
assert len(parse_ical_events(ical_text, days_ahead=21, all_day=False)) == 3
|
|
59
|
+
|
|
60
|
+
@patch("taskcheck.ical.datetime")
|
|
61
|
+
def test_parse_ical_events_all_day(self, mock_datetime):
|
|
62
|
+
mock_datetime.now.return_value = datetime(2023, 12, 1, 12, 0, 0)
|
|
63
|
+
mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)
|
|
64
|
+
ical_text = """BEGIN:VCALENDAR
|
|
65
|
+
VERSION:2.0
|
|
66
|
+
PRODID:test
|
|
67
|
+
BEGIN:VEVENT
|
|
68
|
+
UID:all-day-event
|
|
69
|
+
DTSTART;VALUE=DATE:20231205
|
|
70
|
+
DTEND;VALUE=DATE:20231206
|
|
71
|
+
SUMMARY:All Day Event
|
|
72
|
+
END:VEVENT
|
|
73
|
+
END:VCALENDAR"""
|
|
74
|
+
assert len(parse_ical_events(ical_text, days_ahead=7, all_day=True)) == 1
|
|
75
|
+
assert parse_ical_events(ical_text, days_ahead=7, all_day=False) == []
|
|
76
|
+
|
|
77
|
+
@patch("taskcheck.ical.datetime")
|
|
78
|
+
def test_parse_multi_day_all_day_event_preserves_span(self, mock_datetime):
|
|
79
|
+
mock_datetime.now.return_value = datetime(2023, 12, 1, 12, 0, 0)
|
|
80
|
+
mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)
|
|
81
|
+
ical_text = """BEGIN:VCALENDAR
|
|
82
|
+
VERSION:2.0
|
|
83
|
+
PRODID:test
|
|
84
|
+
BEGIN:VEVENT
|
|
85
|
+
UID:multi-day-event
|
|
86
|
+
DTSTART;VALUE=DATE:20231205
|
|
87
|
+
DTEND;VALUE=DATE:20231207
|
|
88
|
+
SUMMARY:Two Day Event
|
|
89
|
+
END:VEVENT
|
|
90
|
+
END:VCALENDAR"""
|
|
91
|
+
events = parse_ical_events(ical_text, days_ahead=7, all_day=True)
|
|
92
|
+
assert events == [{"start": "2023-12-05T00:00:00", "end": "2023-12-07T00:00:00"}]
|
|
93
|
+
|
|
94
|
+
@patch("taskcheck.ical.datetime")
|
|
95
|
+
def test_parse_ical_events_exdate_and_recurrence_id(self, mock_datetime):
|
|
96
|
+
mock_datetime.now.return_value = datetime(2023, 12, 1, 12, 0, 0)
|
|
97
|
+
mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)
|
|
98
|
+
ical_text = """BEGIN:VCALENDAR
|
|
99
|
+
VERSION:2.0
|
|
100
|
+
PRODID:test
|
|
101
|
+
BEGIN:VEVENT
|
|
102
|
+
UID:rrule-exdate
|
|
103
|
+
DTSTART:20231205T140000Z
|
|
104
|
+
DTEND:20231205T150000Z
|
|
105
|
+
RRULE:FREQ=DAILY;COUNT=3
|
|
106
|
+
EXDATE:20231206T140000Z
|
|
107
|
+
END:VEVENT
|
|
108
|
+
BEGIN:VEVENT
|
|
109
|
+
UID:rrule-exception
|
|
110
|
+
RECURRENCE-ID:20231207T140000Z
|
|
111
|
+
DTSTART:20231207T160000Z
|
|
112
|
+
DTEND:20231207T170000Z
|
|
113
|
+
END:VEVENT
|
|
114
|
+
END:VCALENDAR"""
|
|
115
|
+
events = parse_ical_events(ical_text, days_ahead=10, all_day=False)
|
|
116
|
+
starts = {e["start"] for e in events}
|
|
117
|
+
assert len(events) == 2
|
|
118
|
+
assert any("2023-12-05T14:00:00+00:00" in s for s in starts)
|
|
119
|
+
assert any("2023-12-07T16:00:00+00:00" in s for s in starts)
|
|
120
|
+
|
|
121
|
+
@patch("taskcheck.ical.datetime")
|
|
122
|
+
def test_parse_ical_events_past_until_skipped(self, mock_datetime):
|
|
123
|
+
mock_datetime.now.return_value = datetime(2023, 12, 10, 12, 0, 0)
|
|
124
|
+
mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)
|
|
125
|
+
ical_text = """BEGIN:VCALENDAR
|
|
126
|
+
VERSION:2.0
|
|
127
|
+
PRODID:test
|
|
128
|
+
BEGIN:VEVENT
|
|
129
|
+
UID:past-rule
|
|
130
|
+
DTSTART:20231201T140000Z
|
|
131
|
+
DTEND:20231201T150000Z
|
|
132
|
+
RRULE:FREQ=DAILY;UNTIL=20231205T000000Z
|
|
133
|
+
END:VEVENT
|
|
134
|
+
END:VCALENDAR"""
|
|
135
|
+
assert parse_ical_events(ical_text, days_ahead=7, all_day=False) == []
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class TestIcalCaching:
|
|
139
|
+
def test_get_cache_filename(self):
|
|
140
|
+
filename = get_cache_filename("https://example.com/calendar.ics")
|
|
141
|
+
assert filename.suffix == ".json"
|
|
142
|
+
assert len(filename.stem) == 64
|
|
143
|
+
|
|
144
|
+
@patch("requests.get")
|
|
145
|
+
def test_ical_to_dict_with_cache(self, mock_get, temp_cache_dir, mock_ical_response):
|
|
146
|
+
mock_response = Mock()
|
|
147
|
+
mock_response.text = mock_ical_response
|
|
148
|
+
mock_response.raise_for_status = Mock()
|
|
149
|
+
mock_get.return_value = mock_response
|
|
150
|
+
url = "https://example.com/calendar.ics"
|
|
151
|
+
events1 = ical_to_dict(url, days_ahead=7, all_day=False, expiration=1.0)
|
|
152
|
+
events2 = ical_to_dict(url, days_ahead=7, all_day=False, expiration=1.0)
|
|
153
|
+
assert events1 == events2
|
|
154
|
+
assert mock_get.call_count == 1
|
|
155
|
+
|
|
156
|
+
@patch("requests.get")
|
|
157
|
+
def test_ical_to_dict_force_update(self, mock_get, temp_cache_dir, mock_ical_response):
|
|
158
|
+
mock_response = Mock()
|
|
159
|
+
mock_response.text = mock_ical_response
|
|
160
|
+
mock_response.raise_for_status = Mock()
|
|
161
|
+
mock_get.return_value = mock_response
|
|
162
|
+
url = "https://example.com/calendar.ics"
|
|
163
|
+
events1 = ical_to_dict(url, days_ahead=7, all_day=False, expiration=1.0)
|
|
164
|
+
events2 = ical_to_dict(url, days_ahead=7, all_day=False, expiration=1.0, force_update=True)
|
|
165
|
+
assert events1 == events2
|
|
166
|
+
assert mock_get.call_count == 2
|
|
167
|
+
|
|
168
|
+
@patch("requests.get")
|
|
169
|
+
def test_ical_to_dict_expired_cache_refreshes(self, mock_get, temp_cache_dir, mock_ical_response):
|
|
170
|
+
mock_response = Mock()
|
|
171
|
+
mock_response.text = mock_ical_response
|
|
172
|
+
mock_response.raise_for_status = Mock()
|
|
173
|
+
mock_get.return_value = mock_response
|
|
174
|
+
url = "https://example.com/calendar.ics"
|
|
175
|
+
ical_to_dict(url, days_ahead=7, all_day=False, expiration=1.0)
|
|
176
|
+
ical_to_dict(url, days_ahead=7, all_day=False, expiration=0.0)
|
|
177
|
+
assert mock_get.call_count == 2
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class TestExceptionHandling:
|
|
181
|
+
@patch("requests.get")
|
|
182
|
+
def test_ical_to_dict_malformed_ical(self, mock_get, temp_cache_dir):
|
|
183
|
+
mock_response = Mock()
|
|
184
|
+
mock_response.text = "INVALID ICAL DATA"
|
|
185
|
+
mock_response.raise_for_status = Mock()
|
|
186
|
+
mock_get.return_value = mock_response
|
|
187
|
+
|
|
188
|
+
with pytest.raises(Exception):
|
|
189
|
+
ical_to_dict("https://example.com/calendar.ics", days_ahead=7, all_day=False)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from datetime import datetime
|
|
1
|
+
from datetime import datetime, timedelta
|
|
2
2
|
from unittest.mock import patch
|
|
3
3
|
|
|
4
4
|
from taskcheck.parallel import (
|
|
@@ -121,10 +121,43 @@ class TestTimeAllocation:
|
|
|
121
121
|
|
|
122
122
|
allocate_time_for_day(task_info, 0, coeffs, verbose=True, weight_urgency=1.0)
|
|
123
123
|
|
|
124
|
-
# Should allocate time and update scheduling
|
|
125
124
|
assert task_info["task-1"]["remaining_hours"] < 2.0
|
|
126
125
|
assert len(task_info["task-1"]["scheduling"]) > 0
|
|
127
126
|
|
|
127
|
+
def test_allocate_time_for_day_wait_and_dependency_skip(self):
|
|
128
|
+
today = datetime.today().date()
|
|
129
|
+
wait_date = (today + timedelta(days=1)).strftime("%Y%m%dT%H%M%SZ")
|
|
130
|
+
task_info = {
|
|
131
|
+
"task-1": {
|
|
132
|
+
"task": {"id": 1, "uuid": "task-1", "estimated": "P2H", "wait": wait_date},
|
|
133
|
+
"remaining_hours": 2.0,
|
|
134
|
+
"task_time_map": [8.0],
|
|
135
|
+
"today_used_hours": 0.0,
|
|
136
|
+
"scheduling": {},
|
|
137
|
+
"urgency": 10.0,
|
|
138
|
+
"estimated_urgency": 8.0,
|
|
139
|
+
"due_urgency": 0.0,
|
|
140
|
+
"age_urgency": 1.0,
|
|
141
|
+
"started": False,
|
|
142
|
+
},
|
|
143
|
+
"task-2": {
|
|
144
|
+
"task": {"id": 2, "uuid": "task-2", "estimated": "P2H", "depends": ["task-1"]},
|
|
145
|
+
"remaining_hours": 2.0,
|
|
146
|
+
"task_time_map": [8.0],
|
|
147
|
+
"today_used_hours": 0.0,
|
|
148
|
+
"scheduling": {},
|
|
149
|
+
"urgency": 9.0,
|
|
150
|
+
"estimated_urgency": 8.0,
|
|
151
|
+
"due_urgency": 0.0,
|
|
152
|
+
"age_urgency": 1.0,
|
|
153
|
+
"started": False,
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
coeffs = UrgencyCoefficients({"P2H": 8.0}, False, 4.0, 365, 12, 2)
|
|
157
|
+
allocate_time_for_day(task_info, 0, coeffs, verbose=False, weight_urgency=1.0)
|
|
158
|
+
assert task_info["task-1"]["remaining_hours"] == 2.0
|
|
159
|
+
assert task_info["task-2"]["remaining_hours"] == 2.0
|
|
160
|
+
|
|
128
161
|
|
|
129
162
|
class TestDependencies:
|
|
130
163
|
def test_task_with_dependencies(self, sample_config):
|
|
@@ -237,7 +270,6 @@ class TestWeightConfiguration:
|
|
|
237
270
|
mock_tasks.assert_called_once()
|
|
238
271
|
|
|
239
272
|
def test_recompute_urgencies_with_weights(self):
|
|
240
|
-
"""Test that recompute_urgencies applies weights correctly."""
|
|
241
273
|
tasks_remaining = {
|
|
242
274
|
"task-1": {
|
|
243
275
|
"task": {"uuid": "task-1", "id": 1},
|
|
@@ -250,29 +282,19 @@ class TestWeightConfiguration:
|
|
|
250
282
|
}
|
|
251
283
|
}
|
|
252
284
|
|
|
253
|
-
coeffs = UrgencyCoefficients({"
|
|
285
|
+
coeffs = UrgencyCoefficients({"P2H": 8.0}, False, 0, 365, 12, 2)
|
|
254
286
|
date = datetime.now().date()
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
# Store original urgency to calculate base
|
|
287
|
+
recompute_urgencies(tasks_remaining, coeffs, date, 0.7)
|
|
288
|
+
assert tasks_remaining["task-1"]["urgency"] >= tasks_remaining["task-1"]["due_urgency"]
|
|
258
289
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
# weighted_urgency = base_urgency * weight_urgency + new_due_urgency
|
|
268
|
-
base_urgency = (
|
|
269
|
-
(task_info["urgency"] - task_info["due_urgency"]) / weight_urgency
|
|
270
|
-
if weight_urgency != 0
|
|
271
|
-
else 0
|
|
272
|
-
)
|
|
273
|
-
expected_urgency = base_urgency * weight_urgency + task_info["due_urgency"]
|
|
274
|
-
|
|
275
|
-
assert abs(task_info["urgency"] - expected_urgency) < 0.01
|
|
290
|
+
def test_recompute_urgencies_inherit_and_cycle(self):
|
|
291
|
+
tasks_remaining = {
|
|
292
|
+
"a": {"task": {"uuid": "a", "depends": ["b"]}, "urgency": 1.0, "estimated_urgency": 0.0, "due_urgency": 0.0, "age_urgency": 0.0, "remaining_hours": 1.0, "started": False},
|
|
293
|
+
"b": {"task": {"uuid": "b", "depends": ["a"]}, "urgency": 2.0, "estimated_urgency": 0.0, "due_urgency": 0.0, "age_urgency": 0.0, "remaining_hours": 1.0, "started": False},
|
|
294
|
+
}
|
|
295
|
+
coeffs = UrgencyCoefficients({"P1H": 5.0}, True, 0, 365, 12, 2)
|
|
296
|
+
recompute_urgencies(tasks_remaining, coeffs, datetime.now().date(), 1.0)
|
|
297
|
+
assert tasks_remaining["a"]["urgency"] == tasks_remaining["b"]["urgency"]
|
|
276
298
|
|
|
277
299
|
|
|
278
300
|
class TestMainSchedulingFunction:
|
|
@@ -468,69 +490,52 @@ class TestAutoAdjustUrgency:
|
|
|
468
490
|
sample_config,
|
|
469
491
|
test_taskrc,
|
|
470
492
|
):
|
|
471
|
-
"""Test that the final urgency weight message is printed when auto-adjust is used."""
|
|
472
|
-
# Use relative dates based on current date
|
|
473
|
-
from datetime import datetime, timedelta
|
|
474
|
-
|
|
475
493
|
now = datetime.now()
|
|
476
|
-
|
|
477
|
-
future_date_far = now + timedelta(days=7)
|
|
478
|
-
|
|
479
|
-
# Create tasks that will require at least one reduction in urgency weight
|
|
494
|
+
future_date = now + timedelta(days=3)
|
|
480
495
|
overdue_tasks = [
|
|
481
|
-
{
|
|
482
|
-
|
|
483
|
-
"uuid": "task-1",
|
|
484
|
-
"description": "Tight deadline",
|
|
485
|
-
"estimated": "P16H", # 2 working days, due in 7 days
|
|
486
|
-
"time_map": "work",
|
|
487
|
-
"urgency": 20.0,
|
|
488
|
-
"due": future_date_far.strftime(
|
|
489
|
-
"%Y%m%dT%H%M%SZ"
|
|
490
|
-
), # Due in 7 days - tight deadline
|
|
491
|
-
"status": "pending",
|
|
492
|
-
"entry": now.strftime("%Y%m%dT%H%M%SZ"), # Created today
|
|
493
|
-
},
|
|
494
|
-
{
|
|
495
|
-
"id": 2,
|
|
496
|
-
"uuid": "task-2",
|
|
497
|
-
"description": "Competing task",
|
|
498
|
-
"estimated": "P16H", # 2 working days, due in 3 days
|
|
499
|
-
"time_map": "work",
|
|
500
|
-
"urgency": 15.0,
|
|
501
|
-
"due": future_date_near.strftime(
|
|
502
|
-
"%Y%m%dT%H%M%SZ"
|
|
503
|
-
), # Same deadline to create conflict
|
|
504
|
-
"status": "pending",
|
|
505
|
-
"entry": now.strftime("%Y%m%dT%H%M%SZ"), # Created today
|
|
506
|
-
},
|
|
496
|
+
{"id": 1, "uuid": "task-1", "description": "Tight deadline", "estimated": "P16H", "time_map": "work", "urgency": 20.0, "due": future_date.strftime("%Y%m%dT%H%M%SZ"), "status": "pending", "entry": now.strftime("%Y%m%dT%H%M%SZ")},
|
|
497
|
+
{"id": 2, "uuid": "task-2", "description": "Competing task", "estimated": "P16H", "time_map": "work", "urgency": 15.0, "due": future_date.strftime("%Y%m%dT%H%M%SZ"), "status": "pending", "entry": now.strftime("%Y%m%dT%H%M%SZ")},
|
|
507
498
|
]
|
|
508
|
-
|
|
509
499
|
mock_tasks.return_value = overdue_tasks
|
|
510
|
-
mock_coeffs.return_value = UrgencyCoefficients(
|
|
511
|
-
{"P16H": 10.0, "P8H": 8.0}, False, 4.0, 365, 12, 2
|
|
512
|
-
)
|
|
500
|
+
mock_coeffs.return_value = UrgencyCoefficients({"P16H": 10.0, "P8H": 8.0}, False, 4.0, 365, 12, 2)
|
|
513
501
|
mock_calendars.return_value = []
|
|
514
|
-
|
|
515
|
-
mock_long_range.return_value = ([4.0] * 7, 0.0) # 28 total hours available
|
|
516
|
-
|
|
502
|
+
mock_long_range.return_value = ([4.0] * 7, 0.0)
|
|
517
503
|
with patch("taskcheck.parallel.console.print") as mock_console_print:
|
|
518
|
-
check_tasks_parallel(
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
taskrc=test_taskrc,
|
|
522
|
-
auto_adjust_urgency=True,
|
|
523
|
-
)
|
|
504
|
+
check_tasks_parallel(sample_config, verbose=True, taskrc=test_taskrc, auto_adjust_urgency=True)
|
|
505
|
+
messages = [" ".join(str(arg).lower() for arg in call.args) for call in mock_console_print.call_args_list]
|
|
506
|
+
assert any("final urgency weight" in msg or "cannot find a solution" in msg for msg in messages)
|
|
524
507
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
508
|
+
@patch("taskcheck.parallel.get_calendars")
|
|
509
|
+
@patch("taskcheck.parallel.get_tasks")
|
|
510
|
+
@patch("taskcheck.parallel.get_urgency_coefficients")
|
|
511
|
+
@patch("taskcheck.parallel.get_long_range_time_map")
|
|
512
|
+
def test_check_tasks_parallel_dry_run_and_warning(
|
|
513
|
+
self,
|
|
514
|
+
mock_long_range,
|
|
515
|
+
mock_coeffs,
|
|
516
|
+
mock_tasks,
|
|
517
|
+
mock_calendars,
|
|
518
|
+
sample_config,
|
|
519
|
+
test_taskrc,
|
|
520
|
+
):
|
|
521
|
+
now = datetime.now()
|
|
522
|
+
due = (now + timedelta(days=1)).strftime("%Y%m%dT%H%M%SZ")
|
|
523
|
+
mock_tasks.return_value = [{"id": 1, "uuid": "task-1", "description": "x", "estimated": "P2H", "time_map": "work", "urgency": 1.0, "due": due, "status": "pending", "entry": now.strftime("%Y%m%dT%H%M%SZ") }]
|
|
524
|
+
mock_coeffs.return_value = UrgencyCoefficients({"P2H": 8.0}, False, 4.0, 365, 12, 2)
|
|
525
|
+
mock_calendars.return_value = []
|
|
526
|
+
mock_long_range.return_value = ([0.5] * 7, 0.0)
|
|
527
|
+
with patch("taskcheck.parallel.console.print") as mock_print:
|
|
528
|
+
result = check_tasks_parallel(sample_config, verbose=False, taskrc=test_taskrc, dry_run=True, auto_adjust_urgency=False)
|
|
529
|
+
assert isinstance(result, list)
|
|
530
|
+
assert result
|
|
531
|
+
assert any("warning" in " ".join(str(a).lower() for a in call.args) for call in mock_print.call_args_list)
|
|
532
|
+
|
|
533
|
+
def test_update_tasks_with_scheduling_info_warns_when_late(self):
|
|
534
|
+
task_info = {
|
|
535
|
+
"u": {"task": {"id": 1, "description": "late", "due": "20231201T000000Z"}, "scheduling": {"2023-12-05": 2.0}}
|
|
536
|
+
}
|
|
537
|
+
with patch("taskcheck.parallel.subprocess.run") as mock_run, patch("taskcheck.parallel.console.print") as mock_print:
|
|
538
|
+
from taskcheck.parallel import update_tasks_with_scheduling_info
|
|
539
|
+
update_tasks_with_scheduling_info(task_info, verbose=False, taskrc="/tmp/taskrc")
|
|
540
|
+
mock_run.assert_called_once()
|
|
541
|
+
assert mock_print.called
|
|
@@ -111,6 +111,24 @@ class TestTaskFiltering:
|
|
|
111
111
|
assert 'env' in call_args.kwargs
|
|
112
112
|
assert call_args.kwargs['env']['TASKRC'] == test_taskrc
|
|
113
113
|
|
|
114
|
+
@patch('taskcheck.report.get_taskwarrior_date')
|
|
115
|
+
@patch('subprocess.run')
|
|
116
|
+
def test_get_unplanned_tasks_filters_due_horizon(self, mock_run, mock_date, sample_config, test_taskrc):
|
|
117
|
+
config = {**sample_config["report"], "unplanned_max_due": "30d"}
|
|
118
|
+
mock_date.return_value = datetime(2023, 12, 31)
|
|
119
|
+
mock_result = Mock()
|
|
120
|
+
mock_result.stdout = json.dumps([
|
|
121
|
+
{"id": 1, "description": "No deadline"},
|
|
122
|
+
{"id": 2, "description": "Due soon", "due": "20231215T120000Z"},
|
|
123
|
+
{"id": 3, "description": "Due later", "due": "20240115T120000Z"},
|
|
124
|
+
])
|
|
125
|
+
mock_run.return_value = mock_result
|
|
126
|
+
|
|
127
|
+
result = get_unplanned_tasks(config, [], taskrc=test_taskrc)
|
|
128
|
+
|
|
129
|
+
assert [task["id"] for task in result] == [1, 2]
|
|
130
|
+
mock_date.assert_called_once_with("30d", taskrc=test_taskrc)
|
|
131
|
+
|
|
114
132
|
|
|
115
133
|
class TestStringFormatting:
|
|
116
134
|
def test_tostring_boolean(self):
|
|
@@ -197,7 +215,25 @@ class TestReportGeneration:
|
|
|
197
215
|
}
|
|
198
216
|
]
|
|
199
217
|
|
|
200
|
-
# Should run without error
|
|
201
218
|
generate_report(sample_config, "today", verbose=True, taskrc=test_taskrc)
|
|
202
219
|
|
|
203
220
|
mock_unplanned.assert_called_once_with(sample_config["report"], [], taskrc=test_taskrc)
|
|
221
|
+
|
|
222
|
+
@patch('taskcheck.report.display_unplanned_tasks')
|
|
223
|
+
@patch('taskcheck.report.get_unplanned_tasks')
|
|
224
|
+
@patch('taskcheck.report.get_days_in_constraint')
|
|
225
|
+
@patch('taskcheck.report.fetch_tasks')
|
|
226
|
+
def test_generate_report_dry_run_keeps_unplanned_block(self, mock_fetch, mock_days, mock_unplanned, mock_display, sample_config, test_taskrc):
|
|
227
|
+
mock_fetch.return_value = [
|
|
228
|
+
{"id": 1, "description": "Planned", "scheduling": "2023-12-05 - PT2H", "urgency": 1.0, "project": "x"},
|
|
229
|
+
{"id": 2, "description": "No plan", "estimated": "", "urgency": 2.0, "project": "y"},
|
|
230
|
+
]
|
|
231
|
+
mock_days.return_value = [(2023, 12, 5)]
|
|
232
|
+
mock_unplanned.return_value = [{"id": 2, "description": "No plan", "urgency": 2.0, "project": "y"}]
|
|
233
|
+
scheduling_results = [{"id": 1, "description": "Planned", "scheduling_hours": "PT2H", "project": "x", "urgency": 1.0, "scheduling": "2023-12-05 - PT2H"}]
|
|
234
|
+
|
|
235
|
+
generate_report(sample_config, "today", verbose=False, taskrc=test_taskrc, scheduling_results=scheduling_results)
|
|
236
|
+
|
|
237
|
+
mock_fetch.assert_called_once_with(test_taskrc)
|
|
238
|
+
mock_unplanned.assert_called_once_with(sample_config["report"], mock_fetch.return_value, taskrc=test_taskrc)
|
|
239
|
+
mock_display.assert_called_once()
|
|
@@ -1,171 +0,0 @@
|
|
|
1
|
-
import pytest
|
|
2
|
-
from datetime import datetime, timedelta
|
|
3
|
-
from unittest.mock import patch, Mock
|
|
4
|
-
import json
|
|
5
|
-
import tempfile
|
|
6
|
-
|
|
7
|
-
from taskcheck.ical import (
|
|
8
|
-
fetch_ical_data,
|
|
9
|
-
parse_ical_events,
|
|
10
|
-
ical_to_dict,
|
|
11
|
-
get_cache_filename
|
|
12
|
-
)
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
class TestIcalFetching:
|
|
16
|
-
@patch('requests.get')
|
|
17
|
-
def test_fetch_ical_data_success(self, mock_get, mock_ical_response):
|
|
18
|
-
mock_response = Mock()
|
|
19
|
-
mock_response.text = mock_ical_response
|
|
20
|
-
mock_response.raise_for_status = Mock()
|
|
21
|
-
mock_get.return_value = mock_response
|
|
22
|
-
|
|
23
|
-
result = fetch_ical_data("https://example.com/calendar.ics")
|
|
24
|
-
|
|
25
|
-
assert result == mock_ical_response
|
|
26
|
-
mock_get.assert_called_once_with("https://example.com/calendar.ics")
|
|
27
|
-
|
|
28
|
-
@patch('requests.get')
|
|
29
|
-
def test_fetch_ical_data_error(self, mock_get):
|
|
30
|
-
mock_response = Mock()
|
|
31
|
-
mock_response.raise_for_status.side_effect = Exception("HTTP Error")
|
|
32
|
-
mock_get.return_value = mock_response
|
|
33
|
-
|
|
34
|
-
with pytest.raises(Exception):
|
|
35
|
-
fetch_ical_data("https://example.com/calendar.ics")
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
class TestIcalParsing:
|
|
39
|
-
@patch('taskcheck.ical.datetime')
|
|
40
|
-
def test_parse_ical_events_simple(self, mock_datetime, mock_ical_response):
|
|
41
|
-
# Mock datetime.now() to return a date that makes the test events valid
|
|
42
|
-
mock_datetime.now.return_value = datetime(2023, 12, 1, 12, 0, 0)
|
|
43
|
-
mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)
|
|
44
|
-
|
|
45
|
-
events = parse_ical_events(mock_ical_response, days_ahead=7, all_day=False)
|
|
46
|
-
|
|
47
|
-
assert len(events) >= 1
|
|
48
|
-
for event in events:
|
|
49
|
-
assert "start" in event
|
|
50
|
-
assert "end" in event
|
|
51
|
-
assert isinstance(event["start"], str)
|
|
52
|
-
assert isinstance(event["end"], str)
|
|
53
|
-
|
|
54
|
-
@patch('taskcheck.ical.datetime')
|
|
55
|
-
def test_parse_ical_events_with_timezone(self, mock_datetime, mock_ical_response):
|
|
56
|
-
mock_datetime.now.return_value = datetime(2023, 12, 1, 12, 0, 0)
|
|
57
|
-
mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)
|
|
58
|
-
|
|
59
|
-
events = parse_ical_events(
|
|
60
|
-
mock_ical_response,
|
|
61
|
-
days_ahead=7,
|
|
62
|
-
all_day=False,
|
|
63
|
-
tz_name="America/New_York"
|
|
64
|
-
)
|
|
65
|
-
|
|
66
|
-
assert len(events) >= 0
|
|
67
|
-
|
|
68
|
-
@patch('taskcheck.ical.datetime')
|
|
69
|
-
def test_parse_ical_events_recurring(self, mock_datetime):
|
|
70
|
-
mock_datetime.now.return_value = datetime(2023, 12, 1, 12, 0, 0)
|
|
71
|
-
mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)
|
|
72
|
-
|
|
73
|
-
ical_with_recurring = """BEGIN:VCALENDAR
|
|
74
|
-
VERSION:2.0
|
|
75
|
-
PRODID:test
|
|
76
|
-
BEGIN:VEVENT
|
|
77
|
-
UID:recurring-event
|
|
78
|
-
DTSTART:20231205T140000Z
|
|
79
|
-
DTEND:20231205T150000Z
|
|
80
|
-
SUMMARY:Weekly Meeting
|
|
81
|
-
RRULE:FREQ=WEEKLY;COUNT=3
|
|
82
|
-
END:VEVENT
|
|
83
|
-
END:VCALENDAR"""
|
|
84
|
-
|
|
85
|
-
events = parse_ical_events(ical_with_recurring, days_ahead=21, all_day=False)
|
|
86
|
-
|
|
87
|
-
# Should have 3 occurrences
|
|
88
|
-
assert len(events) == 3
|
|
89
|
-
|
|
90
|
-
@patch('taskcheck.ical.datetime')
|
|
91
|
-
def test_parse_ical_events_all_day(self, mock_datetime):
|
|
92
|
-
mock_datetime.now.return_value = datetime(2023, 12, 1, 12, 0, 0)
|
|
93
|
-
mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)
|
|
94
|
-
|
|
95
|
-
ical_all_day = """BEGIN:VCALENDAR
|
|
96
|
-
VERSION:2.0
|
|
97
|
-
PRODID:test
|
|
98
|
-
BEGIN:VEVENT
|
|
99
|
-
UID:all-day-event
|
|
100
|
-
DTSTART;VALUE=DATE:20231205
|
|
101
|
-
DTEND;VALUE=DATE:20231206
|
|
102
|
-
SUMMARY:All Day Event
|
|
103
|
-
END:VEVENT
|
|
104
|
-
END:VCALENDAR"""
|
|
105
|
-
|
|
106
|
-
events_include = parse_ical_events(ical_all_day, days_ahead=7, all_day=True)
|
|
107
|
-
events_exclude = parse_ical_events(ical_all_day, days_ahead=7, all_day=False)
|
|
108
|
-
|
|
109
|
-
assert len(events_include) >= len(events_exclude)
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
class TestIcalCaching:
|
|
113
|
-
def test_get_cache_filename(self):
|
|
114
|
-
url = "https://example.com/calendar.ics"
|
|
115
|
-
filename = get_cache_filename(url)
|
|
116
|
-
|
|
117
|
-
assert filename.suffix == ".json"
|
|
118
|
-
assert len(filename.stem) == 64 # SHA256 hash length
|
|
119
|
-
|
|
120
|
-
@patch('requests.get')
|
|
121
|
-
def test_ical_to_dict_with_cache(self, mock_get, temp_cache_dir, mock_ical_response):
|
|
122
|
-
mock_response = Mock()
|
|
123
|
-
mock_response.text = mock_ical_response
|
|
124
|
-
mock_response.raise_for_status = Mock()
|
|
125
|
-
mock_get.return_value = mock_response
|
|
126
|
-
|
|
127
|
-
url = "https://example.com/calendar.ics"
|
|
128
|
-
|
|
129
|
-
# First call should fetch and cache
|
|
130
|
-
events1 = ical_to_dict(url, days_ahead=7, all_day=False, expiration=1.0)
|
|
131
|
-
|
|
132
|
-
# Second call should use cache
|
|
133
|
-
events2 = ical_to_dict(url, days_ahead=7, all_day=False, expiration=1.0)
|
|
134
|
-
|
|
135
|
-
assert events1 == events2
|
|
136
|
-
# Should only make one HTTP request
|
|
137
|
-
assert mock_get.call_count == 1
|
|
138
|
-
|
|
139
|
-
@patch('requests.get')
|
|
140
|
-
def test_ical_to_dict_force_update(self, mock_get, temp_cache_dir, mock_ical_response):
|
|
141
|
-
mock_response = Mock()
|
|
142
|
-
mock_response.text = mock_ical_response
|
|
143
|
-
mock_response.raise_for_status = Mock()
|
|
144
|
-
mock_get.return_value = mock_response
|
|
145
|
-
|
|
146
|
-
url = "https://example.com/calendar.ics"
|
|
147
|
-
|
|
148
|
-
# First call
|
|
149
|
-
events1 = ical_to_dict(url, days_ahead=7, all_day=False, expiration=1.0)
|
|
150
|
-
|
|
151
|
-
# Force update should bypass cache
|
|
152
|
-
events2 = ical_to_dict(url, days_ahead=7, all_day=False, expiration=1.0, force_update=True)
|
|
153
|
-
|
|
154
|
-
assert events1 == events2
|
|
155
|
-
# Should make two HTTP requests
|
|
156
|
-
assert mock_get.call_count == 2
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
class TestExceptionHandling:
|
|
160
|
-
@patch('requests.get')
|
|
161
|
-
def test_ical_to_dict_malformed_ical(self, mock_get, temp_cache_dir):
|
|
162
|
-
mock_response = Mock()
|
|
163
|
-
mock_response.text = "INVALID ICAL DATA"
|
|
164
|
-
mock_response.raise_for_status = Mock()
|
|
165
|
-
mock_get.return_value = mock_response
|
|
166
|
-
|
|
167
|
-
url = "https://example.com/calendar.ics"
|
|
168
|
-
|
|
169
|
-
# Should handle malformed iCal gracefully
|
|
170
|
-
with pytest.raises(Exception):
|
|
171
|
-
ical_to_dict(url, days_ahead=7, all_day=False)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|