home · contact · privacy
Slightly reduce the do_POST_todo code. master
authorChristian Heller <c.heller@plomlompom.de>
Mon, 5 Aug 2024 08:01:13 +0000 (10:01 +0200)
committerChristian Heller <c.heller@plomlompom.de>
Mon, 5 Aug 2024 08:01:13 +0000 (10:01 +0200)
15 files changed:
plomtask/conditions.py
plomtask/db.py
plomtask/http.py
plomtask/misc.py [new file with mode: 0644]
plomtask/processes.py
plomtask/todos.py
plomtask/versioned_attributes.py
templates/process.html
templates/todo.html
tests/conditions.py
tests/days.py
tests/misc.py
tests/processes.py
tests/todos.py
tests/utils.py

index e752e91a277936f62cf6a39a4cc57e571e6c49e7..8d4160424423cf6ddfccb06b5b04d77e2bea4b15 100644 (file)
@@ -42,6 +42,7 @@ class Condition(BaseModel[int]):
 
 class ConditionsRelations:
     """Methods for handling relations to Conditions, for Todo and Process."""
+    # pylint: disable=too-few-public-methods
 
     def __init__(self) -> None:
         self.conditions: list[Condition] = []
@@ -49,26 +50,21 @@ class ConditionsRelations:
         self.enables: list[Condition] = []
         self.disables: list[Condition] = []
 
-    def set_conditions(self, db_conn: DatabaseConnection, ids: list[int],
-                       target: str = 'conditions') -> None:
-        """Set self.[target] to Conditions identified by ids."""
-        target_list = getattr(self, target)
-        while len(target_list) > 0:
-            target_list.pop()
-        for id_ in ids:
-            target_list += [Condition.by_id(db_conn, id_)]
-
-    def set_blockers(self, db_conn: DatabaseConnection,
-                     ids: list[int]) -> None:
-        """Set self.enables to Conditions identified by ids."""
-        self.set_conditions(db_conn, ids, 'blockers')
-
-    def set_enables(self, db_conn: DatabaseConnection,
-                    ids: list[int]) -> None:
-        """Set self.enables to Conditions identified by ids."""
-        self.set_conditions(db_conn, ids, 'enables')
-
-    def set_disables(self, db_conn: DatabaseConnection,
-                     ids: list[int]) -> None:
-        """Set self.disables to Conditions identified by ids."""
-        self.set_conditions(db_conn, ids, 'disables')
+    def set_condition_relations(self,
+                                db_conn: DatabaseConnection,
+                                ids_conditions: list[int],
+                                ids_blockers: list[int],
+                                ids_enables: list[int],
+                                ids_disables: list[int]
+                                ) -> None:
+        """Set owned Condition lists to those identified by respective IDs."""
+        # pylint: disable=too-many-arguments
+        for ids, target in [(ids_conditions, 'conditions'),
+                            (ids_blockers, 'blockers'),
+                            (ids_enables, 'enables'),
+                            (ids_disables, 'disables')]:
+            target_list = getattr(self, target)
+            while len(target_list) > 0:
+                target_list.pop()
+            for id_ in ids:
+                target_list += [Condition.by_id(db_conn, id_)]
index 704b709fd81fe14d6a0aa5bc9e1c06009202da47..ee5f3b99f04e66460ef4e0a90c5d10d600210881 100644 (file)
@@ -281,61 +281,29 @@ class BaseModel(Generic[BaseModelId]):
         return list(cls.versioned_defaults.keys())
 
     @property
-    def as_dict(self) -> dict[str, object]:
-        """Return self as (json.dumps-compatible) dict."""
-        library: dict[str, dict[str | int, object]] = {}
-        d: dict[str, object] = {'id': self.id_, '_library': library}
+    def as_dict_and_refs(self) -> tuple[dict[str, object],
+                                        list[BaseModel[int] | BaseModel[str]]]:
+        """Return self as json.dumps-ready dict, list of referenced objects."""
+        d: dict[str, object] = {'id': self.id_}
+        refs: list[BaseModel[int] | BaseModel[str]] = []
         for to_save in self.to_save_simples:
-            attr = getattr(self, to_save)
-            if hasattr(attr, 'as_dict_into_reference'):
-                d[to_save] = attr.as_dict_into_reference(library)
-            else:
-                d[to_save] = attr
+            d[to_save] = getattr(self, to_save)
         if len(self.to_save_versioned()) > 0:
             d['_versioned'] = {}
         for k in self.to_save_versioned():
             attr = getattr(self, k)
             assert isinstance(d['_versioned'], dict)
             d['_versioned'][k] = attr.history
-        for r in self.to_save_relations:
-            attr_name = r[2]
-            l: list[int | str] = []
-            for rel in getattr(self, attr_name):
-                l += [rel.as_dict_into_reference(library)]
-            d[attr_name] = l
-        for k in self.add_to_dict:
-            d[k] = [x.as_dict_into_reference(library)
-                    for x in getattr(self, k)]
-        return d
-
-    def as_dict_into_reference(self,
-                               library: dict[str, dict[str | int, object]]
-                               ) -> int | str:
-        """Return self.id_ while writing .as_dict into library."""
-        def into_library(library: dict[str, dict[str | int, object]],
-                         cls_name: str,
-                         id_: str | int,
-                         d: dict[str, object]
-                         ) -> None:
-            if cls_name not in library:
-                library[cls_name] = {}
-            if id_ in library[cls_name]:
-                if library[cls_name][id_] != d:
-                    msg = 'Unexpected inequality of entries for ' +\
-                            f'_library at: {cls_name}/{id_}'
-                    raise HandledException(msg)
-            else:
-                library[cls_name][id_] = d
-        as_dict = self.as_dict
-        assert isinstance(as_dict['_library'], dict)
-        for cls_name, dict_of_objs in as_dict['_library'].items():
-            for id_, obj in dict_of_objs.items():
-                into_library(library, cls_name, id_, obj)
-        del as_dict['_library']
-        assert self.id_ is not None
-        into_library(library, self.__class__.__name__, self.id_, as_dict)
-        assert isinstance(as_dict['id'], (int, str))
-        return as_dict['id']
+        rels_to_collect = [rel[2] for rel in self.to_save_relations]
+        rels_to_collect += self.add_to_dict
+        for attr_name in rels_to_collect:
+            rel_list = []
+            for item in getattr(self, attr_name):
+                rel_list += [item.id_]
+                if item not in refs:
+                    refs += [item]
+            d[attr_name] = rel_list
+        return d, refs
 
     @classmethod
     def name_lowercase(cls) -> str:
@@ -372,7 +340,8 @@ class BaseModel(Generic[BaseModelId]):
     def __getattribute__(self, name: str) -> Any:
         """Ensure fail if ._disappear() was called, except to check ._exists"""
         if name != '_exists' and not super().__getattribute__('_exists'):
-            raise HandledException('Object does not exist.')
+            msg = f'Object for attribute does not exist: {name}'
+            raise HandledException(msg)
         return super().__getattribute__(name)
 
     def _disappear(self) -> None:
@@ -397,10 +366,11 @@ class BaseModel(Generic[BaseModelId]):
         cls.cache_ = {}
 
     @classmethod
-    def get_cache(cls: type[BaseModelInstance]) -> dict[Any, BaseModel[Any]]:
+    def get_cache(cls: type[BaseModelInstance]
+                  ) -> dict[Any, BaseModelInstance]:
         """Get cache dictionary, create it if not yet existing."""
         if not hasattr(cls, 'cache_'):
-            d: dict[Any, BaseModel[Any]] = {}
+            d: dict[Any, BaseModelInstance] = {}
             cls.cache_ = d
         return cls.cache_
 
@@ -509,7 +479,7 @@ class BaseModel(Generic[BaseModelId]):
                 item = cls.by_id(db_conn, id_)
                 assert item.id_ is not None
                 items[item.id_] = item
-        return list(items.values())
+        return sorted(list(items.values()))
 
     @classmethod
     def by_date_range_with_limits(cls: type[BaseModelInstance],
index b7040f76fa9c3c1d58b0ceedc58fabf02752f616..1aa2e492c9e611877ab1dbe4f160b483c48b2fa1 100644 (file)
@@ -1,6 +1,5 @@
 """Web server stuff."""
 from __future__ import annotations
-from dataclasses import dataclass
 from typing import Any, Callable
 from base64 import b64encode, b64decode
 from binascii import Error as binascii_Exception
@@ -14,10 +13,11 @@ from plomtask.dating import date_in_n_days
 from plomtask.days import Day
 from plomtask.exceptions import (HandledException, BadFormatException,
                                  NotFoundException)
-from plomtask.db import DatabaseConnection, DatabaseFile
+from plomtask.db import DatabaseConnection, DatabaseFile, BaseModel
 from plomtask.processes import Process, ProcessStep, ProcessStepsNode
 from plomtask.conditions import Condition
-from plomtask.todos import Todo
+from plomtask.todos import Todo, TodoOrProcStepNode
+from plomtask.misc import DictableNode
 
 TEMPLATES_DIR = 'templates'
 
@@ -29,47 +29,8 @@ class TaskServer(HTTPServer):
                  *args: Any, **kwargs: Any) -> None:
         super().__init__(*args, **kwargs)
         self.db = db_file
-        self.headers: list[tuple[str, str]] = []
-        self._render_mode = 'html'
-        self._jinja = JinjaEnv(loader=JinjaFSLoader(TEMPLATES_DIR))
-
-    def set_json_mode(self) -> None:
-        """Make server send JSON instead of HTML responses."""
-        self._render_mode = 'json'
-        self.headers += [('Content-Type', 'application/json')]
-
-    @staticmethod
-    def ctx_to_json(ctx: dict[str, object]) -> str:
-        """Render ctx into JSON string."""
-        def walk_ctx(node: object) -> Any:
-            if hasattr(node, 'as_dict_into_reference'):
-                if hasattr(node, 'id_') and node.id_ is not None:
-                    return node.as_dict_into_reference(library)
-            if hasattr(node, 'as_dict'):
-                return node.as_dict
-            if isinstance(node, (list, tuple)):
-                return [walk_ctx(x) for x in node]
-            if isinstance(node, dict):
-                d = {}
-                for k, v in node.items():
-                    d[k] = walk_ctx(v)
-                return d
-            if isinstance(node, HandledException):
-                return str(node)
-            return node
-        library: dict[str, dict[str | int, object]] = {}
-        for k, v in ctx.items():
-            ctx[k] = walk_ctx(v)
-        ctx['_library'] = library
-        return json_dumps(ctx)
-
-    def render(self, ctx: dict[str, object], tmpl_name: str = '') -> str:
-        """Render ctx according to self._render_mode.."""
-        tmpl_name = f'{tmpl_name}.{self._render_mode}'
-        if 'html' == self._render_mode:
-            template = self._jinja.get_template(tmpl_name)
-            return template.render(ctx)
-        return self.__class__.ctx_to_json(ctx)
+        self.render_mode = 'html'
+        self.jinja = JinjaEnv(loader=JinjaFSLoader(TEMPLATES_DIR))
 
 
 class InputsParser:
@@ -78,14 +39,14 @@ class InputsParser:
     def __init__(self, dict_: dict[str, list[str]],
                  strictness: bool = True) -> None:
         self.inputs = dict_
-        self.strict = strictness
+        self.strict = strictness  # return None on absence of key, or fail?
 
     def get_str(self, key: str, default: str = '',
                 ignore_strict: bool = False) -> str:
         """Retrieve single/first string value of key, or default."""
         if key not in self.inputs.keys() or 0 == len(self.inputs[key]):
             if self.strict and not ignore_strict:
-                raise BadFormatException(f'no value found for key {key}')
+                raise NotFoundException(f'no value found for key {key}')
             return default
         return self.inputs[key][0]
 
@@ -123,6 +84,21 @@ class InputsParser:
             msg = f'cannot float form field value for key {key}: {val}'
             raise BadFormatException(msg) from e
 
+    def get_float_or_none(self, key: str) -> float | None:
+        """Retrieve float value of key from self.postvars, None if empty."""
+        val = self.get_str(key)
+        if '' == val:
+            return None
+        try:
+            return float(val)
+        except ValueError as e:
+            msg = f'cannot float form field value for key {key}: {val}'
+            raise BadFormatException(msg) from e
+
+    def get_bool(self, key: str) -> bool:
+        """Return if key occurs and maps to a boolean Truth."""
+        return bool(self.get_all_str(key))
+
     def get_all_str(self, key: str) -> list[str]:
         """Retrieve list of string values at key."""
         if key not in self.inputs.keys():
@@ -162,19 +138,76 @@ class TaskHandler(BaseHTTPRequestHandler):
     _form_data: InputsParser
     _params: InputsParser
 
-    def _send_page(self,
-                   ctx: dict[str, Any],
-                   tmpl_name: str,
-                   code: int = 200
-                   ) -> None:
-        """Send ctx as proper HTTP response."""
-        body = self.server.render(ctx, tmpl_name)
+    def _send_page(
+            self, ctx: dict[str, Any], tmpl_name: str, code: int = 200
+            ) -> None:
+        """HTTP-send ctx as HTML or JSON, as defined by .server.render_mode.
+
+        The differentiation by .server.render_mode serves to allow easily
+        comparable JSON responses for automatic testing.
+        """
+        body: str
+        headers: list[tuple[str, str]] = []
+        if 'html' == self.server.render_mode:
+            tmpl = self.server.jinja.get_template(f'{tmpl_name}.html')
+            body = tmpl.render(ctx)
+        else:
+            body = self._ctx_to_json(ctx)
+            headers += [('Content-Type', 'application/json')]
         self.send_response(code)
-        for header_tuple in self.server.headers:
+        for header_tuple in headers:
             self.send_header(*header_tuple)
         self.end_headers()
         self.wfile.write(bytes(body, 'utf-8'))
 
+    def _ctx_to_json(self, ctx: dict[str, object]) -> str:
+        """Render ctx into JSON string.
+
+        Flattens any objects that json.dumps might not want to serialize, and
+        turns occurrences of BaseModel objects into listings of their .id_, to
+        be resolved to a full dict inside a top-level '_library' dictionary,
+        to avoid endless and circular nesting.
+        """
+
+        def flatten(node: object) -> object:
+
+            def update_library_with(
+                    item: BaseModel[int] | BaseModel[str]) -> None:
+                cls_name = item.__class__.__name__
+                if cls_name not in library:
+                    library[cls_name] = {}
+                if item.id_ not in library[cls_name]:
+                    d, refs = item.as_dict_and_refs
+                    id_key = '?' if item.id_ is None else item.id_
+                    library[cls_name][id_key] = d
+                    for ref in refs:
+                        update_library_with(ref)
+
+            if isinstance(node, BaseModel):
+                update_library_with(node)
+                return node.id_
+            if isinstance(node, DictableNode):
+                d, refs = node.as_dict_and_refs
+                for ref in refs:
+                    update_library_with(ref)
+                return d
+            if isinstance(node, (list, tuple)):
+                return [flatten(item) for item in node]
+            if isinstance(node, dict):
+                d = {}
+                for k, v in node.items():
+                    d[k] = flatten(v)
+                return d
+            if isinstance(node, HandledException):
+                return str(node)
+            return node
+
+        library: dict[str, dict[str | int, object]] = {}
+        for k, v in ctx.items():
+            ctx[k] = flatten(v)
+        ctx['_library'] = library
+        return json_dumps(ctx)
+
     @staticmethod
     def _request_wrapper(http_method: str, not_found_msg: str
                          ) -> Callable[..., Callable[[TaskHandler], None]]:
@@ -299,12 +332,10 @@ class TaskHandler(BaseHTTPRequestHandler):
         same, the only difference being the HTML template they are rendered to,
         which .do_GET selects from their method name.
         """
-        start = self._params.get_str('start')
-        end = self._params.get_str('end')
-        if not end:
-            end = date_in_n_days(366)
-        ret = Day.by_date_range_with_limits(self.conn, (start, end), 'id')
-        days, start, end = ret
+        start, end = self._params.get_str('start'), self._params.get_str('end')
+        end = end if end else date_in_n_days(366)
+        days, start, end = Day.by_date_range_with_limits(self.conn,
+                                                         (start, end), 'id')
         days = Day.with_filled_gaps(days, start, end)
         today = date_in_n_days(0)
         return {'start': start, 'end': end, 'days': days, 'today': today}
@@ -350,27 +381,21 @@ class TaskHandler(BaseHTTPRequestHandler):
     def do_GET_todo(self, todo: Todo) -> dict[str, object]:
         """Show single Todo of ?id=."""
 
-        @dataclass
-        class TodoStepsNode:
-            """Collect what's useful for Todo steps tree display."""
-            id_: int
-            todo: Todo | None
-            process: Process | None
-            children: list[TodoStepsNode]  # pylint: disable=undefined-variable
-            fillable: bool = False
-
-        def walk_process_steps(id_: int,
+        def walk_process_steps(node_id: int,
                                process_step_nodes: list[ProcessStepsNode],
-                               steps_nodes: list[TodoStepsNode]) -> None:
+                               steps_nodes: list[TodoOrProcStepNode]) -> int:
             for process_step_node in process_step_nodes:
-                id_ += 1
-                node = TodoStepsNode(id_, None, process_step_node.process, [])
+                node_id += 1
+                proc = Process.by_id(self.conn,
+                                     process_step_node.step.step_process_id)
+                node = TodoOrProcStepNode(node_id, None, proc, [])
                 steps_nodes += [node]
-                walk_process_steps(id_, list(process_step_node.steps.values()),
-                                   node.children)
+                node_id = walk_process_steps(
+                        node_id, process_step_node.steps, node.children)
+            return node_id
 
