From bfaa8e2183bc80ed4dda3bc1cff8a73a552a4f15 Mon Sep 17 00:00:00 2001 From: colinleach Date: Mon, 18 Dec 2023 17:34:15 -0700 Subject: [PATCH] booking-up-for-beauty concept exercise --- config.json | 8 + .../booking-up-for-beauty/.docs/hints.md | 3 + .../.docs/instructions.md | 61 ++++ .../.docs/introduction.md | 269 ++++++++++++++++++ .../booking-up-for-beauty/.meta/config.json | 20 ++ .../booking-up-for-beauty/.meta/design.md | 50 ++++ .../booking-up-for-beauty/.meta/exemplar.py | 68 +++++ .../booking_up_for_beauty.py | 68 +++++ .../booking_up_for_beauty_test.py | 137 +++++++++ 9 files changed, 684 insertions(+) create mode 100644 exercises/concept/booking-up-for-beauty/.docs/hints.md create mode 100644 exercises/concept/booking-up-for-beauty/.docs/instructions.md create mode 100644 exercises/concept/booking-up-for-beauty/.docs/introduction.md create mode 100644 exercises/concept/booking-up-for-beauty/.meta/config.json create mode 100644 exercises/concept/booking-up-for-beauty/.meta/design.md create mode 100644 exercises/concept/booking-up-for-beauty/.meta/exemplar.py create mode 100644 exercises/concept/booking-up-for-beauty/booking_up_for_beauty.py create mode 100644 exercises/concept/booking-up-for-beauty/booking_up_for_beauty_test.py diff --git a/config.json b/config.json index 2d59e5b0dc6..d220a4f8181 100644 --- a/config.json +++ b/config.json @@ -226,6 +226,14 @@ "loops" ], "status": "wip" + }, + { + "slug": "booking-up-for-beauty", + "name": "Booking Up For Beauty", + "uuid": "287abffa-7989-4e84-9993-b3aae3a0fba3", + "concepts": [], + "prerequisites": [], + "status": "wip" } ], "practice": [ diff --git a/exercises/concept/booking-up-for-beauty/.docs/hints.md b/exercises/concept/booking-up-for-beauty/.docs/hints.md new file mode 100644 index 00000000000..7dccb9291a6 --- /dev/null +++ b/exercises/concept/booking-up-for-beauty/.docs/hints.md @@ -0,0 +1,3 @@ +# Hints + +TODO \ No newline at end of file diff --git a/exercises/concept/booking-up-for-beauty/.docs/instructions.md b/exercises/concept/booking-up-for-beauty/.docs/instructions.md new file mode 100644 index 00000000000..8ddda692068 --- /dev/null +++ b/exercises/concept/booking-up-for-beauty/.docs/instructions.md @@ -0,0 +1,61 @@ +# Instructions + +In this exercise you'll be working on an appointment scheduler for a beauty salon in New York that opened on September 15th in 2012. + +You have six tasks, which will all involve appointment dates. + +## 1. Parse all-number appointment date + +Implement the `schedule_numeric()` function to parse a textual representation of an appointment date into the corresponding `datetime.datetime` format. + +Note that the input is in U.S.-style month/day/year order and numbers are zero-padded (so July is 07, not 7). + +```python +schedule_numeric("07/25/2023 13:45:00") +// => datetime.datetime(2023, 7, 25, 13, 45, 0) +``` + +## 2. Parse mixed-format appointment date + +Implement the `schedule_mixed()` function to parse a textual representation of an appointment date into the corresponding `datetime.datetime` format: + +```python +schedule_mixed("Thursday, July 25, 2023 13:45:00") +// => datetime.datetime(2023, 7, 25, 13, 45, 0) +``` + +## 3. Check if an appointment has already passed + +Implement the `has_passed()` function that takes an appointment date and checks if the appointment was somewhere in the past: + +```python +has_passed(datetime.datetime(1999, 12, 31, 9, 0, 0)) +// => True +``` + +## 4. Check if appointment is in the afternoon + +Implement the `is_afternoon_appointment()` function that takes an appointment date and checks if the appointment is in the afternoon (>= 12:00 and < 18:00): + +```python +is_afternoon_appointment(datetime.datetime(2023, 3, 29, 15, 0, 0)) +// => True +``` + +## 5. Describe the time and date of the appointment + +Implement the `description()` function that takes an appointment date and returns a description of that date and time: + +```python +description(datetime.datetime(2023, 3, 29, 15, 0, 0)) +// => "You have an appointment on 03/29/2023 03:00:00 PM." +``` + +## 6. Return the anniversary date + +Implement the `anniversary_date()` function that returns this year's anniversary date, which is September 15th: + +```python +anniversary_date() +// => datetime.date(2023, 9, 15) +``` \ No newline at end of file diff --git a/exercises/concept/booking-up-for-beauty/.docs/introduction.md b/exercises/concept/booking-up-for-beauty/.docs/introduction.md new file mode 100644 index 00000000000..6a171186404 --- /dev/null +++ b/exercises/concept/booking-up-for-beauty/.docs/introduction.md @@ -0,0 +1,269 @@ +# Introduction + +_"Dates and times are something we teach to young children. How hard can it be?"_ + +Many programmers have made that mistake, and the subsequent experience tends to be negative to their health and happiness. + +Anyone doing non-trivial programming with dates and times should at least be prepared to understand and mitigate potential problems. + +## The `datetime` module + +In python, a wide range of date and time functionality is collected in the [`datetime`][datetime] module. +This can be supplemented by other libraries, but `datetime` is central and often sufficient. + +There are five major classes within `datetime`: + - `datetime.date` for simple dates + - `datetime.time` for simple times + - `datetime.datetime` combines date, time and optionally timezone information + - `datetime.timedelta` for intervals + - `datetime.timezone` to handle the reality that few people use UTC + + ___Notation detail:___ A `datetime.time` or `datetime.datetime` object that includes timezone information is said to be _aware_, otherwise it is _naive_. + A `datetime.date` object is always naive. + +As `datetime` is a large module with many methods and attributes, only some of the most common will be discussed here. + +You are encouraged to explore the [full documentation][datetime]. +Dates and times are complex but important, so the Python developers have put many years of effort into trying to support most use cases. + +Perhaps the most frequent needs are: + +- Parse some appropriate input format to construct a `datetime` object. +This often uses [`strptime()`][strptime-strftime]. +- Get the required numerical or string format from a `datetime` object. +String output often uses [`strftime()`][strptime-strftime]. +- Apply an offset to a `date`, `time` or `datetime` to create a new object (of the same type). +- Calculate the interval between two such objects. +- Get the current date and/or time. +This will be obtained from the host computer and converted to a Python object. + + +### Date and time formats + +There are many ways to write dates and times, which tend to be culturally-specific. +All-number dates such as "7/6/23" are ambiguous, confusing, and have led to many expensive mistakes in multinational organizations. + +The international standard is defined in [`ISO 8601`][ISO8601], with two main advantages: +- Parsing is quick and unambiguous. +- Sorting is easy, as the datetime can be treated as text. + +An example: + +```python +>>> from datetime import datetime +>>> datetime.now(timezone.utc).isoformat() +'2023-12-04T17:54:13.014513+00:00' +``` + +This is built up from various parts, with only the date fields required: +- `YYYY-MM-DD` +- Optionally, `Thh:mm:ss` +- Optionally, microseconds after the decimal point. +- Optionally, timezone offset from UTC with a sign and `hh:mm` value. + +Internally, `date`, `time` and `datetime` are stored as Python objects with separate attributes for year, month, etc. +Examples of this will be shown below, when each class is discussed. + +Most computer operating systems use POSIX timestamps: the number of seconds since `1970-01-01T00:00:00+00.00`. +The `datetime` module makes it easy to import these. + +For code which interacts mainly with computers rather than humans, it may be worth investigating the separate [`time`][time] module, which provides more complete support for POSIX timestamps. + +## The [`datetime.date`][datetime-date] class + +[`datetime.date`][datetime-date] is a relatively small and simple date-only class, with no understanding of times or timezones. + +```python +>>> from datetime import date +>>> date.today() +datetime.date(2023, 12, 4) +>>> date.today().isoformat() +'2023-12-04' +``` + +The default display has the same `date(year, month, day)` syntax as the default constructor. +A `date` object can also be created from an ISO 8601 date string or a POSIX timestamp. + +```python +>>> date(1969, 7, 20) +datetime.date(1969, 7, 20) + +>>> date.fromisoformat('1969-07-20') +datetime.date(1969, 7, 20) + +>>> date.fromisoformat('1969-07-20') == date(1969, 7, 20) +True +``` + +Individual parts of the date can be accessed as instance attributes: + +```python +>>> date.today().month # in December +12 +``` + +There are a number of other methods, mostly related to output formats. +See the [class documentation][datetime-date] for details. + +`datetime.date` is designed to be fairly minimalist, to keep simple applications simple. + +If your application is ever likely to need times or timezones, it may be better to use `datetime.datetime` from the start. + +For more complex date-only applications, compare `datetime.date` with [`calendar`][calendar] and decide which better fits your needs. + +## The [`datetime.time`][datetime-time] class + +`datetime.time` is the basic time-only class. +It has no understanding of dates: times automatically roll over to `time(0, 0, 0)` at midnight. + +Timezone information can optionally be included. + +The full constructor format is `timezone.time(hour, min, sec, microsec, timezone)`. + +All the parameters are optional: numerical values will default to `0`, timezone to `None`. + +```python +>>> from datetime import time +>>> time() +datetime.time(0, 0) +>>> time(14, 30, 23) +datetime.time(14, 30, 23) +``` + +Starting from an ISO 8601 format may be more readable in some cases: + +```python +>>> time.fromisoformat('15:17:01-07:00') # mid-afternoon in Arizona +datetime.time(15, 17, 1, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200))) +``` + +Timezones will be discussed in more detail below. + +Arithmetic is not possible with `datetime.time` objects, but they do support comparisons. + +```python +>>> time1 = time(14, 45) +>>> time2 = time(16, 21, 30) +>>> time1 > time2 +False +``` + +As with `date`, individual parts are available as instance attributes: + +```python +>>> time(16, 21, 30).hour +16 +``` + +For other methods and properties, see the [class documentation][datetime-time]. +Much of it relates to working with timezones. + +## The [`datetime.datetime`][datetime-datetime] class + +`datetime.datetime` combines most of the features of the `date` and `time` classes and adds some extras. + +It is the most versatile of these three classes, at the cost of some additional complexity. + +```python +>>> from datetime import datetime + +>>> datetime.now() +datetime.datetime(2023, 12, 4, 15, 45, 50, 66178) + +>>> datetime.now().isoformat() +'2023-12-04T15:46:30.311480' +``` + +As with `date`, the default constructor has the same syntax as the default display. + +The year, month and day parameters are required. Time parameters default to `0`. Timezone defaults to `None`, as in the example above. + +Keeping all these parameters straight can be a challenge, so the ISO format may be preferable: + +```python +>>> datetime.fromisoformat('2023-12-04T15:53+05:30') # Delhi time +datetime.datetime(2023, 12, 4, 15, 53, tzinfo=datetime.timezone(datetime.timedelta(seconds=19800))) +``` + +Much of the functionality in `datetime.datetime` will be familar from `date` and time. + +One addition that may be useful is `combine(date, time)` which constructs a `datetime` instance from a `date` and a `time` instance (and optionally a timezone). + +```python +>>> today = date.today() +>>> current_time = time(4, 5) + +>>> datetime.combine(today, current_time) +datetime.datetime(2023, 12, 4, 4, 5) + +>>> datetime.combine(today, current_time).isoformat() +'2023-12-04T04:05:00' +``` + +For other methods and properties, see the [class documentation][datetime-time]. +Much of it relates to working with timezones. + + +## The [`strptime()` and `strftime()`][strptime-strftime] methods + +The `datetime.datetime` class supports a complementary pair of methods: +- `strptime()` parses a string representation to a `datetime` object. +- `strftime()` outputs a string representation of a `datetime` object. + +Only `strftime()` is available in `datetime.date` and `datetime.time`. + +A wide variety of format codes is available. +Some of the common ones are shown in the examples below, but see the [official documentation][strptime-strftime] for the full list. +These format codes are copied directly from C, and may be familiar to programmers who have worked in other languages. + +```python +>>> date_string = '14/10/23 23:59:59.999999' +>>> format_string = '%d/%m/%y %H:%M:%S.%f' +>>> dt = datetime.strptime(date_string, format_string) +>>> dt +datetime.datetime(2023, 10, 14, 23, 59, 59, 999999) + +>>> dt.strftime('%a %d %b %Y, %I:%M%p') +'Sat 14 Oct 2023, 11:59PM' +``` + +## Related modules + +This Concept has concentrated on the [`datetime`][datetime] module. + +Python has other modules which work with dates and times. + +### The [`time`][time] module +Optimized for working with computer timestanps, for example in software logs. + +Not to be confused with `datetime.time`, a completely separate class. + +### The [`calendar`][calendar] module +An alternative to `datetime.date`, `calendar` is more sophisticated in dealing with dates across a wide span of historical and future time. + +It also has CSS methods to halp with displaying calendars. + + +### The [`zoneinfo`][zoneinfo] module +Mainly consisting of the `ZoneInfo` class, a subclass of `datetime.tzinfo` which supports the [IANA database][IANA] and automatic DST adjustments. + + + +[ISO8601]: https://en.wikipedia.org/wiki/ISO_8601 +[datetime]: https://docs.python.org/3/library/datetime.html +[datetime-date]: https://docs.python.org/3/library/datetime.html#date-objects +[datetime-time]: https://docs.python.org/3/library/datetime.html#time-objects +[datetime-datetime]: https://docs.python.org/3/library/datetime.html#datetime-objects +[datetime-timedelta]: https://docs.python.org/3/library/datetime.html#timedelta-objects +[datetime-tzinfo]: https://docs.python.org/3/library/datetime.html#tzinfo-objects +[datetime-timezone]: https://docs.python.org/3/library/datetime.html#timezone-objects +[strptime-strftime]: https://docs.python.org/3/library/datetime.html#strftime-and-strptime-behavior +[time]: https://docs.python.org/3/library/time.html +[calendar]: https://docs.python.org/3/library/calendar.html +[ABC]: https://docs.python.org/3/library/abc.html +[zoneinfo]: https://docs.python.org/3/library/zoneinfo.html +[tzdata]: https://peps.python.org/pep-0615/ +[IANA]: https://en.wikipedia.org/wiki/Tz_database +[IANA-names]: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones + + diff --git a/exercises/concept/booking-up-for-beauty/.meta/config.json b/exercises/concept/booking-up-for-beauty/.meta/config.json new file mode 100644 index 00000000000..cf32964ea2f --- /dev/null +++ b/exercises/concept/booking-up-for-beauty/.meta/config.json @@ -0,0 +1,20 @@ +{ + "authors": [ + "colinleach", + "BethanyG" + ], + "contributors": [], + "files": { + "solution": [ + "booking_up_for_beauty.py" + ], + "test": [ + "booking_up_for_beauty_test.py" + ], + "exemplar": [ + ".meta/exemplar.py" + ] + }, + "forked_from": ["csharp/booking-up-for-beauty"], + "blurb": "Learn about datetime manipulation by handling beauty salon appointments." +} diff --git a/exercises/concept/booking-up-for-beauty/.meta/design.md b/exercises/concept/booking-up-for-beauty/.meta/design.md new file mode 100644 index 00000000000..1b6ed0f2a70 --- /dev/null +++ b/exercises/concept/booking-up-for-beauty/.meta/design.md @@ -0,0 +1,50 @@ +# Design + +## Goal + +The goal of this exercise is to teach the basics of `datetime` objects in Python. + +## Learning objectives + +- Understand ways to create `datetime` objects +- Understand the use of `strptime()` to parse strings into `datetime` +- Understand the use of `strftime()` to format string representations of `datetime` objects +- Understand how to isolate and use components such as `year` or `hour` + +## Out of scope + +- Anything related to timezones +- Working with `timedelta` + +## Concepts covered + +- `datetime.date` +- `strptime()` +- `strftime()` + +## Prerequisites + +- `string-formatting` +- `functions` +- `classes` + +## Resources to refer to + +TODO + +### Hints + +TODO + +## Concept Description + +TODO + +## Implementing + +The general Python track concept exercise implantation guide can be found [here](https://github.com/exercism/v3/blob/master/languages/python/reference/implementing-a-concept-exercise.md). + +Tests should be written using `unittest.TestCase` and the test file named `generators_test.py`. + +Code in the `.meta/example.py` file should **only use syntax & concepts introduced in this exercise or one of its prerequisites.** + diff --git a/exercises/concept/booking-up-for-beauty/.meta/exemplar.py b/exercises/concept/booking-up-for-beauty/.meta/exemplar.py new file mode 100644 index 00000000000..b6e7e08db95 --- /dev/null +++ b/exercises/concept/booking-up-for-beauty/.meta/exemplar.py @@ -0,0 +1,68 @@ +from datetime import date, time, datetime + + +def schedule_numeric(appointment): + """ + Convert an all-numeric date/time string to a datetime object. + + :param appointment: string - representation of a date and time. + :return: datetime.datetime - a datetime object corresponding to the input string. + """ + + return datetime.strptime(appointment, '%m/%d/%Y %H:%M:%S') + + +def schedule_mixed(appointment): + """ + Convert a date/time string including text to a datetime object. + + :param appointment: string - representation of a date and time. + :return: datetime.datetime - a datetime object corresponding to the input string. + """ + + return datetime.strptime(appointment, '%A, %B %d, %Y %H:%M:%S') + + +def has_passed(appointment): + """ + Check if the appointment has already passed. + + :param appointment: datetime.datetime - The appointment to check. + :return: bool - was the appointment in the past? + """ + + return datetime.now() > appointment + + +def is_afternoon_appointment(appointment): + """ + Check if the given appointment is in the afternoon. + + :param appointment: The appointment to check. + :type appointment: datetime.datetime + :return: True if the appointment is in the afternoon, False otherwise. + :rtype: bool + """ + + return 12 <= appointment.hour < 18 + + +def description(appointment): + """ + Return appointment details as a customer-friendly text string. + + :param appointment: datetime.datetime + :return: string: The customer message + """ + + return "You have an appointment on " + appointment.strftime('%m/%d/%y %I:%M:%S %p.') + + +def anniversary(): + """ + Calculate this year's anniversary date. + + :return: datetime.date + """ + + return date(date.today().year, 9, 15) diff --git a/exercises/concept/booking-up-for-beauty/booking_up_for_beauty.py b/exercises/concept/booking-up-for-beauty/booking_up_for_beauty.py new file mode 100644 index 00000000000..e033960caa8 --- /dev/null +++ b/exercises/concept/booking-up-for-beauty/booking_up_for_beauty.py @@ -0,0 +1,68 @@ +from datetime import date, time, datetime + + +def schedule_numeric(appointment): + """ + Convert an all-numeric date/time string to a datetime object. + + :param appointment: string - representation of a date and time. + :return: datetime.datetime - a datetime object corresponding to the input string. + """ + + pass + + +def schedule_mixed(appointment): + """ + Convert a date/time string including text to a datetime object. + + :param appointment: string - representation of a date and time. + :return: datetime.datetime - a datetime object corresponding to the input string. + """ + + pass + + +def has_passed(appointment): + """ + Check if the appointment has already passed. + + :param appointment: datetime.datetime - The appointment to check. + :return: bool - was the appointment in the past? + """ + + pass + + +def is_afternoon_appointment(appointment): + """ + Check if the given appointment is in the afternoon. + + :param appointment: The appointment to check. + :type appointment: datetime.datetime + :return: True if the appointment is in the afternoon, False otherwise. + :rtype: bool + """ + + pass + + +def description(appointment): + """ + Return appointment details as a customer-friendly text string. + + :param appointment: datetime.datetime + :return: string: The customer message + """ + + pass + + +def anniversary(): + """ + Calculate this year's anniversary date. + + :return: datetime.date + """ + + pass diff --git a/exercises/concept/booking-up-for-beauty/booking_up_for_beauty_test.py b/exercises/concept/booking-up-for-beauty/booking_up_for_beauty_test.py new file mode 100644 index 00000000000..35f9688d96e --- /dev/null +++ b/exercises/concept/booking-up-for-beauty/booking_up_for_beauty_test.py @@ -0,0 +1,137 @@ +import unittest +import pytest +import datetime + +from booking_up_for_beauty import ( + schedule_numeric, + schedule_mixed, + has_passed, + is_afternoon_appointment, + description, + anniversary) + + +class BookingUpForBeautyTest(unittest.TestCase): + + @pytest.mark.task(taskno=1) + def test_schedule_numeric_date(self): + + test_data = ["07/25/2019 13:45:00",] + result_data = [datetime.datetime(2019, 7, 25, 13, 45, 0),] + + for variant, (date_string, expected) in enumerate(zip(test_data, result_data), start=1): + with self.subTest(f'variation #{variant}', date_string=date_string, expected=expected): + + actual_result = schedule_numeric(date_string) + error_message = (f'Called schedule_numeric({date_string}). ' + f'The function returned {actual_result}, but ' + f'the tests expected {expected}.') + + self.assertEqual(actual_result, expected, msg=error_message) + + @pytest.mark.task(taskno=2) + def test_schedule_mixed_date(self): + + test_data = ["Thursday, December 5, 2019 09:00:00",] + result_data = [datetime.datetime(2019, 12, 5, 9, 0, 0),] + + for variant, (date_string, expected) in enumerate(zip(test_data, result_data), start=1): + with self.subTest(f'variation #{variant}', date_string=date_string, expected=expected): + + actual_result = schedule_mixed(date_string) + error_message = (f'Called schedule({date_string}). ' + f'The function returned {actual_result}, but ' + f'the tests expected {expected}.') + + self.assertEqual(actual_result, expected, msg=error_message) + + @pytest.mark.task(taskno=3) + def test_has_passed(self): + + now = datetime.datetime.now() + test_data = [now + datetime.timedelta(weeks=-52, hours=2), + now + datetime.timedelta(weeks=-8), + now + datetime.timedelta(days=-23), + now + datetime.timedelta(hours=-12), + now + datetime.timedelta(minutes=-55), + now + datetime.timedelta(minutes=-1), + now + datetime.timedelta(minutes=1), + now + datetime.timedelta(minutes=5), + ] + result_data = [True, + True, + True, + True, + True, + True, + False, + False] + + for variant, (appointment, expected) in enumerate(zip(test_data, result_data), start=1): + with self.subTest(f'variation #{variant}', appointment=appointment, expected=expected): + + actual_result = has_passed(appointment) + error_message = (f'Called schedule({appointment}). ' + f'The function returned {actual_result}, but ' + f'the tests expected {expected}.') + + self.assertEqual(actual_result, expected, msg=error_message) + + @pytest.mark.task(taskno=4) + def test_is_afternoon_appointment(self): + + test_data = [datetime.datetime(2019, 6, 17, 8, 15, 0), + datetime.datetime(2019, 2, 23, 11, 59, 59), + datetime.datetime(2019, 8, 9, 12, 0, 0), + datetime.datetime(2019, 8, 9, 12, 0, 1), + datetime.datetime(2019, 9, 1, 17, 59, 59), + datetime.datetime(2019, 9, 1, 18, 0, 0), + datetime.datetime(2019, 9, 1, 23, 59, 59)] + result_data = [False, + False, + True, + True, + True, + False, + False] + + for variant, (appointment, expected) in enumerate(zip(test_data, result_data), start=1): + with self.subTest(f'variation #{variant}', appointment=appointment, expected=expected): + + actual_result = is_afternoon_appointment(appointment) + error_message = (f'Called schedule({appointment}). ' + f'The function returned {actual_result}, but ' + f'the tests expected {expected}.') + + self.assertEqual(actual_result, expected, msg=error_message) + + @pytest.mark.task(taskno=5) + def test_description(self): + + test_data = [datetime.datetime(2019, 3, 29, 15, 0, 0), + datetime.datetime(2019, 7, 25, 13, 45, 0), + datetime.datetime(2020, 9, 9, 9, 9, 9)] + result_data = ["You have an appointment on 03/29/19 03:00:00 PM.", + "You have an appointment on 07/25/19 01:45:00 PM.", + "You have an appointment on 09/09/20 09:09:09 AM."] + + for variant, (appointment, expected) in enumerate(zip(test_data, result_data), start=1): + with self.subTest(f'variation #{variant}', appointment=appointment, expected=expected): + + actual_result = description(appointment) + error_message = (f'Called schedule({appointment}). ' + f'The function returned {actual_result}, but ' + f'the tests expected {expected}.') + + self.assertEqual(actual_result, expected, msg=error_message) + + @pytest.mark.task(taskno=6) + def test_anniversary(self): + + actual_result = anniversary() + expected = datetime.date(datetime.datetime.now().year, 9, 15) + error_message = (f'Called anniversary()' + f'The function returned {actual_result}, but ' + f'the tests expected {expected}.') + + self.assertEqual(actual_result, expected, msg=error_message)