From dc69de7ffd96a907539e466b9093517c17ef1bf4 Mon Sep 17 00:00:00 2001 From: Christian Heller Date: Fri, 12 Jul 2024 08:59:27 +0200 Subject: [PATCH] Overhaul date ranging and its tests. --- plomtask/days.py | 15 ++---- plomtask/db.py | 2 +- tests/days.py | 138 ++++++++++++++++++++++++++--------------------- tests/todos.py | 4 ++ tests/utils.py | 67 +++++++++++++++++++++-- 5 files changed, 152 insertions(+), 74 deletions(-) diff --git a/plomtask/days.py b/plomtask/days.py index 18c9769..3d9d041 100644 --- a/plomtask/days.py +++ b/plomtask/days.py @@ -50,20 +50,15 @@ class Day(BaseModel[str]): day.todos = Todo.by_date(db_conn, day.id_) return day - @classmethod - def by_date_range_filled(cls, db_conn: DatabaseConnection, - start: str, end: str) -> list[Day]: - """Return days existing and non-existing between dates start/end.""" - ret = cls.by_date_range_with_limits(db_conn, (start, end), 'id') - days, start_date, end_date = ret - return cls.with_filled_gaps(days, start_date, end_date) - @classmethod def with_filled_gaps(cls, days: list[Day], start_date: str, end_date: str ) -> list[Day]: - """In days, fill with (un-saved) Days gaps between start/end_date.""" + """In days, fill with (un-stored) Days gaps between start/end_date.""" + days = days[:] + start_date, end_date = valid_date(start_date), valid_date(end_date) if start_date > end_date: - return days + return [] + days = [d for d in days if d.date >= start_date and d.date <= end_date] days.sort() if start_date not in [d.date for d in days]: days[:] = [Day(start_date)] + days diff --git a/plomtask/db.py b/plomtask/db.py index dac3e39..704b709 100644 --- a/plomtask/db.py +++ b/plomtask/db.py @@ -518,7 +518,7 @@ class BaseModel(Generic[BaseModelId]): date_col: str = 'day' ) -> tuple[list[BaseModelInstance], str, str]: - """Return list of items in database within (open) date_range interval. + """Return list of items in DB within (closed) date_range interval. If no range values provided, defaults them to 'yesterday' and 'tomorrow'. Knows to properly interpret these and 'today' as value. diff --git a/tests/days.py b/tests/days.py index 8e3768c..f16c69c 100644 --- a/tests/days.py +++ b/tests/days.py @@ -1,79 +1,92 @@ """Test Days module.""" -from unittest import TestCase -from datetime import datetime +from datetime import datetime, timedelta from typing import Callable -from tests.utils import TestCaseWithDB, TestCaseWithServer -from plomtask.dating import date_in_n_days +from tests.utils import TestCaseSansDB, TestCaseWithDB, TestCaseWithServer +from plomtask.dating import date_in_n_days, DATE_FORMAT from plomtask.days import Day -class TestsSansDB(TestCase): +class TestsSansDB(TestCaseSansDB): """Days module tests not requiring DB setup.""" - legal_ids = ['2024-01-01'] - illegal_ids = ['foo', '2024-02-30', '2024-02-01 23:00:00'] + checked_class = Day + legal_ids = ['2024-01-01', '2024-02-29'] + 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.""" + 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)) def test_Day_datetime_weekday_neighbor_dates(self) -> None: - """Test Day's date parsing.""" + """Test Day's date parsing and neighbourhood resolution.""" self.assertEqual(datetime(2024, 5, 1), Day('2024-05-01').datetime) self.assertEqual('Sunday', Day('2024-03-17').weekday) self.assertEqual('March', Day('2024-03-17').month_name) self.assertEqual('2023-12-31', Day('2024-01-01').prev_date) self.assertEqual('2023-03-01', Day('2023-02-28').next_date) - def test_Day_sorting(self) -> None: - """Test sorting by .__lt__ and Day.__eq__.""" - day1 = Day('2024-01-01') - day2 = Day('2024-01-02') - day3 = Day('2024-01-03') - days = [day3, day1, day2] - self.assertEqual(sorted(days), [day1, day2, day3]) - class TestsWithDB(TestCaseWithDB): """Tests requiring DB, but not server setup.""" checked_class = Day default_ids = ('2024-01-01', '2024-01-02', '2024-01-03') - def test_Day_by_date_range_filled(self) -> None: - """Test Day.by_date_range_filled.""" - date1, date2, date3 = self.default_ids - day1 = Day(date1) - day2 = Day(date2) - day3 = Day(date3) - for day in [day1, day2, day3]: - day.save(self.db_conn) - # check date range includes limiter days - self.assertEqual(Day.by_date_range_filled(self.db_conn, date1, date3), - [day1, day2, day3]) - # check first date range value excludes what's earlier - self.assertEqual(Day.by_date_range_filled(self.db_conn, date2, date3), - [day2, day3]) - # check second date range value excludes what's later - self.assertEqual(Day.by_date_range_filled(self.db_conn, date1, date2), - [day1, day2]) - # check swapped (impossible) date range returns emptiness - self.assertEqual(Day.by_date_range_filled(self.db_conn, date3, date1), - []) - # check fill_gaps= instantiates unsaved dates within date range - # (but does not store them) - day5 = Day('2024-01-05') - day6 = Day('2024-01-06') - day6.save(self.db_conn) - day7 = Day('2024-01-07') - self.assertEqual(Day.by_date_range_filled(self.db_conn, - day5.date, day7.date), - [day5, day6, day7]) - self.check_identity_with_cache_and_db([day1, day2, day3, day6]) - # check 'today' is interpreted as today's date - today = Day(date_in_n_days(0)) - self.assertEqual(Day.by_date_range_filled(self.db_conn, - 'today', 'today'), - [today]) - prev_day = Day(date_in_n_days(-1)) - next_day = Day(date_in_n_days(1)) - self.assertEqual(Day.by_date_range_filled(self.db_conn, - 'yesterday', 'tomorrow'), - [prev_day, today, next_day]) + def test_Day_by_date_range_with_limits(self) -> None: + """Test .by_date_range_with_limits.""" + self.check_by_date_range_with_limits('id', set_id_field=False) + + def test_Day_with_filled_gaps(self) -> None: + """Test .with_filled_gaps.""" + + def test(range_indexes: tuple[int, int], indexes_to_provide: list[int] + ) -> None: + start_i, end_i = range_indexes + days_provided = [] + days_expected = days_sans_comment[:] + for i in indexes_to_provide: + day_with_comment = days_with_comment[i] + days_provided += [day_with_comment] + days_expected[i] = day_with_comment + days_expected = days_expected[start_i:end_i+1] + start, end = dates[start_i], dates[end_i] + days_result = self.checked_class.with_filled_gaps(days_provided, + start, end) + 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) + 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] + # check provided Days recognizable in (full-range) interval + test((0, 8), [0, 4, 8]) + # check limited range, but limiting Days provided + test((2, 6), [2, 5, 6]) + # check Days within range but beyond provided Days also filled in + test((1, 7), [2, 5]) + # check provided Days beyond range ignored + test((3, 5), [1, 2, 4, 6, 7]) + # check inversion of start_date and end_date returns empty list + test((5, 3), [2, 4, 6]) + # check empty provision still creates filler elements in interval + test((3, 5), []) + # check single-element selection creating only filler beyond provided + test((1, 1), [2, 4, 6]) + # check (un-saved) filler Days don't show up in cache or DB + # dates = [f'2024-02-0{n}' for n in range(1, 6)] + day = Day(dates[3]) + day.save(self.db_conn) + self.checked_class.with_filled_gaps([day], dates[0], dates[-1]) + self.check_identity_with_cache_and_db([day]) + # check 'today', 'yesterday', 'tomorrow' are interpreted + yesterday = Day('yesterday') + tomorrow = Day('tomorrow') + today = Day('today') + result = self.checked_class.with_filled_gaps([today], 'yesterday', + 'tomorrow') + self.assertEqual(result, [yesterday, today, tomorrow]) class TestsWithServer(TestCaseWithServer): @@ -96,7 +109,12 @@ class TestsWithServer(TestCaseWithServer): @classmethod def GET_calendar_dict(cls, start: int, end: int) -> dict[str, object]: - """Return JSON of GET /calendar to expect.""" + """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) @@ -352,17 +370,17 @@ class TestsWithServer(TestCaseWithServer): # check illegal date range delimiters self.check_get('/calendar?start=foo', 400) self.check_get('/calendar?end=foo', 400) - # check default range without saved days + # 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) - # check named days as delimiters + # check with named days as delimiters expected = self.GET_calendar_dict(-1, +1) self.check_json_get('/calendar?start=yesterday&end=tomorrow', expected) # check zero-element range expected = self.GET_calendar_dict(+1, 0) self.check_json_get('/calendar?start=tomorrow&end=today', expected) - # check saved day shows up in results with proven by its comment + # 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) diff --git a/tests/todos.py b/tests/todos.py index 6b6276f..dcd4856 100644 --- a/tests/todos.py +++ b/tests/todos.py @@ -60,6 +60,10 @@ class TestsWithDB(TestCaseWithDB, TestCaseSansDB): with self.assertRaises(BadFormatException): self.assertEqual(Todo.by_date(self.db_conn, 'foo'), []) + def test_Todo_by_date_range_with_limits(self) -> None: + """Test .by_date_range_with_limits.""" + self.check_by_date_range_with_limits('day') + def test_Todo_on_conditions(self) -> None: """Test effect of Todos on Conditions.""" assert isinstance(self.cond1.id_, int) diff --git a/tests/utils.py b/tests/utils.py index 9f7bf7e..eb874e3 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -4,7 +4,7 @@ from unittest import TestCase from typing import Mapping, Any, Callable from threading import Thread from http.client import HTTPConnection -from datetime import datetime +from datetime import datetime, timedelta from time import sleep from json import loads as json_loads from urllib.parse import urlencode @@ -15,6 +15,7 @@ from plomtask.http import TaskHandler, TaskServer from plomtask.processes import Process, ProcessStep from plomtask.conditions import Condition from plomtask.days import Day +from plomtask.dating import DATE_FORMAT from plomtask.todos import Todo from plomtask.versioned_attributes import VersionedAttribute, TIMESTAMP_FMT from plomtask.exceptions import NotFoundException, HandledException @@ -59,8 +60,8 @@ class TestCaseAugmented(TestCase): class TestCaseSansDB(TestCaseAugmented): """Tests requiring no DB setup.""" - legal_ids = [1, 5] - illegal_ids = [0] + legal_ids: list[str] | list[int] = [1, 5] + illegal_ids: list[str] | list[int] = [0] @TestCaseAugmented._run_if_checked_class def test_id_validation(self) -> None: @@ -213,6 +214,66 @@ class TestCaseWithDB(TestCaseAugmented): hashes_db_found = [hash(x) for x in db_found] self.assertEqual(sorted(hashes_content), sorted(hashes_db_found)) + def check_by_date_range_with_limits(self, + date_col: str, + set_id_field: bool = True + ) -> None: + """Test .by_date_range_with_limits.""" + # pylint: disable=too-many-locals + f = self.checked_class.by_date_range_with_limits + # check illegal ranges + legal_range = ('yesterday', 'tomorrow') + for i in [0, 1]: + for bad_date in ['foo', '2024-02-30', '2024-01-01 12:00:00']: + date_range = list(legal_range[:]) + date_range[i] = bad_date + with self.assertRaises(HandledException): + f(self.db_conn, date_range, date_col) + # check empty, translation of 'yesterday' and 'tomorrow' + items, start, end = f(self.db_conn, legal_range, date_col) + self.assertEqual(items, []) + yesterday = datetime.now() + timedelta(days=-1) + 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 + kwargs_with_date = self.default_init_kwargs.copy() + if set_id_field: + kwargs_with_date['id_'] = None + objs = [] + dates = ['2024-01-01', '2024-01-02', '2024-01-04'] + for date in ['2024-01-01', '2024-01-02', '2024-01-04']: + kwargs_with_date['date'] = date + obj = self.checked_class(**kwargs_with_date) + objs += [obj] + # check ranges still empty before saving + date_range = [dates[0], dates[-1]] + self.assertEqual(f(self.db_conn, date_range, date_col)[0], []) + # check all objs displayed within closed interval + for obj in objs: + obj.save(self.db_conn) + self.assertEqual(f(self.db_conn, date_range, date_col)[0], objs) + # check that only displayed what exists within interval + date_range = ['2023-12-20', '2024-01-03'] + expected = [objs[0], objs[1]] + self.assertEqual(f(self.db_conn, date_range, date_col)[0], expected) + date_range = ['2024-01-03', '2024-01-30'] + expected = [objs[2]] + self.assertEqual(f(self.db_conn, date_range, date_col)[0], expected) + # check that inverted interval displays nothing + date_range = [dates[-1], dates[0]] + self.assertEqual(f(self.db_conn, date_range, date_col)[0], []) + # check that "today" is interpreted, and single-element interval + today_date = datetime.now().strftime(DATE_FORMAT) + kwargs_with_date['date'] = today_date + obj_today = self.checked_class(**kwargs_with_date) + obj_today.save(self.db_conn) + date_range = ['today', 'today'] + items, start, end = f(self.db_conn, date_range, date_col) + self.assertEqual(start, today_date) + self.assertEqual(start, end) + self.assertEqual(items, [obj_today]) + @TestCaseAugmented._run_on_versioned_attributes def test_saving_versioned_attributes(self, owner: Any, -- 2.30.2