-        def walk_todo_steps(id_: int, todos: list[Todo],
-                            steps_nodes: list[TodoStepsNode]) -> None:
+        def walk_todo_steps(node_id: int, todos: list[Todo],
+                            steps_nodes: list[TodoOrProcStepNode]) -> int:
             for todo in todos:
                 matched = False
                 for match in [item for item in steps_nodes
@@ -380,15 +405,18 @@ class TaskHandler(BaseHTTPRequestHandler):
                     matched = True
                     for child in match.children:
                         child.fillable = True
-                    walk_todo_steps(id_, todo.children, match.children)
+                    node_id = walk_todo_steps(
+                            node_id, todo.children, match.children)
                 if not matched:
-                    id_ += 1
-                    node = TodoStepsNode(id_, todo, None, [])
+                    node_id += 1
+                    node = TodoOrProcStepNode(node_id, todo, None, [])
                     steps_nodes += [node]
-                    walk_todo_steps(id_, todo.children, node.children)
+                    node_id = walk_todo_steps(
+                            node_id, todo.children, node.children)
+            return node_id
 
-        def collect_adoptables_keys(steps_nodes: list[TodoStepsNode]
-                                    ) -> set[int]:
+        def collect_adoptables_keys(
+                steps_nodes: list[TodoOrProcStepNode]) -> set[int]:
             ids = set()
             for node in steps_nodes:
                 if not node.todo:
@@ -400,13 +428,12 @@ class TaskHandler(BaseHTTPRequestHandler):
 
         todo_steps = [step.todo for step in todo.get_step_tree(set()).children]
         process_tree = todo.process.get_steps(self.conn, None)
-        steps_todo_to_process: list[TodoStepsNode] = []
-        walk_process_steps(0, list(process_tree.values()),
-                           steps_todo_to_process)
+        steps_todo_to_process: list[TodoOrProcStepNode] = []
+        last_node_id = walk_process_steps(0, process_tree,
+                                          steps_todo_to_process)
         for steps_node in steps_todo_to_process:
             steps_node.fillable = True
-        walk_todo_steps(len(steps_todo_to_process), todo_steps,
-                        steps_todo_to_process)
+        walk_todo_steps(last_node_id, todo_steps, steps_todo_to_process)
         adoptables: dict[int, list[Todo]] = {}
         any_adoptables = [Todo.by_id(self.conn, t.id_)
                           for t in Todo.by_date(self.conn, todo.date)
@@ -415,9 +442,10 @@ class TaskHandler(BaseHTTPRequestHandler):
         for id_ in collect_adoptables_keys(steps_todo_to_process):
             adoptables[id_] = [t for t in any_adoptables
                                if t.process.id_ == id_]
-        return {'todo': todo, 'steps_todo_to_process': steps_todo_to_process,
+        return {'todo': todo,
+                'steps_todo_to_process': steps_todo_to_process,
                 'adoption_candidates_for': adoptables,
-                'process_candidates': Process.all(self.conn),
+                'process_candidates': sorted(Process.all(self.conn)),
                 'todo_candidates': any_adoptables,
                 'condition_candidates': Condition.all(self.conn)}
 
@@ -491,7 +519,8 @@ class TaskHandler(BaseHTTPRequestHandler):
             preset_top_step = process_id
         return {'process': process, 'is_new': process.id_ is None,
                 'preset_top_step': preset_top_step,
-                'steps': process.get_steps(self.conn), 'owners': owners,
+                'steps': process.get_steps(self.conn),
+                'owners': owners,
                 'n_todos': len(Todo.by_process_id(self.conn, process.id_)),
                 'process_candidates': Process.all(self.conn),
                 'condition_candidates': Condition.all(self.conn)}
@@ -562,11 +591,14 @@ class TaskHandler(BaseHTTPRequestHandler):
     def do_POST_day(self) -> str:
         """Update or insert Day of date and Todos mapped to it."""
         # pylint: disable=too-many-locals
-        date = self._params.get_str('date')
-        day_comment = self._form_data.get_str('day_comment')
-        make_type = self._form_data.get_str('make_type')
+        try:
+            date = self._params.get_str('date')
+            day_comment = self._form_data.get_str('day_comment')
+            make_type = self._form_data.get_str('make_type')
+        except NotFoundException as e:
+            raise BadFormatException from e
         old_todos = self._form_data.get_all_int('todo_id')
-        new_todos = self._form_data.get_all_int('new_todo')
+        new_todos_by_process = self._form_data.get_all_int('new_todo')
         comments = self._form_data.get_all_str('comment')
         efforts = self._form_data.get_all_floats_or_nones('effort')
         done_todos = self._form_data.get_all_int('done')
@@ -581,13 +613,15 @@ class TaskHandler(BaseHTTPRequestHandler):
         day = Day.by_id_or_create(self.conn, date)
         day.comment = day_comment
         day.save(self.conn)
-        for process_id in sorted(new_todos):
-            if 'empty' == make_type:
-                process = Process.by_id(self.conn, process_id)
-                todo = Todo(None, process, False, date)
-                todo.save(self.conn)
-            else:
-                Todo.create_with_children(self.conn, process_id, date)
+        new_todos = []
+        for process_id in sorted(new_todos_by_process):
+            process = Process.by_id(self.conn, process_id)
+            todo = Todo(None, process, False, date)
+            todo.save(self.conn)
+            new_todos += [todo]
+        if 'full' == make_type:
+            for todo in new_todos:
+                todo.ensure_children(self.conn)
         for i, todo_id in enumerate(old_todos):
             todo = Todo.by_id(self.conn, todo_id)
             todo.is_done = is_done[i]
@@ -600,56 +634,62 @@ class TaskHandler(BaseHTTPRequestHandler):
     def do_POST_todo(self, todo: Todo) -> str:
         """Update Todo and its children."""
         # pylint: disable=too-many-locals
+        # pylint: disable=too-many-branches
         adopted_child_ids = self._form_data.get_all_int('adopt')
-        processes_to_make_full = self._form_data.get_all_int('make_full')
-        processes_to_make_empty = self._form_data.get_all_int('make_empty')
-        fill_fors = self._form_data.get_first_strings_starting('fill_for_')
-        effort = self._form_data.get_str('effort', ignore_strict=True)
-        conditions = self._form_data.get_all_int('conditions')
-        disables = self._form_data.get_all_int('disables')
-        blockers = self._form_data.get_all_int('blockers')
-        enables = self._form_data.get_all_int('enables')
-        is_done = len(self._form_data.get_all_str('done')) > 0
-        calendarize = len(self._form_data.get_all_str('calendarize')) > 0
-        comment = self._form_data.get_str('comment', ignore_strict=True)
-        for v in fill_fors.values():
-            if v.startswith('make_empty_'):
-                processes_to_make_empty += [int(v[11:])]
-            elif v.startswith('make_full_'):
-                processes_to_make_full += [int(v[10:])]
-            elif v != 'ignore':
-                adopted_child_ids += [int(v)]
+        to_make = {'full': self._form_data.get_all_int('make_full'),
+                   'empty': self._form_data.get_all_int('make_empty')}
+        step_fillers = self._form_data.get_all_str('step_filler')
+        to_update = {
+            'is_done': self._form_data.get_bool('done'),
+            'calendarize': self._form_data.get_bool('calendarize'),
+            'comment': self._form_data.get_str('comment', ignore_strict=True)}
+        cond_rels = [self._form_data.get_all_int(name) for name in
+                     ['conditions', 'blockers', 'enables', 'disables']]
+        try:
+            to_update['effort'] = self._form_data.get_float_or_none('effort')
+        except NotFoundException:
+            pass
+        todo.set_condition_relations(self.conn, *cond_rels)
+        for filler in [f for f in step_fillers if f != 'ignore']:
+            target_id: int
+            to_int = filler
+            for prefix in [p for p in ['make_empty_', 'make_full_']
+                           if filler.startswith(p)]:
+                to_int = filler[len(prefix):]
+            try:
+                target_id = int(to_int)
+            except ValueError as e:
+                msg = 'bad fill_for target: {filler}'
+                raise BadFormatException(msg) from e
+            if filler.startswith('make_empty_'):
+                to_make['empty'] += [target_id]
+            elif filler.startswith('make_full_'):
+                to_make['full'] += [target_id]
+            else:
+                adopted_child_ids += [target_id]
         to_remove = []
         for child in todo.children:
-            assert isinstance(child.id_, int)
-            if child.id_ not in adopted_child_ids:
+            if child.id_ and (child.id_ not in adopted_child_ids):
                 to_remove += [child.id_]
         for id_ in to_remove:
             child = Todo.by_id(self.conn, id_)
             todo.remove_child(child)
         for child_id in adopted_child_ids:
-            if child_id in [c.id_ for c in todo.children]:
-                continue
-            child = Todo.by_id(self.conn, child_id)
-            todo.add_child(child)
-        for process_id in processes_to_make_empty:
-            process = Process.by_id(self.conn, process_id)
-            made = Todo(None, process, False, todo.date)
-            made.save(self.conn)
-            todo.add_child(made)
-        for process_id in processes_to_make_full:
-            made = Todo.create_with_children(self.conn, process_id, todo.date)
-            todo.add_child(made)
-        todo.effort = float(effort) if effort else None
-        todo.set_conditions(self.conn, conditions)
-        todo.set_blockers(self.conn, blockers)
-        todo.set_enables(self.conn, enables)
-        todo.set_disables(self.conn, disables)
-        todo.is_done = is_done
-        todo.calendarize = calendarize
-        todo.comment = comment
+            if child_id not in [c.id_ for c in todo.children]:
+                todo.add_child(Todo.by_id(self.conn, child_id))
+        todo.update_attrs(**to_update)
+        for approach, proc_ids in to_make.items():
+            for process_id in proc_ids:
+                process = Process.by_id(self.conn, process_id)
+                made = Todo(None, process, False, todo.date)
+                made.save(self.conn)
+                if 'full' == approach:
+                    made.ensure_children(self.conn)
+                todo.add_child(made)
+        # todo.save() may destroy Todo if .effort < 0, so retrieve .id_ early
+        url = f'/todo?id={todo.id_}'
         todo.save(self.conn)
-        return f'/todo?id={todo.id_}'
+        return url
 
     def do_POST_process_descriptions(self) -> str:
         """Update history timestamps for Process.description."""
@@ -667,63 +707,36 @@ class TaskHandler(BaseHTTPRequestHandler):
     def do_POST_process(self, process: Process) -> str:
         """Update or insert Process of ?id= and fields defined in postvars."""
         # pylint: disable=too-many-locals
-        # pylint: disable=too-many-statements
-        title = self._form_data.get_str('title')
-        description = self._form_data.get_str('description')
-        effort = self._form_data.get_float('effort')
-        conditions = self._form_data.get_all_int('conditions')
-        blockers = self._form_data.get_all_int('blockers')
-        enables = self._form_data.get_all_int('enables')
-        disables = self._form_data.get_all_int('disables')
-        calendarize = self._form_data.get_all_str('calendarize') != []
-        suppresses = self._form_data.get_all_int('suppresses')
+        try:
+            versioned = {'title': self._form_data.get_str('title'),
+                         'description': self._form_data.get_str('description'),
+                         'effort': self._form_data.get_float('effort')}
+        except NotFoundException as e:
+            raise BadFormatException from e
+        cond_rels = [self._form_data.get_all_int(s) for s
+                     in ['conditions', 'blockers', 'enables', 'disables']]
+        calendarize = self._form_data.get_bool('calendarize')
         step_of = self._form_data.get_all_str('step_of')
-        keep_steps = self._form_data.get_all_int('keep_step')
-        step_ids = self._form_data.get_all_int('steps')
+        suppresses = self._form_data.get_all_int('suppresses')
+        kept_steps = self._form_data.get_all_int('kept_steps')
         new_top_steps = self._form_data.get_all_str('new_top_step')
-        step_process_id_to = {}
-        step_parent_id_to = {}
         new_steps_to = {}
-        for step_id in step_ids:
+        for step_id in kept_steps:
             name = f'new_step_to_{step_id}'
             new_steps_to[step_id] = self._form_data.get_all_int(name)
-        for step_id in keep_steps:
-            name = f'step_{step_id}_process_id'
-            step_process_id_to[step_id] = self._form_data.get_int(name)
-            name = f'step_{step_id}_parent_id'
-            step_parent_id_to[step_id] = self._form_data.get_int_or_none(name)
-        process.title.set(title)
-        process.description.set(description)
-        process.effort.set(effort)
-        process.set_conditions(self.conn, conditions)
-        process.set_blockers(self.conn, blockers)
-        process.set_enables(self.conn, enables)
-        process.set_disables(self.conn, disables)
+        for k, v in versioned.items():
+            getattr(process, k).set(v)
+        process.set_condition_relations(self.conn, *cond_rels)
         process.calendarize = calendarize
         process.save(self.conn)
         assert isinstance(process.id_, int)
-        new_step_title = None
-        steps: list[ProcessStep] = []
-        for step_id in keep_steps:
-            if step_id not in step_ids:
-                raise BadFormatException('trying to keep unknown step')
-            step = ProcessStep(step_id, process.id_,
-                               step_process_id_to[step_id],
-                               step_parent_id_to[step_id])
-            steps += [step]
-        for step_id in step_ids:
-            new = [ProcessStep(None, process.id_, step_process_id, step_id)
-                   for step_process_id in new_steps_to[step_id]]
-            steps += new
-        for step_identifier in new_top_steps:
-            try:
-                step_process_id = int(step_identifier)
-                step = ProcessStep(None, process.id_, step_process_id, None)
-                steps += [step]
-            except ValueError:
-                new_step_title = step_identifier
-        process.set_steps(self.conn, steps)
-        process.set_step_suppressions(self.conn, suppresses)
+        # set relations to, and if non-existant yet: create, other Processes
+        # pylint: disable=fixme
+        # TODO: in what order to set owners, owneds, and possibly step
+        # suppressions can make the difference between recursion checks
+        # failing; should probably be handled class-internally to Process
+        # rather than here!
+        # 1. owners (upwards)
         owners_to_set = []
         new_owner_title = None
         for owner_identifier in step_of:
@@ -732,6 +745,25 @@ class TaskHandler(BaseHTTPRequestHandler):
             except ValueError:
                 new_owner_title = owner_identifier
         process.set_owners(self.conn, owners_to_set)
+        # 2. owneds (downwards)
+        new_step_title = None
+        steps: list[ProcessStep] = [ProcessStep.by_id(self.conn, step_id)
+                                    for step_id in kept_steps]
+        for step_id in kept_steps:
+            new_sub_steps = [
+                    ProcessStep(None, process.id_, step_process_id, step_id)
+                    for step_process_id in new_steps_to[step_id]]
+            steps += new_sub_steps
+        for step_id_or_new_title in new_top_steps:
+            try:
+                step_process_id = int(step_id_or_new_title)
+                step = ProcessStep(None, process.id_, step_process_id, None)
+                steps += [step]
+            except ValueError:
+                new_step_title = step_id_or_new_title
+        process.set_steps(self.conn, steps)
+        process.set_step_suppressions(self.conn, suppresses)
+        # encode titles for potentially newly created Processes up or down
         params = f'id={process.id_}'
         if new_step_title:
             title_b64_encoded = b64encode(new_step_title.encode()).decode()
@@ -753,9 +785,12 @@ class TaskHandler(BaseHTTPRequestHandler):
     @_delete_or_post(Condition, '/conditions')
     def do_POST_condition(self, condition: Condition) -> str:
         """Update/insert Condition of ?id= and fields defined in postvars."""
-        is_active = self._form_data.get_str('is_active') == 'True'
-        title = self._form_data.get_str('title')
-        description = self._form_data.get_str('description')
+        try:
+            is_active = self._form_data.get_str('is_active') == 'True'
+            title = self._form_data.get_str('title')
+            description = self._form_data.get_str('description')
+        except NotFoundException as e:
+            raise BadFormatException(e) from e
         condition.is_active = is_active
         condition.title.set(title)
         condition.description.set(description)
diff --git a/plomtask/misc.py b/plomtask/misc.py
new file mode 100644 (file)
index 0000000..fa79bf5
--- /dev/null
@@ -0,0 +1,33 @@
+"""What doesn't fit elsewhere so far."""
+from typing import Any
+
+
+class DictableNode:
+    """Template for display chain nodes providing .as_dict_and_refs."""
+    # pylint: disable=too-few-public-methods
+    _to_dict: list[str] = []
+
+    def __init__(self, *args: Any) -> None:
+        for i, arg in enumerate(args):
+            setattr(self, self._to_dict[i], arg)
+
+    @property
+    def as_dict_and_refs(self) -> tuple[dict[str, object], list[Any]]:
+        """Return self as json.dumps-ready dict, list of referenced objects."""
+        d = {}
+        refs = []
+        for name in self._to_dict:
+            attr = getattr(self, name)
+            if hasattr(attr, 'id_'):
+                d[name] = attr.id_
+                continue
+            if isinstance(attr, list):
+                d[name] = []
+                for item in attr:
+                    item_d, item_refs = item.as_dict_and_refs
+                    d[name] += [item_d]
+                    for item_ref in [r for r in item_refs if r not in refs]:
+                        refs += [item_ref]
+                continue
+            d[name] = attr
+        return d, refs
index 9870ab3c572517d498e631d479d9996b949fb26f..56dd282acce8147d24b502cc61c81862765e9359 100644 (file)
@@ -1,8 +1,8 @@
 """Collecting Processes and Process-related items."""
 from __future__ import annotations
-from dataclasses import dataclass
 from typing import Set, Any
 from sqlite3 import Row
+from plomtask.misc import DictableNode
 from plomtask.db import DatabaseConnection, BaseModel
 from plomtask.versioned_attributes import VersionedAttribute
 from plomtask.conditions import Condition, ConditionsRelations
@@ -10,15 +10,17 @@ from plomtask.exceptions import (NotFoundException, BadFormatException,
                                  HandledException)
 
 
-@dataclass
-class ProcessStepsNode:
+class ProcessStepsNode(DictableNode):
     """Collects what's useful to know for ProcessSteps tree display."""
+    # pylint: disable=too-few-public-methods
+    step: ProcessStep
     process: Process
-    parent_id: int | None
     is_explicit: bool
-    steps: dict[int, ProcessStepsNode]
+    steps: list[ProcessStepsNode]
     seen: bool = False
     is_suppressed: bool = False
+    _to_dict = ['step', 'process', 'is_explicit', 'steps', 'seen',
+                'is_suppressed']
 
 
 class Process(BaseModel[int], ConditionsRelations):
@@ -87,7 +89,7 @@ class Process(BaseModel[int], ConditionsRelations):
         return [self.__class__.by_id(db_conn, id_) for id_ in owner_ids]
 
     def get_steps(self, db_conn: DatabaseConnection, external_owner:
-                  Process | None = None) -> dict[int, ProcessStepsNode]:
+                  Process | None = None) -> list[ProcessStepsNode]:
         """Return tree of depended-on explicit and implicit ProcessSteps."""
 
         def make_node(step: ProcessStep, suppressed: bool) -> ProcessStepsNode:
@@ -95,26 +97,26 @@ class Process(BaseModel[int], ConditionsRelations):
             if external_owner is not None:
                 is_explicit = step.owner_id == external_owner.id_
             process = self.__class__.by_id(db_conn, step.step_process_id)
-            step_steps = {}
+            step_steps = []
             if not suppressed:
                 step_steps = process.get_steps(db_conn, external_owner)
-            return ProcessStepsNode(process, step.parent_step_id,
-                                    is_explicit, step_steps, False, suppressed)
+            return ProcessStepsNode(step, process, is_explicit, step_steps,
+                                    False, suppressed)
 
-        def walk_steps(node_id: int, node: ProcessStepsNode) -> None:
-            node.seen = node_id in seen_step_ids
-            seen_step_ids.add(node_id)
+        def walk_steps(node: ProcessStepsNode) -> None:
+            node.seen = node.step.id_ in seen_step_ids
+            assert isinstance(node.step.id_, int)
+            seen_step_ids.add(node.step.id_)
             if node.is_suppressed:
                 return
             explicit_children = [s for s in self.explicit_steps
-                                 if s.parent_step_id == node_id]
+                                 if s.parent_step_id == node.step.id_]
             for child in explicit_children:
-                assert isinstance(child.id_, int)
-                node.steps[child.id_] = make_node(child, False)
-            for id_, step in node.steps.items():
-                walk_steps(id_, step)
+                node.steps += [make_node(child, False)]
+            for step in node.steps:
+                walk_steps(step)
 
-        steps: dict[int, ProcessStepsNode] = {}
+        step_nodes: list[ProcessStepsNode] = []
         seen_step_ids: Set[int] = set()
         if external_owner is None:
             external_owner = self
@@ -122,10 +124,10 @@ class Process(BaseModel[int], ConditionsRelations):
                      if s.parent_step_id is None]:
             assert isinstance(step.id_, int)
             new_node = make_node(step, step in external_owner.suppressed_steps)
-            steps[step.id_] = new_node
-        for step_id, step_node in steps.items():
-            walk_steps(step_id, step_node)
-        return steps
+            step_nodes += [new_node]
+        for step_node in step_nodes:
+            walk_steps(step_node)
+        return step_nodes
 
     def set_step_suppressions(self, db_conn: DatabaseConnection,
                               step_ids: list[int]) -> None:
index 1f55ae792661261a8fa28dc24bed8f466c9f0b98..9f9fdb4aadb3c9912b258453dbe507e96f6c4503 100644 (file)
@@ -2,6 +2,7 @@
 from __future__ import annotations
 from typing import Any, Set
 from sqlite3 import Row
+from plomtask.misc import DictableNode
 from plomtask.db import DatabaseConnection, BaseModel
 from plomtask.processes import Process, ProcessStepsNode
 from plomtask.versioned_attributes import VersionedAttribute
@@ -11,27 +12,24 @@ from plomtask.exceptions import (NotFoundException, BadFormatException,
 from plomtask.dating import valid_date
 
 
-class TodoNode:
+class TodoNode(DictableNode):
     """Collects what's useful to know for Todo/Condition tree display."""
     # pylint: disable=too-few-public-methods
     todo: Todo
     seen: bool
     children: list[TodoNode]
+    _to_dict = ['todo', 'seen', 'children']
 
-    def __init__(self,
-                 todo: Todo,
-                 seen: bool,
-                 children: list[TodoNode]) -> None:
-        self.todo = todo
-        self.seen = seen
-        self.children = children
 
-    @property
-    def as_dict(self) -> dict[str, object]:
-        """Return self as (json.dumps-coompatible) dict."""
-        return {'todo': self.todo.id_,
-                'seen': self.seen,
-                'children': [c.as_dict for c in self.children]}
+class TodoOrProcStepNode(DictableNode):
+    """Collect what's useful for Todo-or-ProcessStep tree display."""
+    # pylint: disable=too-few-public-methods
+    node_id: int
+    todo: Todo | None
+    process: Process | None
+    children: list[TodoOrProcStepNode]  # pylint: disable=undefined-variable
+    fillable: bool = False
+    _to_dict = ['node_id', 'todo', 'process', 'children', 'fillable']
 
 
 class Todo(BaseModel[int], ConditionsRelations):
@@ -89,36 +87,31 @@ class Todo(BaseModel[int], ConditionsRelations):
         todos, _, _ = cls.by_date_range_with_limits(db_conn, date_range)
         return todos
 
-    @classmethod
-    def create_with_children(cls, db_conn: DatabaseConnection,
-                             process_id: int, date: str) -> Todo:
-        """Create Todo of process for date, ensure children."""
-
-        def key_order_func(n: ProcessStepsNode) -> int:
-            assert isinstance(n.process.id_, int)
-            return n.process.id_
+    def ensure_children(self, db_conn: DatabaseConnection) -> None:
+        """Ensure Todo children (create or adopt) demanded by Process chain."""
 
         def walk_steps(parent: Todo, step_node: ProcessStepsNode) -> Todo:
-            adoptables = [t for t in cls.by_date(db_conn, date)
+            adoptables = [t for t in Todo.by_date(db_conn, parent.date)
                           if (t not in parent.children)
                           and (t != parent)
-                          and step_node.process == t.process]
+                          and step_node.process.id_ == t.process_id]
             satisfier = None
             for adoptable in adoptables:
                 satisfier = adoptable
                 break
             if not satisfier:
-                satisfier = cls(None, step_node.process, False, date)
+                satisfier = Todo(None, step_node.process, False, parent.date)
                 satisfier.save(db_conn)
-            sub_step_nodes = list(step_node.steps.values())
-            sub_step_nodes.sort(key=key_order_func)
+            sub_step_nodes = sorted(
+                    step_node.steps,
+                    key=lambda s: s.process.id_ if s.process.id_ else 0)
             for sub_node in sub_step_nodes:
                 if sub_node.is_suppressed:
                     continue
                 n_slots = len([n for n in sub_step_nodes
                                if n.process == sub_node.process])
                 filled_slots = len([t for t in satisfier.children
-                                    if t.process == sub_node.process])
+                                    if t.process.id_ == sub_node.process.id_])
                 # if we did not newly create satisfier, it may already fill
                 # some step dependencies, so only fill what remains open
                 if n_slots - filled_slots > 0:
@@ -126,16 +119,13 @@ class Todo(BaseModel[int], ConditionsRelations):
             satisfier.save(db_conn)
             return satisfier
 
-        process = Process.by_id(db_conn, process_id)
-        todo = cls(None, process, False, date)
-        todo.save(db_conn)
+        process = Process.by_id(db_conn, self.process_id)
         steps_tree = process.get_steps(db_conn)
-        for step_node in steps_tree.values():
+        for step_node in steps_tree:
             if step_node.is_suppressed:
                 continue
