home · contact · privacy
Improve readability of POST /todo code.
[plomtask] / tests / todos.py
index 4bc1411b74cbddace9cf0a745527c0dcbb971b1d..25fa05bbd4e1a030d578fc60ef22cecf3ef3accb 100644 (file)
@@ -1,4 +1,5 @@
 """Test Todos module."""
 """Test Todos module."""
+from typing import Any
 from tests.utils import TestCaseSansDB, TestCaseWithDB, TestCaseWithServer
 from plomtask.todos import Todo, TodoNode
 from plomtask.processes import Process, ProcessStep
 from tests.utils import TestCaseSansDB, TestCaseWithDB, TestCaseWithServer
 from plomtask.todos import Todo, TodoNode
 from plomtask.processes import Process, ProcessStep
@@ -10,15 +11,12 @@ from plomtask.exceptions import (NotFoundException, BadFormatException,
 class TestsWithDB(TestCaseWithDB, TestCaseSansDB):
     """Tests requiring DB, but not server setup.
 
 class TestsWithDB(TestCaseWithDB, TestCaseSansDB):
     """Tests requiring DB, but not server setup.
 
-    NB: We subclass TestCaseSansDB too, to pull in its .test_id_validation,
-    which for Todo wouldn't run without a DB being set up due to the need for
-    Processes with set IDs.
+    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'}
     """
     checked_class = Todo
     default_init_kwargs = {'process': None, 'is_done': False,
                            'date': '2024-01-01'}
-    # solely used for TestCaseSansDB.test_id_setting
-    default_init_args = [None, False, '2024-01-01']
 
     def setUp(self) -> None:
         super().setUp()
 
     def setUp(self) -> None:
         super().setUp()
@@ -31,7 +29,6 @@ class TestsWithDB(TestCaseWithDB, TestCaseSansDB):
         self.cond2 = Condition(None)
         self.cond2.save(self.db_conn)
         self.default_init_kwargs['process'] = self.proc
         self.cond2 = Condition(None)
         self.cond2.save(self.db_conn)
         self.default_init_kwargs['process'] = self.proc
-        self.default_init_args[0] = self.proc
 
     def test_Todo_init(self) -> None:
         """Test creation of Todo and what they default to."""
 
     def test_Todo_init(self) -> None:
         """Test creation of Todo and what they default to."""
@@ -41,9 +38,9 @@ class TestsWithDB(TestCaseWithDB, TestCaseSansDB):
         process.save(self.db_conn)
         assert isinstance(self.cond1.id_, int)
         assert isinstance(self.cond2.id_, int)
         process.save(self.db_conn)
         assert isinstance(self.cond1.id_, int)
         assert isinstance(self.cond2.id_, int)
-        process.set_conditions(self.db_conn, [self.cond1.id_, self.cond2.id_])
-        process.set_enables(self.db_conn, [self.cond1.id_])
-        process.set_disables(self.db_conn, [self.cond2.id_])
+        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])
         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])
@@ -64,14 +61,18 @@ class TestsWithDB(TestCaseWithDB, TestCaseSansDB):
         with self.assertRaises(BadFormatException):
             self.assertEqual(Todo.by_date(self.db_conn, 'foo'), [])
 
         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)
     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_enables(self.db_conn, [self.cond1.id_])
-        todo.set_disables(self.db_conn, [self.cond2.id_])
+        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 = True
         self.assertEqual(self.cond1.is_active, True)
         self.assertEqual(self.cond2.is_active, False)
@@ -112,7 +113,8 @@ class TestsWithDB(TestCaseWithDB, TestCaseSansDB):
         todo_1.is_done = True
         todo_2.is_done = True
         todo_2.is_done = False
         todo_1.is_done = True
         todo_2.is_done = True
         todo_2.is_done = False
-        todo_2.set_conditions(self.db_conn, [self.cond1.id_])
+        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
         with self.assertRaises(BadFormatException):
             todo_2.is_done = True
         self.cond1.is_active = True
@@ -120,15 +122,24 @@ class TestsWithDB(TestCaseWithDB, TestCaseSansDB):
 
     def test_Todo_step_tree(self) -> None:
         """Test self-configuration of TodoStepsNode tree for Day view."""
 
     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, [])
         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, [])
-        self.assertEqual(todo_1.get_step_tree(set()), node_0)
+        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
         # test non_emtpy seen_todo does something
         node_0.seen = True
-        self.assertEqual(todo_1.get_step_tree({todo_1.id_}), node_0)
+        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)
         # test child shows up
         todo_2 = Todo(None, self.proc, False, self.date1)
         todo_2.save(self.db_conn)
@@ -137,7 +148,9 @@ class TestsWithDB(TestCaseWithDB, TestCaseSansDB):
         node_2 = TodoNode(todo_2, False, [])
         node_0.children = [node_2]
         node_0.seen = False
         node_2 = TodoNode(todo_2, False, [])
         node_0.children = [node_2]
         node_0.seen = False
-        self.assertEqual(todo_1.get_step_tree(set()), node_0)
+        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)
         # test child shows up with child
         todo_3 = Todo(None, self.proc, False, self.date1)
         todo_3.save(self.db_conn)
@@ -145,15 +158,19 @@ class TestsWithDB(TestCaseWithDB, TestCaseSansDB):
         todo_2.add_child(todo_3)
         node_3 = TodoNode(todo_3, False, [])
         node_2.children = [node_3]
         todo_2.add_child(todo_3)
         node_3 = TodoNode(todo_3, False, [])
         node_2.children = [node_3]
-        self.assertEqual(todo_1.get_step_tree(set()), node_0)
+        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]
         # 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]
-        self.assertEqual(todo_1.get_step_tree(set()), node_0)
+        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:
 
     def test_Todo_create_with_children(self) -> None:
-        """Test parenthood guaranteeds of Todo.create_with_children."""
+        """Test parenthood guarantees of Todo.create_with_children."""
         assert isinstance(self.proc.id_, int)
         proc2 = Process(None)
         proc2.save(self.db_conn)
         assert isinstance(self.proc.id_, int)
         proc2 = Process(None)
         proc2.save(self.db_conn)
@@ -191,134 +208,425 @@ class TestsWithDB(TestCaseWithDB, TestCaseSansDB):
         self.assertEqual(len(todo_3.children), 1)
         self.assertEqual(todo_3.children[0].process, proc4)
 
         self.assertEqual(len(todo_3.children), 1)
         self.assertEqual(todo_3.children[0].process, proc4)
 
-    def test_Todo_remove(self) -> None:
-        """Test removal."""
-        todo_1 = Todo(None, self.proc, False, self.date1)
-        todo_1.save(self.db_conn)
-        assert todo_1.id_ is not None
-        todo_0 = Todo(None, self.proc, False, self.date1)
-        todo_0.save(self.db_conn)
-        todo_0.add_child(todo_1)
-        todo_2 = Todo(None, self.proc, False, self.date1)
-        todo_2.save(self.db_conn)
-        todo_1.add_child(todo_2)
-        todo_1_id = todo_1.id_
-        todo_1.remove(self.db_conn)
-        with self.assertRaises(NotFoundException):
-            Todo.by_id(self.db_conn, todo_1_id)
-        self.assertEqual(todo_0.children, [])
-        self.assertEqual(todo_2.parents, [])
-        todo_2.comment = 'foo'
-        with self.assertRaises(HandledException):
-            todo_2.remove(self.db_conn)
-        todo_2.comment = ''
-        todo_2.effort = 5
-        with self.assertRaises(HandledException):
-            todo_2.remove(self.db_conn)
-
-    def test_Todo_autoremoval(self) -> None:
-        """"Test automatic removal for Todo.effort < 0."""
-        todo_1 = Todo(None, self.proc, False, self.date1)
-        todo_1.save(self.db_conn)
-        todo_1.comment = 'foo'
-        todo_1.effort = -0.1
-        todo_1.save(self.db_conn)
-        assert todo_1.id_ is not None
-        Todo.by_id(self.db_conn, todo_1.id_)
-        todo_1.comment = ''
-        todo_1_id = todo_1.id_
-        todo_1.save(self.db_conn)
-        with self.assertRaises(NotFoundException):
-            Todo.by_id(self.db_conn, todo_1_id)
-
 
 class TestsWithServer(TestCaseWithServer):
     """Tests against our HTTP server/handler (and database)."""
 
 
 class TestsWithServer(TestCaseWithServer):
     """Tests against our HTTP server/handler (and database)."""
 
-    def test_do_POST_day(self) -> None:
-        """Test Todo posting of POST /day."""
-        self.post_process()
-        self.post_process(2)
-        proc = Process.by_id(self.db_conn, 1)
-        proc2 = Process.by_id(self.db_conn, 2)
-        form_data = {'day_comment': '', 'make_type': 'full'}
-        self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302)
-        self.assertEqual(Todo.by_date(self.db_conn, '2024-01-01'), [])
-        form_data['new_todo'] = str(proc.id_)
-        self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 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_, proc.id_)
-        self.assertEqual(todo1.is_done, False)
-        form_data['new_todo'] = str(proc2.id_)
-        self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302)
-        todos = Todo.by_date(self.db_conn, '2024-01-01')
-        todo1 = todos[1]
-        self.assertEqual(todo1.id_, 2)
-        self.assertEqual(todo1.process.id_, proc2.id_)
-        self.assertEqual(todo1.is_done, False)
-
-    def test_do_POST_todo(self) -> None:
-        """Test POST /todo."""
-        def post_and_reload(form_data: dict[str, object], status: int = 302,
-                            redir_url: str = '/todo?id=1') -> Todo:
-            self.check_post(form_data, '/todo?id=1', status, redir_url)
-            return Todo.by_date(self.db_conn, '2024-01-01')[0]
-        # test minimum
-        self.post_process()
-        self.check_post({'day_comment': '', 'new_todo': 1,
-                         'make_type': 'full'},
-                        '/day?date=2024-01-01&make_type=full', 302)
-        # test posting to bad URLs
-        self.check_post({}, '/todo=', 404)
-        self.check_post({}, '/todo?id=', 400)
+    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=FOO', 400)
         self.check_post({}, '/todo?id=0', 404)
