1 """Test Days module."""
2 from datetime import datetime, timedelta
4 from tests.utils import (TestCaseSansDB, TestCaseWithDB, TestCaseWithServer,
6 from plomtask.dating import date_in_n_days as tested_date_in_n_days
7 from plomtask.days import Day
9 # so far the same as plomtask.dating.DATE_FORMAT, but for testing purposes we
10 # want to explicitly state our expectations here indepedently from that
11 TESTING_DATE_FORMAT = '%Y-%m-%d'
14 def _testing_date_in_n_days(n: int) -> str:
15 """Return in TEST_DATE_FORMAT date from today + n days.
17 As with TESTING_DATE_FORMAT, we assume this equal the original's code
18 at plomtask.dating.date_in_n_days, but want to state our expectations
19 explicitly to rule out importing issues from the original.
21 date = datetime.now() + timedelta(days=n)
22 return date.strftime(TESTING_DATE_FORMAT)
25 class TestsSansDB(TestCaseSansDB):
26 """Days module tests not requiring DB setup."""
28 legal_ids = ['2024-01-01', '2024-02-29']
29 illegal_ids = ['foo', '2023-02-29', '2024-02-30', '2024-02-01 23:00:00']
31 def test_date_in_n_days(self) -> None:
32 """Test dating.date_in_n_days"""
33 for n in [-100, -2, -1, 0, 1, 2, 1000]:
34 date = datetime.now() + timedelta(days=n)
35 self.assertEqual(tested_date_in_n_days(n),
36 date.strftime(TESTING_DATE_FORMAT))
38 def test_Day_datetime_weekday_neighbor_dates(self) -> None:
39 """Test Day's date parsing and neighbourhood resolution."""
40 self.assertEqual(datetime(2024, 5, 1), Day('2024-05-01').datetime)
41 self.assertEqual('Sunday', Day('2024-03-17').weekday)
42 self.assertEqual('March', Day('2024-03-17').month_name)
43 self.assertEqual('2023-12-31', Day('2024-01-01').prev_date)
44 self.assertEqual('2023-03-01', Day('2023-02-28').next_date)
47 class TestsWithDB(TestCaseWithDB):
48 """Tests requiring DB, but not server setup."""
50 default_ids = ('2024-01-01', '2024-01-02', '2024-01-03')
52 def test_Day_by_date_range_with_limits(self) -> None:
53 """Test .by_date_range_with_limits."""
54 self.check_by_date_range_with_limits('id', set_id_field=False)
56 def test_Day_with_filled_gaps(self) -> None:
57 """Test .with_filled_gaps."""
59 def expect_within_full_range_as_commented(
60 range_indexes: tuple[int, int],
61 indexes_to_provide: list[int]
63 start_i, end_i = range_indexes
65 days_expected = days_sans_comment[:]
66 for i in indexes_to_provide:
67 day_with_comment = days_with_comment[i]
68 days_provided += [day_with_comment]
69 days_expected[i] = day_with_comment
70 days_expected = days_expected[start_i:end_i+1]
71 start, end = dates[start_i], dates[end_i]
72 days_result = self.checked_class.with_filled_gaps(days_provided,
74 self.assertEqual(days_result, days_expected)
76 # for provided Days we use those from days_with_comment, to identify
77 # them against same-dated mere filler Days by their lack of comment
78 # (identity with Day at the respective position in days_sans_comment)
79 dates = [f'2024-02-0{n+1}' for n in range(9)]
80 days_with_comment = [Day(date, comment=date[-1:]) for date in dates]
81 days_sans_comment = [Day(date, comment='') for date in dates]
82 # check provided Days recognizable in (full-range) interval
83 expect_within_full_range_as_commented((0, 8), [0, 4, 8])
84 # check limited range, but limiting Days provided
85 expect_within_full_range_as_commented((2, 6), [2, 5, 6])
86 # check Days within range but beyond provided Days also filled in
87 expect_within_full_range_as_commented((1, 7), [2, 5])
88 # check provided Days beyond range ignored
89 expect_within_full_range_as_commented((3, 5), [1, 2, 4, 6, 7])
90 # check inversion of start_date and end_date returns empty list
91 expect_within_full_range_as_commented((5, 3), [2, 4, 6])
92 # check empty provision still creates filler elements in interval
93 expect_within_full_range_as_commented((3, 5), [])
94 # check single-element selection creating only filler beyond provided
95 expect_within_full_range_as_commented((1, 1), [2, 4, 6])
96 # check (un-saved) filler Days don't show up in cache or DB
98 day.save(self.db_conn)
99 self.checked_class.with_filled_gaps([day], dates[0], dates[-1])
100 self.check_identity_with_cache_and_db([day])
103 class ExpectedGetCalendar(Expected):
104 """Builder of expectations for GET /calendar."""
106 def __init__(self, start: int, end: int, *args: Any, **kwargs: Any
108 self._fields = {'start': _testing_date_in_n_days(start),
109 'end': _testing_date_in_n_days(end),
110 'today': _testing_date_in_n_days(0)}
111 self._fields['days'] = [_testing_date_in_n_days(i)
112 for i in range(start, end+1)]
113 super().__init__(*args, **kwargs)
114 for date in self._fields['days']:
115 self.lib_set('Day', [self.day_as_dict(date)])
118 class ExpectedGetDay(Expected):
119 """Builder of expectations for GET /day."""
120 _default_dict = {'make_type': 'full'}
121 _on_empty_make_temp = ('Day', 'day_as_dict')
123 def __init__(self, date: str, *args: Any, **kwargs: Any) -> None:
124 self._fields = {'day': date}
125 super().__init__(*args, **kwargs)
127 def recalc(self) -> None:
129 todos = [t for t in self.lib_all('Todo')
130 if t['date'] == self._fields['day']]
131 self.lib_get('Day', self._fields['day'])['todos'] = self.as_ids(todos)
132 self._fields['top_nodes'] = [
133 {'children': [], 'seen': 0, 'todo': todo['id']}
136 proc = self.lib_get('Process', todo['process_id'])
137 for title in ['conditions', 'enables', 'blockers', 'disables']:
138 todo[title] = proc[title]
139 conds_present = set()
141 for title in ['conditions', 'enables', 'blockers', 'disables']:
142 for cond_id in todo[title]:
143 conds_present.add(cond_id)
144 self._fields['conditions_present'] = list(conds_present)
145 for prefix in ['en', 'dis']:
147 for cond_id in conds_present:
148 blers[str(cond_id)] = self.as_ids(
149 [t for t in todos if cond_id in t[f'{prefix}ables']])
150 self._fields[f'{prefix}ablers_for'] = blers
151 self._fields['processes'] = self.as_ids(self.lib_all('Process'))
154 class TestsWithServer(TestCaseWithServer):
155 """Tests against our HTTP server/handler (and database)."""
158 def test_basic_GET_day(self) -> None:
159 """Test basic (no Processes/Conditions/Todos) GET /day basics."""
160 # check illegal date parameters
161 self.check_get_defaults('/day', '2024-01-01', 'date')
162 self.check_get('/day?date=2024-02-30', 400)
163 # check undefined day
164 exp = ExpectedGetDay(_testing_date_in_n_days(0))
165 self.check_json_get('/day', exp)
166 # check defined day with make_type parameter
168 exp = ExpectedGetDay(date)
169 exp.set('make_type', 'bar')
170 self.check_json_get(f'/day?date={date}&make_type=bar', exp)
171 # check parsing of 'yesterday', 'today', 'tomorrow'
172 for name, dist in [('yesterday', -1), ('today', 0), ('tomorrow', +1)]:
173 date = _testing_date_in_n_days(dist)
174 exp = ExpectedGetDay(date)
175 self.check_json_get(f'/day?date={name}', exp)
177 def test_fail_POST_day(self) -> None:
178 """Test malformed/illegal POST /day requests."""
179 # check payloads lacking minimum expecteds
180 url = '/day?date=2024-01-01'
181 minimal_post = {'make_type': '', 'day_comment': ''}
182 self.check_minimal_inputs(url, minimal_post)
183 # to next check illegal new_todo values, we need an actual Process
184 self.post_exp_process([], {}, 1)
185 # check illegal new_todo values
186 self.check_post(minimal_post | {'new_todo': ['foo']}, url, 400)
187 self.check_post(minimal_post | {'new_todo': [1, 2]}, url, 404)
188 # to next check illegal old_todo inputs, we need to first post Todo
189 self.check_post(minimal_post | {'new_todo': [1]}, url, 302,
190 '/day?date=2024-01-01&make_type=')
191 # check illegal old_todo inputs (equal list lengths though)
192 post = minimal_post | {'comment': ['foo'], 'effort': [3.3],
193 'done': [], 'todo_id': [1]}
194 self.check_post(post, url, 302, '/day?date=2024-01-01&make_type=')
195 post['todo_id'] = [2] # reference to non-existant Process
196 self.check_post(post, url, 404)
197 post['todo_id'] = ['a']
198 self.check_post(post, url, 400)
199 post['todo_id'] = [1]
200 post['done'] = ['foo']
201 self.check_post(post, url, 400)
202 post['done'] = [2] # reference to non-posted todo_id
203 self.check_post(post, url, 400)
205 post['effort'] = ['foo']
206 self.check_post(post, url, 400)
207 post['effort'] = [None]
208 self.check_post(post, url, 400)
209 post['effort'] = [3.3]
210 # check illegal old_todo inputs: unequal list lengths
212 self.check_post(post, url, 400)
213 post['comment'] = ['foo', 'foo']
214 self.check_post(post, url, 400)
215 post['comment'] = ['foo']
217 self.check_post(post, url, 400)
218 post['effort'] = [3.3, 3.3]
219 self.check_post(post, url, 400)
220 post['effort'] = [3.3]
221 post['todo_id'] = [1, 1]
222 self.check_post(post, url, 400)
223 post['todo_id'] = [1]
224 # # check valid POST payload on bad paths
225 self.check_post(post, '/day', 400)
226 self.check_post(post, '/day?date=', 400)
227 self.check_post(post, '/day?date=foo', 400)
229 def test_basic_POST_day(self) -> None:
230 """Test basic (no Processes/Conditions/Todos) POST /day.
232 Check POST requests properly parse 'today', 'tomorrow', 'yesterday',
233 and actual date strings; store 'day_comment'; preserve 'make_type'
234 setting in redirect even if nonsensical; and allow '' as 'new_todo'.
236 for name, dist, test_str in [('2024-01-01', None, 'a'),
238 ('yesterday', -1, 'c'),
239 ('tomorrow', +1, 'd')]:
240 date = name if dist is None else _testing_date_in_n_days(dist)
241 post = {'day_comment': test_str, 'make_type': f'x:{test_str}',
242 'new_todo': ['', '']}
243 post_url = f'/day?date={name}'
244 redir_url = f'{post_url}&make_type={post["make_type"]}'
245 self.check_post(post, post_url, 302, redir_url)
246 exp = ExpectedGetDay(date)
247 exp.set_day_from_post(date, post)
248 self.check_json_get(post_url, exp)
250 def test_GET_day_with_processes_and_todos(self) -> None:
251 """Test GET /day displaying Processes and Todos (no trees)."""
253 exp = ExpectedGetDay(date)
254 # check Processes get displayed in ['processes'] and ['_library'],
255 # even without any Todos referencing them
256 proc_posts = [{'title': 'foo', 'description': 'oof', 'effort': 1.1},
257 {'title': 'bar', 'description': 'rab', 'effort': 0.9}]
258 for i, proc_post in enumerate(proc_posts):
259 self.post_exp_process([exp], proc_post, i+1)
260 self.check_json_get(f'/day?date={date}', exp)
261 # post Todos of either Process and check their display
262 self.post_exp_day([exp], {'new_todo': [1, 2]})
263 self.check_json_get(f'/day?date={date}', exp)
264 # test malformed Todo manipulation posts
265 post_day = {'day_comment': '', 'make_type': '', 'comment': [''],
266 'new_todo': [], 'done': [1], 'effort': [2.3]}
267 self.check_post(post_day, f'/day?date={date}', 400) # no todo_id
268 post_day['todo_id'] = [2] # not identifying Todo refered by done
269 self.check_post(post_day, f'/day?date={date}', 400)
270 post_day['todo_id'] = [1, 2] # imply range beyond that of effort etc.
271 self.check_post(post_day, f'/day?date={date}', 400)
272 post_day['comment'] = ['FOO', '']
273 self.check_post(post_day, f'/day?date={date}', 400)
274 post_day['effort'] = [2.3, '']
275 post_day['comment'] = ['']
276 self.check_post(post_day, f'/day?date={date}', 400)
277 # add a comment to one Todo and set the other's doneness and effort
278 post_day['comment'] = ['FOO', '']
279 self.post_exp_day([exp], post_day)
280 self.check_json_get(f'/day?date={date}', exp)
281 # invert effort and comment between both Todos
282 # (cannot invert doneness, /day only collects positive setting)
283 post_day['comment'] = ['', 'FOO']
284 post_day['effort'] = ['', 2.3]
285 self.post_exp_day([exp], post_day)
286 self.check_json_get(f'/day?date={date}', exp)
288 def test_POST_day_todo_make_types(self) -> None:
289 """Test behavior of POST /todo on 'make_type'='full' and 'empty'."""
291 exp = ExpectedGetDay(date)
292 # create two Processes, with second one step of first one
293 self.post_exp_process([exp], {}, 2)
294 self.post_exp_process([exp], {'new_top_step': 2}, 1)
295 exp.lib_set('ProcessStep', [
296 exp.procstep_as_dict(1, owner_id=1, step_process_id=2)])
297 self.check_json_get(f'/day?date={date}', exp)
298 # post Todo of adopting Process, with make_type=full
299 self.post_exp_day([exp], {'make_type': 'full', 'new_todo': [1]})
300 exp.lib_get('Todo', 1)['children'] = [2]
301 exp.lib_set('Todo', [exp.todo_as_dict(2, 2)])
302 top_nodes = [{'todo': 1,
304 'children': [{'todo': 2,
307 exp.force('top_nodes', top_nodes)
308 self.check_json_get(f'/day?date={date}', exp)
309 # post another Todo of adopting Process, expect to adopt existing
310 self.post_exp_day([exp], {'make_type': 'full', 'new_todo': [1]})
311 exp.lib_set('Todo', [exp.todo_as_dict(3, 1, children=[2])])
312 top_nodes += [{'todo': 3,
314 'children': [{'todo': 2,
317 exp.force('top_nodes', top_nodes)
318 self.check_json_get(f'/day?date={date}', exp)
319 # post another Todo of adopting Process, no adopt with make_type=empty
320 self.post_exp_day([exp], {'make_type': 'empty', 'new_todo': [1]})
321 exp.lib_set('Todo', [exp.todo_as_dict(4, 1)])
322 top_nodes += [{'todo': 4,
325 exp.force('top_nodes', top_nodes)
326 self.check_json_get(f'/day?date={date}', exp)
328 def test_POST_day_new_todo_order_commutative(self) -> None:
329 """Check that order of 'new_todo' values in POST /day don't matter."""
331 exp = ExpectedGetDay(date)
332 self.post_exp_process([exp], {}, 2)
333 self.post_exp_process([exp], {'new_top_step': 2}, 1)
334 exp.lib_set('ProcessStep', [
335 exp.procstep_as_dict(1, owner_id=1, step_process_id=2)])
336 # make-full-day-post batch of Todos of both Processes in one order …,
337 self.post_exp_day([exp], {'make_type': 'full', 'new_todo': [1, 2]})
338 top_nodes: list[dict[str, Any]] = [{'todo': 1,
340 'children': [{'todo': 2,
343 exp.force('top_nodes', top_nodes)
344 exp.lib_get('Todo', 1)['children'] = [2]
345 self.check_json_get(f'/day?date={date}', exp)
346 # … and then in the other, expecting same node tree / relations
347 exp.lib_del('Day', date)
350 day_post = {'make_type': 'full', 'new_todo': [2, 1]}
351 self.post_exp_day([exp], day_post, date)
352 exp.lib_del('Todo', 1)
353 exp.lib_del('Todo', 2)
354 top_nodes[0]['todo'] = 3 # was: 1
355 top_nodes[0]['children'][0]['todo'] = 4 # was: 2
356 exp.lib_get('Todo', 3)['children'] = [4]
357 self.check_json_get(f'/day?date={date}', exp)
359 def test_POST_day_todo_deletion_by_negative_effort(self) -> None:
360 """Test POST /day removal of Todos by setting negative effort."""
362 exp = ExpectedGetDay(date)
363 self.post_exp_process([exp], {}, 1)
364 self.post_exp_day([exp], {'new_todo': [1]})
365 # check cannot remove Todo if commented
366 self.post_exp_day([exp],
367 {'todo_id': [1], 'comment': ['foo'], 'effort': [-1]})
368 self.check_json_get(f'/day?date={date}', exp)
369 # check *can* remove Todo while getting done
370 self.post_exp_day([exp],
371 {'todo_id': [1], 'comment': [''], 'effort': [-1],
373 exp.lib_del('Todo', 1)
374 self.check_json_get(f'/day?date={date}', exp)
376 def test_GET_day_with_conditions(self) -> None:
377 """Test GET /day displaying Conditions and their relations."""
379 exp = ExpectedGetDay(date)
380 # check non-referenced Conditions not shown
381 cond_posts = [{'is_active': 0, 'title': 'A', 'description': 'a'},
382 {'is_active': 1, 'title': 'B', 'description': 'b'}]
383 for i, cond_post in enumerate(cond_posts):
384 self.check_post(cond_post, f'/condition?id={i+1}')
385 self.check_json_get(f'/day?date={date}', exp)
386 # add Processes with Conditions, check Conditions now shown
387 for i, (c1, c2) in enumerate([(1, 2), (2, 1)]):
388 post = {'conditions': [c1], 'disables': [c1],
389 'blockers': [c2], 'enables': [c2]}
390 self.post_exp_process([exp], post, i+1)
391 for i, cond_post in enumerate(cond_posts):
392 exp.set_cond_from_post(i+1, cond_post)
393 self.check_json_get(f'/day?date={date}', exp)
394 # add Todos in relation to Conditions, check consequence relations
395 self.post_exp_day([exp], {'new_todo': [1, 2]})
396 self.check_json_get(f'/day?date={date}', exp)
398 def test_GET_calendar(self) -> None:
399 """Test GET /calendar responses based on various inputs, DB states."""
400 # check illegal date range delimiters
401 self.check_get('/calendar?start=foo', 400)
402 self.check_get('/calendar?end=foo', 400)
403 # check default range for expected selection/order without saved days
404 exp = ExpectedGetCalendar(-1, 366)
405 self.check_json_get('/calendar', exp)
406 self.check_json_get('/calendar?start=&end=', exp)
407 # check with named days as delimiters
408 exp = ExpectedGetCalendar(-1, +1)
409 self.check_json_get('/calendar?start=yesterday&end=tomorrow', exp)
410 # check zero-element range
411 exp = ExpectedGetCalendar(+1, 0)
412 self.check_json_get('/calendar?start=tomorrow&end=today', exp)
413 # check saved day shows up in results, proven by its comment
414 start_date = _testing_date_in_n_days(-5)
415 date = _testing_date_in_n_days(-2)
416 end_date = _testing_date_in_n_days(+5)
417 exp = ExpectedGetCalendar(-5, +5)
418 self.post_exp_day([exp], {'day_comment': 'foo'}, date)
419 url = f'/calendar?start={start_date}&end={end_date}'
420 self.check_json_get(url, exp)