-            todo.add_child(walk_steps(todo, step_node))
-        todo.save(db_conn)
-        return todo
+            self.add_child(walk_steps(self, step_node))
+        self.save(db_conn)
 
     @classmethod
     def from_table_row(cls, db_conn: DatabaseConnection,
@@ -207,8 +197,9 @@ class Todo(BaseModel[int], ConditionsRelations):
         return 0
 
     @property
-    def process_id(self) -> int | str | None:
+    def process_id(self) -> int:
         """Needed for super().save to save Processes as attributes."""
+        assert isinstance(self.process.id_, int)
         return self.process.id_
 
     @property
@@ -312,6 +303,11 @@ class Todo(BaseModel[int], ConditionsRelations):
         self.children.remove(child)
         child.parents.remove(self)
 
+    def update_attrs(self, **kwargs: Any) -> None:
+        """Update self's attributes listed in kwargs."""
+        for k, v in kwargs.items():
+            setattr(self, k, v)
+
     def save(self, db_conn: DatabaseConnection) -> None:
         """On save calls, also check if auto-deletion by effort < 0."""
         if self.effort and self.effort < 0 and self.is_deletable:
index cfcbf87f2c79de1c21f85169b0e29db8d30e6b70..f5e17f3a848dd08f9066155af036a90c2c1b0941 100644 (file)
@@ -19,6 +19,10 @@ class VersionedAttribute:
         self.table_name = table_name
         self._default = default
         self.history: dict[str, str | float] = {}
+        # NB: For tighter mypy testing, we might prefer self.history to be
+        # dict[str, float] | dict[str, str] instead, but my current coding
+        # knowledge only manages to make that work by adding much further
+        # complexity, so let's leave it at that for now …
 
     def __hash__(self) -> int:
         history_tuples = tuple((k, v) for k, v in self.history.items())
index 7bb503eeafd2a68e21987021a7db0a110c4e548b..a4029dc333193ae95c20e93624d0a478b91dd4c1 100644 (file)
@@ -17,14 +17,12 @@ details[open] > summary::after {
 
 
 
-{% macro step_with_steps(step_id, step_node, indent) %}
+{% macro step_with_steps(step_node, indent) %}
 <tr>
 <td>
-<input type="hidden" name="steps" value="{{step_id}}" />
+<input type="hidden" name="steps" value="{{step_node.step.id_}}" />
 {% if step_node.is_explicit %}
-<input type="checkbox" name="keep_step" value="{{step_id}}" checked />
-<input type="hidden" name="step_{{step_id}}_process_id" value="{{step_node.process.id_}}" />
-<input type="hidden" name="step_{{step_id}}_parent_id" value="{{step_node.parent_id or ''}}" />
+<input type="checkbox" name="kept_steps" value="{{step_node.step.id_}}" checked />
 {% endif %}
 </td>
 
@@ -60,8 +58,8 @@ details[open] > summary::after {
 {% endif %}
 </tr>
 {% if step_node.is_explicit or not step_node.seen %}
-{% for substep_id, substep in step_node.steps.items() %}
-{{ step_with_steps(substep_id, substep, indent+1) }}
+{% for substep in step_node.steps %}
+{{ step_with_steps(substep, indent+1) }}
 {% endfor %}
 {% endif %}
 {% endmacro %}
@@ -116,8 +114,8 @@ edit process of ID {{process.id_}}
 <td>
 {% if steps %}
 <table>
-{% for step_id, step_node in steps.items() %}
-{{ step_with_steps(step_id, step_node, 0) }}
+{% for step_node in steps %}
+{{ step_with_steps(step_node, 0) }}
 {% endfor %}
 </table>
 {% endif %}
index fea931ab83ddf57536ab375ce3773a3f656204ce..732f7885d118a3d0aee383f41d329ef278d06d3e 100644 (file)
@@ -23,7 +23,7 @@ select{ font-size: 0.5em; margin: 0; padding: 0; }
 {% else %}
 {{item.process.title.newest|e}}
 {% if indent == 0 %}
-· fill: <select name="fill_for_{{item.id_}}">
+· fill: <select name="step_filler">
 <option value="ignore">--</option>
 <option value="make_empty_{{item.process.id_}}">make empty</option>
 <option value="make_full_{{item.process.id_}}">make full</option>
index 1a6b08ee1c083acd121644833c096c096e685c8e..333267f5edbf123a70f22385178d90d5d6ea5678 100644 (file)
@@ -1,5 +1,7 @@
 """Test Conditions module."""
-from tests.utils import TestCaseWithDB, TestCaseWithServer, TestCaseSansDB
+from typing import Any
+from tests.utils import (TestCaseSansDB, TestCaseWithDB, TestCaseWithServer,
+                         Expected)
 from plomtask.conditions import Condition
 from plomtask.processes import Process
 from plomtask.todos import Todo
@@ -25,44 +27,52 @@ class TestsWithDB(TestCaseWithDB):
         todo.save(self.db_conn)
         # check condition can only be deleted if not depended upon
         for depender in (proc, todo):
-            assert hasattr(depender, 'save')
-            assert hasattr(depender, 'set_conditions')
             c = Condition(None)
             c.save(self.db_conn)
-            depender.set_conditions(self.db_conn, [c.id_])
+            assert isinstance(c.id_, int)
+            depender.set_condition_relations(self.db_conn, [c.id_], [], [], [])
             depender.save(self.db_conn)
             with self.assertRaises(HandledException):
                 c.remove(self.db_conn)
-            depender.set_conditions(self.db_conn, [])
+            depender.set_condition_relations(self.db_conn, [], [], [], [])
             depender.save(self.db_conn)
             c.remove(self.db_conn)
 
 
+class ExpectedGetConditions(Expected):
+    """Builder of expectations for GET /conditions."""
+    _default_dict = {'sort_by': 'title', 'pattern': ''}
+
+    def recalc(self) -> None:
+        """Update internal dictionary by subclass-specific rules."""
+        super().recalc()
+        self._fields['conditions'] = self.as_ids(self.lib_all('Condition'))
+
+
+class ExpectedGetCondition(Expected):
+    """Builder of expectations for GET /condition."""
+    _on_empty_make_temp = ('Condition', 'cond_as_dict')
+
+    def __init__(self, id_: int, *args: Any, **kwargs: Any) -> None:
+        self._fields = {'condition': id_}
+        super().__init__(*args, **kwargs)
+
+    def recalc(self) -> None:
+        """Update internal dictionary by subclass-specific rules."""
+        super().recalc()
+        for p_field, c_field in [('conditions', 'enabled_processes'),
+                                 ('disables', 'disabling_processes'),
+                                 ('blockers', 'disabled_processes'),
+                                 ('enables', 'enabling_processes')]:
+            self._fields[c_field] = self.as_ids([
+                p for p in self.lib_all('Process')
+                if self._fields['condition'] in p[p_field]])
+        self._fields['is_new'] = False
+
+
 class TestsWithServer(TestCaseWithServer):
     """Module tests against our HTTP server/handler (and database)."""
 
-    @classmethod
-    def GET_condition_dict(cls, cond: dict[str, object]) -> dict[str, object]:
-        """Return JSON of GET /condition to expect."""
-        return {'is_new': False,
-                'enabled_processes': [],
-                'disabled_processes': [],
-                'enabling_processes': [],
-                'disabling_processes': [],
-                'condition': cond['id'],
-                '_library': {'Condition': cls.as_refs([cond])}}
-
-    @classmethod
-    def GET_conditions_dict(cls, conds: list[dict[str, object]]
-                            ) -> dict[str, object]:
-        """Return JSON of GET /conditions to expect."""
-        library = {'Condition': cls.as_refs(conds)} if conds else {}
-        d: dict[str, object] = {'conditions': cls.as_id_list(conds),
-                                'sort_by': 'title',
-                                'pattern': '',
-                                '_library': library}
-        return d
-
     def test_fail_POST_condition(self) -> None:
         """Test malformed/illegal POST /condition requests."""
         # check incomplete POST payloads
@@ -78,111 +88,84 @@ class TestsWithServer(TestCaseWithServer):
 
     def test_POST_condition(self) -> None:
         """Test (valid) POST /condition and its effect on GET /condition[s]."""
-        # test valid POST's effect on …
+        exp_single = ExpectedGetCondition(1)
+        exp_all = ExpectedGetConditions()
+        all_exps = [exp_single, exp_all]
+        # test valid POST's effect on single /condition and full /conditions
         post = {'title': 'foo', 'description': 'oof', 'is_active': False}
-        self.check_post(post, '/condition', redir='/condition?id=1')
-        # … single /condition
-        expected_cond = self.cond_as_dict(titles=['foo'], descriptions=['oof'])
-        assert isinstance(expected_cond['_versioned'], dict)
-        expected_single = self.GET_condition_dict(expected_cond)
-        self.check_json_get('/condition?id=1', expected_single)
-        # … full /conditions
-        expected_all = self.GET_conditions_dict([expected_cond])
-        self.check_json_get('/conditions', expected_all)
+        self.post_exp_cond(all_exps, 1, post, '', '?id=1')
+        self.check_json_get('/condition?id=1', exp_single)
+        self.check_json_get('/conditions', exp_all)
         # test (no) effect of invalid POST to existing Condition on /condition
         self.check_post({}, '/condition?id=1', 400)
-        self.check_json_get('/condition?id=1', expected_single)
+        self.check_json_get('/condition?id=1', exp_single)
         # test effect of POST changing title and activeness
         post = {'title': 'bar', 'description': 'oof', 'is_active': True}
-        self.check_post(post, '/condition?id=1')
-        expected_cond['_versioned']['title'][1] = 'bar'
-        expected_cond['is_active'] = True
-        self.check_json_get('/condition?id=1', expected_single)
-        # test deletion POST's effect, both to return id=1 into empty single, …
-        self.check_post({'delete': ''}, '/condition?id=1', redir='/conditions')
-        expected_cond = self.cond_as_dict()
-        assert isinstance(expected_single['_library'], dict)
-        expected_single['_library']['Condition'] = self.as_refs(
-                [expected_cond])
-        self.check_json_get('/condition?id=1', expected_single)
-        # … and full /conditions into empty list
-        expected_all['conditions'] = []
-        expected_all['_library'] = {}
-        self.check_json_get('/conditions', expected_all)
+        self.post_exp_cond(all_exps, 1, post, '?id=1', '?id=1')
+        self.check_json_get('/condition?id=1', exp_single)
+        self.check_json_get('/conditions', exp_all)
+        # test deletion POST's effect, both to return id=1 into empty single,
+        # full /conditions into empty list
+        self.post_exp_cond(all_exps, 1, {'delete': ''}, '?id=1', 's')
+        self.check_json_get('/condition?id=1', exp_single)
+        self.check_json_get('/conditions', exp_all)
 
     def test_GET_condition(self) -> None:
         """More GET /condition testing, especially for Process relations."""
         # check expected default status codes
         self.check_get_defaults('/condition')
         # make Condition and two Processes that among them establish all
-        # possible ConditionsRelations to it, …
+        # possible ConditionsRelations to it, check /condition displays all
+        exp = ExpectedGetCondition(1)
         cond_post = {'title': 'foo', 'description': 'oof', 'is_active': False}
-        self.check_post(cond_post, '/condition', redir='/condition?id=1')
-        proc1_post = {'title': 'A', 'description': '', 'effort': 1.0,
+        self.post_exp_cond([exp], 1, cond_post, '', '?id=1')
+        proc1_post = {'title': 'A', 'description': '', 'effort': 1.1,
                       'conditions': [1], 'disables': [1]}
-        proc2_post = {'title': 'B', 'description': '', 'effort': 1.0,
+        proc2_post = {'title': 'B', 'description': '', 'effort': 0.9,
                       'enables': [1], 'blockers': [1]}
-        self.post_process(1, proc1_post)
-        self.post_process(2, proc2_post)
-        # … then check /condition displays all these properly.
-        cond_expected = self.cond_as_dict(titles=['foo'], descriptions=['oof'])
-        assert isinstance(cond_expected['id'], int)
-        proc1 = self.proc_as_dict(conditions=[cond_expected['id']],
-                                  disables=[cond_expected['id']])
-        proc2 = self.proc_as_dict(2, 'B',
-                                  blockers=[cond_expected['id']],
-                                  enables=[cond_expected['id']])
-        display_expected = self.GET_condition_dict(cond_expected)
-        assert isinstance(display_expected['_library'], dict)
-        display_expected['enabled_processes'] = self.as_id_list([proc1])
-        display_expected['disabled_processes'] = self.as_id_list([proc2])
-        display_expected['enabling_processes'] = self.as_id_list([proc2])
-        display_expected['disabling_processes'] = self.as_id_list([proc1])
-        display_expected['_library']['Process'] = self.as_refs([proc1, proc2])
-        self.check_json_get('/condition?id=1', display_expected)
+        self.post_exp_process([exp], proc1_post, 1)
+        self.post_exp_process([exp], proc2_post, 2)
+        self.check_json_get('/condition?id=1', exp)
 
     def test_GET_conditions(self) -> None:
         """Test GET /conditions."""
         # test empty result on empty DB, default-settings on empty params
-        expected = self.GET_conditions_dict([])
-        self.check_json_get('/conditions', expected)
+        exp = ExpectedGetConditions()
+        self.check_json_get('/conditions', exp)
         # test ignorance of meaningless non-empty params (incl. unknown key),
         # that 'sort_by' default to 'title' (even if set to something else, as
         # long as without handler) and 'pattern' get preserved
-        expected['pattern'] = 'bar'  # preserved despite zero effect!
-        expected['sort_by'] = 'title'  # for clarity (actually already set)
-        url = '/conditions?sort_by=foo&pattern=bar&foo=x'
-        self.check_json_get(url, expected)
+        exp.set('pattern', 'bar')  # preserved despite zero effect!
+        exp.set('sort_by', 'title')  # for clarity (already default)
+        self.check_json_get('/conditions?sort_by=foo&pattern=bar&foo=x', exp)
         # test non-empty result, automatic (positive) sorting by title
         post_cond1 = {'is_active': False, 'title': 'foo', 'description': 'oof'}
         post_cond2 = {'is_active': False, 'title': 'bar', 'description': 'rab'}
         post_cond3 = {'is_active': True, 'title': 'baz', 'description': 'zab'}
-        self.check_post(post_cond1, '/condition', redir='/condition?id=1')
-        self.check_post(post_cond2, '/condition', redir='/condition?id=2')
-        self.check_post(post_cond3, '/condition', redir='/condition?id=3')
-        cond1 = self.cond_as_dict(1, False, ['foo'], ['oof'])
-        cond2 = self.cond_as_dict(2, False, ['bar'], ['rab'])
-        cond3 = self.cond_as_dict(3, True, ['baz'], ['zab'])
-        expected = self.GET_conditions_dict([cond2, cond3, cond1])
-        self.check_json_get('/conditions', expected)
+        for i, post in enumerate([post_cond1, post_cond2, post_cond3]):
+            self.post_exp_cond([exp], i+1, post, '', f'?id={i+1}')
+        exp.set('pattern', '')
+        exp.force('conditions', [2, 3, 1])
+        self.check_json_get('/conditions', exp)
         # test other sortings
-        expected['sort_by'] = '-title'
-        assert isinstance(expected['conditions'], list)
-        expected['conditions'].reverse()
-        self.check_json_get('/conditions?sort_by=-title', expected)
-        expected['sort_by'] = 'is_active'
-        expected['conditions'] = self.as_id_list([cond1, cond2, cond3])
-        self.check_json_get('/conditions?sort_by=is_active', expected)
-        expected['sort_by'] = '-is_active'
-        expected['conditions'].reverse()
-        self.check_json_get('/conditions?sort_by=-is_active', expected)
+        exp.set('sort_by', '-title')
+        exp.force('conditions', [1, 3, 2])
+        self.check_json_get('/conditions?sort_by=-title', exp)
+        exp.set('sort_by', 'is_active')
+        exp.force('conditions', [1, 2, 3])
+        self.check_json_get('/conditions?sort_by=is_active', exp)
+        exp.set('sort_by', '-is_active')
+        exp.force('conditions', [3, 2, 1])
+        self.check_json_get('/conditions?sort_by=-is_active', exp)
         # test pattern matching on title
-        expected = self.GET_conditions_dict([cond2, cond3])
-        expected['pattern'] = 'ba'
-        self.check_json_get('/conditions?pattern=ba', expected)
+        exp.set('sort_by', 'title')
+        exp.set('pattern', 'ba')
+        exp.force('conditions', [2, 3])
+        exp.lib_del('Condition', 1)
+        self.check_json_get('/conditions?pattern=ba', exp)
         # test pattern matching on description
-        assert isinstance(expected['_library'], dict)
-        expected['pattern'] = 'of'
-        expected['conditions'] = self.as_id_list([cond1])
-        expected['_library']['Condition'] = self.as_refs([cond1])
-        self.check_json_get('/conditions?pattern=of', expected)
+        exp.set('pattern', 'of')
+        exp.lib_wipe('Condition')
+        exp.set_cond_from_post(1, post_cond1)
+        exp.force('conditions', [1])
+        self.check_json_get('/conditions?pattern=of', exp)
index f16c69ccc5b3beeb2dfc229e51cdc24001432da9..78c5552467101fff7508433114e3d3a50f28c720 100644 (file)
@@ -1,10 +1,26 @@
 """Test Days module."""
 from datetime import datetime, timedelta
-from typing import Callable
-from tests.utils import TestCaseSansDB, TestCaseWithDB, TestCaseWithServer
-from plomtask.dating import date_in_n_days, DATE_FORMAT
+from typing import Any
+from tests.utils import (TestCaseSansDB, TestCaseWithDB, TestCaseWithServer,
+                         Expected)
+from plomtask.dating import date_in_n_days as tested_date_in_n_days
 from plomtask.days import Day
 
+# so far the same as plomtask.dating.DATE_FORMAT, but for testing purposes we
+# want to explicitly state our expectations here indepedently from that
+TESTING_DATE_FORMAT = '%Y-%m-%d'
+
+
+def _testing_date_in_n_days(n: int) -> str:
+    """Return in TEST_DATE_FORMAT date from today + n days.
+
+    As with TESTING_DATE_FORMAT, we assume this equal the original's code
+    at plomtask.dating.date_in_n_days, but want to state our expectations
+    explicitly to rule out importing issues from the original.
+    """
+    date = datetime.now() + timedelta(days=n)
+    return date.strftime(TESTING_DATE_FORMAT)
+
 
 class TestsSansDB(TestCaseSansDB):
     """Days module tests not requiring DB setup."""
@@ -13,10 +29,11 @@ class TestsSansDB(TestCaseSansDB):
     illegal_ids = ['foo', '2023-02-29', '2024-02-30', '2024-02-01 23:00:00']
 
     def test_date_in_n_days(self) -> None:
-        """Test dating.date_in_n_days, as we rely on it in later tests."""
+        """Test dating.date_in_n_days"""
         for n in [-100, -2, -1, 0, 1, 2, 1000]:
             date = datetime.now() + timedelta(days=n)
-            self.assertEqual(date_in_n_days(n), date.strftime(DATE_FORMAT))
+            self.assertEqual(tested_date_in_n_days(n),
+                             date.strftime(TESTING_DATE_FORMAT))
 
     def test_Day_datetime_weekday_neighbor_dates(self) -> None:
         """Test Day's date parsing and neighbourhood resolution."""
@@ -55,8 +72,8 @@ class TestsWithDB(TestCaseWithDB):
             self.assertEqual(days_result, days_expected)
 
         # for provided Days we use those from days_with_comment, to identify
-        # them against mere filler days by their lack of comment (identity
-        # with Day at the respective position in days_sans_comment)
+        # them against same-dated mere filler Days by their lack of comment
+        # (identity with Day at the respective position in days_sans_comment)
         dates = [f'2024-02-0{n+1}' for n in range(9)]
         days_with_comment = [Day(date, comment=date[-1:]) for date in dates]
         days_sans_comment = [Day(date, comment='') for date in dates]
@@ -89,132 +106,79 @@ class TestsWithDB(TestCaseWithDB):
         self.assertEqual(result, [yesterday, today, tomorrow])
 
 
+class ExpectedGetCalendar(Expected):
+    """Builder of expectations for GET /calendar."""
+
+    def __init__(self, start: int, end: int, *args: Any, **kwargs: Any
+                 ) -> None:
+        self._fields = {'start': _testing_date_in_n_days(start),
+                        'end': _testing_date_in_n_days(end),
+                        'today': _testing_date_in_n_days(0)}
+        self._fields['days'] = [_testing_date_in_n_days(i)
+                                for i in range(start, end+1)]
+        super().__init__(*args, **kwargs)
+        for date in self._fields['days']:
+            self.lib_set('Day', [self.day_as_dict(date)])
+
+
+class ExpectedGetDay(Expected):
+    """Builder of expectations for GET /day."""
+    _default_dict = {'make_type': ''}
+    _on_empty_make_temp = ('Day', 'day_as_dict')
+
+    def __init__(self, date: str, *args: Any, **kwargs: Any) -> None:
+        self._fields = {'day': date}
+        super().__init__(*args, **kwargs)
+
+    def recalc(self) -> None:
+        super().recalc()
+        todos = [t for t in self.lib_all('Todo')
+                 if t['date'] == self._fields['day']]
+        self.lib_get('Day', self._fields['day'])['todos'] = self.as_ids(todos)
+        self._fields['top_nodes'] = [
+                {'children': [], 'seen': False, 'todo': todo['id']}
+                for todo in todos]
+        for todo in todos:
+            proc = self.lib_get('Process', todo['process_id'])
+            for title in ['conditions', 'enables', 'blockers', 'disables']:
+                todo[title] = proc[title]
+        conds_present = set()
+        for todo in todos:
+            for title in ['conditions', 'enables', 'blockers', 'disables']:
+                for cond_id in todo[title]:
+                    conds_present.add(cond_id)
+        self._fields['conditions_present'] = list(conds_present)
+        for prefix in ['en', 'dis']:
+            blers = {}
+            for cond_id in conds_present:
+                blers[str(cond_id)] = self.as_ids(
+                        [t for t in todos if cond_id in t[f'{prefix}ables']])
+            self._fields[f'{prefix}ablers_for'] = blers
+        self._fields['processes'] = self.as_ids(self.lib_all('Process'))
+
+
 class TestsWithServer(TestCaseWithServer):
     """Tests against our HTTP server/handler (and database)."""
 
-    @classmethod
-    def GET_day_dict(cls, date: str) -> dict[str, object]:
-        """Return JSON of GET /day to expect."""
-        # day: dict[str, object] = {'id': date, 'comment': '', 'todos': []}
-        day = cls._day_as_dict(date)
-        d: dict[str, object] = {'day': date,
-                                'top_nodes': [],
-                                'make_type': '',
-                                'enablers_for': {},
-                                'disablers_for': {},
-                                'conditions_present': [],
-                                'processes': [],
-                                '_library': {'Day': cls.as_refs([day])}}
-        return d
-
-    @classmethod
-    def GET_calendar_dict(cls, start: int, end: int) -> dict[str, object]:
-        """Return JSON of GET /calendar to expect.
-
-        NB: the date string list to key 'days' implies/expects a continuous (=
-        gaps filled) alphabetical order of dates by virtue of range(start,
-        end+1) and date_in_n_days tested in TestsSansDB.test_date_in_n_days.
-        """
-        today_date = date_in_n_days(0)
-        start_date = date_in_n_days(start)
-        end_date = date_in_n_days(end)
-        dates = [date_in_n_days(i) for i in range(start, end+1)]
-        days = [cls._day_as_dict(d) for d in dates]
-        library = {'Day': cls.as_refs(days)} if len(days) > 0 else {}
-        return {'today': today_date, 'start': start_date, 'end': end_date,
-                'days': dates, '_library': library}
-
-    @staticmethod
-    def _todo_as_dict(id_: int = 1,
-                      process_id: int = 1,
-                      date: str = '2024-01-01',
-                      conditions: None | list[int] = None,
-                      disables: None | list[int] = None,
-                      blockers: None | list[int] = None,
-                      enables: None | list[int] = None
-                      ) -> dict[str, object]:
-        """Return JSON of Todo to expect."""
-        # pylint: disable=too-many-arguments
-        d = {'id': id_,
-             'date': date,
-             'process_id': process_id,
-             'is_done': False,
-             'calendarize': False,
-             'comment': '',
-             'children': [],
-             'parents': [],
-             'effort': None,
-             'conditions': conditions if conditions else [],
-             'disables': disables if disables else [],
-             'blockers': blockers if blockers else [],
-             'enables': enables if enables else []}
-        return d
-
-    @staticmethod
-    def _todo_node_as_dict(todo_id: int) -> dict[str, object]:
-        """Return JSON of TodoNode to expect."""
-        return {'children': [], 'seen': False, 'todo': todo_id}
-
-    @staticmethod
-    def _day_as_dict(date: str) -> dict[str, object]:
-        return {'id': date, 'comment': '', 'todos': []}
-
-    @staticmethod
-    def _post_batch(list_of_args: list[list[object]],
-                    names_of_simples: list[str],
-                    names_of_versioneds: list[str],
-                    f_as_dict: Callable[..., dict[str, object]],
-                    f_to_post: Callable[..., None | dict[str, object]]
-                    ) -> list[dict[str, object]]:
-        """Post expected=f_as_dict(*args) as input to f_to_post, for many."""
-        expecteds = []
-        for args in list_of_args:
-            expecteds += [f_as_dict(*args)]
-        for expected in expecteds:
-            assert isinstance(expected['_versioned'], dict)
-            post = {}
-            for name in names_of_simples:
-                post[name] = expected[name]
-            for name in names_of_versioneds:
-                post[name] = expected['_versioned'][name][0]
-            f_to_post(expected['id'], post)
-        return expecteds
-
-    def _post_day(self, params: str = '',
-                  form_data: None | dict[str, object] = None,
-                  redir_to: str = '',
-                  status: int = 302,
-                  ) -> None:
-        """POST /day?{params} with form_data."""
-        if not form_data:
-            form_data = {'day_comment': '', 'make_type': ''}
-        target = f'/day?{params}'
-        if not redir_to:
-            redir_to = f'{target}&make_type={form_data["make_type"]}'
-        self.check_post(form_data, target, status, redir_to)
-
     def test_basic_GET_day(self) -> None:
         """Test basic (no Processes/Conditions/Todos) GET /day basics."""
         # check illegal date parameters
         self.check_get('/day?date=foo', 400)
         self.check_get('/day?date=2024-02-30', 400)
         # check undefined day
-        date = date_in_n_days(0)
-        expected = self.GET_day_dict(date)
-        self.check_json_get('/day', expected)
-        # NB: GET ?date="today"/"yesterday"/"tomorrow" in test_basic_POST_day
-        # check 'make_type' GET parameter affects immediate reply, but …
+        date = _testing_date_in_n_days(0)
+        exp = ExpectedGetDay(date)
+        self.check_json_get('/day', exp)
+        # check defined day, with and without make_type parameter
         date = '2024-01-01'
-        expected = self.GET_day_dict(date)
-        expected['make_type'] = 'bar'
-        self.check_json_get(f'/day?date={date}&make_type=bar', expected)
-        # … not any following, …
-        expected['make_type'] = ''
-        self.check_json_get(f'/day?date={date}', expected)
-        # … not even when part of a POST request
-        post: dict[str, object] = {'day_comment': '', 'make_type': 'foo'}
-        self._post_day(f'date={date}', post)
-        self.check_json_get(f'/day?date={date}', expected)
+        exp = ExpectedGetDay(date)
+        exp.set('make_type', 'bar')
+        self.check_json_get(f'/day?date={date}&make_type=bar', exp)
+        # check parsing of 'yesterday', 'today', 'tomorrow'
+        for name, dist in [('yesterday', -1), ('today', 0), ('tomorrow', +1)]:
+            date = _testing_date_in_n_days(dist)
+            exp = ExpectedGetDay(date)
+            self.check_json_get(f'/day?date={name}', exp)
 
     def test_fail_POST_day(self) -> None:
         """Test malformed/illegal POST /day requests."""
@@ -224,7 +188,7 @@ class TestsWithServer(TestCaseWithServer):
         self.check_post({'day_comment': ''}, url, 400)
         self.check_post({'make_type': ''}, url, 400)
         # to next check illegal new_todo values, we need an actual Process
-        self.post_process(1)
+        self.post_exp_process([], {}, 1)
         # check illegal new_todo values
         post: dict[str, object]
         post = {'make_type': '', 'day_comment': '', 'new_todo': ['foo']}
@@ -272,98 +236,154 @@ class TestsWithServer(TestCaseWithServer):
         self.check_post(post, '/day?date=foo', 400)
 
     def test_basic_POST_day(self) -> None:
-        """Test basic (no Todos) POST /day.
+        """Test basic (no Processes/Conditions/Todos) POST /day.
 
-        Check POST (& GET!) requests properly parse 'today', 'tomorrow',
-        'yesterday', and actual date strings;
+        Check POST requests properly parse 'today', 'tomorrow', 'yesterday',
+        and actual date strings;
         preserve 'make_type' setting in redirect even if nonsensical;
-        and store 'day_comment'
+        and store 'day_comment'.
         """
         for name, dist, test_str in [('2024-01-01', None, 'a'),
                                      ('today', 0, 'b'),
                                      ('yesterday', -1, 'c'),
                                      ('tomorrow', +1, 'd')]:
-            date = name if dist is None else date_in_n_days(dist)
+            date = name if dist is None else _testing_date_in_n_days(dist)
             post = {'day_comment': test_str, 'make_type': f'x:{test_str}'}
             post_url = f'/day?date={name}'
             redir_url = f'{post_url}&make_type={post["make_type"]}'
             self.check_post(post, post_url, 302, redir_url)
-            expected = self.GET_day_dict(date)
-            assert isinstance(expected['_library'], dict)
-            expected['_library']['Day'][date]['comment'] = test_str
-            self.check_json_get(post_url, expected)
+            exp = ExpectedGetDay(date)
+            exp.set_day_from_post(date, post)
+            self.check_json_get(post_url, exp)
 
     def test_GET_day_with_processes_and_todos(self) -> None:
         """Test GET /day displaying Processes and Todos (no trees)."""
         date = '2024-01-01'
-        # check Processes get displayed in ['processes'] and ['_library']
-        procs_data = [[1, 'foo', 'oof', 1.1], [2, 'bar', 'rab', 0.9]]
-        procs_expected = self._post_batch(procs_data, [],
-                                          ['title', 'description', 'effort'],
-                                          self.proc_as_dict, self.post_process)
-        expected = self.GET_day_dict(date)
-        assert isinstance(expected['_library'], dict)
-        expected['processes'] = self.as_id_list(procs_expected)
-        expected['_library']['Process'] = self.as_refs(procs_expected)
-        self._post_day(f'date={date}')
-        self.check_json_get(f'/day?date={date}', expected)
+        exp = ExpectedGetDay(date)
+        # check Processes get displayed in ['processes'] and ['_library'],
+        # even without any Todos referencing them
+        proc_posts = [{'title': 'foo', 'description': 'oof', 'effort': 1.1},
+                      {'title': 'bar', 'description': 'rab', 'effort': 0.9}]
+        for i, proc_post in enumerate(proc_posts):
+            self.post_exp_process([exp], proc_post, i+1)
+        self.check_json_get(f'/day?date={date}', exp)
         # post Todos of either process and check their display
-        post_day: dict[str, object]
-        post_day = {'day_comment': '', 'make_type': '', 'new_todo': [1, 2]}
-        todos = [self._todo_as_dict(1, 1, date),
-                 self._todo_as_dict(2, 2, date)]
-        expected['_library']['Todo'] = self.as_refs(todos)
-        expected['_library']['Day'][date]['todos'] = self.as_id_list(todos)
-        nodes = [self._todo_node_as_dict(1), self._todo_node_as_dict(2)]
-        expected['top_nodes'] = nodes
-        self._post_day(f'date={date}', post_day)
-        self.check_json_get(f'/day?date={date}', expected)
+        self.post_exp_day([exp], {'new_todo': [1, 2]})
+        self.check_json_get(f'/day?date={date}', exp)
+        # test malformed Todo manipulation posts
+        post_day = {'day_comment': '', 'make_type': '', 'comment': [''],
+                    'new_todo': [], 'done': [1], 'effort': [2.3]}
+        self.check_post(post_day, f'/day?date={date}', 400)  # no todo_id
+        post_day['todo_id'] = [2]  # not identifying Todo refered by done
+        self.check_post(post_day, f'/day?date={date}', 400)
+        post_day['todo_id'] = [1, 2]  # imply range beyond that of effort etc.
+        self.check_post(post_day, f'/day?date={date}', 400)
+        post_day['comment'] = ['FOO', '']
+        self.check_post(post_day, f'/day?date={date}', 400)
+        post_day['effort'] = [2.3, '']
+        post_day['comment'] = ['']
+        self.check_post(post_day, f'/day?date={date}', 400)
         # add a comment to one Todo and set the other's doneness and effort
-        post_day = {'day_comment': '', 'make_type': '', 'new_todo': [],
-                    'todo_id': [1, 2], 'done': [2], 'comment': ['FOO', ''],
-                    'effort': [2.3, '']}
-        expected['_library']['Todo']['1']['comment'] = 'FOO'
-        expected['_library']['Todo']['1']['effort'] = 2.3
-        expected['_library']['Todo']['2']['is_done'] = True
-        self._post_day(f'date={date}', post_day)
-        self.check_json_get(f'/day?date={date}', expected)
+        post_day['comment'] = ['FOO', '']
+        self.post_exp_day([exp], post_day)
+        self.check_json_get(f'/day?date={date}', exp)
+        # invert effort and comment between both Todos
+        # (cannot invert doneness, /day only collects positive setting)
+        post_day['comment'] = ['', 'FOO']
+        post_day['effort'] = ['', 2.3]
+        self.post_exp_day([exp], post_day)
+        self.check_json_get(f'/day?date={date}', exp)
+
+    def test_POST_day_todo_make_types(self) -> None:
+        """Test behavior of POST /todo on 'make_type'='full' and 'empty'."""
+        date = '2024-01-01'
+        exp = ExpectedGetDay(date)
+        # create two Processes, with second one step of first one
+        self.post_exp_process([exp], {}, 2)
+        self.post_exp_process([exp], {'new_top_step': 2}, 1)
+        exp.lib_set('ProcessStep', [exp.procstep_as_dict(1, 1, 2, None)])
+        self.check_json_get(f'/day?date={date}', exp)
+        # post Todo of adopting Process, with make_type=full
+        self.post_exp_day([exp], {'make_type': 'full', 'new_todo': [1]})
+        exp.lib_get('Todo', 1)['children'] = [2]
+        exp.lib_set('Todo', [exp.todo_as_dict(2, 2)])
+        top_nodes = [{'todo': 1,
+                      'seen': False,
+                      'children': [{'todo': 2,
+                                    'seen': False,
+                                    'children': []}]}]
+        exp.force('top_nodes', top_nodes)
+        self.check_json_get(f'/day?date={date}', exp)
+        # post another Todo of adopting Process, expect to adopt existing
+        self.post_exp_day([exp], {'make_type': 'full', 'new_todo': [1]})
+        exp.lib_set('Todo', [exp.todo_as_dict(3, 1, children=[2])])
+        top_nodes += [{'todo': 3,
+                       'seen': False,
+                       'children': [{'todo': 2,
+                                     'seen': True,
+                                     'children': []}]}]
+        exp.force('top_nodes', top_nodes)
+        self.check_json_get(f'/day?date={date}', exp)
+        # post another Todo of adopting Process, make_type=empty
+        self.post_exp_day([exp], {'make_type': 'empty', 'new_todo': [1]})
+        exp.lib_set('Todo', [exp.todo_as_dict(4, 1)])
+        top_nodes += [{'todo': 4,
+                       'seen': False,
+                       'children': []}]
+        exp.force('top_nodes', top_nodes)
+        self.check_json_get(f'/day?date={date}', exp)
+
+    def test_POST_day_new_todo_order_commutative(self) -> None:
+        """Check that order of 'new_todo' values in POST /day don't matter."""
+        date = '2024-01-01'
+        exp = ExpectedGetDay(date)
+        self.post_exp_process([exp], {}, 2)
+        self.post_exp_process([exp], {'new_top_step': 2}, 1)
+        exp.lib_set('ProcessStep', [exp.procstep_as_dict(1, 1, 2, None)])
+        # make-full-day-post batch of Todos of both Processes in one order …,
+        self.post_exp_day([exp], {'make_type': 'full', 'new_todo': [1, 2]})
+        top_nodes: list[dict[str, Any]] = [{'todo': 1,
+                                            'seen': False,
+                                            'children': [{'todo': 2,
+                                                          'seen': False,
+                                                          'children': []}]}]
+        exp.force('top_nodes', top_nodes)
+        exp.lib_get('Todo', 1)['children'] = [2]
+        self.check_json_get(f'/day?date={date}', exp)
+        # … and then in the other, expecting same node tree / relations
+        exp.lib_del('Day', date)
+        date = '2024-01-02'
+        exp.set('day', date)
+        day_post = {'make_type': 'full', 'new_todo': [2, 1]}
+        self.post_exp_day([exp], day_post, date)
+        exp.lib_del('Todo', 1)
+        exp.lib_del('Todo', 2)
+        top_nodes[0]['todo'] = 3  # was: 1
+        top_nodes[0]['children'][0]['todo'] = 4  # was: 2
+        exp.lib_get('Todo', 3)['children'] = [4]
+        self.check_json_get(f'/day?date={date}', exp)
 
     def test_GET_day_with_conditions(self) -> None:
         """Test GET /day displaying Conditions and their relations."""
         date = '2024-01-01'
-        # add Process with Conditions and their Todos, check display
-        conds_data = [[1, False, ['A'], ['a']], [2, True, ['B'], ['b']]]
-        conds_expected = self._post_batch(
-                conds_data, ['is_active'], ['title', 'description'],
-                self.cond_as_dict,
-                lambda x, y: self.check_post(y, f'/condition?id={x}', 302))
-        cond_names = ['conditions', 'disables', 'blockers', 'enables']
-        procs_data = [[1, 'foo', 'oof', 1.1, [1], [1], [2], [2]],
-                      [2, 'bar', 'rab', 0.9, [2], [2], [1], [1]]]
-        procs_expected = self._post_batch(procs_data, cond_names,
-                                          ['title', 'description', 'effort'],
-                                          self.proc_as_dict, self.post_process)
-        expected = self.GET_day_dict(date)
-        assert isinstance(expected['_library'], dict)
-        expected['processes'] = self.as_id_list(procs_expected)
-        expected['_library']['Process'] = self.as_refs(procs_expected)
-        expected['_library']['Condition'] = self.as_refs(conds_expected)
-        self._post_day(f'date={date}')
-        self.check_json_get(f'/day?date={date}', expected)
-        # add Todos in relation to Conditions, check consequences
-        post_day: dict[str, object]
-        post_day = {'day_comment': '', 'make_type': '', 'new_todo': [1, 2]}
-        todos = [self._todo_as_dict(1, 1, date, [1], [1], [2], [2]),
-                 self._todo_as_dict(2, 2, date, [2], [2], [1], [1])]
-        expected['_library']['Todo'] = self.as_refs(todos)
-        expected['_library']['Day'][date]['todos'] = self.as_id_list(todos)
-        nodes = [self._todo_node_as_dict(1), self._todo_node_as_dict(2)]
-        expected['top_nodes'] = nodes
-        expected['disablers_for'] = {'1': [1], '2': [2]}
-        expected['enablers_for'] = {'1': [2], '2': [1]}
-        expected['conditions_present'] = self.as_id_list(conds_expected)
-        self._post_day(f'date={date}', post_day)
-        self.check_json_get(f'/day?date={date}', expected)
+        exp = ExpectedGetDay(date)
+        # check non-referenced Conditions not shown
+        cond_posts = [{'is_active': False, 'title': 'A', 'description': 'a'},
+                      {'is_active': True, 'title': 'B', 'description': 'b'}]
+        for i, cond_post in enumerate(cond_posts):
+            self.check_post(cond_post, f'/condition?id={i+1}')
+        self.check_json_get(f'/day?date={date}', exp)
+        # add Processes with Conditions, check Conditions now shown
+        for i, (c1, c2) in enumerate([(1, 2), (2, 1)]):
+            post = {'conditions': [c1], 'disables': [c1],
+                    'blockers': [c2], 'enables': [c2]}
+            self.post_exp_process([exp], post, i+1)
+        for i, cond_post in enumerate(cond_posts):
+            exp.set_cond_from_post(i+1, cond_post)
+        self.check_json_get(f'/day?date={date}', exp)
+        # add Todos in relation to Conditions, check consequence relations
+        self.post_exp_day([exp], {'new_todo': [1, 2]})
+        self.check_json_get(f'/day?date={date}', exp)
 
     def test_GET_calendar(self) -> None:
         """Test GET /calendar responses based on various inputs, DB states."""
@@ -371,23 +391,20 @@ class TestsWithServer(TestCaseWithServer):
         self.check_get('/calendar?start=foo', 400)
         self.check_get('/calendar?end=foo', 400)
         # check default range for expected selection/order without saved days
-        expected = self.GET_calendar_dict(-1, 366)
-        self.check_json_get('/calendar', expected)
-        self.check_json_get('/calendar?start=&end=', expected)
+        exp = ExpectedGetCalendar(-1, 366)
+        self.check_json_get('/calendar', exp)
+        self.check_json_get('/calendar?start=&end=', exp)
         # check with named days as delimiters
-        expected = self.GET_calendar_dict(-1, +1)
-        self.check_json_get('/calendar?start=yesterday&end=tomorrow', expected)
+        exp = ExpectedGetCalendar(-1, +1)
+        self.check_json_get('/calendar?start=yesterday&end=tomorrow', exp)
         # check zero-element range
-        expected = self.GET_calendar_dict(+1, 0)
-        self.check_json_get('/calendar?start=tomorrow&end=today', expected)
+        exp = ExpectedGetCalendar(+1, 0)
+        self.check_json_get('/calendar?start=tomorrow&end=today', exp)
         # check saved day shows up in results, proven by its comment
-        post_day: dict[str, object] = {'day_comment': 'foo', 'make_type': ''}
-        date1 = date_in_n_days(-2)
-        self._post_day(f'date={date1}', post_day)
-        start_date = date_in_n_days(-5)
-        end_date = date_in_n_days(+5)
+        start_date = _testing_date_in_n_days(-5)
+        date = _testing_date_in_n_days(-2)
+        end_date = _testing_date_in_n_days(+5)
+        exp = ExpectedGetCalendar(-5, +5)
+        self.post_exp_day([exp], {'day_comment': 'foo'}, date)
         url = f'/calendar?start={start_date}&end={end_date}'
-        expected = self.GET_calendar_dict(-5, +5)
-        assert isinstance(expected['_library'], dict)
-        expected['_library']['Day'][date1]['comment'] = post_day['day_comment']
-        self.check_json_get(url, expected)
+        self.check_json_get(url, exp)
index a27f0d0a1f8c0a3330be0e6c6906e3a7d6d53fd2..ce5f87a4234f1ded58d26676ffc62ccda8c583f1 100644 (file)
@@ -2,7 +2,7 @@
 from unittest import TestCase
 from tests.utils import TestCaseWithServer
 from plomtask.http import InputsParser
-from plomtask.exceptions import BadFormatException
+from plomtask.exceptions import BadFormatException, NotFoundException
 
 
 class TestsSansServer(TestCase):
@@ -14,13 +14,13 @@ class TestsSansServer(TestCase):
         self.assertEqual('', parser.get_str('foo'))
         self.assertEqual('bar', parser.get_str('foo', 'bar'))
         parser.strict = True
-        with self.assertRaises(BadFormatException):
+        with self.assertRaises(NotFoundException):
             parser.get_str('foo')
-        with self.assertRaises(BadFormatException):
+        with self.assertRaises(NotFoundException):
             parser.get_str('foo', 'bar')
         parser = InputsParser({'foo': []}, False)
         self.assertEqual('bar', parser.get_str('foo', 'bar'))
-        with self.assertRaises(BadFormatException):
+        with self.assertRaises(NotFoundException):
             InputsParser({'foo': []}, True).get_str('foo', 'bar')
         for strictness in (False, True):
             parser = InputsParser({'foo': ['baz']}, strictness)
@@ -99,10 +99,6 @@ class TestsSansServer(TestCase):
     def test_InputsParser_get_float(self) -> None:
         """Test InputsParser.get_float on strict and non-strict."""
         for strictness in (False, True):
-            with self.assertRaises(BadFormatException):
-                InputsParser({}, strictness).get_float('foo')
-            with self.assertRaises(BadFormatException):
-                InputsParser({'foo': []}, strictness).get_float('foo')
             with self.assertRaises(BadFormatException):
                 InputsParser({'foo': ['']}, strictness).get_float('foo')
             with self.assertRaises(BadFormatException):
@@ -113,6 +109,69 @@ class TestsSansServer(TestCase):
             self.assertEqual(0.1, parser.get_float('foo'))
             parser = InputsParser({'foo': ['1.23', '456']}, strictness)
             self.assertEqual(1.23, parser.get_float('foo'))
+        if strictness:
+            with self.assertRaises(NotFoundException):
+                InputsParser({}, strictness).get_float('foo')
+            with self.assertRaises(NotFoundException):
+                InputsParser({'foo': []}, strictness).get_float('foo')
+        else:
+            with self.assertRaises(BadFormatException):
+                InputsParser({}, strictness).get_float('foo')
+            with self.assertRaises(BadFormatException):
+                InputsParser({'foo': []}, strictness).get_float('foo')
+
+    def test_InputsParser_get_float_or_none(self) -> None:
+        """Test InputsParser.get_float_or_none on strict and non-strict."""
+        for strictness in (False, True):
+            with self.assertRaises(BadFormatException):
+                InputsParser({'foo': ['bar']}, strictness).\
+                        get_float_or_none('foo')
+            parser = InputsParser({'foo': ['']}, strictness)
+            self.assertEqual(None, parser.get_float_or_none('foo'))
+            parser = InputsParser({'foo': ['0']}, strictness)
+            self.assertEqual(0, parser.get_float_or_none('foo'))
+            parser = InputsParser({'foo': ['0.1']}, strictness)
+            self.assertEqual(0.1, parser.get_float_or_none('foo'))
+            parser = InputsParser({'foo': ['1.23', '456']}, strictness)
+            self.assertEqual(1.23, parser.get_float_or_none('foo'))
+        if strictness:
+            with self.assertRaises(NotFoundException):
+                InputsParser({}, strictness).get_float_or_none('foo')
+            with self.assertRaises(NotFoundException):
+                InputsParser({'foo': []}, strictness).get_float_or_none('foo')
+        else:
+            parser = InputsParser({}, strictness)
+            self.assertEqual(None, parser.get_float_or_none('foo'))
+            parser = InputsParser({'foo': []}, strictness)
+            self.assertEqual(None, parser.get_float_or_none('foo'))
+
+    def test_InputsParser_get_bool(self) -> None:
+        """Test InputsParser.get_all_str on strict and non-strict."""
+        for strictness in (False, True):
+            parser = InputsParser({}, strictness)
+            self.assertEqual(False, parser.get_bool('foo'))
+            parser = InputsParser({'val': ['true']}, strictness)
+            self.assertEqual(False, parser.get_bool('foo'))
+            parser = InputsParser({'val': ['True']}, strictness)
+            self.assertEqual(False, parser.get_bool('foo'))
+            parser = InputsParser({'val': ['1']}, strictness)
+            self.assertEqual(False, parser.get_bool('foo'))
+            parser = InputsParser({'val': ['foo']}, strictness)
+            self.assertEqual(False, parser.get_bool('foo'))
+            parser = InputsParser({'foo': []}, strictness)
+            self.assertEqual(False, parser.get_bool('foo'))
+            parser = InputsParser({'foo': ['None']}, strictness)
+            self.assertEqual(True, parser.get_bool('foo'))
+            parser = InputsParser({'foo': ['0']}, strictness)
+            self.assertEqual(True, parser.get_bool('foo'))
+            parser = InputsParser({'foo': ['']}, strictness)
+            self.assertEqual(True, parser.get_bool('foo'))
+            parser = InputsParser({'foo': ['bar']}, strictness)
+            self.assertEqual(True, parser.get_bool('foo'))
+            parser = InputsParser({'foo': ['bar', 'baz']}, strictness)
+            self.assertEqual(True, parser.get_bool('foo'))
+            parser = InputsParser({'foo': ['False']}, strictness)
+            self.assertEqual(True, parser.get_bool('foo'))
 
     def test_InputsParser_get_all_str(self) -> None:
         """Test InputsParser.get_all_str on strict and non-strict."""
index 501a163fc15f51a7a0f07d786de2e536917937ad..649aee5f96663780ed91f8e447f7bee47d8c50b0 100644 (file)
@@ -1,10 +1,10 @@
 """Test Processes module."""
 from typing import Any
-from tests.utils import TestCaseWithDB, TestCaseWithServer, TestCaseSansDB
-from plomtask.processes import Process, ProcessStep, ProcessStepsNode
+from tests.utils import (TestCaseSansDB, TestCaseWithDB, TestCaseWithServer,
+                         Expected)
+from plomtask.processes import Process, ProcessStep
 from plomtask.conditions import Condition
-from plomtask.exceptions import HandledException, NotFoundException
-from plomtask.todos import Todo
+from plomtask.exceptions import NotFoundException
 
 
 class TestsSansDB(TestCaseSansDB):
@@ -43,12 +43,10 @@ class TestsWithDB(TestCaseWithDB):
         set_1 = [c1, c2]
         set_2 = [c2, c3]
         set_3 = [c1, c3]
-        p.set_conditions(self.db_conn, [c.id_ for c in set_1
-                                        if isinstance(c.id_, int)])
-        p.set_enables(self.db_conn, [c.id_ for c in set_2
-                                     if isinstance(c.id_, int)])
-        p.set_disables(self.db_conn, [c.id_ for c in set_3
-                                      if isinstance(c.id_, int)])
+        conds = [c.id_ for c in set_1 if isinstance(c.id_, int)]
+        enables = [c.id_ for c in set_2 if isinstance(c.id_, int)]
+        disables = [c.id_ for c in set_3 if isinstance(c.id_, int)]
+        p.set_condition_relations(self.db_conn, conds, [], enables, disables)
         p.save(self.db_conn)
         return p, set_1, set_2, set_3
 
@@ -74,107 +72,114 @@ class TestsWithDB(TestCaseWithDB):
             self.assertEqual(sorted(r.enables), sorted(set2))
             self.assertEqual(sorted(r.disables), sorted(set3))
 
-    def test_Process_steps(self) -> None:
-        """Test addition, nesting, and non-recursion of ProcessSteps"""
-        # pylint: disable=too-many-locals
-        # pylint: disable=too-many-statements
-        p1, p2, p3 = self.three_processes()
-        assert isinstance(p1.id_, int)
-        assert isinstance(p2.id_, int)
-        assert isinstance(p3.id_, int)
-        steps_p1: list[ProcessStep] = []
-        # add step of process p2 as first (top-level) step to p1
-        s_p2_to_p1 = ProcessStep(None, p1.id_, p2.id_, None)
-        steps_p1 += [s_p2_to_p1]
-        p1.set_steps(self.db_conn, steps_p1)
-        p1_dict: dict[int, ProcessStepsNode] = {}
-        p1_dict[1] = ProcessStepsNode(p2, None, True, {})
-        self.assertEqual(p1.get_steps(self.db_conn, None), p1_dict)
-        # add step of process p3 as second (top-level) step to p1
-        s_p3_to_p1 = ProcessStep(None, p1.id_, p3.id_, None)
-        steps_p1 += [s_p3_to_p1]
-        p1.set_steps(self.db_conn, steps_p1)
-        p1_dict[2] = ProcessStepsNode(p3, None, True, {})
-        self.assertEqual(p1.get_steps(self.db_conn, None), p1_dict)
-        # add step of process p3 as first (top-level) step to p2,
-        steps_p2: list[ProcessStep] = []
-        s_p3_to_p2 = ProcessStep(None, p2.id_, p3.id_, None)
-        steps_p2 += [s_p3_to_p2]
-        p2.set_steps(self.db_conn, steps_p2)
-        # expect it as implicit sub-step of p1's second (p3) step
-        p2_dict = {3: ProcessStepsNode(p3, None, False, {})}
-        p1_dict[1].steps[3] = p2_dict[3]
-        self.assertEqual(p1.get_steps(self.db_conn, None), p1_dict)
-        # add step of process p2 as explicit sub-step to p1's second sub-step
-        s_p2_to_p1_first = ProcessStep(None, p1.id_, p2.id_, s_p3_to_p1.id_)
-        steps_p1 += [s_p2_to_p1_first]
-        p1.set_steps(self.db_conn, steps_p1)
-        seen_3 = ProcessStepsNode(p3, None, False, {}, False)
-        p1_dict[1].steps[3].seen = True
-        p1_dict[2].steps[4] = ProcessStepsNode(p2, s_p3_to_p1.id_, True,
-                                               {3: seen_3})
-        self.assertEqual(p1.get_steps(self.db_conn, None), p1_dict)
-        # add step of process p3 as explicit sub-step to non-existing p1
-        # sub-step (of id=999), expect it to become another p1 top-level step
-        s_p3_to_p1_999 = ProcessStep(None, p1.id_, p3.id_, 999)
-        steps_p1 += [s_p3_to_p1_999]
-        p1.set_steps(self.db_conn, steps_p1)
-        p1_dict[5] = ProcessStepsNode(p3, None, True, {})
-        self.assertEqual(p1.get_steps(self.db_conn, None), p1_dict)
-        # add step of process p3 as explicit sub-step to p1's implicit p3
-        # sub-step, expect it to become another p1 top-level step
-        s_p3_to_p1_impl_p3 = ProcessStep(None, p1.id_, p3.id_, s_p3_to_p2.id_)
-        steps_p1 += [s_p3_to_p1_impl_p3]
-        p1.set_steps(self.db_conn, steps_p1)
-        p1_dict[6] = ProcessStepsNode(p3, None, True, {})
-        self.assertEqual(p1.get_steps(self.db_conn, None), p1_dict)
-        self.assertEqual(p1.used_as_step_by(self.db_conn), [])
-        self.assertEqual(p2.used_as_step_by(self.db_conn), [p1])
-        self.assertEqual(p3.used_as_step_by(self.db_conn), [p1, p2])
-        # # add step of process p3 as explicit sub-step to p1's first sub-step,
-        # # expect it to eliminate implicit p3 sub-step
-        # s_p3_to_p1_first_explicit = ProcessStep(None, p1.id_, p3.id_,
-        #                                         s_p2_to_p1.id_)
-        # p1_dict[1].steps = {7: ProcessStepsNode(p3, 1, True, {})}
-        # p1_dict[2].steps[4].steps[3].seen = False
-        # steps_p1 += [s_p3_to_p1_first_explicit]
-        # p1.set_steps(self.db_conn, steps_p1)
-        # self.assertEqual(p1.get_steps(self.db_conn, None), p1_dict)
-        # ensure implicit steps non-top explicit steps are shown
-        s_p3_to_p2_first = ProcessStep(None, p2.id_, p3.id_, s_p3_to_p2.id_)
-        steps_p2 += [s_p3_to_p2_first]
-        p2.set_steps(self.db_conn, steps_p2)
-        p1_dict[1].steps[3].steps[7] = ProcessStepsNode(p3, 3, False, {}, True)
-        p1_dict[2].steps[4].steps[3].steps[7] = ProcessStepsNode(p3, 3, False,
-                                                                 {}, False)
-        self.assertEqual(p1.get_steps(self.db_conn, None), p1_dict)
-        # ensure suppressed step nodes are hidden
-        assert isinstance(s_p3_to_p2.id_, int)
-        p1.set_step_suppressions(self.db_conn, [s_p3_to_p2.id_])
-        p1_dict[1].steps[3].steps = {}
-        p1_dict[1].steps[3].is_suppressed = True
-        p1_dict[2].steps[4].steps[3].steps = {}
-        p1_dict[2].steps[4].steps[3].is_suppressed = True
-        self.assertEqual(p1.get_steps(self.db_conn), p1_dict)
+    # def test_Process_steps(self) -> None:
+    #     """Test addition, nesting, and non-recursion of ProcessSteps"""
+    #     # pylint: disable=too-many-locals
+    #     # pylint: disable=too-many-statements
+    #     p1, p2, p3 = self.three_processes()
+    #     assert isinstance(p1.id_, int)
+    #     assert isinstance(p2.id_, int)
+    #     assert isinstance(p3.id_, int)
+    #     steps_p1: list[ProcessStep] = []
+    #     # add step of process p2 as first (top-level) step to p1
+    #     s_p2_to_p1 = ProcessStep(None, p1.id_, p2.id_, None)
+    #     steps_p1 += [s_p2_to_p1]
+    #     p1.set_steps(self.db_conn, steps_p1)
+    #     p1_dict: dict[int, ProcessStepsNode] = {}
+    #     p1_dict[1] = ProcessStepsNode(p2, None, True, {})
+    #     self.assertEqual(p1.get_steps(self.db_conn, None), p1_dict)
+    #     # add step of process p3 as second (top-level) step to p1
+    #     s_p3_to_p1 = ProcessStep(None, p1.id_, p3.id_, None)
+    #     steps_p1 += [s_p3_to_p1]
+    #     p1.set_steps(self.db_conn, steps_p1)
+    #     p1_dict[2] = ProcessStepsNode(p3, None, True, {})
+    #     self.assertEqual(p1.get_steps(self.db_conn, None), p1_dict)
+    #     # add step of process p3 as first (top-level) step to p2,
+    #     steps_p2: list[ProcessStep] = []
+    #     s_p3_to_p2 = ProcessStep(None, p2.id_, p3.id_, None)
+    #     steps_p2 += [s_p3_to_p2]
+    #     p2.set_steps(self.db_conn, steps_p2)
+    #     # expect it as implicit sub-step of p1's second (p3) step
+    #     p2_dict = {3: ProcessStepsNode(p3, None, False, {})}
+    #     p1_dict[1].steps[3] = p2_dict[3]
+    #     self.assertEqual(p1.get_steps(self.db_conn, None), p1_dict)
+    #     # add step of process p2 as explicit sub-step to p1's second sub-step
+    #     s_p2_to_p1_first = ProcessStep(None, p1.id_, p2.id_, s_p3_to_p1.id_)
+    #     steps_p1 += [s_p2_to_p1_first]
+    #     p1.set_steps(self.db_conn, steps_p1)
+    #     seen_3 = ProcessStepsNode(p3, None, False, {}, False)
+    #     p1_dict[1].steps[3].seen = True
+    #     p1_dict[2].steps[4] = ProcessStepsNode(p2, s_p3_to_p1.id_, True,
+    #                                            {3: seen_3})
+    #     self.assertEqual(p1.get_steps(self.db_conn, None), p1_dict)
+    #     # add step of process p3 as explicit sub-step to non-existing p1
+    #     # sub-step (of id=999), expect it to become another p1 top-level step
+    #     s_p3_to_p1_999 = ProcessStep(None, p1.id_, p3.id_, 999)
+    #     steps_p1 += [s_p3_to_p1_999]
+    #     p1.set_steps(self.db_conn, steps_p1)
+    #     p1_dict[5] = ProcessStepsNode(p3, None, True, {})
+    #     self.assertEqual(p1.get_steps(self.db_conn, None), p1_dict)
+    #     # add step of process p3 as explicit sub-step to p1's implicit p3
+    #     # sub-step, expect it to become another p1 top-level step
+    #     s_p3_to_p1_impl_p3 = ProcessStep(None, p1.id_, p3.id_,
+    #                                      s_p3_to_p2.id_)
+    #     steps_p1 += [s_p3_to_p1_impl_p3]
+    #     p1.set_steps(self.db_conn, steps_p1)
+    #     p1_dict[6] = ProcessStepsNode(p3, None, True, {})
+    #     self.assertEqual(p1.get_steps(self.db_conn, None), p1_dict)
+    #     self.assertEqual(p1.used_as_step_by(self.db_conn), [])
+    #     self.assertEqual(p2.used_as_step_by(self.db_conn), [p1])
+    #     self.assertEqual(p3.used_as_step_by(self.db_conn), [p1, p2])
+    #     # # add step of process p3 as explicit sub-step to p1's first
+    #     # # sub-step, expect it to eliminate implicit p3 sub-step
+    #     # s_p3_to_p1_first_explicit = ProcessStep(None, p1.id_, p3.id_,
+    #     #                                         s_p2_to_p1.id_)
+    #     # p1_dict[1].steps = {7: ProcessStepsNode(p3, 1, True, {})}
+    #     # p1_dict[2].steps[4].steps[3].seen = False
+    #     # steps_p1 += [s_p3_to_p1_first_explicit]
+    #     # p1.set_steps(self.db_conn, steps_p1)
+    #     # self.assertEqual(p1.get_steps(self.db_conn, None), p1_dict)
+    #     # ensure implicit steps non-top explicit steps are shown
+    #     s_p3_to_p2_first = ProcessStep(None, p2.id_, p3.id_, s_p3_to_p2.id_)
+    #     steps_p2 += [s_p3_to_p2_first]
+    #     p2.set_steps(self.db_conn, steps_p2)
+    #     p1_dict[1].steps[3].steps[7] = ProcessStepsNode(p3, 3, False, {},
+    #                                                     True)
+    #     p1_dict[2].steps[4].steps[3].steps[7] = ProcessStepsNode(
+    #             p3, 3, False, {}, False)
+    #     self.assertEqual(p1.get_steps(self.db_conn, None), p1_dict)
+    #     # ensure suppressed step nodes are hidden
+    #     assert isinstance(s_p3_to_p2.id_, int)
+    #     p1.set_step_suppressions(self.db_conn, [s_p3_to_p2.id_])
+    #     p1_dict[1].steps[3].steps = {}
+    #     p1_dict[1].steps[3].is_suppressed = True
+    #     p1_dict[2].steps[4].steps[3].steps = {}
+    #     p1_dict[2].steps[4].steps[3].is_suppressed = True
+    #     self.assertEqual(p1.get_steps(self.db_conn), p1_dict)
 
     def test_Process_conditions(self) -> None:
         """Test setting Process.conditions/enables/disables."""
         p = Process(None)
         p.save(self.db_conn)
-        for target in ('conditions', 'enables', 'disables'):
-            method = getattr(p, f'set_{target}')
+        targets = ['conditions', 'blockers', 'enables', 'disables']
+        for i, target in enumerate(targets):
             c1, c2 = Condition(None), Condition(None)
             c1.save(self.db_conn)
             c2.save(self.db_conn)
             assert isinstance(c1.id_, int)
             assert isinstance(c2.id_, int)
-            method(self.db_conn, [])
+            args: list[list[int]] = [[], [], [], []]
+            args[i] = []
+            p.set_condition_relations(self.db_conn, *args)
             self.assertEqual(getattr(p, target), [])
-            method(self.db_conn, [c1.id_])
+            args[i] = [c1.id_]
+            p.set_condition_relations(self.db_conn, *args)
             self.assertEqual(getattr(p, target), [c1])
-            method(self.db_conn, [c2.id_])
+            args[i] = [c2.id_]
+            p.set_condition_relations(self.db_conn, *args)
             self.assertEqual(getattr(p, target), [c2])
-            method(self.db_conn, [c1.id_, c2.id_])
+            args[i] = [c1.id_, c2.id_]
+            p.set_condition_relations(self.db_conn, *args)
             self.assertEqual(getattr(p, target), [c1, c2])
 
     def test_remove(self) -> None:
@@ -187,26 +192,21 @@ class TestsWithDB(TestCaseWithDB):
         step = ProcessStep(None, p2.id_, p1.id_, None)
         p2.set_steps(self.db_conn, [step])
         step_id = step.id_
-        with self.assertRaises(HandledException):
-            p1.remove(self.db_conn)
         p2.set_steps(self.db_conn, [])
         with self.assertRaises(NotFoundException):
+            # check unset ProcessSteps actually cannot be found anymore
             assert step_id is not None
             ProcessStep.by_id(self.db_conn, step_id)
         p1.remove(self.db_conn)
         step = ProcessStep(None, p2.id_, p3.id_, None)
         p2.set_steps(self.db_conn, [step])
         step_id = step.id_
+        # check _can_ remove Process pointed to by ProcessStep.owner_id, and …
         p2.remove(self.db_conn)
         with self.assertRaises(NotFoundException):
+            # … being dis-owned eliminates ProcessStep
             assert step_id is not None
             ProcessStep.by_id(self.db_conn, step_id)
-        todo = Todo(None, p3, False, '2024-01-01')
-        todo.save(self.db_conn)
-        with self.assertRaises(HandledException):
-            p3.remove(self.db_conn)
-        todo.remove(self.db_conn)
-        p3.remove(self.db_conn)
 
 
 class TestsWithDBForProcessStep(TestCaseWithDB):
@@ -233,155 +233,176 @@ class TestsWithDBForProcessStep(TestCaseWithDB):
         self.check_identity_with_cache_and_db([])
 
 
+class ExpectedGetProcess(Expected):
+    """Builder of expectations for GET /processes."""
+    _default_dict = {'is_new': False, 'preset_top_step': None, 'n_todos': 0}
+    _on_empty_make_temp = ('Process', 'proc_as_dict')
+
+    def __init__(self,
+                 proc_id: int,
+                 *args: Any, **kwargs: Any) -> None:
+        self._fields = {'process': proc_id, 'steps': []}
+        super().__init__(*args, **kwargs)
+
+    @staticmethod
+    def stepnode_as_dict(step_id: int,
+                         proc_id: int,
+                         seen: bool = False,
+                         steps: None | list[dict[str, object]] = None,
+                         is_explicit: bool = True,
+                         is_suppressed: bool = False) -> dict[str, object]:
+        # pylint: disable=too-many-arguments
+        """Return JSON of ProcessStepNode to expect."""
+        return {'step': step_id,
+                'process': proc_id,
+                'seen': seen,
+                'steps': steps if steps else [],
+                'is_explicit': is_explicit,
+                'is_suppressed': is_suppressed}
+
+    def recalc(self) -> None:
+        """Update internal dictionary by subclass-specific rules."""
+        super().recalc()
+        self._fields['process_candidates'] = self.as_ids(
+                self.lib_all('Process'))
+        self._fields['condition_candidates'] = self.as_ids(
+                self.lib_all('Condition'))
+        self._fields['owners'] = [
+                s['owner_id'] for s in self.lib_all('ProcessStep')
+                if s['step_process_id'] == self._fields['process']]
+
+
+class ExpectedGetProcesses(Expected):
+    """Builder of expectations for GET /processes."""
+    _default_dict = {'sort_by': 'title', 'pattern': ''}
+
+    def recalc(self) -> None:
+        """Update internal dictionary by subclass-specific rules."""
+        super().recalc()
+        self._fields['processes'] = self.as_ids(self.lib_all('Process'))
+
+
 class TestsWithServer(TestCaseWithServer):
     """Module tests against our HTTP server/handler (and database)."""
 
-    def test_do_POST_process(self) -> None:
+    def _post_process(self, id_: int = 1,
+                      form_data: dict[str, Any] | None = None
+                      ) -> dict[str, Any]:
+        """POST basic Process."""
+        if not form_data:
+            form_data = {'title': 'foo', 'description': 'foo', 'effort': 1.1}
+        self.check_post(form_data, f'/process?id={id_}',
+                        redir=f'/process?id={id_}')
+        return form_data
+
+    def test_fail_POST_process(self) -> None:
         """Test POST /process and its effect on the database."""
-        self.assertEqual(0, len(Process.all(self.db_conn)))
-        form_data = self.post_process()
-        self.assertEqual(1, len(Process.all(self.db_conn)))
-        self.check_post(form_data, '/process?id=FOO', 400)
-        self.check_post(form_data | {'effort': 'foo'}, '/process?id=', 400)
-        self.check_post({}, '/process?id=', 400)
-        self.check_post({'title': '', 'description': ''}, '/process?id=', 400)
-        self.check_post({'title': '', 'effort': 1.1}, '/process?id=', 400)
-        self.check_post({'description': '', 'effort': 1.0},
-                        '/process?id=', 400)
-        self.assertEqual(1, len(Process.all(self.db_conn)))
-        form_data = {'title': 'foo', 'description': 'foo', 'effort': 1.0}
-        self.post_process(2, form_data | {'conditions': []})
-        self.check_post(form_data | {'conditions': [1]}, '/process?id=', 404)
-        self.check_post({'title': 'foo', 'description': 'foo',
-                         'is_active': False},
-                        '/condition', 302, '/condition?id=1')
-        self.post_process(3, form_data | {'conditions': [1]})
-        self.post_process(4, form_data | {'disables': [1]})
-        self.post_process(5, form_data | {'enables': [1]})
-        form_data['delete'] = ''
-        self.check_post(form_data, '/process?id=', 404)
-        self.check_post(form_data, '/process?id=6', 404)
-        self.check_post(form_data, '/process?id=5', 302, '/processes')
-
-    def test_do_POST_process_steps(self) -> None:
+        valid_post = {'title': '', 'description': '', 'effort': 1.0}
+        # check payloads lacking minimum expecteds
+        self.check_post({}, '/process', 400)
+        self.check_post({'title': '', 'description': ''}, '/process', 400)
+        self.check_post({'title': '', 'effort': 1}, '/process', 400)
+        self.check_post({'description': '', 'effort': 1}, '/process', 400)
+        # check payloads of bad data types
+        self.check_post(valid_post | {'effort': ''}, '/process', 400)
+        # check references to non-existant items
+        self.check_post(valid_post | {'conditions': [1]}, '/process', 404)
+        self.check_post(valid_post | {'disables': [1]}, '/process', 404)
+        self.check_post(valid_post | {'blockers': [1]}, '/process', 404)
+        self.check_post(valid_post | {'enables': [1]}, '/process', 404)
+        self.check_post(valid_post | {'new_top_step': 2}, '/process', 404)
+        # check deletion of non-existant
+        self.check_post({'delete': ''}, '/process?id=1', 404)
+
+    def test_basic_POST_process(self) -> None:
+        """Test basic GET/POST /process operations."""
+        # check on un-saved
+        exp = ExpectedGetProcess(1)
+        exp.force('process_candidates', [])
+        self.check_json_get('/process?id=1', exp)
+        # check on minimal payload post
+        valid_post = {'title': 'foo', 'description': 'oof', 'effort': 2.3}
+        exp.unforce('process_candidates')
+        self.post_exp_process([exp], valid_post, 1)
+        self.check_json_get('/process?id=1', exp)
+        # check n_todos field
+        self.post_exp_day([], {'new_todo': ['1']}, '2024-01-01')
+        self.post_exp_day([], {'new_todo': ['1']}, '2024-01-02')
+        exp.set('n_todos', 2)
+        self.check_json_get('/process?id=1', exp)
+        # check cannot delete if Todos to Process
+        self.check_post({'delete': ''}, '/process?id=1', 500)
+        # check cannot delete if some ProcessStep's .step_process_id
+        self.post_exp_process([exp], valid_post, 2)
+        self.post_exp_process([exp], valid_post | {'new_top_step': 2}, 3)
+        self.check_post({'delete': ''}, '/process?id=2', 500)
+        # check successful deletion
+        self.post_exp_process([exp], valid_post, 4)
+        self.check_post({'delete': ''}, '/process?id=4', 302, '/processes')
+        exp = ExpectedGetProcess(4)
+        exp.set_proc_from_post(1, valid_post)
+        exp.set_proc_from_post(2, valid_post)
+        exp.set_proc_from_post(3, valid_post)
+        exp.lib_set('ProcessStep', [exp.procstep_as_dict(1, 3, 2)])
+        exp.force('process_candidates', [1, 2, 3])
+        self.check_json_get('/process?id=4', exp)
+
+    def test_POST_process_steps(self) -> None:
         """Test behavior of ProcessStep posting."""
         # pylint: disable=too-many-statements
-        form_data_1 = self.post_process(1)
-        self.post_process(2)
-        self.post_process(3)
-        # post first (top-level) step of process 2 to process 1
-        form_data_1['new_top_step'] = [2]
-        self.post_process(1, form_data_1)
-        retrieved_process = Process.by_id(self.db_conn, 1)
-        self.assertEqual(len(retrieved_process.explicit_steps), 1)
-        retrieved_step = retrieved_process.explicit_steps[0]
-        retrieved_step_id = retrieved_step.id_
-        self.assertEqual(retrieved_step.step_process_id, 2)
-        self.assertEqual(retrieved_step.owner_id, 1)
-        self.assertEqual(retrieved_step.parent_step_id, None)
-        # post empty steps list to process, expect clean slate, and old step to
-        # completely disappear
-        form_data_1['new_top_step'] = []
-        self.post_process(1, form_data_1)
-        retrieved_process = Process.by_id(self.db_conn, 1)
-        self.assertEqual(retrieved_process.explicit_steps, [])
-        assert retrieved_step_id is not None
-        with self.assertRaises(NotFoundException):
-            ProcessStep.by_id(self.db_conn, retrieved_step_id)
-        # post new first (top_level) step of process 3 to process 1
-        form_data_1['new_top_step'] = [3]
-        self.post_process(1, form_data_1)
-        retrieved_process = Process.by_id(self.db_conn, 1)
-        retrieved_step = retrieved_process.explicit_steps[0]
-        self.assertEqual(retrieved_step.step_process_id, 3)
-        self.assertEqual(retrieved_step.owner_id, 1)
-        self.assertEqual(retrieved_step.parent_step_id, None)
-        # post to process steps list without keeps, expect clean slate
-        form_data_1['new_top_step'] = []
-        form_data_1['steps'] = [retrieved_step.id_]
-        self.post_process(1, form_data_1)
-        retrieved_process = Process.by_id(self.db_conn, 1)
-        self.assertEqual(retrieved_process.explicit_steps, [])
-        # post to process empty steps list but keep, expect 400
-        form_data_1['steps'] = []
-        form_data_1['keep_step'] = [retrieved_step_id]
-        self.check_post(form_data_1, '/process?id=1', 400, '/process?id=1')
-        # post to process steps list with keep on non-created step, expect 400
-        form_data_1['steps'] = [retrieved_step_id]
-        form_data_1['keep_step'] = [retrieved_step_id]
-        self.check_post(form_data_1, '/process?id=1', 400, '/process?id=1')
-        # post to process steps list with keep and process ID, expect 200
-        form_data_1[f'step_{retrieved_step_id}_process_id'] = [2]
-        self.post_process(1, form_data_1)
-        retrieved_process = Process.by_id(self.db_conn, 1)
-        self.assertEqual(len(retrieved_process.explicit_steps), 1)
-        retrieved_step = retrieved_process.explicit_steps[0]
-        self.assertEqual(retrieved_step.step_process_id, 2)
-        self.assertEqual(retrieved_step.owner_id, 1)
-        self.assertEqual(retrieved_step.parent_step_id, None)
-        # post nonsense, expect 400 and preservation of previous state
-        form_data_1['steps'] = ['foo']
-        form_data_1['keep_step'] = []
-        self.check_post(form_data_1, '/process?id=1', 400, '/process?id=1')
-        retrieved_process = Process.by_id(self.db_conn, 1)
-        self.assertEqual(len(retrieved_process.explicit_steps), 1)
-        retrieved_step = retrieved_process.explicit_steps[0]
-        self.assertEqual(retrieved_step.step_process_id, 2)
-        self.assertEqual(retrieved_step.owner_id, 1)
-        self.assertEqual(retrieved_step.parent_step_id, None)
-        # post to process steps list with keep and process ID, expect 200
-        form_data_1['new_top_step'] = [3]
-        form_data_1['steps'] = [retrieved_step.id_]
-        form_data_1['keep_step'] = [retrieved_step.id_]
-        self.post_process(1, form_data_1)
-        retrieved_process = Process.by_id(self.db_conn, 1)
-        self.assertEqual(len(retrieved_process.explicit_steps), 2)
-        retrieved_step_0 = retrieved_process.explicit_steps[1]
-        self.assertEqual(retrieved_step_0.step_process_id, 3)
-        self.assertEqual(retrieved_step_0.owner_id, 1)
-        self.assertEqual(retrieved_step_0.parent_step_id, None)
-        retrieved_step_1 = retrieved_process.explicit_steps[0]
-        self.assertEqual(retrieved_step_1.step_process_id, 2)
-        self.assertEqual(retrieved_step_1.owner_id, 1)
-        self.assertEqual(retrieved_step_1.parent_step_id, None)
-        # post to process steps list with keeps etc., but trigger recursion
-        form_data_1['new_top_step'] = []
-        form_data_1['steps'] = [retrieved_step_0.id_, retrieved_step_1.id_]
-        form_data_1['keep_step'] = [retrieved_step_0.id_, retrieved_step_1.id_]
-        form_data_1[f'step_{retrieved_step_0.id_}_process_id'] = [2]
-        form_data_1[f'step_{retrieved_step_1.id_}_process_id'] = [1]
-        self.check_post(form_data_1, '/process?id=1', 400, '/process?id=1')
-        # check previous status preserved despite failed steps setting
-        retrieved_process = Process.by_id(self.db_conn, 1)
-        self.assertEqual(len(retrieved_process.explicit_steps), 2)
-        retrieved_step_0 = retrieved_process.explicit_steps[0]
-        self.assertEqual(retrieved_step_0.step_process_id, 2)
-        self.assertEqual(retrieved_step_0.owner_id, 1)
-        self.assertEqual(retrieved_step_0.parent_step_id, None)
-        retrieved_step_1 = retrieved_process.explicit_steps[1]
-        self.assertEqual(retrieved_step_1.step_process_id, 3)
-        self.assertEqual(retrieved_step_1.owner_id, 1)
-        self.assertEqual(retrieved_step_1.parent_step_id, None)
-        # post sub-step to step
-        form_data_1[f'step_{retrieved_step_0.id_}_process_id'] = [3]
-        form_data_1[f'new_step_to_{retrieved_step_0.id_}'] = [3]
-        self.post_process(1, form_data_1)
-        retrieved_process = Process.by_id(self.db_conn, 1)
-        self.assertEqual(len(retrieved_process.explicit_steps), 3)
-        retrieved_step_0 = retrieved_process.explicit_steps[1]
-        self.assertEqual(retrieved_step_0.step_process_id, 2)
-        self.assertEqual(retrieved_step_0.owner_id, 1)
-        self.assertEqual(retrieved_step_0.parent_step_id, None)
-        retrieved_step_1 = retrieved_process.explicit_steps[0]
-        self.assertEqual(retrieved_step_1.step_process_id, 3)
-        self.assertEqual(retrieved_step_1.owner_id, 1)
-        self.assertEqual(retrieved_step_1.parent_step_id, None)
-        retrieved_step_2 = retrieved_process.explicit_steps[2]
-        self.assertEqual(retrieved_step_2.step_process_id, 3)
-        self.assertEqual(retrieved_step_2.owner_id, 1)
-        self.assertEqual(retrieved_step_2.parent_step_id, retrieved_step_1.id_)
-
-    def test_do_GET(self) -> None:
+        url = '/process?id=1'
+        exp = ExpectedGetProcess(1)
+        self.post_exp_process([exp], {}, 1)
+        # post first (top-level) step of proc 2 to proc 1 by 'step_of' in 2
+        self.post_exp_process([exp], {'step_of': 1}, 2)
+        exp.lib_set('ProcessStep', [exp.procstep_as_dict(1, 1, 2)])
+        exp.set('steps', [exp.stepnode_as_dict(1, 2)])
+        self.check_json_get(url, exp)
+        # post empty/absent steps list to process, expect clean slate, and old
+        # step to completely disappear
+        self.post_exp_process([exp], {}, 1)
+        exp.lib_wipe('ProcessStep')
+        exp.set('steps', [])
+        self.check_json_get(url, exp)
+        # post new step of proc2 to proc1 by 'new_top_step'
+        self.post_exp_process([exp], {'new_top_step': 2}, 1)
+        exp.lib_set('ProcessStep', [exp.procstep_as_dict(1, 1, 2)])
+        self.post_exp_process([exp], {'kept_steps': [1]}, 1)
+        exp.set('steps', [exp.stepnode_as_dict(1, 2)])
+        self.check_json_get(url, exp)
+        # fail on single- and multi-step recursion
+        p_min = {'title': '', 'description': '', 'effort': 0}
+        self.check_post(p_min | {'new_top_step': 1}, url, 400)
+        self.check_post(p_min | {'step_of': 1}, url, 400)
+        self.post_exp_process([exp], {'new_top_step': 1}, 2)
+        self.check_post(p_min | {'step_of': 2, 'new_top_step': 2}, url, 400)
+        self.post_exp_process([exp], {}, 3)
+        self.post_exp_process([exp], {'step_of': 3}, 4)
+        self.check_post(p_min | {'new_top_step': 3, 'step_of': 4}, url, 400)
+        # post sibling steps
+        self.post_exp_process([exp], {}, 4)
+        self.post_exp_process([exp], {'new_top_step': 4}, 1)
+        self.post_exp_process([exp], {'kept_steps': [1], 'new_top_step': 4}, 1)
+        exp.lib_set('ProcessStep', [exp.procstep_as_dict(1, 1, 4),
+                                    exp.procstep_as_dict(2, 1, 4)])
+        exp.set('steps', [exp.stepnode_as_dict(1, 4),
+                          exp.stepnode_as_dict(2, 4)])
+        self.check_json_get(url, exp)
+        # post sub-step chain
+        p = {'kept_steps': [1, 2], 'new_step_to_2': 4}
+        self.post_exp_process([exp], p, 1)
+        exp.lib_set('ProcessStep', [exp.procstep_as_dict(3, 1, 4, 2)])
+        exp.set('steps', [exp.stepnode_as_dict(1, 4),
+                          exp.stepnode_as_dict(2, 4, steps=[
+                              exp.stepnode_as_dict(3, 4)])])
+        self.check_json_get(url, exp)
+        # fail posting sub-step that would cause recursion
+        self.post_exp_process([exp], {}, 6)
+        self.post_exp_process([exp], {'new_top_step': 6}, 5)
+        p = p_min | {'kept_steps': [1, 2, 3], 'new_step_to_2': 5, 'step_of': 6}
+        self.check_post(p, url, 400)
+
+    def test_GET(self) -> None:
         """Test /process and /processes response codes."""
         self.check_get('/process', 200)
         self.check_get('/process?id=', 200)
@@ -400,109 +421,70 @@ class TestsWithServer(TestCaseWithServer):
         # of ID=1 here so we know the 404 comes from step_to=2 etc. (that tie
         # the Process displayed by /process to others), not from not finding
         # the main Process itself
-        self.post_process(1)
+        self.post_exp_process([], {}, 1)
         self.check_get('/process?id=1&step_to=2', 404)
         self.check_get('/process?id=1&has_step=2', 404)
 
-    @classmethod
-    def GET_processes_dict(cls, procs: list[dict[str, object]]
-                           ) -> dict[str, object]:
-        """Return JSON of GET /processes to expect."""
-        library = {'Process': cls.as_refs(procs)} if procs else {}
-        d: dict[str, object] = {'processes': cls.as_id_list(procs),
-                                'sort_by': 'title',
-                                'pattern': '',
-                                '_library': library}
-        return d
-
-    @staticmethod
-    def procstep_as_dict(id_: int,
-                         owner_id: int,
-                         step_process_id: int,
-                         parent_step_id: int | None = None
-                         ) -> dict[str, object]:
-        """Return JSON of Process to expect."""
-        return {'id': id_,
-                'owner_id': owner_id,
-                'step_process_id': step_process_id,
-                'parent_step_id': parent_step_id}
-
     def test_GET_processes(self) -> None:
         """Test GET /processes."""
         # pylint: disable=too-many-statements
         # test empty result on empty DB, default-settings on empty params
-        expected = self.GET_processes_dict([])
-        self.check_json_get('/processes', expected)
+        exp = ExpectedGetProcesses()
+        self.check_json_get('/processes', exp)
         # test on meaningless non-empty params (incl. entirely un-used key),
         # that 'sort_by' default to 'title' (even if set to something else, as
         # long as without handler) and 'pattern' get preserved
-        expected['pattern'] = 'bar'  # preserved despite zero effect!
+        exp.set('pattern', 'bar')  # preserved despite zero effect!
         url = '/processes?sort_by=foo&pattern=bar&foo=x'
-        self.check_json_get(url, expected)
+        self.check_json_get(url, exp)
         # test non-empty result, automatic (positive) sorting by title
-        post1: dict[str, Any]
-        post2: dict[str, Any]
-        post3: dict[str, Any]
-        post1 = {'title': 'foo', 'description': 'oof', 'effort': 1.0}
-        post2 = {'title': 'bar', 'description': 'rab', 'effort': 1.1}
-        post2['new_top_step'] = 1
-        post3 = {'title': 'baz', 'description': 'zab', 'effort': 0.9}
-        post3['new_top_step'] = 1
-        self.post_process(1, post1)
-        self.post_process(2, post2)
-        self.post_process(3, post3)
-        post3['new_top_step'] = 2
-        post3['keep_step'] = 2
-        post3['steps'] = [2]
-        post3['step_2_process_id'] = 1
-        self.post_process(3, post3)
-        proc1 = self.proc_as_dict(1, post1['title'],
-                                  post1['description'], post1['effort'])
-        proc2 = self.proc_as_dict(2, post2['title'],
-                                  post2['description'], post2['effort'])
-        proc3 = self.proc_as_dict(3, post3['title'],
-                                  post3['description'], post3['effort'])
-        proc2['explicit_steps'] = [1]
-        proc3['explicit_steps'] = [2, 3]
-        step1 = self.procstep_as_dict(1, 2, 1)
-        step2 = self.procstep_as_dict(2, 3, 1)
-        step3 = self.procstep_as_dict(3, 3, 2)
-        expected = self.GET_processes_dict([proc2, proc3, proc1])
-        assert isinstance(expected['_library'], dict)
-        expected['_library']['ProcessStep'] = self.as_refs([step1, step2,
-                                                            step3])
-        self.check_json_get('/processes', expected)
+        proc1_post = {'title': 'foo', 'description': 'oof', 'effort': 1.0}
+        self.post_exp_process([exp], proc1_post, 1)
+        proc2_post = {'title': 'bar', 'description': 'rab', 'effort': 1.1}
+        self.post_exp_process([exp], proc2_post | {'new_top_step': [1]}, 2)
+        proc3_post = {'title': 'baz', 'description': 'zab', 'effort': 0.9}
+        self.post_exp_process([exp], proc3_post | {'new_top_step': [1]}, 3)
+        proc3_post = proc3_post | {'new_top_step': [2], 'kept_steps': [2]}
+        self.post_exp_process([exp], proc3_post, 3)
+        exp.lib_set('ProcessStep', [exp.procstep_as_dict(1, 2, 1),
+                                    exp.procstep_as_dict(2, 3, 1),
+                                    exp.procstep_as_dict(3, 3, 2)])
+        exp.lib_get('Process', '')
+        exp.set('pattern', '')
+        exp.force('processes', [2, 3, 1])
+        self.check_json_get('/processes', exp)
         # test other sortings
-        expected['sort_by'] = '-title'
-        expected['processes'] = self.as_id_list([proc1, proc3, proc2])
-        self.check_json_get('/processes?sort_by=-title', expected)
-        expected['sort_by'] = 'effort'
-        expected['processes'] = self.as_id_list([proc3, proc1, proc2])
-        self.check_json_get('/processes?sort_by=effort', expected)
-        expected['sort_by'] = '-effort'
-        expected['processes'] = self.as_id_list([proc2, proc1, proc3])
-        self.check_json_get('/processes?sort_by=-effort', expected)
-        expected['sort_by'] = 'steps'
-        expected['processes'] = self.as_id_list([proc1, proc2, proc3])
-        self.check_json_get('/processes?sort_by=steps', expected)
-        expected['sort_by'] = '-steps'
-        expected['processes'] = self.as_id_list([proc3, proc2, proc1])
-        self.check_json_get('/processes?sort_by=-steps', expected)
-        expected['sort_by'] = 'owners'
-        expected['processes'] = self.as_id_list([proc3, proc2, proc1])
-        self.check_json_get('/processes?sort_by=owners', expected)
-        expected['sort_by'] = '-owners'
-        expected['processes'] = self.as_id_list([proc1, proc2, proc3])
-        self.check_json_get('/processes?sort_by=-owners', expected)
+        exp.set('sort_by', '-title')
+        exp.force('processes', [1, 3, 2])
+        self.check_json_get('/processes?sort_by=-title', exp)
+        exp.set('sort_by', 'effort')
+        exp.force('processes', [3, 1, 2])
+        self.check_json_get('/processes?sort_by=effort', exp)
+        exp.set('sort_by', '-effort')
+        exp.force('processes', [2, 1, 3])
+        self.check_json_get('/processes?sort_by=-effort', exp)
+        exp.set('sort_by', 'steps')
+        exp.force('processes', [1, 2, 3])
+        self.check_json_get('/processes?sort_by=steps', exp)
+        exp.set('sort_by', '-steps')
+        exp.force('processes', [3, 2, 1])
+        self.check_json_get('/processes?sort_by=-steps', exp)
+        exp.set('sort_by', 'owners')
+        exp.force('processes', [3, 2, 1])
+        self.check_json_get('/processes?sort_by=owners', exp)
+        exp.set('sort_by', '-owners')
+        exp.force('processes', [1, 2, 3])
+        self.check_json_get('/processes?sort_by=-owners', exp)
         # test pattern matching on title
-        expected = self.GET_processes_dict([proc2, proc3])
-        assert isinstance(expected['_library'], dict)
-        expected['pattern'] = 'ba'
-        expected['_library']['ProcessStep'] = self.as_refs([step1, step2,
-                                                            step3])
-        self.check_json_get('/processes?pattern=ba', expected)
+        exp.set('pattern', 'ba')
+        exp.set('sort_by', 'title')
+        exp.lib_del('Process', '1')
+        exp.force('processes', [2, 3])
+        self.check_json_get('/processes?pattern=ba', exp)
         # test pattern matching on description
-        expected['processes'] = self.as_id_list([proc1])
-        expected['_library'] = {'Process': self.as_refs([proc1])}
-        expected['pattern'] = 'of'
-        self.check_json_get('/processes?pattern=of', expected)
+        exp.set('pattern', 'of')
+        exp.lib_wipe('Process')
+        exp.lib_wipe('ProcessStep')
+        self.post_exp_process([exp], {'description': 'oof', 'effort': 1.0}, 1)
+        exp.force('processes', [1])
+        self.check_json_get('/processes?pattern=of', exp)
index dcd485622886c76077736a6a250a6ec3a9e7d9ff..752e28920bcd0b7a4011f670fb3a195ddd885f3a 100644 (file)
@@ -1,5 +1,7 @@
 """Test Todos module."""
-from tests.utils import TestCaseSansDB, TestCaseWithDB, TestCaseWithServer
+from typing import Any
+from tests.utils import (TestCaseSansDB, TestCaseWithDB, TestCaseWithServer,
+                         Expected)
 from plomtask.todos import Todo, TodoNode
 from plomtask.processes import Process, ProcessStep
 from plomtask.conditions import Condition
@@ -37,9 +39,9 @@ class TestsWithDB(TestCaseWithDB, TestCaseSansDB):
         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])
@@ -70,8 +72,8 @@ class TestsWithDB(TestCaseWithDB, TestCaseSansDB):
         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)
@@ -112,7 +114,8 @@ class TestsWithDB(TestCaseWithDB, TestCaseSansDB):
         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
@@ -120,16 +123,24 @@ class TestsWithDB(TestCaseWithDB, TestCaseSansDB):
 
     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, [])
-        self.assertEqual(todo_1.get_step_tree(set()).as_dict, node_0.as_dict)
+        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
-        self.assertEqual(todo_1.get_step_tree({todo_1.id_}).as_dict,
-                         node_0.as_dict)
+        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)
@@ -138,7 +149,9 @@ class TestsWithDB(TestCaseWithDB, TestCaseSansDB):
         node_2 = TodoNode(todo_2, False, [])
         node_0.children = [node_2]
         node_0.seen = False