-        # test posting naked entity
-        todo1 = post_and_reload({})
-        self.assertEqual(todo1.children, [])
-        self.assertEqual(todo1.parents, [])
-        self.assertEqual(todo1.is_done, False)
-        # test posting doneness
-        todo1 = post_and_reload({'done': ''})
-        self.assertEqual(todo1.is_done, True)
-        # test implicitly posting non-doneness
-        todo1 = post_and_reload({})
-        self.assertEqual(todo1.is_done, False)
-        # test malformed adoptions
-        self.check_post({'adopt': 'foo'}, '/todo?id=1', 400)
+        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)
         self.check_post({'adopt': 1}, '/todo?id=1', 400)
-        self.check_post({'adopt': 2}, '/todo?id=1', 404)
-        # test posting second todo of same process
-        self.check_post({'day_comment': '', 'new_todo': 1,
-                         'make_type': 'full'},
-                        '/day?date=2024-01-01&make_type=full', 302)
-        # test todo 1 adopting todo 2
-        todo1 = post_and_reload({'adopt': 2})
-        todo2 = Todo.by_date(self.db_conn, '2024-01-01')[1]
-        self.assertEqual(todo1.children, [todo2])
-        self.assertEqual(todo1.parents, [])
-        self.assertEqual(todo2.children, [])
-        self.assertEqual(todo2.parents, [todo1])
-        # test todo1 cannot be set done with todo2 not done yet
-        todo1 = post_and_reload({'done': '', 'adopt': 2}, 400)
-        self.assertEqual(todo1.is_done, False)
-        # test todo1 un-adopting todo 2 by just not sending an adopt
-        todo1 = post_and_reload({}, 302)
-        todo2 = Todo.by_date(self.db_conn, '2024-01-01')[1]
-        self.assertEqual(todo1.children, [])
-        self.assertEqual(todo1.parents, [])
-        self.assertEqual(todo2.children, [])
-        self.assertEqual(todo2.parents, [])
-        # test todo1 deletion
-        todo1 = post_and_reload({'delete': ''}, 302, '/')
+        # 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."""
 
     def test_do_POST_day_todo_adoption(self) -> None:
         """Test Todos posted to Day view may adopt existing Todos."""
