X-Git-Url: https://plomlompom.com/repos/berlin_corona.txt?a=blobdiff_plain;ds=inline;f=tests%2Ftodos.py;h=b73f5d7fcb7b0a2c53256593e8e9867e86d2b89e;hb=HEAD;hp=93b34d197f33aebbd5b096619c3804ca0b8f75ce;hpb=a4ca74f81ae42abe27cf6dbab7ef18c850db72c2;p=plomtask diff --git a/tests/todos.py b/tests/todos.py index 93b34d1..25fa05b 100644 --- a/tests/todos.py +++ b/tests/todos.py @@ -1,60 +1,705 @@ """Test Todos module.""" -from tests.utils import TestCaseWithDB, TestCaseWithServer -from plomtask.todos import Todo -from plomtask.days import Day -from plomtask.processes import Process -from plomtask.exceptions import NotFoundException +from typing import Any +from tests.utils import TestCaseSansDB, TestCaseWithDB, TestCaseWithServer +from plomtask.todos import Todo, TodoNode +from plomtask.processes import Process, ProcessStep +from plomtask.conditions import Condition +from plomtask.exceptions import (NotFoundException, BadFormatException, + HandledException) -class TestsWithDB(TestCaseWithDB): - """Tests not requiring DB setup.""" +class TestsWithDB(TestCaseWithDB, TestCaseSansDB): + """Tests requiring DB, but not server setup. - def test_Todo_by_date(self) -> None: - """Test creation and findability of Todos.""" - day1 = Day('2024-01-01') - day2 = Day('2024-01-02') - process1 = Process(None) - todo1 = Todo(None, process1, False, day1) - with self.assertRaises(NotFoundException): - todo1.save(self.db_conn) - process1.save_without_steps(self.db_conn) - todo1.save(self.db_conn) - todo2 = Todo(None, process1, False, day1) - todo2.save(self.db_conn) + NB: We subclass TestCaseSansDB too, to run any tests there that due to any + Todo requiring a _saved_ Process wouldn't run without a DB. + """ + checked_class = Todo + default_init_kwargs = {'process': None, 'is_done': False, + 'date': '2024-01-01'} + + def setUp(self) -> None: + super().setUp() + self.date1 = '2024-01-01' + self.date2 = '2024-01-02' + self.proc = Process(None) + self.proc.save(self.db_conn) + self.cond1 = Condition(None) + self.cond1.save(self.db_conn) + self.cond2 = Condition(None) + self.cond2.save(self.db_conn) + self.default_init_kwargs['process'] = self.proc + + def test_Todo_init(self) -> None: + """Test creation of Todo and what they default to.""" + process = Process(None) with self.assertRaises(NotFoundException): - Todo.by_date(self.db_conn, day1.date), - day1.save(self.db_conn) - day2.save(self.db_conn) - self.assertEqual(Todo.by_date(self.db_conn, day1.date), [todo1, todo2]) - self.assertEqual(Todo.by_date(self.db_conn, day2.date), []) - self.assertEqual(Todo.by_date(self.db_conn, 'foo'), []) + Todo(None, process, False, self.date1) + process.save(self.db_conn) + assert isinstance(self.cond1.id_, int) + assert isinstance(self.cond2.id_, int) + process.set_condition_relations(self.db_conn, + [self.cond1.id_, self.cond2.id_], [], + [self.cond1.id_], [self.cond2.id_]) + todo_no_id = Todo(None, process, False, self.date1) + self.assertEqual(todo_no_id.conditions, [self.cond1, self.cond2]) + self.assertEqual(todo_no_id.enables, [self.cond1]) + self.assertEqual(todo_no_id.disables, [self.cond2]) + todo_yes_id = Todo(5, process, False, self.date1) + self.assertEqual(todo_yes_id.conditions, []) + self.assertEqual(todo_yes_id.enables, []) + self.assertEqual(todo_yes_id.disables, []) + + def test_Todo_by_date(self) -> None: + """Test findability of Todos by date.""" + t1 = Todo(None, self.proc, False, self.date1) + t1.save(self.db_conn) + t2 = Todo(None, self.proc, False, self.date1) + t2.save(self.db_conn) + self.assertEqual(Todo.by_date(self.db_conn, self.date1), [t1, t2]) + self.assertEqual(Todo.by_date(self.db_conn, self.date2), []) + with self.assertRaises(BadFormatException): + self.assertEqual(Todo.by_date(self.db_conn, 'foo'), []) + + def test_Todo_by_date_range_with_limits(self) -> None: + """Test .by_date_range_with_limits.""" + self.check_by_date_range_with_limits('day') + + def test_Todo_on_conditions(self) -> None: + """Test effect of Todos on Conditions.""" + assert isinstance(self.cond1.id_, int) + assert isinstance(self.cond2.id_, int) + todo = Todo(None, self.proc, False, self.date1) + todo.save(self.db_conn) + todo.set_condition_relations(self.db_conn, [], [], + [self.cond1.id_], [self.cond2.id_]) + todo.is_done = True + self.assertEqual(self.cond1.is_active, True) + self.assertEqual(self.cond2.is_active, False) + todo.is_done = False + self.assertEqual(self.cond1.is_active, True) + self.assertEqual(self.cond2.is_active, False) + + def test_Todo_children(self) -> None: + """Test Todo.children relations.""" + todo_1 = Todo(None, self.proc, False, self.date1) + todo_2 = Todo(None, self.proc, False, self.date1) + todo_2.save(self.db_conn) + with self.assertRaises(HandledException): + todo_1.add_child(todo_2) + todo_1.save(self.db_conn) + todo_3 = Todo(None, self.proc, False, self.date1) + with self.assertRaises(HandledException): + todo_1.add_child(todo_3) + todo_3.save(self.db_conn) + todo_1.add_child(todo_3) + todo_1.save(self.db_conn) + assert isinstance(todo_1.id_, int) + todo_retrieved = Todo.by_id(self.db_conn, todo_1.id_) + self.assertEqual(todo_retrieved.children, [todo_3]) + with self.assertRaises(BadFormatException): + todo_3.add_child(todo_1) + + def test_Todo_conditioning(self) -> None: + """Test Todo.doability conditions.""" + assert isinstance(self.cond1.id_, int) + todo_1 = Todo(None, self.proc, False, self.date1) + todo_1.save(self.db_conn) + todo_2 = Todo(None, self.proc, False, self.date1) + todo_2.save(self.db_conn) + todo_2.add_child(todo_1) + with self.assertRaises(BadFormatException): + todo_2.is_done = True + todo_1.is_done = True + todo_2.is_done = True + todo_2.is_done = False + todo_2.set_condition_relations( + self.db_conn, [self.cond1.id_], [], [], []) + with self.assertRaises(BadFormatException): + todo_2.is_done = True + self.cond1.is_active = True + todo_2.is_done = True + + def test_Todo_step_tree(self) -> None: + """Test self-configuration of TodoStepsNode tree for Day view.""" + + def todo_node_as_dict(node: TodoNode) -> dict[str, object]: + return {'todo': node.todo.id_, 'seen': node.seen, + 'children': [todo_node_as_dict(c) for c in node.children]} + + todo_1 = Todo(None, self.proc, False, self.date1) + todo_1.save(self.db_conn) + assert isinstance(todo_1.id_, int) + # test minimum + node_0 = TodoNode(todo_1, False, []) + cmp_0_dict = todo_node_as_dict(todo_1.get_step_tree(set())) + cmp_1_dict = todo_node_as_dict(node_0) + self.assertEqual(cmp_0_dict, cmp_1_dict) + # test non_emtpy seen_todo does something + node_0.seen = True + cmp_0_dict = todo_node_as_dict(todo_1.get_step_tree({todo_1.id_})) + cmp_1_dict = todo_node_as_dict(node_0) + self.assertEqual(cmp_0_dict, cmp_1_dict) + # test child shows up + todo_2 = Todo(None, self.proc, False, self.date1) + todo_2.save(self.db_conn) + assert isinstance(todo_2.id_, int) + todo_1.add_child(todo_2) + node_2 = TodoNode(todo_2, False, []) + node_0.children = [node_2] + node_0.seen = False + cmp_0_dict = todo_node_as_dict(todo_1.get_step_tree(set())) + cmp_1_dict = todo_node_as_dict(node_0) + self.assertEqual(cmp_0_dict, cmp_1_dict) + # test child shows up with child + todo_3 = Todo(None, self.proc, False, self.date1) + todo_3.save(self.db_conn) + assert isinstance(todo_3.id_, int) + todo_2.add_child(todo_3) + node_3 = TodoNode(todo_3, False, []) + node_2.children = [node_3] + cmp_0_dict = todo_node_as_dict(todo_1.get_step_tree(set())) + cmp_1_dict = todo_node_as_dict(node_0) + self.assertEqual(cmp_0_dict, cmp_1_dict) + # test same todo can be child-ed multiple times at different locations + todo_1.add_child(todo_3) + node_4 = TodoNode(todo_3, True, []) + node_0.children += [node_4] + cmp_0_dict = todo_node_as_dict(todo_1.get_step_tree(set())) + cmp_1_dict = todo_node_as_dict(node_0) + self.assertEqual(cmp_0_dict, cmp_1_dict) + + def test_Todo_create_with_children(self) -> None: + """Test parenthood guarantees of Todo.create_with_children.""" + assert isinstance(self.proc.id_, int) + proc2 = Process(None) + proc2.save(self.db_conn) + assert isinstance(proc2.id_, int) + proc3 = Process(None) + proc3.save(self.db_conn) + assert isinstance(proc3.id_, int) + proc4 = Process(None) + proc4.save(self.db_conn) + assert isinstance(proc4.id_, int) + # make proc4 step of proc3 + step = ProcessStep(None, proc3.id_, proc4.id_, None) + proc3.set_steps(self.db_conn, [step]) + # give proc2 three steps; 2× proc1, 1× proc3 + step1 = ProcessStep(None, proc2.id_, self.proc.id_, None) + step2 = ProcessStep(None, proc2.id_, self.proc.id_, None) + step3 = ProcessStep(None, proc2.id_, proc3.id_, None) + proc2.set_steps(self.db_conn, [step1, step2, step3]) + # test mere creation does nothing + todo_ignore = Todo(None, proc2, False, self.date1) + todo_ignore.save(self.db_conn) + self.assertEqual(todo_ignore.children, []) + # test create_with_children on step-less does nothing + todo_1 = Todo.create_with_children(self.db_conn, self.proc.id_, + self.date1) + self.assertEqual(todo_1.children, []) + self.assertEqual(len(Todo.all(self.db_conn)), 2) + # test create_with_children adopts and creates, and down tree too + todo_2 = Todo.create_with_children(self.db_conn, proc2.id_, self.date1) + self.assertEqual(3, len(todo_2.children)) + self.assertEqual(todo_1, todo_2.children[0]) + self.assertEqual(self.proc, todo_2.children[2].process) + self.assertEqual(proc3, todo_2.children[1].process) + todo_3 = todo_2.children[1] + self.assertEqual(len(todo_3.children), 1) + self.assertEqual(todo_3.children[0].process, proc4) class TestsWithServer(TestCaseWithServer): """Tests against our HTTP server/handler (and database).""" - def test_do_POST_todo(self) -> None: - """Test Todo posting of POST /day.""" - form_data = {'title': '', 'description': '', 'effort': 1} - self.check_post(form_data, '/process?id=', 302, '/') - self.check_post(form_data, '/process?id=', 302, '/') - process1 = Process.by_id(self.db_conn, 1) - process2 = Process.by_id(self.db_conn, 2) - form_data = {'comment': ''} - self.check_post(form_data, '/day?date=2024-01-01', 302, '/') - self.assertEqual(Todo.by_date(self.db_conn, '2024-01-01'), []) - form_data['new_todo'] = str(process1.id_) - self.check_post(form_data, '/day?date=2024-01-01', 302, '/') - todos = Todo.by_date(self.db_conn, '2024-01-01') - self.assertEqual(1, len(todos)) - todo1 = todos[0] - self.assertEqual(todo1.id_, 1) - self.assertEqual(todo1.process.id_, process1.id_) - self.assertEqual(todo1.is_done, False) - form_data['new_todo'] = str(process2.id_) - self.check_post(form_data, '/day?date=2024-01-01', 302, '/') - todos = Todo.by_date(self.db_conn, '2024-01-01') - todo1 = todos[1] - self.assertEqual(todo1.id_, 2) - self.assertEqual(todo1.process.id_, process2.id_) - self.assertEqual(todo1.is_done, False) + def setUp(self) -> None: + super().setUp() + self._proc1_form_data: Any = self.post_process(1) + self._date = '2024-01-01' + + @classmethod + def GET_todo_dict(cls, + target_id: int, + todos: list[dict[str, object]], + processes: list[dict[str, object]], + process_steps: list[dict[str, object]] | None = None, + conditions: list[dict[str, object]] | None = None + ) -> dict[str, object]: + """Return JSON of GET /todo to expect.""" + # pylint: disable=too-many-arguments + library = {'Todo': cls.as_refs(todos), + 'Process': cls.as_refs(processes)} + if process_steps: + library['ProcessStep'] = cls.as_refs(process_steps) + conditions = conditions if conditions else [] + if conditions: + library['Condition'] = cls.as_refs(conditions) + return {'todo': target_id, + 'steps_todo_to_process': [], + 'adoption_candidates_for': {}, + 'process_candidates': [p['id'] for p in processes], + 'todo_candidates': [t['id'] for t in todos + if t['id'] != target_id], + 'condition_candidates': [c['id'] for c in conditions], + '_library': library} + + @staticmethod + def _step_as_dict(node_id: int, + children: list[dict[str, object]], + process: int | None = None, + todo: int | None = None, + fillable: bool = False, + ) -> dict[str, object]: + return {'node_id': node_id, + 'children': children, + 'process': process, + 'fillable': fillable, + 'todo': todo} + + def _make_todo_via_day_post(self, proc_id: int) -> None: + payload = {'day_comment': '', + 'new_todo': proc_id, + 'make_type': 'empty'} + self.check_post(payload, f'/day?date={self._date}&make_type=empty') + + def test_basic_fail_POST_todo(self) -> None: + """Test basic malformed/illegal POST /todo requests.""" + # test we cannot just POST into non-existing Todo + self.check_post({}, '/todo', 404) + self.check_post({}, '/todo?id=FOO', 400) + self.check_post({}, '/todo?id=0', 404) + self.check_post({}, '/todo?id=1', 404) + # test malformed values on existing Todo + self._make_todo_via_day_post(1) + for name in [ + 'adopt', 'effort', 'make_full', 'make_empty', 'step_filler', + 'conditions', 'disables', 'blockers', 'enables']: + self.check_post({name: 'x'}, '/todo?id=1', 400, '/todo') + for prefix in ['make_empty_', 'make_full_']: + for suffix in ['', 'x', '1.1']: + self.check_post({'step_filler': f'{prefix}{suffix}'}, + '/todo?id=1', 400, '/todo') + + def test_basic_POST_todo(self) -> None: + """Test basic POST /todo manipulations.""" + self._make_todo_via_day_post(1) + # test posting naked entity at first changes nothing + todo_dict = self.todo_as_dict(1, 1) + proc_dict = self.proc_as_dict(**self._proc1_form_data) + expected = self.GET_todo_dict(1, [todo_dict], [proc_dict]) + self.check_json_get('/todo?id=1', expected) + self.check_post({}, '/todo?id=1') + self.check_json_get('/todo?id=1', expected) + # test posting doneness, comment, calendarization, effort + todo_post = {'done': '', 'calendarize': '', 'comment': 'foo', + 'effort': 2.3} + todo_dict = self.todo_as_dict(1, 1, is_done=True, calendarize=True, + comment='foo', effort=2.3) + expected = self.GET_todo_dict(1, [todo_dict], [proc_dict]) + self.check_post(todo_post, '/todo?id=1') + self.check_json_get('/todo?id=1', expected) + # test implicitly un-setting all of those except effort by empty post + self.check_post({}, '/todo?id=1') + todo_dict = self.todo_as_dict(1, 1, effort=2.3) + expected = self.GET_todo_dict(1, [todo_dict], [proc_dict]) + self.check_json_get('/todo?id=1', expected) + # test empty effort post can be explicitly unset by "" post + self.check_post({'effort': ''}, '/todo?id=1') + todo_dict['effort'] = None + self.check_json_get('/todo?id=1', expected) + # test Condition posts + c1_post = {'title': 'foo', 'description': 'oof', 'is_active': False} + c2_post = {'title': 'bar', 'description': 'rab', 'is_active': True} + self.check_post(c1_post, '/condition', redir='/condition?id=1') + self.check_post(c2_post, '/condition', redir='/condition?id=2') + c1_dict = self.cond_as_dict(1, False, ['foo'], ['oof']) + c2_dict = self.cond_as_dict(2, True, ['bar'], ['rab']) + conditions = [c1_dict, c2_dict] + todo_post = {'conditions': [1], 'disables': [1], + 'blockers': [2], 'enables': [2]} + for k, v in todo_post.items(): + todo_dict[k] = v + self.check_post(todo_post, '/todo?id=1') + expected = self.GET_todo_dict(1, [todo_dict], [proc_dict], + conditions=conditions) + self.check_json_get('/todo?id=1', expected) + + def test_POST_todo_deletion(self) -> None: + """Test deletions via POST /todo.""" + self._make_todo_via_day_post(1) + todo_dict = self.todo_as_dict(1, process_id=1) + proc_dict = self.proc_as_dict(**self._proc1_form_data) + expected = self.GET_todo_dict(1, [todo_dict], [proc_dict]) + # test failure of deletion on non-existing Todo + self.check_post({'delete': ''}, '/todo?id=2', 404, '/') + # test deletion of existing Todo + self.check_post({'delete': ''}, '/todo?id=1', 302, '/') + self.check_get('/todo?id=1', 404) + # test deletion of adopted Todo + self._make_todo_via_day_post(1) + self._make_todo_via_day_post(1) + self.check_post({'adopt': 2}, '/todo?id=1') + self.check_post({'delete': ''}, '/todo?id=2', 302, '/') + self.check_json_get('/todo?id=1', expected) + # test deletion of adopting Todo + self._make_todo_via_day_post(1) + self.check_post({'adopt': 2}, '/todo?id=1') + self.check_post({'delete': ''}, '/todo?id=1', 302, '/') + todo_dict['id'] = 2 + expected = self.GET_todo_dict(2, [todo_dict], [proc_dict]) + self.check_json_get('/todo?id=2', expected) + # test cannot delete Todo with comment or effort + self.check_post({'comment': 'foo'}, '/todo?id=2') + self.check_post({'delete': ''}, '/todo?id=2', 500, '/') + self.check_post({'effort': 5}, '/todo?id=2') + self.check_post({'delete': ''}, '/todo?id=2', 500, '/') + # test deletion via effort < 0, but only once deletable + self.check_post({'effort': -1, 'comment': 'foo'}, '/todo?id=2') + todo_dict['comment'] = 'foo' + todo_dict['effort'] = -1 + self.check_json_get('/todo?id=2', expected) + self.check_post({}, '/todo?id=2') + self.check_get('/todo?id=2', 404) + + def test_POST_todo_adoption(self) -> None: + """Test adoption via POST /todo with "adopt".""" + # pylint: disable=too-many-locals + # pylint: disable=too-many-statements + # post two Todos to Day, have first adopt second + self._make_todo_via_day_post(1) + self._make_todo_via_day_post(1) + proc1_dict = self.proc_as_dict(**self._proc1_form_data) + todo1_dict = self.todo_as_dict(1, process_id=1, children=[2]) + todo2_dict = self.todo_as_dict(2, process_id=1, parents=[1]) + todos = [todo1_dict, todo2_dict] + expected = self.GET_todo_dict(1, todos, [proc1_dict]) + expected['steps_todo_to_process'] = [self._step_as_dict(1, [], todo=2)] + self.check_post({'adopt': 2}, '/todo?id=1') + self.check_json_get('/todo?id=1', expected) + # test Todo un-adopting by just not sending an adopt + self.check_post({}, '/todo?id=1') + todo1_dict['children'] = [] + todo2_dict['parents'] = [] + expected['steps_todo_to_process'] = [] + self.check_json_get('/todo?id=1', expected) + # test fail on trying to adopt non-existing Todo + self.check_post({'adopt': 3}, '/todo?id=1', 404) + # test cannot self-adopt + self.check_post({'adopt': 1}, '/todo?id=1', 400) + # test cannot do 1-step circular adoption + self.check_post({'adopt': 1}, '/todo?id=2') + todo1_dict['parents'] = [2] + todo2_dict['children'] = [1] + self.check_post({'adopt': 2}, '/todo?id=1', 400) + # test cannot do 2-step circular adoption + self._make_todo_via_day_post(1) + self.check_post({'adopt': 2}, '/todo?id=3') + todo3_dict = self.todo_as_dict(3, process_id=1, children=[2]) + todo2_dict['parents'] = [3] + todos += [todo3_dict] + self.check_post({'adopt': 3}, '/todo?id=1', 400) + # test can adopt Todo into ProcessStep chain via its Process (with key + # 'step_filler' equivalent to single-element 'adopt' if intable) + proc_post = {'title': 'A', 'description': '', 'effort': 1.0} + self.post_process(3, proc_post) + self.post_process(2, proc_post) + self.post_process(1, self._proc1_form_data | {'new_top_step': [2, 3]}) + self._make_todo_via_day_post(2) + self._make_todo_via_day_post(3) + self.check_post({'step_filler': 5, 'adopt': [4]}, '/todo?id=1') + proc3_dict = self.proc_as_dict(3) + proc2_dict = self.proc_as_dict(2) + proc1_dict['explicit_steps'] = [1, 2] + procs = [proc1_dict, proc2_dict, proc3_dict] + procsteps = [self.procstep_as_dict(1, 1, 2), + self.procstep_as_dict(2, 1, 3)] + todo1_dict['children'] = [4, 5] + todo4_dict = self.todo_as_dict(4, process_id=2, parents=[1]) + todo5_dict = self.todo_as_dict(5, process_id=3, parents=[1]) + todos += [todo4_dict, todo5_dict] + expected = self.GET_todo_dict(1, todos, procs, procsteps) + step_proc2 = self._step_as_dict(1, [], 2, 4, True) + step_proc3 = self._step_as_dict(2, [], 3, 5, True) + expected['steps_todo_to_process'] = [step_proc2, step_proc3] + self.check_json_get('/todo?id=1', expected) + # test 'ignore' values for 'step_filler' are ignored, and intable + # 'step_filler' values are interchangeable with those of 'adopt' + todo_post = {'adopt': 5, 'step_filler': ['ignore', 4]} + self.check_post(todo_post, '/todo?id=1') + self.check_json_get('/todo?id=1', expected) + # test cannot adopt into non-top-level elements of chain + self.post_process(4, proc_post) + self.post_process(3, proc_post | {'new_top_step': 4, 'step_of': [1]}) + proc4_dict = self.proc_as_dict(4) + proc3_dict['explicit_steps'] = [3] + procs += [proc4_dict] + procsteps += [self.procstep_as_dict(3, 3, 4)] + step_proc4 = self._step_as_dict(3, [], 4, None, True) + step_proc3['children'] = [step_proc4] + self._make_todo_via_day_post(4) + self.check_post({'adopt': [4, 5, 6]}, '/todo?id=1') + todo6_dict = self.todo_as_dict(6, process_id=4, parents=[1]) + todo1_dict['children'] = [4, 5, 6] + todos += [todo6_dict] + expected = self.GET_todo_dict(1, todos, procs, procsteps) + step2_proc4 = self._step_as_dict(4, [], None, 6, False) + expected['steps_todo_to_process'] = [step_proc2, step_proc3, + step2_proc4] + expected['adoption_candidates_for'] = {'4': [6]} + self.check_json_get('/todo?id=1', expected) + + def test_POST_todo_make_full(self) -> None: + """Test creation and adoption via POST /todo with "make_full".""" + # pylint: disable=too-many-locals + # create chain of Processes + proc_post = {'title': 'A', 'description': '', 'effort': 1.0} + self.post_process(2, proc_post | {'new_top_step': 1}) + self.post_process(3, proc_post | {'new_top_step': 2}) + self.post_process(4, proc_post | {'new_top_step': 3}) + proc1_dict = self.proc_as_dict(**self._proc1_form_data) + proc2_dict = self.proc_as_dict(2, explicit_steps=[1]) + proc3_dict = self.proc_as_dict(3, explicit_steps=[2]) + proc4_dict = self.proc_as_dict(4, explicit_steps=[3]) + procs = [proc1_dict, proc2_dict, proc3_dict, proc4_dict] + procsteps = [self.procstep_as_dict(1, 2, 1), + self.procstep_as_dict(2, 3, 2), + self.procstep_as_dict(3, 4, 3)] + # post (childless) Todo of chain end, then make_full on next in line + self._make_todo_via_day_post(4) + todo1_dict = self.todo_as_dict(1, 4, children=[2]) + todo2_dict = self.todo_as_dict(2, 3, children=[3], parents=[1]) + todo3_dict = self.todo_as_dict(3, 2, parents=[2], children=[4]) + todo4_dict = self.todo_as_dict(4, 1, parents=[3]) + todos = [todo1_dict, todo2_dict, todo3_dict, todo4_dict] + expected = self.GET_todo_dict(1, todos, procs, procsteps) + step_proc1 = self._step_as_dict(3, [], 1, 4, True) + step_proc2 = self._step_as_dict(2, [step_proc1], 2, 3, True) + step_proc3 = self._step_as_dict(1, [step_proc2], 3, 2, True) + expected['steps_todo_to_process'] = [step_proc3] + self.check_post({'step_filler': 'make_full_3'}, '/todo?id=1') + self.check_json_get('/todo?id=1', expected) + # make new chain next to expected, find steps_todo_to_process extended, + # expect existing Todo demanded by new chain be adopted into new chain + self.check_post({'make_full': 2, 'adopt': [2]}, '/todo?id=1') + todo5_dict = self.todo_as_dict(5, 2, parents=[1], children=[4]) + todo1_dict['children'] = [2, 5] + todo4_dict['parents'] = [3, 5] + todos += [todo5_dict] + step2_proc1 = self._step_as_dict(5, [], None, 4) + step2_proc2 = self._step_as_dict(4, [step2_proc1], None, 5) + expected = self.GET_todo_dict(1, todos, procs, procsteps) + expected['steps_todo_to_process'] = [step_proc3, step2_proc2] + self.check_json_get('/todo?id=1', expected) + # fail on trying to call make_full on non-existing Process + self.check_post({'make_full': 5}, '/todo?id=1', 404) + + def test_POST_todo_make_empty(self) -> None: + """Test creation and adoption via POST /todo with "make_empty".""" + # pylint: disable=too-many-locals + # create chain of Processes + proc_post = {'title': 'A', 'description': '', 'effort': 1.0} + self.post_process(2, proc_post | {'new_top_step': 1}) + self.post_process(3, proc_post | {'new_top_step': 2}) + self.post_process(4, proc_post | {'new_top_step': 3}) + proc1_dict = self.proc_as_dict(**self._proc1_form_data) + proc2_dict = self.proc_as_dict(2, explicit_steps=[1]) + proc3_dict = self.proc_as_dict(3, explicit_steps=[2]) + proc4_dict = self.proc_as_dict(4, explicit_steps=[3]) + procs = [proc1_dict, proc2_dict, proc3_dict, proc4_dict] + procsteps = [self.procstep_as_dict(1, 2, 1), + self.procstep_as_dict(2, 3, 2), + self.procstep_as_dict(3, 4, 3)] + # post (childless) Todo of chain end, then make empty on next in line + self._make_todo_via_day_post(4) + todo1_dict = self.todo_as_dict(1, 4, children=[2]) + todo2_dict = self.todo_as_dict(2, 3, parents=[1]) + todos = [todo1_dict, todo2_dict] + expected = self.GET_todo_dict(1, todos, procs, procsteps) + step_proc1 = self._step_as_dict(3, [], 1, None) + step_proc2 = self._step_as_dict(2, [step_proc1], 2, None, True) + step_proc3 = self._step_as_dict(1, [step_proc2], 3, 2, True) + expected['steps_todo_to_process'] = [step_proc3] + expected['adoption_candidates_for'] = {'1': [], '2': []} + self.check_post({'step_filler': 'make_empty_3'}, '/todo?id=1') + self.check_json_get('/todo?id=1', expected) + # make new top-level Todo without chain implied by its Process + self.check_post({'make_empty': 2, 'adopt': [2]}, '/todo?id=1') + todo3_dict = self.todo_as_dict(3, 2, parents=[1], children=[]) + todo1_dict['children'] = [2, 3] + todos += [todo3_dict] + step2_proc2 = self._step_as_dict(4, [], None, 3) + expected = self.GET_todo_dict(1, todos, procs, procsteps) + expected['steps_todo_to_process'] = [step_proc3, step2_proc2] + expected['adoption_candidates_for'] = {'1': [], '2': [3]} + self.check_json_get('/todo?id=1', expected) + # fail on trying to call make_empty on non-existing Process + self.check_post({'make_full': 5}, '/todo?id=1', 404) + + def test_do_GET_todo(self) -> None: + """Test GET /todo response codes.""" + self._make_todo_via_day_post(1) + # test malformed or illegal parameter values + self.check_get('/todo', 404) + self.check_get('/todo?id=', 404) + self.check_get('/todo?id=foo', 400) + self.check_get('/todo?id=0', 404) + self.check_get('/todo?id=2', 404) + # test all existing Processes are shown as available + proc_post = {'title': 'A', 'description': '', 'effort': 1.0} + self.post_process(2, proc_post) + todo1_dict = self.todo_as_dict(1, process_id=1) + proc1_dict = self.proc_as_dict(1, **self._proc1_form_data) + proc2_dict = self.proc_as_dict(2) + procs = [proc1_dict, proc2_dict] + expected = self.GET_todo_dict(1, [todo1_dict], procs) + self.check_json_get('/todo?id=1', expected) + # test chain of Processes shown as potential step nodes + self.post_process(2, proc_post) + self.post_process(3, proc_post) + self.post_process(4, proc_post) + self.post_process(1, self._proc1_form_data | {'new_top_step': 2}) + self.post_process(2, proc_post | {'new_top_step': 3, 'step_of': [1]}) + self.post_process(3, proc_post | {'new_top_step': 4, 'step_of': [2]}) + proc1_dict['explicit_steps'] = [1] + proc2_dict['explicit_steps'] = [2] + proc3_dict = self.proc_as_dict(3, explicit_steps=[3]) + proc4_dict = self.proc_as_dict(4) + procs += [proc3_dict, proc4_dict] + procsteps = [self.procstep_as_dict(1, 1, 2, None), + self.procstep_as_dict(2, 2, 3, None), + self.procstep_as_dict(3, 3, 4, None)] + expected = self.GET_todo_dict(1, [todo1_dict], procs, procsteps) + step_proc4 = self._step_as_dict(3, [], 4) + step_proc3 = self._step_as_dict(2, [step_proc4], 3) + step_proc2 = self._step_as_dict(1, [step_proc3], 2, fillable=True) + expected['steps_todo_to_process'] = [step_proc2] + expected['adoption_candidates_for'] = {'2': [], '3': [], '4': []} + self.check_json_get('/todo?id=1', expected) + # test display of parallel chains + proc_steps_post = {'new_top_step': 4, 'keep_step': [1], + 'step_1_process_id': 2, 'steps': [1, 4]} + self.post_process(1, self._proc1_form_data | proc_steps_post) + proc1_dict['explicit_steps'] = [1, 4] + step2_proc4 = self._step_as_dict(4, [], 4, fillable=True) + procsteps += [self.procstep_as_dict(4, 1, 4, None)] + expected = self.GET_todo_dict(1, [todo1_dict], procs, procsteps) + expected['steps_todo_to_process'] = [step_proc2, step2_proc4] + expected['adoption_candidates_for'] = {'2': [], '3': [], '4': []} + self.check_json_get('/todo?id=1', expected) + + def test_do_POST_doneness_relations(self) -> None: + """Test Todo.is_done Condition, adoption relations for /todo POSTs.""" + # test Todo with adoptee can only be set done if adoptee is done too + self._make_todo_via_day_post(1) + self._make_todo_via_day_post(1) + self.check_post({'adopt': 2, 'done': ''}, '/todo?id=1', 400) + self.check_post({'done': ''}, '/todo?id=2') + self.check_post({'adopt': 2, 'done': ''}, '/todo?id=1', 302) + # test Todo cannot be set undone with adopted Todo not done yet + self.check_post({}, '/todo?id=2') + self.check_post({'adopt': 2}, '/todo?id=1', 400) + # test unadoption relieves block + self.check_post({}, '/todo?id=1', 302) + # test Condition being set or unset can block doneness setting + c1_post = {'title': '', 'description': '', 'is_active': False} + c2_post = {'title': '', 'description': '', 'is_active': True} + self.check_post(c1_post, '/condition', redir='/condition?id=1') + self.check_post(c2_post, '/condition', redir='/condition?id=2') + self.check_post({'conditions': [1], 'done': ''}, '/todo?id=1', 400) + self.check_post({'done': ''}, '/todo?id=1', 302) + self.check_post({'blockers': [2]}, '/todo?id=1', 400) + self.check_post({'done': ''}, '/todo?id=1', 302) + # test setting Todo doneness can set/un-set Conditions, but only on + # doneness change, not by mere passive state + self.check_post({'enables': [1], 'done': ''}, '/todo?id=1') + self.check_post({'conditions': [1], 'done': ''}, '/todo?id=2', 400) + self.check_post({'enables': [1]}, '/todo?id=1') + self.check_post({'enables': [1], 'done': ''}, '/todo?id=1') + self.check_post({'conditions': [1], 'done': ''}, '/todo?id=2') + self.check_post({'blockers': [1]}, '/todo?id=2', 400) + self.check_post({'disables': [1], 'done': ''}, '/todo?id=1') + self.check_post({'blockers': [1]}, '/todo?id=2', 400) + self.check_post({'disables': [1]}, '/todo?id=1') + self.check_post({'disables': [1], 'done': ''}, '/todo?id=1') + self.check_post({'blockers': [1]}, '/todo?id=2') + + def test_do_POST_day_todo_adoption(self) -> None: + """Test Todos posted to Day view may adopt existing Todos.""" + form_data = self.post_process( + 2, self._proc1_form_data | {'new_top_step': 1}) + form_data = {'day_comment': '', 'new_todo': 1, 'make_type': 'full'} + self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302) + form_data['new_todo'] = 2 + self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302) + todo1 = Todo.by_date(self.db_conn, '2024-01-01')[0] + todo2 = Todo.by_date(self.db_conn, '2024-01-01')[1] + self.assertEqual(todo1.children, []) + self.assertEqual(todo1.parents, [todo2]) + self.assertEqual(todo2.children, [todo1]) + self.assertEqual(todo2.parents, []) + + def test_do_POST_day_todo_multiple_inner_adoption(self) -> None: + """Test multiple Todos can be posted to Day view w. inner adoption.""" + + def key_order_func(t: Todo) -> int: + assert isinstance(t.process.id_, int) + return t.process.id_ + + def check_adoption(date: str, new_todos: list[int]) -> None: + form_data = {'day_comment': '', 'new_todo': new_todos, + 'make_type': 'full'} + self.check_post(form_data, f'/day?date={date}&make_type=full', 302) + day_todos = Todo.by_date(self.db_conn, date) + day_todos.sort(key=key_order_func) + todo1 = day_todos[0] + todo2 = day_todos[1] + self.assertEqual(todo1.children, []) + self.assertEqual(todo1.parents, [todo2]) + self.assertEqual(todo2.children, [todo1]) + self.assertEqual(todo2.parents, []) + + def check_nesting_adoption(process_id: int, date: str, + new_top_steps: list[int]) -> None: + form_data = {'title': '', 'description': '', 'effort': 1, + 'step_of': [2]} + form_data = self.post_process(1, form_data) + form_data['new_top_step'] = new_top_steps + form_data['step_of'] = [] + form_data = self.post_process(process_id, form_data) + form_data = {'day_comment': '', 'new_todo': [process_id], + 'make_type': 'full'} + self.check_post(form_data, f'/day?date={date}&make_type=full', 302) + day_todos = Todo.by_date(self.db_conn, date) + day_todos.sort(key=key_order_func, reverse=True) + self.assertEqual(len(day_todos), 3) + todo1 = day_todos[0] # process of process_id + todo2 = day_todos[1] # process 2 + todo3 = day_todos[2] # process 1 + self.assertEqual(sorted(todo1.children), sorted([todo2, todo3])) + self.assertEqual(todo1.parents, []) + self.assertEqual(todo2.children, [todo3]) + self.assertEqual(todo2.parents, [todo1]) + self.assertEqual(todo3.children, []) + self.assertEqual(sorted(todo3.parents), sorted([todo2, todo1])) + + self.post_process(2, self._proc1_form_data | {'new_top_step': 1}) + check_adoption('2024-01-01', [1, 2]) + check_adoption('2024-01-02', [2, 1]) + check_nesting_adoption(3, '2024-01-03', [1, 2]) + check_nesting_adoption(4, '2024-01-04', [2, 1]) + + def test_do_POST_day_todo_doneness(self) -> None: + """Test Todo doneness can be posted to Day view.""" + form_data = {'day_comment': '', 'new_todo': [1], 'make_type': 'full'} + self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302) + todo = Todo.by_date(self.db_conn, '2024-01-01')[0] + form_data = {'day_comment': '', 'todo_id': [1], 'make_type': 'full', + 'comment': [''], 'done': [], 'effort': ['']} + self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302) + todo = Todo.by_date(self.db_conn, '2024-01-01')[0] + self.assertEqual(todo.is_done, False) + form_data = {'day_comment': '', 'todo_id': [1], 'done': [1], + 'make_type': 'full', 'comment': [''], 'effort': ['']} + self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302) + todo = Todo.by_date(self.db_conn, '2024-01-01')[0] + self.assertEqual(todo.is_done, True)