-        self.assertEqual(todo_1.get_step_tree(set()).as_dict, node_0.as_dict)
+        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)
@@ -146,15 +159,19 @@ class TestsWithDB(TestCaseWithDB, TestCaseSansDB):
         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()).as_dict, node_0.as_dict)
+        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]
-        self.assertEqual(todo_1.get_step_tree(set()).as_dict, node_0.as_dict)
+        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 guaranteeds of Todo.create_with_children."""
+    def test_Todo_ensure_children(self) -> None:
+        """Test parenthood guarantees of Todo.ensure_children."""
         assert isinstance(self.proc.id_, int)
         proc2 = Process(None)
         proc2.save(self.db_conn)
@@ -178,12 +195,15 @@ class TestsWithDB(TestCaseWithDB, TestCaseSansDB):
         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)
+        todo_1 = Todo(None, self.proc, False, self.date1)
+        todo_1.save(self.db_conn)
+        todo_1.ensure_children(self.db_conn)
         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)
+        todo_2 = Todo(None, proc2, False, self.date1)
+        todo_2.save(self.db_conn)
+        todo_2.ensure_children(self.db_conn)
         self.assertEqual(3, len(todo_2.children))
         self.assertEqual(todo_1, todo_2.children[0])
         self.assertEqual(self.proc, todo_2.children[2].process)
@@ -192,238 +212,357 @@ class TestsWithDB(TestCaseWithDB, TestCaseSansDB):
         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 ExpectedGetTodo(Expected):
+    """Builder of expectations for GET /todo."""
+
+    def __init__(self,
+                 todo_id: int,
+                 *args: Any, **kwargs: Any) -> None:
+        self._fields = {'todo': todo_id,
+                        'steps_todo_to_process': []}
+        super().__init__(*args, **kwargs)
+
+    def recalc(self) -> None:
+        """Update internal dictionary by subclass-specific rules."""
+
+        def walk_steps(step: dict[str, Any]) -> None:
+            if not step['todo']:
+                proc_id = step['process']
+                cands = self.as_ids(
+                        [t for t in todos if proc_id == t['process_id']
+                         and t['id'] in self._fields['todo_candidates']])
+                self._fields['adoption_candidates_for'][str(proc_id)] = cands
+            for child in step['children']:
+                walk_steps(child)
+
+        super().recalc()
+        self.lib_wipe('Day')
+        todos = self.lib_all('Todo')
+        procs = self.lib_all('Process')
+        conds = self.lib_all('Condition')
+        self._fields['todo_candidates'] = self.as_ids(
+                [t for t in todos if t['id'] != self._fields['todo']])
+        self._fields['process_candidates'] = self.as_ids(procs)
+        self._fields['condition_candidates'] = self.as_ids(conds)
+        self._fields['adoption_candidates_for'] = {}
+        for step in self._fields['steps_todo_to_process']:
+            walk_steps(step)
+
+    @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 JSON of TodoOrProcStepsNode to expect."""
+        return {'node_id': node_id,
+                'children': children,
+                'process': process,
+                'fillable': fillable,
+                'todo': todo}
 
 
 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'), [])