-        form_data = self.post_process()
-        form_data = self.post_process(2, form_data | {'new_top_step': 1})
+        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
         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
@@ -330,18 +638,6 @@ class TestsWithServer(TestCaseWithServer):
         self.assertEqual(todo2.children, [todo1])
         self.assertEqual(todo2.parents, [])
 
         self.assertEqual(todo2.children, [todo1])
         self.assertEqual(todo2.parents, [])
 
-    def test_do_POST_day_todo_multiple(self) -> None:
-        """Test multiple Todos can be posted to Day view."""
-        form_data = self.post_process()
-        form_data = self.post_process(2)
-        form_data = {'day_comment': '', 'new_todo': [1, 2],
-                     'make_type': 'full'}
-        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.process.id_, 1)
-        self.assertEqual(todo2.process.id_, 2)
-
     def test_do_POST_day_todo_multiple_inner_adoption(self) -> None:
         """Test multiple Todos can be posted to Day view w. inner adoption."""
 
     def test_do_POST_day_todo_multiple_inner_adoption(self) -> None:
         """Test multiple Todos can be posted to Day view w. inner adoption."""
 
@@ -386,8 +682,7 @@ class TestsWithServer(TestCaseWithServer):
             self.assertEqual(todo3.children, [])
             self.assertEqual(sorted(todo3.parents), sorted([todo2, todo1]))
 
             self.assertEqual(todo3.children, [])
             self.assertEqual(sorted(todo3.parents), sorted([todo2, todo1]))
 
