diff --git a/tests/test_things.py b/tests/test_things.py index c22d974..7e2575a 100644 --- a/tests/test_things.py +++ b/tests/test_things.py @@ -423,6 +423,17 @@ def test_thingstime(self): test_task = things.tasks("7F4vqUNiTvGKaCUfv5pqYG") self.assertEqual(test_task.get("reminder_time"), "12:34") + def test_status_mapping(self): + task = things.tasks(uuid = '5HLnvorXMbqcbjUuPN6ywi') + assert task['title'] == 'Cancelled To-Do in Project' + assert task['status'] == 'canceled' + task = things.tasks(uuid='5u2yGhP4rMQUmPQYEpGYDd') + assert task['title'] == 'Completed To-Do in Project' + assert task['status'] == 'completed' + task = things.tasks(uuid='7F4vqUNiTvGKaCUfv5pqYG') + assert task['title'] == 'To-Do in Upcoming' + assert task['status'] == 'incomplete' + if __name__ == "__main__": unittest.main() diff --git a/tests/with_fixtures/testloop.sh b/tests/with_fixtures/testloop.sh new file mode 100755 index 0000000..590a57d --- /dev/null +++ b/tests/with_fixtures/testloop.sh @@ -0,0 +1,27 @@ +#!/bin/zsh + + +echo "-----1: ----$1" +if [ $1 ]; then + subdir=$1 +else + subdir=tasktinder +fi + +# echo "-----subdir: ----$subdir" + +while true; do + clear + pipenv run pytest . + + # pytest parameter + # -x fail after first failur + # -v + # -vv + +# ----- fswatch + # -1 -> exit after one change + fswatch **/*.py -1 + # fswatch lib -1 + +done \ No newline at end of file diff --git a/things/database.py b/things/database.py index 08e17e0..388f96e 100755 --- a/things/database.py +++ b/things/database.py @@ -11,8 +11,7 @@ from textwrap import dedent from typing import Optional, Union import weakref - - +from .database_mappings import * # -------------------------------------------------- # Core constants # -------------------------------------------------- @@ -43,12 +42,6 @@ "Someday": "start = 2", } -STATUS_TO_FILTER = { - "incomplete": "status = 0", - "canceled": "status = 2", - "completed": "status = 3", -} - TRASHED_TO_FILTER = {True: "trashed = 1", False: "trashed = 0"} TYPE_TO_FILTER = { @@ -121,9 +114,7 @@ IS_HEADING = TYPE_TO_FILTER["heading"] # Status -IS_INCOMPLETE = STATUS_TO_FILTER["incomplete"] -IS_CANCELED = STATUS_TO_FILTER["canceled"] -IS_COMPLETED = STATUS_TO_FILTER["completed"] +# moved to database_mappings # Start IS_INBOX = START_TO_FILTER["Inbox"] @@ -247,7 +238,7 @@ def get_tasks( # pylint: disable=R0914,R0917 validate("start", start, [None] + list(START_TO_FILTER)) validate_date("start_date", start_date) validate_date("stop_date", stop_date) - validate("status", status, [None] + list(STATUS_TO_FILTER)) + validate("status", status, [None] + valid_values_for("status")) validate("trashed", trashed, [None] + list(TRASHED_TO_FILTER)) validate("type", type, [None] + list(TYPE_TO_FILTER)) validate("context_trashed", context_trashed, [None, True, False]) @@ -263,7 +254,6 @@ def get_tasks( # pylint: disable=R0914,R0917 # See: https://docs.python.org/3/library/sqlite3.html#sqlite3.Cursor.execute start_filter: str = START_TO_FILTER.get(start, "") # type: ignore - status_filter: str = STATUS_TO_FILTER.get(status, "") # type: ignore trashed_filter: str = TRASHED_TO_FILTER.get(trashed, "") # type: ignore type_filter: str = TYPE_TO_FILTER.get(type, "") # type: ignore @@ -291,7 +281,7 @@ def get_tasks( # pylint: disable=R0914,R0917 {project_of_heading_trashed_filter} {type_filter and f"AND TASK.{type_filter}"} {start_filter and f"AND TASK.{start_filter}"} - {status_filter and f"AND TASK.{status_filter}"} + {make_complete_sql_filter_for('status', status)} {make_filter('TASK.uuid', uuid)} {make_filter("TASK.area", area)} {project_filter} @@ -374,11 +364,7 @@ def get_checklist_items(self, todo_uuid=None): sql_query = f""" SELECT CHECKLIST_ITEM.title, - CASE - WHEN CHECKLIST_ITEM.{IS_INCOMPLETE} THEN 'incomplete' - WHEN CHECKLIST_ITEM.{IS_CANCELED} THEN 'canceled' - WHEN CHECKLIST_ITEM.{IS_COMPLETED} THEN 'completed' - END AS status, + {make_optional_field_select('status','CHECKLIST_ITEM')} date(CHECKLIST_ITEM.stopDate, "unixepoch", "localtime") AS stop_date, 'checklist-item' as type, CHECKLIST_ITEM.uuid, @@ -547,11 +533,7 @@ def make_tasks_sql_query(where_predicate=None, order_predicate=None): WHEN TASK.{IS_TRASHED} THEN 1 END AS trashed, TASK.title, - CASE - WHEN TASK.{IS_INCOMPLETE} THEN 'incomplete' - WHEN TASK.{IS_CANCELED} THEN 'canceled' - WHEN TASK.{IS_COMPLETED} THEN 'completed' - END AS status, + {make_optional_field_select('status','TASK')} CASE WHEN AREA.uuid IS NOT NULL THEN AREA.uuid END AS area, diff --git a/things/database_mappings.py b/things/database_mappings.py new file mode 100644 index 0000000..0de44fb --- /dev/null +++ b/things/database_mappings.py @@ -0,0 +1,97 @@ +""" +all repetitions of the status value mapping. + +STATUS_TO_FILTER is used in these forms: + +- in get_tasks: + +[x] list(STATUS_TO_FILTER) + + +[x] status_filter: str = STATUS_TO_FILTER.get(status, "") # type: ignore + + +IS_INCOMPLETE etc is used in these forms: + +- in get_checklist_items +[x] CASE + WHEN CHECKLIST_ITEM.{IS_INCOMPLETE} THEN 'incomplete' + WHEN CHECKLIST_ITEM.{IS_CANCELED} THEN 'canceled' + WHEN CHECKLIST_ITEM.{IS_COMPLETED} THEN 'completed' +- in make_tasks_sql_query + [x] CASE + WHEN TASK.{IS_INCOMPLETE} THEN 'incomplete' + WHEN TASK.{IS_CANCELED} THEN 'canceled' + WHEN TASK.{IS_COMPLETED} THEN 'completed' + + + +- [x] status_filter is used only once: + +status_filter +status_filter: str = make_sql_filter_for("status", status) # type: ignore +{status_filter and f"AND TASK.{status_filter}"} + +""" + +# this is the only place where the mapping is defined + +VALUE_TO_SQL = { + 'status': { + "incomplete": 0, + "canceled": 2, + "completed": 3, + } +} + +# the inverse could be used to create test data: +VALUE_TO_API = {'status' : {v: k for k, v in VALUE_TO_SQL['status'].items()}} +# for more fields, above and below need to be created in a loop over the field names +VALID_VALUES = { + "status" : list(VALUE_TO_SQL['status']), +} + +# these are more generic than needed just for status by taking the field parameter +def value_to_sql(field,value): + if field in list(VALUE_TO_SQL): + return VALUE_TO_SQL[field][value] + raise NotImplementedError + +def value_to_api(field,value): + if field in list(VALUE_TO_API): + return VALUE_TO_API[field][value] + raise NotImplementedError + +def valid_values_for(field): + if field in VALID_VALUES.keys(): + return VALID_VALUES[field] + raise NotImplementedError + + +def _make_sql_filter_for(field, value): + if value in valid_values_for(field): + return f"{field} = {value_to_sql(field,value)}" + return "" + +def make_complete_sql_filter_for(field, value): + if value in valid_values_for(field): + return f"AND TASK.{_make_sql_filter_for(field, value)}" + return "" + +def make_optional_field_select(field,table): + indentation = " " + wcl = [] + for value in valid_values_for(field): + wcl.append(f"{indentation} WHEN {table}.{_make_sql_filter_for(field, value)} THEN '{value}'") + when_clauses = "\n".join(wcl) + optional_select = f""" +CASE +{when_clauses} +{indentation}END AS status, +""" + return optional_select + + + + + diff --git a/things/database_mappings_test.py b/things/database_mappings_test.py new file mode 100644 index 0000000..8cf40ed --- /dev/null +++ b/things/database_mappings_test.py @@ -0,0 +1,66 @@ + +from things.database_mappings import * +from things.database_mappings import (_make_sql_filter_for,) # not included in * +import pytest + +# old constants, kept for testing purposes +_STATUS_TO_FILTER = { + "incomplete": "status = 0", + "canceled": "status = 2", + "completed": "status = 3", +} + +# Status +_IS_INCOMPLETE = _STATUS_TO_FILTER["incomplete"] +_IS_CANCELED = _STATUS_TO_FILTER["canceled"] +_IS_COMPLETED = _STATUS_TO_FILTER["completed"] + +def test_list(): + assert list(_STATUS_TO_FILTER) == ["incomplete", "canceled", "completed"] + +status_test_cases = [ + ("incomplete","status = 0"),("completed","status = 3"),("canceled","status = 2"),("",""),("something else",""), +] + +@pytest.mark.parametrize("status,old_behaviour", status_test_cases) +def test_make_sql_filter_characterization(status,old_behaviour): + assert old_behaviour == _STATUS_TO_FILTER.get(status, "") + +@pytest.mark.parametrize("status,expected", status_test_cases) +def test_make_sql_filter(status,expected): + assert _make_sql_filter_for("status", status) == expected + + +status_field_select_test_cases = [("status","TASK", +f""" +CASE + WHEN TASK.{_IS_INCOMPLETE} THEN 'incomplete' + WHEN TASK.{_IS_CANCELED} THEN 'canceled' + WHEN TASK.{_IS_COMPLETED} THEN 'completed' + END AS status, +"""),("status","CHECKLIST_ITEM", +f""" +CASE + WHEN CHECKLIST_ITEM.{_IS_INCOMPLETE} THEN 'incomplete' + WHEN CHECKLIST_ITEM.{_IS_CANCELED} THEN 'canceled' + WHEN CHECKLIST_ITEM.{_IS_COMPLETED} THEN 'completed' + END AS status, +""" + +)] + + +@pytest.mark.parametrize("field,table,expected", status_field_select_test_cases) +def test_make_optional_field_select(field, table, expected): + canned_answers = { + status_field_select_test_cases[0][1]: status_field_select_test_cases[0][2], + status_field_select_test_cases[1][1]: status_field_select_test_cases[1][2]} + + assert make_optional_field_select(field,table) == expected + +def test_complete_status_filter(): + status_filter = _make_sql_filter_for('status', 'completed') + complete_status_filter = status_filter and f"AND TASK.{status_filter}" + assert complete_status_filter == 'AND TASK.status = 3' + expected = 'AND TASK.status = 3' + assert make_complete_sql_filter_for('status','completed') == expected \ No newline at end of file