-        proc = Process.by_id(self.db_conn, 1)
-        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)
-        proc = Process.by_id(self.db_conn, 1)
-        self.assertEqual(todo1.process.id_, proc.id_)
-        self.assertEqual(todo1.is_done, False)
-        proc2 = Process.by_id(self.db_conn, 2)
-        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)
-        proc2 = Process.by_id(self.db_conn, 1)
-        todo1 = Todo.by_date(self.db_conn, '2024-01-01')[0]
-        self.assertEqual(todo1.id_, 1)
-        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=', 404)
+    def _post_exp_todo(
+            self, id_: int, payload: dict[str, Any], exp: Expected) -> None:
+        self.check_post(payload, f'/todo?id={id_}')
+        exp.set_todo_from_post(id_, payload)
+
+    def test_basic_fail_POST_todo(self) -> None:
+        """Test basic malformed/illegal POST /todo requests."""
+        self.post_exp_process([], {}, 1)
+        # 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)
-        # 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.post_exp_day([], {'new_todo': [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."""
+        exp = ExpectedGetTodo(1)
+        self.post_exp_process([exp], {}, 1)
+        self.post_exp_day([exp], {'new_todo': [1]})
+        # test posting naked entity at first changes nothing
+        self.check_json_get('/todo?id=1', exp)
+        self.check_post({}, '/todo?id=1')
+        self.check_json_get('/todo?id=1', exp)
+        # test posting doneness, comment, calendarization, effort
+        todo_post = {'done': '', 'calendarize': '',
+                     'comment': 'foo', 'effort': 2.3}
+        self._post_exp_todo(1, todo_post, exp)
+        self.check_json_get('/todo?id=1', exp)
+        # test implicitly un-setting all of those except effort by empty post
+        self.check_post({}, '/todo?id=1')
+        exp.lib_set('Todo', [exp.todo_as_dict(effort=2.3)])
+        self.check_json_get('/todo?id=1', exp)
+        # test effort post can be explicitly unset by "effort":"" post
+        self.check_post({'effort': ''}, '/todo?id=1')
+        exp.lib_set('Todo', [exp.todo_as_dict(effort=None)])
+        self.check_json_get('/todo?id=1', exp)
+        # test Condition posts
+        c1_post = {'title': 'foo', 'description': 'oof', 'is_active': False}
+        c2_post = {'title': 'bar', 'description': 'rab', 'is_active': True}
+        self.post_exp_cond([exp], 1, c1_post, '?id=1', '?id=1')
+        self.post_exp_cond([exp], 2, c2_post, '?id=2', '?id=2')
+        todo_post = {'conditions': [1], 'disables': [1],
+                     'blockers': [2], 'enables': [2]}
+        self._post_exp_todo(1, todo_post, exp)
+        self.check_json_get('/todo?id=1', exp)
+
+    def test_POST_todo_deletion(self) -> None:
+        """Test deletions via POST /todo."""
+        exp = ExpectedGetTodo(1)
+        self.post_exp_process([exp], {}, 1)
+        # test failure of deletion on non-existing Todo
+        self.check_post({'delete': ''}, '/todo?id=2', 404, '/')
+        # test deletion of existing Todo
+        self.post_exp_day([exp], {'new_todo': [1]})
+        self.check_post({'delete': ''}, '/todo?id=1', 302, '/')
+        self.check_get('/todo?id=1', 404)
+        exp.lib_del('Todo', 1)
+        # test deletion of adopted Todo
+        self.post_exp_day([exp], {'new_todo': [1]})
+        self.post_exp_day([exp], {'new_todo': [1]})
+        self.check_post({'adopt': 2}, '/todo?id=1')
+        self.check_post({'delete': ''}, '/todo?id=2', 302, '/')
+        exp.lib_del('Todo', 2)
+        self.check_get('/todo?id=2', 404)
+        self.check_json_get('/todo?id=1', exp)
+        # test deletion of adopting Todo
+        self.post_exp_day([exp], {'new_todo': [1]})
+        self.check_post({'adopt': 2}, '/todo?id=1')
+        self.check_post({'delete': ''}, '/todo?id=1', 302, '/')
+        exp.set('todo', 2)
+        exp.lib_del('Todo', 1)
+        self.check_json_get('/todo?id=2', exp)
+        # 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 if deletable
+        self.check_post({'effort': -1, 'comment': 'foo'}, '/todo?id=2')
+        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"."""
+        # post two Todos to Day, have first adopt second
+        exp = ExpectedGetTodo(1)
+        self.post_exp_process([exp], {}, 1)
+        self.post_exp_day([exp], {'new_todo': [1]})
+        self.post_exp_day([exp], {'new_todo': [1]})
+        self._post_exp_todo(1, {'adopt': 2}, exp)
+        exp.set('steps_todo_to_process', [exp.step_as_dict(1, [], todo=2)])
+        self.check_json_get('/todo?id=1', exp)
+        # test Todo un-adopting by just not sending an adopt
+        self._post_exp_todo(1, {}, exp)
+        exp.set('steps_todo_to_process', [])
+        self.check_json_get('/todo?id=1', exp)
+        # 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': 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, '/')
-
-    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 = {'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(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 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]))
-
-        form_data = self.post_process()
-        form_data = self.post_process(2, 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."""
-        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': '', '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)
-
-    def test_do_GET_todo(self) -> None:
+        # test cannot do 1-step circular adoption
+        self._post_exp_todo(2, {'adopt': 1}, exp)
+        self.check_post({'adopt': 2}, '/todo?id=1', 400)
+        # test cannot do 2-step circular adoption
+        self.post_exp_day([exp], {'new_todo': [1]})
+        self._post_exp_todo(3, {'adopt': 2}, exp)
+        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)
+        self.post_exp_process([exp], {}, 2)
+        self.post_exp_process([exp], {}, 3)
+        self.post_exp_process([exp], {'new_top_step': [2, 3]}, 1)
+        exp.lib_set('ProcessStep', [exp.procstep_as_dict(1, 1, 2),
+                                    exp.procstep_as_dict(2, 1, 3)])
+        step1_proc2 = exp.step_as_dict(1, [], 2, None, True)
+        step2_proc3 = exp.step_as_dict(2, [], 3, None, True)
+        exp.set('steps_todo_to_process', [step1_proc2, step2_proc3])
+        self.post_exp_day([exp], {'new_todo': [2]})
+        self.post_exp_day([exp], {'new_todo': [3]})
+        self.check_json_get('/todo?id=1', exp)
+        self._post_exp_todo(1, {'step_filler': 5, 'adopt': [4]}, exp)
+        step1_proc2 = exp.step_as_dict(1, [], 2, 4, True)
+        step2_proc3 = exp.step_as_dict(2, [], 3, 5, True)
+        exp.set('steps_todo_to_process', [step1_proc2, step2_proc3])
+        self.check_json_get('/todo?id=1', exp)
+        # 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', exp)
+        # test cannot adopt into non-top-level elements of chain, instead
+        # creating new top-level steps when adopting of respective Process
+        self.post_exp_process([exp], {}, 4)
+        self.post_exp_process([exp], {'new_top_step': 4, 'step_of': [1]}, 3)
+        exp.lib_set('ProcessStep', [exp.procstep_as_dict(3, 3, 4)])
+        step3_proc4 = exp.step_as_dict(3, [], 4, None, True)
+        step2_proc3 = exp.step_as_dict(2, [step3_proc4], 3, 5, True)
+        exp.set('steps_todo_to_process', [step1_proc2, step2_proc3])
+        self.post_exp_day([exp], {'new_todo': [4]})
+        self._post_exp_todo(1, {'adopt': [4, 5, 6]}, exp)
+        step4_todo6 = exp.step_as_dict(4, [], None, 6, False)
+        exp.set('steps_todo_to_process', [step1_proc2, step2_proc3,
+                                          step4_todo6])
+        self.check_json_get('/todo?id=1', exp)
+
+    def test_POST_todo_make_full(self) -> None:
+        """Test creation and adoption via POST /todo with "make_full"."""
+        # create chain of Processes
+        exp = ExpectedGetTodo(1)
+        self.post_exp_process([exp], {}, 1)
+        for i in range(1, 4):
+            self.post_exp_process([exp], {'new_top_step': i}, i+1)
+        exp.lib_set('ProcessStep', [exp.procstep_as_dict(1, 2, 1),
+                                    exp.procstep_as_dict(2, 3, 2),
+                                    exp.procstep_as_dict(3, 4, 3)])
+        step3_proc1 = exp.step_as_dict(3, [], 1, None, False)
+        step2_proc2 = exp.step_as_dict(2, [step3_proc1], 2, None, False)
+        step1_proc3 = exp.step_as_dict(1, [step2_proc2], 3, None, True)
+        exp.set('steps_todo_to_process', [step1_proc3])
+        # post (childless) Todo of chain end, then make_full on next in line
+        self.post_exp_day([exp], {'new_todo': [4]})
+        self.check_post({'step_filler': 'make_full_3'}, '/todo?id=1')
+        exp.set_todo_from_post(4, {'process_id': 1})
+        exp.set_todo_from_post(3, {'process_id': 2, 'children': [4]})
+        exp.set_todo_from_post(2, {'process_id': 3, 'children': [3]})
+        exp.set_todo_from_post(1, {'process_id': 4, 'children': [2]})
+        step3_proc1 = exp.step_as_dict(3, [], 1, 4, True)
+        step2_proc2 = exp.step_as_dict(2, [step3_proc1], 2, 3, True)
+        step1_proc3 = exp.step_as_dict(1, [step2_proc2], 3, 2, True)
+        exp.set('steps_todo_to_process', [step1_proc3])
+        self.check_json_get('/todo?id=1', exp)
+        # 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')
+        exp.set_todo_from_post(5, {'process_id': 2, 'children': [4]})
+        exp.set_todo_from_post(1, {'process_id': 4, 'children': [2, 5]})
+        step5_todo4 = exp.step_as_dict(5, [], None, 4)
+        step4_todo5 = exp.step_as_dict(4, [step5_todo4], None, 5)
+        exp.set('steps_todo_to_process', [step1_proc3, step4_todo5])
+        self.check_json_get('/todo?id=1', exp)
+        # 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"."""
+        # create chain of Processes
+        exp = ExpectedGetTodo(1)
+        self.post_exp_process([exp], {}, 1)
+        for i in range(1, 4):
+            self.post_exp_process([exp], {'new_top_step': i}, i+1)
+        exp.lib_set('ProcessStep', [exp.procstep_as_dict(1, 2, 1),
+                                    exp.procstep_as_dict(2, 3, 2),
+                                    exp.procstep_as_dict(3, 4, 3)])
+        # post (childless) Todo of chain end, then make empty on next in line
+        self.post_exp_day([exp], {'new_todo': [4]})
+        step3_proc1 = exp.step_as_dict(3, [], 1)
+        step2_proc2 = exp.step_as_dict(2, [step3_proc1], 2)
+        step1_proc3 = exp.step_as_dict(1, [step2_proc2], 3, None, True)
+        exp.set('steps_todo_to_process', [step1_proc3])
+        self.check_json_get('/todo?id=1', exp)
+        self.check_post({'step_filler': 'make_empty_3'}, '/todo?id=1')
+        exp.set_todo_from_post(2, {'process_id': 3})
+        exp.set_todo_from_post(1, {'process_id': 4, 'children': [2]})
+        step2_proc2 = exp.step_as_dict(2, [step3_proc1], 2, None, True)
+        step1_proc3 = exp.step_as_dict(1, [step2_proc2], 3, 2, True)
+        exp.set('steps_todo_to_process', [step1_proc3])
+        self.check_json_get('/todo?id=1', exp)
+        # make new top-level Todo without chain implied by its Process
+        self.check_post({'make_empty': 2, 'adopt': [2]}, '/todo?id=1')
+        exp.set_todo_from_post(3, {'process_id': 2})
+        exp.set_todo_from_post(1, {'process_id': 4, 'children': [2, 3]})
+        step4_todo3 = exp.step_as_dict(4, [], None, 3)
+        exp.set('steps_todo_to_process', [step1_proc3, step4_todo3])
+        self.check_json_get('/todo?id=1', exp)
+        # fail on trying to call make_empty on non-existing Process
+        self.check_post({'make_full': 5}, '/todo?id=1', 404)
+
+    def test_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)
+        # 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=1', 200)
+        self.check_get('/todo?id=2', 404)
+        # test all existing Processes are shown as available
+        exp = ExpectedGetTodo(1)
+        self.post_exp_process([exp], {}, 1)
+        self.post_exp_day([exp], {'new_todo': [1]})
+        self.post_exp_process([exp], {}, 2)
+        self.check_json_get('/todo?id=1', exp)
+        # test chain of Processes shown as potential step nodes
+        self.post_exp_process([exp], {}, 3)
+        self.post_exp_process([exp], {}, 4)
+        self.post_exp_process([exp], {'new_top_step': 2}, 1)
+        self.post_exp_process([exp], {'new_top_step': 3, 'step_of': [1]}, 2)
+        self.post_exp_process([exp], {'new_top_step': 4, 'step_of': [2]}, 3)
+        exp.lib_set('ProcessStep', [exp.procstep_as_dict(1, 1, 2, None),
+                                    exp.procstep_as_dict(2, 2, 3, None),
+                                    exp.procstep_as_dict(3, 3, 4, None)])
+        step3_proc4 = exp.step_as_dict(3, [], 4)
+        step2_proc3 = exp.step_as_dict(2, [step3_proc4], 3)
+        step1_proc2 = exp.step_as_dict(1, [step2_proc3], 2, fillable=True)
+        exp.set('steps_todo_to_process', [step1_proc2])
+        self.check_json_get('/todo?id=1', exp)
+        # test display of parallel chains
+        proc_steps_post = {'new_top_step': 4, 'kept_steps': [1, 3]}
+        self.post_exp_process([], proc_steps_post, 1)
+        step4_proc4 = exp.step_as_dict(4, [], 4, fillable=True)
+        exp.lib_set('ProcessStep', [exp.procstep_as_dict(4, 1, 4, None)])
+        exp.set('steps_todo_to_process', [step1_proc2, step4_proc4])
+        self.check_json_get('/todo?id=1', exp)
+
+    def test_POST_todo_doneness_relations(self) -> None:
+        """Test Todo.is_done Condition, adoption relations for /todo POSTs."""
+        self.post_exp_process([], {}, 1)
+        # test Todo with adoptee can only be set done if adoptee is done too
+        self.post_exp_day([], {'new_todo': [1]})
+        self.post_exp_day([], {'new_todo': [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')
index eb874e3b4e3e535e1e18ebec170ec4340781df19..df3feea7e5fa818012f11b1aedf4978b58290752 100644 (file)
@@ -1,4 +1,5 @@
 """Shared test utilities."""
+# pylint: disable=too-many-lines
 from __future__ import annotations
 from unittest import TestCase
 from typing import Mapping, Any, Callable
@@ -6,10 +7,11 @@ from threading import Thread
 from http.client import HTTPConnection
 from datetime import datetime, timedelta
 from time import sleep
-from json import loads as json_loads
+from json import loads as json_loads, dumps as json_dumps
 from urllib.parse import urlencode
 from uuid import uuid4
 from os import remove as remove_file
+from pprint import pprint
 from plomtask.db import DatabaseFile, DatabaseConnection
 from plomtask.http import TaskHandler, TaskServer
 from plomtask.processes import Process, ProcessStep
@@ -79,7 +81,7 @@ class TestCaseSansDB(TestCaseAugmented):
                            __: str,
                            attr: VersionedAttribute,
                            default: str | float,
-                           to_set: list[str | float]
+                           to_set: list[str] | list[float]
                            ) -> None:
         """Test VersionedAttribute.set() behaves as expected."""
         attr.set(default)
@@ -118,7 +120,7 @@ class TestCaseSansDB(TestCaseAugmented):
                               __: str,
                               attr: VersionedAttribute,
                               default: str | float,
-                              to_set: list[str | float]
+                              to_set: list[str] | list[float]
                               ) -> None:
         """Test VersionedAttribute.newest."""
         # check .newest on empty history returns .default
@@ -137,7 +139,7 @@ class TestCaseSansDB(TestCaseAugmented):
                           __: str,
                           attr: VersionedAttribute,
                           default: str | float,
-                          to_set: list[str | float]
+                          to_set: list[str] | list[float]
                           ) -> None:
         """Test .at() returns values nearest to queried time, or default."""
         # check .at() return default on empty history
@@ -164,7 +166,7 @@ class TestCaseSansDB(TestCaseAugmented):
 
 class TestCaseWithDB(TestCaseAugmented):
     """Module tests not requiring DB setup."""
-    default_ids: tuple[int | str, int | str, int | str] = (1, 2, 3)
+    default_ids: tuple[int, int, int] | tuple[str, str, str] = (1, 2, 3)
 
     def setUp(self) -> None:
         Condition.empty_cache()
@@ -236,7 +238,7 @@ class TestCaseWithDB(TestCaseAugmented):
         tomorrow = datetime.now() + timedelta(days=+1)
         self.assertEqual(start, yesterday.strftime(DATE_FORMAT))
         self.assertEqual(end, tomorrow.strftime(DATE_FORMAT))
-        # make dated items for non-empty results
+        # prepare dated items for non-empty results
         kwargs_with_date = self.default_init_kwargs.copy()
         if set_id_field:
             kwargs_with_date['id_'] = None
@@ -280,7 +282,7 @@ class TestCaseWithDB(TestCaseAugmented):
                                          attr_name: str,
                                          attr: VersionedAttribute,
                                          _: str | float,
-                                         to_set: list[str | float]
+                                         to_set: list[str] | list[float]
                                          ) -> None:
         """Test storage and initialization of versioned attributes."""
 