-        form_data = self.post_process()
-        form_data = self.post_process(2, form_data | {'new_top_step': 1})
+        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_adoption('2024-01-01', [1, 2])
         check_adoption('2024-01-02', [2, 1])
         check_nesting_adoption(3, '2024-01-03', [1, 2])
@@ -395,25 +690,16 @@ class TestsWithServer(TestCaseWithServer):
 
     def test_do_POST_day_todo_doneness(self) -> None:
         """Test Todo doneness can be posted to Day view."""
 
     def test_do_POST_day_todo_doneness(self) -> None:
         """Test Todo doneness can be posted to Day view."""
-        self.post_process()
         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': '', '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'}
+        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)
         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],
         self.assertEqual(todo.is_done, False)
         form_data = {'day_comment': '', 'todo_id': [1], 'done': [1],
-                     'make_type': 'full'}
+                     'make_type': 'full', 'comment': [''], 'effort': ['']}
         self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302)
         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)
         self.assertEqual(todo.is_done, True)
-
-    def test_do_GET_todo(self) -> None:
-        """Test GET /todo response codes."""
-        self.post_process()
-        form_data = {'day_comment': '', 'new_todo': 1, 'make_type': 'full'}
-        self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302)
-        self.check_get('/todo', 400)
-        self.check_get('/todo?id=', 400)
-        self.check_get('/todo?id=foo', 400)
-        self.check_get('/todo?id=0', 404)
-        self.check_get('/todo?id=1', 200)