1 """Test Todos module."""
3 from tests.utils import TestCaseSansDB, TestCaseWithDB, TestCaseWithServer
4 from plomtask.todos import Todo, TodoNode
5 from plomtask.processes import Process, ProcessStep
6 from plomtask.conditions import Condition
7 from plomtask.exceptions import (NotFoundException, BadFormatException,
11 class TestsWithDB(TestCaseWithDB, TestCaseSansDB):
12 """Tests requiring DB, but not server setup.
14 NB: We subclass TestCaseSansDB too, to run any tests there that due to any
15 Todo requiring a _saved_ Process wouldn't run without a DB.
18 default_init_kwargs = {'process': None, 'is_done': False,
21 def setUp(self) -> None:
23 self.date1 = '2024-01-01'
24 self.date2 = '2024-01-02'
25 self.proc = Process(None)
26 self.proc.save(self.db_conn)
27 self.cond1 = Condition(None)
28 self.cond1.save(self.db_conn)
29 self.cond2 = Condition(None)
30 self.cond2.save(self.db_conn)
31 self.default_init_kwargs['process'] = self.proc
33 def test_Todo_init(self) -> None:
34 """Test creation of Todo and what they default to."""
35 process = Process(None)
36 with self.assertRaises(NotFoundException):
37 Todo(None, process, False, self.date1)
38 process.save(self.db_conn)
39 assert isinstance(self.cond1.id_, int)
40 assert isinstance(self.cond2.id_, int)
41 process.set_conditions(self.db_conn, [self.cond1.id_, self.cond2.id_])
42 process.set_enables(self.db_conn, [self.cond1.id_])
43 process.set_disables(self.db_conn, [self.cond2.id_])
44 todo_no_id = Todo(None, process, False, self.date1)
45 self.assertEqual(todo_no_id.conditions, [self.cond1, self.cond2])
46 self.assertEqual(todo_no_id.enables, [self.cond1])
47 self.assertEqual(todo_no_id.disables, [self.cond2])
48 todo_yes_id = Todo(5, process, False, self.date1)
49 self.assertEqual(todo_yes_id.conditions, [])
50 self.assertEqual(todo_yes_id.enables, [])
51 self.assertEqual(todo_yes_id.disables, [])
53 def test_Todo_by_date(self) -> None:
54 """Test findability of Todos by date."""
55 t1 = Todo(None, self.proc, False, self.date1)
57 t2 = Todo(None, self.proc, False, self.date1)
59 self.assertEqual(Todo.by_date(self.db_conn, self.date1), [t1, t2])
60 self.assertEqual(Todo.by_date(self.db_conn, self.date2), [])
61 with self.assertRaises(BadFormatException):
62 self.assertEqual(Todo.by_date(self.db_conn, 'foo'), [])
64 def test_Todo_by_date_range_with_limits(self) -> None:
65 """Test .by_date_range_with_limits."""
66 self.check_by_date_range_with_limits('day')
68 def test_Todo_on_conditions(self) -> None:
69 """Test effect of Todos on Conditions."""
70 assert isinstance(self.cond1.id_, int)
71 assert isinstance(self.cond2.id_, int)
72 todo = Todo(None, self.proc, False, self.date1)
73 todo.save(self.db_conn)
74 todo.set_enables(self.db_conn, [self.cond1.id_])
75 todo.set_disables(self.db_conn, [self.cond2.id_])
77 self.assertEqual(self.cond1.is_active, True)
78 self.assertEqual(self.cond2.is_active, False)
80 self.assertEqual(self.cond1.is_active, True)
81 self.assertEqual(self.cond2.is_active, False)
83 def test_Todo_children(self) -> None:
84 """Test Todo.children relations."""
85 todo_1 = Todo(None, self.proc, False, self.date1)
86 todo_2 = Todo(None, self.proc, False, self.date1)
87 todo_2.save(self.db_conn)
88 with self.assertRaises(HandledException):
89 todo_1.add_child(todo_2)
90 todo_1.save(self.db_conn)
91 todo_3 = Todo(None, self.proc, False, self.date1)
92 with self.assertRaises(HandledException):
93 todo_1.add_child(todo_3)
94 todo_3.save(self.db_conn)
95 todo_1.add_child(todo_3)
96 todo_1.save(self.db_conn)
97 assert isinstance(todo_1.id_, int)
98 todo_retrieved = Todo.by_id(self.db_conn, todo_1.id_)
99 self.assertEqual(todo_retrieved.children, [todo_3])
100 with self.assertRaises(BadFormatException):
101 todo_3.add_child(todo_1)
103 def test_Todo_conditioning(self) -> None:
104 """Test Todo.doability conditions."""
105 assert isinstance(self.cond1.id_, int)
106 todo_1 = Todo(None, self.proc, False, self.date1)
107 todo_1.save(self.db_conn)
108 todo_2 = Todo(None, self.proc, False, self.date1)
109 todo_2.save(self.db_conn)
110 todo_2.add_child(todo_1)
111 with self.assertRaises(BadFormatException):
112 todo_2.is_done = True
113 todo_1.is_done = True
114 todo_2.is_done = True
115 todo_2.is_done = False
116 todo_2.set_conditions(self.db_conn, [self.cond1.id_])
117 with self.assertRaises(BadFormatException):
118 todo_2.is_done = True
119 self.cond1.is_active = True
120 todo_2.is_done = True
122 def test_Todo_step_tree(self) -> None:
123 """Test self-configuration of TodoStepsNode tree for Day view."""
124 todo_1 = Todo(None, self.proc, False, self.date1)
125 todo_1.save(self.db_conn)
126 assert isinstance(todo_1.id_, int)
128 node_0 = TodoNode(todo_1, False, [])
129 self.assertEqual(todo_1.get_step_tree(set()).as_dict, node_0.as_dict)
130 # test non_emtpy seen_todo does something
132 self.assertEqual(todo_1.get_step_tree({todo_1.id_}).as_dict,
134 # test child shows up
135 todo_2 = Todo(None, self.proc, False, self.date1)
136 todo_2.save(self.db_conn)
137 assert isinstance(todo_2.id_, int)
138 todo_1.add_child(todo_2)
139 node_2 = TodoNode(todo_2, False, [])
140 node_0.children = [node_2]
142 self.assertEqual(todo_1.get_step_tree(set()).as_dict, node_0.as_dict)
143 # test child shows up with child
144 todo_3 = Todo(None, self.proc, False, self.date1)
145 todo_3.save(self.db_conn)
146 assert isinstance(todo_3.id_, int)
147 todo_2.add_child(todo_3)
148 node_3 = TodoNode(todo_3, False, [])
149 node_2.children = [node_3]
150 self.assertEqual(todo_1.get_step_tree(set()).as_dict, node_0.as_dict)
151 # test same todo can be child-ed multiple times at different locations
152 todo_1.add_child(todo_3)
153 node_4 = TodoNode(todo_3, True, [])
154 node_0.children += [node_4]
155 self.assertEqual(todo_1.get_step_tree(set()).as_dict, node_0.as_dict)
157 def test_Todo_create_with_children(self) -> None:
158 """Test parenthood guaranteeds of Todo.create_with_children."""
159 assert isinstance(self.proc.id_, int)
160 proc2 = Process(None)
161 proc2.save(self.db_conn)
162 assert isinstance(proc2.id_, int)
163 proc3 = Process(None)
164 proc3.save(self.db_conn)
165 assert isinstance(proc3.id_, int)
166 proc4 = Process(None)
167 proc4.save(self.db_conn)
168 assert isinstance(proc4.id_, int)
169 # make proc4 step of proc3
170 step = ProcessStep(None, proc3.id_, proc4.id_, None)
171 proc3.set_steps(self.db_conn, [step])
172 # give proc2 three steps; 2× proc1, 1× proc3
173 step1 = ProcessStep(None, proc2.id_, self.proc.id_, None)
174 step2 = ProcessStep(None, proc2.id_, self.proc.id_, None)
175 step3 = ProcessStep(None, proc2.id_, proc3.id_, None)
176 proc2.set_steps(self.db_conn, [step1, step2, step3])
177 # test mere creation does nothing
178 todo_ignore = Todo(None, proc2, False, self.date1)
179 todo_ignore.save(self.db_conn)
180 self.assertEqual(todo_ignore.children, [])
181 # test create_with_children on step-less does nothing
182 todo_1 = Todo.create_with_children(self.db_conn, self.proc.id_,
184 self.assertEqual(todo_1.children, [])
185 self.assertEqual(len(Todo.all(self.db_conn)), 2)
186 # test create_with_children adopts and creates, and down tree too
187 todo_2 = Todo.create_with_children(self.db_conn, proc2.id_, self.date1)
188 self.assertEqual(3, len(todo_2.children))
189 self.assertEqual(todo_1, todo_2.children[0])
190 self.assertEqual(self.proc, todo_2.children[2].process)
191 self.assertEqual(proc3, todo_2.children[1].process)
192 todo_3 = todo_2.children[1]
193 self.assertEqual(len(todo_3.children), 1)
194 self.assertEqual(todo_3.children[0].process, proc4)
196 def test_Todo_remove(self) -> None:
198 todo_1 = Todo(None, self.proc, False, self.date1)
199 todo_1.save(self.db_conn)
200 assert todo_1.id_ is not None
201 todo_0 = Todo(None, self.proc, False, self.date1)
202 todo_0.save(self.db_conn)
203 todo_0.add_child(todo_1)
204 todo_2 = Todo(None, self.proc, False, self.date1)
205 todo_2.save(self.db_conn)
206 todo_1.add_child(todo_2)
207 todo_1_id = todo_1.id_
208 todo_1.remove(self.db_conn)
209 with self.assertRaises(NotFoundException):
210 Todo.by_id(self.db_conn, todo_1_id)
211 self.assertEqual(todo_0.children, [])
212 self.assertEqual(todo_2.parents, [])
213 todo_2.comment = 'foo'
214 with self.assertRaises(HandledException):
215 todo_2.remove(self.db_conn)
218 with self.assertRaises(HandledException):
219 todo_2.remove(self.db_conn)
221 def test_Todo_autoremoval(self) -> None:
222 """"Test automatic removal for Todo.effort < 0."""
223 todo_1 = Todo(None, self.proc, False, self.date1)
224 todo_1.save(self.db_conn)
225 todo_1.comment = 'foo'
227 todo_1.save(self.db_conn)
228 assert todo_1.id_ is not None
229 Todo.by_id(self.db_conn, todo_1.id_)
231 todo_1_id = todo_1.id_
232 todo_1.save(self.db_conn)
233 with self.assertRaises(NotFoundException):
234 Todo.by_id(self.db_conn, todo_1_id)
237 class TestsWithServer(TestCaseWithServer):
238 """Tests against our HTTP server/handler (and database)."""
240 def setUp(self) -> None:
242 self._proc1_form_data: Any = self.post_process(1)
245 def GET_todo_dict(cls,
247 todos: list[dict[str, object]],
248 processes: list[dict[str, object]]
249 ) -> dict[str, object]:
250 """Return JSON of GET /todo to expect."""
251 library = {'Todo': cls.as_refs(todos),
252 'Process': cls.as_refs(processes)}
253 return {'todo': target_id,
254 'steps_todo_to_process': [],
255 'adoption_candidates_for': {},
256 'process_candidates': [p['id'] for p in processes],
257 'todo_candidates': [],
258 'condition_candidates': [],
261 def test_basic_fail_POST_todo(self) -> None:
262 """Test basic malformed/illegal POST /todo requests."""
263 # test we cannot just POST into non-existing Todo
264 self.check_post({}, '/todo', 404)
265 self.check_post({}, '/todo?id=FOO', 400)
266 self.check_post({}, '/todo?id=0', 404)
267 self.check_post({}, '/todo?id=1', 404)
268 # test malformed values on existing Todo
269 day_post = {'day_comment': '', 'new_todo': 1, 'make_type': 'full'}
270 self.check_post(day_post, '/day?date=2024-01-01&make_type=full')
271 for name in ['adopt', 'effort', 'make_full', 'make_empty',
272 'conditions', 'disables', 'blockers', 'enables']:
273 self.check_post({name: 'x'}, '/todo?id=1', 400, '/todo')
274 for prefix in ['make_empty_', 'make_full_']:
275 for suffix in ['', 'x', '1.1']:
276 self.check_post({'fill_for_1': f'{prefix}{suffix}'},
277 '/todo?id=1', 400, '/todo')
278 # test we cannot POST adoption of self or non-existing Todo
279 self.check_post({'adopt': 1}, '/todo?id=1', 400)
280 self.check_post({'adopt': 2}, '/todo?id=1', 404)
282 def test_do_POST_todo(self) -> None:
283 """Test POST /todo."""
285 day_post = {'day_comment': '', 'new_todo': 1, 'make_type': 'full'}
286 self.check_post(day_post, f'/day?date={date}&make_type=full')
287 # test posting naked entity at first changes nothing
288 todo_dict = self.todo_as_dict(1, process_id=1, date=date)
289 proc_dict = self.proc_as_dict(**self._proc1_form_data)
290 expected = self.GET_todo_dict(1, [todo_dict], [proc_dict])
291 self.check_json_get('/todo?id=1', expected)
292 self.check_post({}, '/todo?id=1')
293 self.check_json_get('/todo?id=1', expected)
294 # test posting doneness
295 todo_dict['is_done'] = True
296 self.check_post({'done': ''}, '/todo?id=1')
297 self.check_json_get('/todo?id=1', expected)
298 # test implicitly posting non-doneness
299 self.check_post({}, '/todo?id=1')
300 todo_dict['is_done'] = False
301 self.check_json_get('/todo?id=1', expected)
302 # post new Todo to Day and adopt it
303 self.check_post(day_post, f'/day?date={date}&make_type=full')
304 todo2_dict = self.todo_as_dict(2, process_id=1, date=date)
305 expected['todo_candidates'] = [2]
306 assert isinstance(expected['_library'], dict)
307 expected['_library']['Todo']['2'] = todo2_dict
308 expected['_library']['Todo']['2']['parents'] = [1]
309 expected['_library']['Todo']['1']['children'] = [2]
310 expected['steps_todo_to_process'] = [{
316 self.check_post({'adopt': 2}, '/todo?id=1')
317 self.check_json_get('/todo?id=1', expected)
318 # # test todo1 cannot be set done with todo2 not done yet
319 self.check_post({'adopt': 2, 'done': ''}, '/todo?id=1', 400)
320 self.check_json_get('/todo?id=1', expected)
321 # # test todo1 un-adopting todo 2 by just not sending an adopt
322 self.check_post({}, '/todo?id=1')
323 expected['_library']['Todo']['2']['parents'] = []
324 expected['_library']['Todo']['1']['children'] = []
325 expected['steps_todo_to_process'] = []
326 self.check_json_get('/todo?id=1', expected)
327 # test todo2 deletion
328 self.check_post({'delete': ''}, '/todo?id=2', 302, '/')
329 del expected['_library']['Todo']['2']
330 expected['todo_candidates'] = []
331 self.check_json_get('/todo?id=1', expected)
333 def test_do_GET_todo(self) -> None:
334 """Test GET /todo response codes."""
336 day_post = {'day_comment': '', 'new_todo': 1, 'make_type': 'full'}
337 self.check_post(day_post, f'/day?date={date}&make_type=full')
338 # test malformed or illegal parameter values
339 self.check_get('/todo', 404)
340 self.check_get('/todo?id=', 404)
341 self.check_get('/todo?id=foo', 400)
342 self.check_get('/todo?id=0', 404)
343 self.check_get('/todo?id=2', 404)
344 # test all existing Processes are shown as available
345 p2_post: Any = {'title': 'bar', 'description': 'baz', 'effort': 0.9}
346 self.post_process(2, p2_post)
347 todo1_dict = self.todo_as_dict(1, process_id=1, date=date)
348 proc1_dict = self.proc_as_dict(1, **self._proc1_form_data)
349 proc2_dict = self.proc_as_dict(2, **p2_post)
350 expected = self.GET_todo_dict(1, [todo1_dict], [proc1_dict,
352 self.check_json_get('/todo?id=1', expected)
353 # post new Todo to Day and expect visibility as candidate
354 self.check_post(day_post, f'/day?date={date}&make_type=full')
355 todo2_dict = self.todo_as_dict(2, process_id=1, date=date)
356 assert isinstance(expected['_library'], dict)
357 expected['_library']['Todo']['2'] = todo2_dict
358 expected['todo_candidates'] = [2]
359 self.check_json_get('/todo?id=1', expected)
361 def test_do_POST_day(self) -> None:
362 """Test Todo posting of POST /day."""
364 proc = Process.by_id(self.db_conn, 1)
365 proc2 = Process.by_id(self.db_conn, 2)
366 # check posting no Todos to Day makes Todo.by_date return empty list
367 form_data = {'day_comment': '', 'make_type': 'full'}
368 self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302)
369 self.assertEqual(Todo.by_date(self.db_conn, '2024-01-01'), [])
370 proc = Process.by_id(self.db_conn, 1)
371 # post Todo to Day and check its display
372 form_data['new_todo'] = str(proc.id_)
373 self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302)
374 todos = Todo.by_date(self.db_conn, '2024-01-01')
375 self.assertEqual(1, len(todos))
377 self.assertEqual(todo1.id_, 1)
378 proc = Process.by_id(self.db_conn, 1)
379 self.assertEqual(todo1.process.id_, proc.id_)
380 self.assertEqual(todo1.is_done, False)
381 # post second Todo, check its appearance
382 proc2 = Process.by_id(self.db_conn, 2)
383 form_data['new_todo'] = str(proc2.id_)
384 self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302)
385 todos = Todo.by_date(self.db_conn, '2024-01-01')
387 self.assertEqual(todo1.id_, 2)
388 proc2 = Process.by_id(self.db_conn, 1)
389 todo1 = Todo.by_date(self.db_conn, '2024-01-01')[0]
390 self.assertEqual(todo1.id_, 1)
391 self.assertEqual(todo1.process.id_, proc2.id_)
392 self.assertEqual(todo1.is_done, False)
394 def test_do_POST_day_todo_adoption(self) -> None:
395 """Test Todos posted to Day view may adopt existing Todos."""
396 form_data = self.post_process(
397 2, self._proc1_form_data | {'new_top_step': 1})
398 form_data = {'day_comment': '', 'new_todo': 1, 'make_type': 'full'}
399 self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302)
400 form_data['new_todo'] = 2
401 self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302)
402 todo1 = Todo.by_date(self.db_conn, '2024-01-01')[0]
403 todo2 = Todo.by_date(self.db_conn, '2024-01-01')[1]
404 self.assertEqual(todo1.children, [])
405 self.assertEqual(todo1.parents, [todo2])
406 self.assertEqual(todo2.children, [todo1])
407 self.assertEqual(todo2.parents, [])
409 def test_do_POST_day_todo_multiple(self) -> None:
410 """Test multiple Todos can be posted to Day view."""
411 # form_data = self.post_process()
412 form_data = self.post_process(2)
413 form_data = {'day_comment': '', 'new_todo': [1, 2],
415 self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302)
416 todo1 = Todo.by_date(self.db_conn, '2024-01-01')[0]
417 todo2 = Todo.by_date(self.db_conn, '2024-01-01')[1]
418 self.assertEqual(todo1.process.id_, 1)
419 self.assertEqual(todo2.process.id_, 2)
421 def test_do_POST_day_todo_multiple_inner_adoption(self) -> None:
422 """Test multiple Todos can be posted to Day view w. inner adoption."""
424 def key_order_func(t: Todo) -> int:
425 assert isinstance(t.process.id_, int)
428 def check_adoption(date: str, new_todos: list[int]) -> None:
429 form_data = {'day_comment': '', 'new_todo': new_todos,
431 self.check_post(form_data, f'/day?date={date}&make_type=full', 302)
432 day_todos = Todo.by_date(self.db_conn, date)
433 day_todos.sort(key=key_order_func)
436 self.assertEqual(todo1.children, [])
437 self.assertEqual(todo1.parents, [todo2])
438 self.assertEqual(todo2.children, [todo1])
439 self.assertEqual(todo2.parents, [])
441 def check_nesting_adoption(process_id: int, date: str,
442 new_top_steps: list[int]) -> None:
443 form_data = {'title': '', 'description': '', 'effort': 1,
445 form_data = self.post_process(1, form_data)
446 form_data['new_top_step'] = new_top_steps
447 form_data['step_of'] = []
448 form_data = self.post_process(process_id, form_data)
449 form_data = {'day_comment': '', 'new_todo': [process_id],
451 self.check_post(form_data, f'/day?date={date}&make_type=full', 302)
452 day_todos = Todo.by_date(self.db_conn, date)
453 day_todos.sort(key=key_order_func, reverse=True)
454 self.assertEqual(len(day_todos), 3)
455 todo1 = day_todos[0] # process of process_id
456 todo2 = day_todos[1] # process 2
457 todo3 = day_todos[2] # process 1
458 self.assertEqual(sorted(todo1.children), sorted([todo2, todo3]))
459 self.assertEqual(todo1.parents, [])
460 self.assertEqual(todo2.children, [todo3])
461 self.assertEqual(todo2.parents, [todo1])
462 self.assertEqual(todo3.children, [])
463 self.assertEqual(sorted(todo3.parents), sorted([todo2, todo1]))
465 self.post_process(2, self._proc1_form_data | {'new_top_step': 1})
466 check_adoption('2024-01-01', [1, 2])
467 check_adoption('2024-01-02', [2, 1])
468 check_nesting_adoption(3, '2024-01-03', [1, 2])
469 check_nesting_adoption(4, '2024-01-04', [2, 1])
471 def test_do_POST_day_todo_doneness(self) -> None:
472 """Test Todo doneness can be posted to Day view."""
473 form_data = {'day_comment': '', 'new_todo': [1], 'make_type': 'full'}
474 self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302)
475 todo = Todo.by_date(self.db_conn, '2024-01-01')[0]
476 form_data = {'day_comment': '', 'todo_id': [1], 'make_type': 'full',
477 'comment': [''], 'done': [], 'effort': ['']}
478 self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302)
479 todo = Todo.by_date(self.db_conn, '2024-01-01')[0]
480 self.assertEqual(todo.is_done, False)
481 form_data = {'day_comment': '', 'todo_id': [1], 'done': [1],
482 'make_type': 'full', 'comment': [''], 'effort': ['']}
483 self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302)
484 todo = Todo.by_date(self.db_conn, '2024-01-01')[0]
485 self.assertEqual(todo.is_done, True)