@@ -341,7 +343,7 @@ class TestCaseWithDB(TestCaseAugmented):
         obj2.save(self.db_conn)
         self.assertEqual(self.checked_class.get_cache(), {id1: obj2})
         # NB: we'll only compare hashes because obj2 itself disappears on
-        # .from_table_row-trioggered database reload
+        # .from_table_row-triggered database reload
         obj2_hash = hash(obj2)
         found_in_db += self._load_from_db(id1)
         self.assertEqual([hash(o) for o in found_in_db], [obj2_hash])
@@ -419,7 +421,7 @@ class TestCaseWithDB(TestCaseAugmented):
                                         _: str,
                                         attr: VersionedAttribute,
                                         default: str | float,
-                                        to_set: list[str | float]
+                                        to_set: list[str] | list[float]
                                         ) -> None:
         """"Test VersionedAttribute.history_from_row() knows its DB rows."""
         attr.set(to_set[0])
@@ -472,7 +474,7 @@ class TestCaseWithDB(TestCaseAugmented):
                                    attr_name: str,
                                    attr: VersionedAttribute,
                                    _: str | float,
-                                   to_set: list[str | float]
+                                   to_set: list[str] | list[float]
                                    ) -> None:
         """Test singularity of VersionedAttributes on saving."""
         owner.save(self.db_conn)
@@ -499,90 +501,414 @@ class TestCaseWithDB(TestCaseAugmented):
         self.check_identity_with_cache_and_db([])
 
 
-class TestCaseWithServer(TestCaseWithDB):
-    """Module tests against our HTTP server/handler (and database)."""
-
-    def setUp(self) -> None:
-        super().setUp()
-        self.httpd = TaskServer(self.db_file, ('localhost', 0), TaskHandler)
-        self.server_thread = Thread(target=self.httpd.serve_forever)
-        self.server_thread.daemon = True
-        self.server_thread.start()
-        self.conn = HTTPConnection(str(self.httpd.server_address[0]),
-                                   self.httpd.server_address[1])
-        self.httpd.set_json_mode()
-
-    def tearDown(self) -> None:
-        self.httpd.shutdown()
-        self.httpd.server_close()
-        self.server_thread.join()
-        super().tearDown()
+class Expected:
+    """Builder of (JSON-like) dict to compare against responses of test server.
+
+    Collects all items and relations we expect expressed in the server's JSON
+    responses and puts them into the proper json.dumps-friendly dict structure,
+    accessibla via .as_dict, to compare them in TestsWithServer.check_json_get.
+
+    On its own provides for .as_dict output only {"_library": …}, initialized
+    from .__init__ and to be directly manipulated via the .lib* methods.
+    Further structures of the expected response may be added and kept
+    up-to-date by subclassing .__init__, .recalc, and .d.
+
+    NB: Lots of expectations towards server behavior will be made explicit here
+    (or in the subclasses) rather than in the actual TestCase methods' code.
+    """
+    _default_dict: dict[str, Any]
+    _forced: dict[str, Any]
+    _fields: dict[str, Any]
+    _on_empty_make_temp: tuple[str, str]
+
+    def __init__(self,
+                 todos: list[dict[str, Any]] | None = None,
+                 procs: list[dict[str, Any]] | None = None,
+                 procsteps: list[dict[str, Any]] | None = None,
+                 conds: list[dict[str, Any]] | None = None,
+                 days: list[dict[str, Any]] | None = None
+                 ) -> None:
+        # pylint: disable=too-many-arguments
+        for name in ['_default_dict', '_fields', '_forced']:
+            if not hasattr(self, name):
+                setattr(self, name, {})
+        self._lib = {}
+        for title, items in [('Todo', todos),
+                             ('Process', procs),
+                             ('ProcessStep', procsteps),
+                             ('Condition', conds),
+                             ('Day', days)]:
+            if items:
+                self._lib[title] = self._as_refs(items)
+        for k, v in self._default_dict.items():
+            if k not in self._fields:
+                self._fields[k] = v
+
+    def recalc(self) -> None:
+        """Update internal dictionary by subclass-specific rules."""
+        todos = self.lib_all('Todo')
+        for todo in todos:
+            todo['parents'] = []
+        for todo in todos:
+            for child_id in todo['children']:
+                self.lib_get('Todo', child_id)['parents'] += [todo['id']]
+            todo['children'].sort()
+        procsteps = self.lib_all('ProcessStep')
+        procs = self.lib_all('Process')
+        for proc in procs:
+            proc['explicit_steps'] = [s['id'] for s in procsteps
+                                      if s['owner_id'] == proc['id']]
+
+    @property
+    def as_dict(self) -> dict[str, Any]:
+        """Return dict to compare against test server JSON responses."""
+        make_temp = False
+        if hasattr(self, '_on_empty_make_temp'):
+            category, dicter = getattr(self, '_on_empty_make_temp')
+            id_ = self._fields[category.lower()]
+            make_temp = not bool(self.lib_get(category, id_))
+            if make_temp:
+                f = getattr(self, dicter)
+                self.lib_set(category, [f(id_)])
+        self.recalc()
+        d = {'_library': self._lib}
+        for k, v in self._fields.items():
+            # we expect everything sortable to be sorted
+            if isinstance(v, list) and k not in self._forced:
+                # NB: if we don't test for v being list, sorted() on an empty
+                # dict may return an empty list
+                try:
+                    v = sorted(v)
+                except TypeError:
+                    pass
+            d[k] = v
+        for k, v in self._forced.items():
+            d[k] = v
+        if make_temp:
+            json = json_dumps(d)
+            self.lib_del(category, id_)
+            d = json_loads(json)
+        return d
 
-    @staticmethod
-    def as_id_list(items: list[dict[str, object]]) -> list[int | str]:
-        """Return list of only 'id' fields of items."""
-        id_list = []
-        for item in items:
-            assert isinstance(item['id'], (int, str))
-            id_list += [item['id']]
-        return id_list
+    def lib_get(self, category: str, id_: str | int) -> dict[str, Any]:
+        """From library, return item of category and id_, or empty dict."""
+        str_id = str(id_)
+        if category in self._lib and str_id in self._lib[category]:
+            return self._lib[category][str_id]
+        return {}
+
+    def lib_all(self, category: str) -> list[dict[str, Any]]:
+        """From library, return items of category, or [] if none."""
+        if category in self._lib:
+            return list(self._lib[category].values())
+        return []
+
+    def lib_set(self, category: str, items: list[dict[str, object]]) -> None:
+        """Update library for category with items."""
+        if category not in self._lib:
+            self._lib[category] = {}
+        for k, v in self._as_refs(items).items():
+            self._lib[category][k] = v
+
+    def lib_del(self, category: str, id_: str | int) -> None:
+        """Remove category element of id_ from library."""
+        del self._lib[category][str(id_)]
+        if 0 == len(self._lib[category]):
+            del self._lib[category]
+
+    def lib_wipe(self, category: str) -> None:
+        """Remove category from library."""
+        if category in self._lib:
+            del self._lib[category]
+
+    def set(self, field_name: str, value: object) -> None:
+        """Set top-level .as_dict field."""
+        self._fields[field_name] = value
+
+    def force(self, field_name: str, value: object) -> None:
+        """Set ._forced field to ensure value in .as_dict."""
+        self._forced[field_name] = value
+
+    def unforce(self, field_name: str) -> None:
+        """Unset ._forced field."""
+        del self._forced[field_name]
 
     @staticmethod
-    def as_refs(items: list[dict[str, object]]
-                ) -> dict[str, dict[str, object]]:
+    def _as_refs(items: list[dict[str, object]]
+                 ) -> dict[str, dict[str, object]]:
         """Return dictionary of items by their 'id' fields."""
         refs = {}
         for item in items:
             refs[str(item['id'])] = item
         return refs
 
+    @staticmethod
+    def as_ids(items: list[dict[str, Any]]) -> list[int] | list[str]:
+        """Return list of only 'id' fields of items."""
+        return [item['id'] for item in items]
+
+    @staticmethod
+    def day_as_dict(date: str, comment: str = '') -> dict[str, object]:
+        """Return JSON of Day to expect."""
+        return {'id': date, 'comment': comment, 'todos': []}
+
+    def set_day_from_post(self, date: str, d: dict[str, Any]) -> None:
+        """Set Day of date in library based on POST dict d."""
+        day = self.day_as_dict(date)
+        for k, v in d.items():
+            if 'day_comment' == k:
+                day['comment'] = v
+            elif 'new_todo' == k:
+                next_id = 1
+                for todo in self.lib_all('Todo'):
+                    if next_id <= todo['id']:
+                        next_id = todo['id'] + 1
+                for proc_id in sorted(v):
+                    todo = self.todo_as_dict(next_id, proc_id, date)
+                    self.lib_set('Todo', [todo])
+                    next_id += 1
+            elif 'done' == k:
+                for todo_id in v:
+                    self.lib_get('Todo', todo_id)['is_done'] = True
+            elif 'todo_id' == k:
+                for i, todo_id in enumerate(v):
+                    t = self.lib_get('Todo', todo_id)
+                    if 'comment' in d:
+                        t['comment'] = d['comment'][i]
+                    if 'effort' in d:
+                        effort = d['effort'][i] if d['effort'][i] else None
+                        t['effort'] = effort
+        self.lib_set('Day', [day])
+
     @staticmethod
     def cond_as_dict(id_: int = 1,
                      is_active: bool = False,
-                     titles: None | list[str] = None,
-                     descriptions: None | list[str] = None
+                     title: None | str = None,
+                     description: None | str = None,
                      ) -> dict[str, object]:
         """Return JSON of Condition to expect."""
+        versioned: dict[str, dict[str, object]]
+        versioned = {'title': {}, 'description': {}}
+        if title is not None:
+            versioned['title']['0'] = title
+        if description is not None:
+            versioned['description']['0'] = description
+        return {'id': id_, 'is_active': is_active, '_versioned': versioned}
+
+    def set_cond_from_post(self, id_: int, d: dict[str, Any]) -> None:
+        """Set Condition of id_ in library based on POST dict d."""
+        if d == {'delete': ''}:
+            self.lib_del('Condition', id_)
+            return
+        cond = self.lib_get('Condition', id_)
+        if cond:
+            cond['is_active'] = d['is_active']
+            for category in ['title', 'description']:
+                history = cond['_versioned'][category]
+                if len(history) > 0:
+                    last_i = sorted([int(k) for k in history.keys()])[-1]
+                    if d[category] != history[str(last_i)]:
+                        history[str(last_i + 1)] = d[category]
+                else:
+                    history['0'] = d[category]
+        else:
+            cond = self.cond_as_dict(
+                    id_, d['is_active'], d['title'], d['description'])
+        self.lib_set('Condition', [cond])
+
+    @staticmethod
+    def todo_as_dict(id_: int = 1,
+                     process_id: int = 1,
+                     date: str = '2024-01-01',
+                     conditions: None | list[int] = None,
+                     disables: None | list[int] = None,
+                     blockers: None | list[int] = None,
+                     enables: None | list[int] = None,
+                     calendarize: bool = False,
+                     comment: str = '',
+                     is_done: bool = False,
+                     effort: float | None = None,
+                     children: list[int] | None = None,
+                     parents: list[int] | None = None,
+                     ) -> dict[str, object]:
+        """Return JSON of Todo to expect."""
+        # pylint: disable=too-many-arguments
         d = {'id': id_,
-             'is_active': is_active,
-             '_versioned': {
-                 'title': {},
-                 'description': {}}}
-        titles = titles if titles else []
-        descriptions = descriptions if descriptions else []
-        assert isinstance(d['_versioned'], dict)
-        for i, title in enumerate(titles):
-            d['_versioned']['title'][i] = title
-        for i, description in enumerate(descriptions):
-            d['_versioned']['description'][i] = description
+             'date': date,
+             'process_id': process_id,
+             'is_done': is_done,
+             'calendarize': calendarize,
+             'comment': comment,
+             'children': children if children else [],
+             'parents': parents if parents else [],
+             'effort': effort,
+             'conditions': conditions if conditions else [],
+             'disables': disables if disables else [],
+             'blockers': blockers if blockers else [],
+             'enables': enables if enables else []}
         return d
 
+    def set_todo_from_post(self, id_: int, d: dict[str, Any]) -> None:
+        """Set Todo of id_ in library based on POST dict d."""
+        corrected_kwargs: dict[str, Any] = {}
+        for k, v in d.items():
+            if k in {'adopt', 'step_filler'}:
+                if 'children' not in corrected_kwargs:
+                    corrected_kwargs['children'] = []
+                new_children = v if isinstance(v, list) else [v]
+                corrected_kwargs['children'] += new_children
+                continue
+            if 'done' == k:
+                k = 'is_done'
+            if k in {'is_done', 'calendarize'}:
+                v = True
+            corrected_kwargs[k] = v
+        todo = self.todo_as_dict(id_, **corrected_kwargs)
+        self.lib_set('Todo', [todo])
+
+    @staticmethod
+    def procstep_as_dict(id_: int,
+                         owner_id: int,
+                         step_process_id: int,
+                         parent_step_id: int | None = None
+                         ) -> dict[str, object]:
+        """Return JSON of ProcessStep to expect."""
+        return {'id': id_,
+                'owner_id': owner_id,
+                'step_process_id': step_process_id,
+                'parent_step_id': parent_step_id}
+
     @staticmethod
     def proc_as_dict(id_: int = 1,
-                     title: str = 'A',
-                     description: str = '',
-                     effort: float = 1.0,
+                     title: None | str = None,
+                     description: None | str = None,
+                     effort: None | float = None,
                      conditions: None | list[int] = None,
                      disables: None | list[int] = None,
                      blockers: None | list[int] = None,
-                     enables: None | list[int] = None
+                     enables: None | list[int] = None,
+                     explicit_steps: None | list[int] = None
                      ) -> dict[str, object]:
         """Return JSON of Process to expect."""
         # pylint: disable=too-many-arguments
+        versioned: dict[str, dict[str, object]]
+        versioned = {'title': {}, 'description': {}, 'effort': {}}
+        if title is not None:
+            versioned['title']['0'] = title
+        if description is not None:
+            versioned['description']['0'] = description
+        if effort is not None:
+            versioned['effort']['0'] = effort
         d = {'id': id_,
              'calendarize': False,
              'suppressed_steps': [],
-             'explicit_steps': [],
-             '_versioned': {
-                 'title': {0: title},
-                 'description': {0: description},
-                 'effort': {0: effort}},
+             'explicit_steps': explicit_steps if explicit_steps else [],
+             '_versioned': versioned,
              'conditions': conditions if conditions else [],
              'disables': disables if disables else [],
              'enables': enables if enables else [],
              'blockers': blockers if blockers else []}
         return d
 
+    def set_proc_from_post(self, id_: int, d: dict[str, Any]) -> None:
+        """Set Process of id_ in library based on POST dict d."""
+        proc = self.lib_get('Process', id_)
+        if proc:
+            for category in ['title', 'description', 'effort']:
+                history = proc['_versioned'][category]
+                if len(history) > 0:
+                    last_i = sorted([int(k) for k in history.keys()])[-1]
+                    if d[category] != history[str(last_i)]:
+                        history[str(last_i + 1)] = d[category]
+                else:
+                    history['0'] = d[category]
+        else:
+            proc = self.proc_as_dict(id_,
+                                     d['title'], d['description'], d['effort'])
+        ignore = {'title', 'description', 'effort', 'new_top_step', 'step_of',
+                  'kept_steps'}
+        for k, v in d.items():
+            if k in ignore\
+                    or k.startswith('step_') or k.startswith('new_step_to'):
+                continue
+            if k in {'calendarize'}:
+                v = True
+            elif k in {'suppressed_steps', 'explicit_steps', 'conditions',
+                       'disables', 'enables', 'blockers'}:
+                if not isinstance(v, list):
+                    v = [v]
+            proc[k] = v
+        self.lib_set('Process', [proc])
+
+
+class TestCaseWithServer(TestCaseWithDB):
+    """Module tests against our HTTP server/handler (and database)."""
+
+    def setUp(self) -> None:
+        super().setUp()
+        self.httpd = TaskServer(self.db_file, ('localhost', 0), TaskHandler)
+        self.server_thread = Thread(target=self.httpd.serve_forever)
+        self.server_thread.daemon = True
+        self.server_thread.start()
+        self.conn = HTTPConnection(str(self.httpd.server_address[0]),
+                                   self.httpd.server_address[1])
+        self.httpd.render_mode = 'json'
+
+    def tearDown(self) -> None:
+        self.httpd.shutdown()
+        self.httpd.server_close()
+        self.server_thread.join()
+        super().tearDown()
+
+    def post_exp_cond(self,
+                      exps: list[Expected],
+                      id_: int,
+                      payload: dict[str, object],
+                      path_suffix: str = '',
+                      redir_suffix: str = ''
+                      ) -> None:
+        """POST /condition(s), appropriately update Expecteds."""
+        # pylint: disable=too-many-arguments
+        path = f'/condition{path_suffix}'
+        redir = f'/condition{redir_suffix}'
+        self.check_post(payload, path, redir=redir)
+        for exp in exps:
+            exp.set_cond_from_post(id_, payload)
+
+    def post_exp_day(self,
+                     exps: list[Expected],
+                     payload: dict[str, Any],
+                     date: str = '2024-01-01'
+                     ) -> None:
+        """POST /day, appropriately update Expecteds."""
+        if 'make_type' not in payload:
+            payload['make_type'] = 'empty'
+        if 'day_comment' not in payload:
+            payload['day_comment'] = ''
+        target = f'/day?date={date}'
+        redir_to = f'{target}&make_type={payload["make_type"]}'
+        self.check_post(payload, target, 302, redir_to)
+        for exp in exps:
+            exp.set_day_from_post(date, payload)
+
+    def post_exp_process(self,
+                         exps: list[Expected],
+                         payload: dict[str, Any],
+                         id_: int,
+                         ) -> dict[str, object]:
+        """POST /process, appropriately update Expecteds."""
+        if 'title' not in payload:
+            payload['title'] = 'foo'
+        if 'description' not in payload:
+            payload['description'] = 'foo'
+        if 'effort' not in payload:
+            payload['effort'] = 1.1
+        self.check_post(payload, f'/process?id={id_}',
+                        redir=f'/process?id={id_}')
+        for exp in exps:
+            exp.set_proc_from_post(id_, payload)
+        return payload
+
     def check_redirect(self, target: str) -> None:
         """Check that self.conn answers with a 302 redirect to target."""
         response = self.conn.getresponse()
@@ -616,42 +942,72 @@ class TestCaseWithServer(TestCaseWithDB):
         self.check_get(f'/{path}?id=0', 500)
         self.check_get(f'{path}?id=1', 200)
 
-    def post_process(self, id_: int = 1,
-                     form_data: dict[str, Any] | None = None
-                     ) -> dict[str, Any]:
-        """POST basic Process."""
-        if not form_data:
-            form_data = {'title': 'foo', 'description': 'foo', 'effort': 1.1}
-        self.check_post(form_data, f'/process?id={id_}',
-                        redir=f'/process?id={id_}')
-        return form_data
-
-    def check_json_get(self, path: str, expected: dict[str, object]) -> None:
+    def check_json_get(self, path: str, expected: Expected) -> None:
         """Compare JSON on GET path with expected.
 
         To simplify comparison of VersionedAttribute histories, transforms
-        timestamp keys of VersionedAttribute history keys into integers
-        counting chronologically forward from 0.
+        timestamp keys of VersionedAttribute history keys into (strings of)
+        integers counting chronologically forward from 0.
         """
 
         def rewrite_history_keys_in(item: Any) -> Any:
             if isinstance(item, dict):
                 if '_versioned' in item.keys():
-                    for k in item['_versioned']:
-                        vals = item['_versioned'][k].values()
+                    for category in item['_versioned']:
+                        vals = item['_versioned'][category].values()
                         history = {}
                         for i, val in enumerate(vals):
-                            history[i] = val
-                        item['_versioned'][k] = history
-                for k in list(item.keys()):
-                    rewrite_history_keys_in(item[k])
+                            history[str(i)] = val
+                        item['_versioned'][category] = history
+                for category in list(item.keys()):
+                    rewrite_history_keys_in(item[category])
             elif isinstance(item, list):
                 item[:] = [rewrite_history_keys_in(i) for i in item]
             return item
 
+        def walk_diffs(path: str, cmp1: object, cmp2: object) -> None:
+            # pylint: disable=too-many-branches
+            def warn(intro: str, val: object) -> None:
+                if isinstance(val, (str, int, float)):
+                    print(intro, val)
+                else:
+                    print(intro)
+                    pprint(val)
+            if cmp1 != cmp2:
+                if isinstance(cmp1, dict) and isinstance(cmp2, dict):
+                    for k, v in cmp1.items():
+                        if k not in cmp2:
+                            warn(f'DIFF {path}: retrieved lacks {k}', v)
+                        elif v != cmp2[k]:
+                            walk_diffs(f'{path}:{k}', v, cmp2[k])
+                    for k in [k for k in cmp2.keys() if k not in cmp1]:
+                        warn(f'DIFF {path}: expected lacks retrieved\'s {k}',
+                             cmp2[k])
+                elif isinstance(cmp1, list) and isinstance(cmp2, list):
+                    for i, v1 in enumerate(cmp1):
+                        if i >= len(cmp2):
+                            warn(f'DIFF {path}[{i}] retrieved misses:', v1)
+                        elif v1 != cmp2[i]:
+                            walk_diffs(f'{path}[{i}]', v1, cmp2[i])
+                    if len(cmp2) > len(cmp1):
+                        for i, v2 in enumerate(cmp2[len(cmp1):]):
+                            warn(f'DIFF {path}[{len(cmp1)+i}] misses:', v2)
+                else:
+                    warn(f'DIFF {path} – for expected:', cmp1)
+                    warn('… and for retrieved:', cmp2)
+
         self.conn.request('GET', path)
         response = self.conn.getresponse()
         self.assertEqual(response.status, 200)
         retrieved = json_loads(response.read().decode())
         rewrite_history_keys_in(retrieved)
-        self.assertEqual(expected, retrieved)
+        cmp = expected.as_dict
+        try:
+            self.assertEqual(cmp, retrieved)
+        except AssertionError as e:
+            print('EXPECTED:')
+            pprint(cmp)
+            print('RETRIEVED:')
+            pprint(retrieved)
+            walk_diffs('', cmp, retrieved)
+            raise e