Repository: peopledoc/workalendar
Pull request #636 (2021-03-05 13:07:26)

Bugfix: USA calendar would take the `shift_exceptions` into account
refs #610

- [x] Tests with a significant number of years to be tested for your calendar.
- [x] Changelog amended with a mention describing your changes.



Patch:
diff --git a/Changelog.md b/Changelog.md
index 29550e1..4029cb3 100644
--- a/Changelog.md
+++ b/Changelog.md
@@ -2,7 +2,7 @@
 
 ## master (unreleased)
 
-Nothing here yet.
+- Bugfix: USA calendar would take the `shift_exceptions` into account, even if the exceptions are set in the next or previous year (#610).
 
 ## v15.0.1 (2021-02-26)
 
diff --git a/workalendar/usa/core.py b/workalendar/usa/core.py
index 6c99552..3f2ba9e 100644
--- a/workalendar/usa/core.py
+++ b/workalendar/usa/core.py
@@ -65,16 +65,23 @@ class UnitedStates(WesternCalendar):
     # Shift day mechanism
     # These days won't be shifted to next MON or previous FRI
     shift_exceptions = (
-        # Exemple:
+        # Example:
         # (11, 11),  # Veterans day won't be shifted
     )
 
     def shift(self, holidays, year):
+        """
+        Shift all holidays of the year, according to the shifting rules.
+        """
         new_holidays = []
         holiday_lookup = [x[0] for x in holidays]
-        exceptions = [
-            date(year, month, day) for month, day in self.shift_exceptions
-        ]
+        exceptions = []
+        if self.shift_exceptions:
+            exceptions = [
+                *[date(year - 1, m, d) for m, d in self.shift_exceptions],
+                *[date(year, m, d) for m, d in self.shift_exceptions],
+                *[date(year + 1, m, d) for m, d in self.shift_exceptions]
+            ]
 
         # For each holiday available:
         # * if it falls on SUN, add the observed on MON
@@ -84,16 +91,18 @@ class UnitedStates(WesternCalendar):
             if day in exceptions:
                 continue
             if day.weekday() == SAT:
-                new_holidays.append((day - timedelta(days=1),
-                                     f"{label} (Observed)"))
+                new_holidays.append(
+                    (day - timedelta(days=1), f"{label} (Observed)"))
             elif day.weekday() == SUN:
-                new_holidays.append((day + timedelta(days=1),
-                                     f"{label} (Observed)"))
+                new_holidays.append(
+                    (day + timedelta(days=1), f"{label} (Observed)"))
 
         # If year+1 January the 1st is on SAT, add the FRI before to observed
-        if date(year + 1, 1, 1).weekday() == SAT:
-            new_holidays.append((date(year, 12, 31,),
-                                 "New Years Day (Observed)"))
+        next_year_jan_1st = date(year + 1, 1, 1)
+        if next_year_jan_1st.weekday() == SAT and \
+                next_year_jan_1st not in exceptions:
+            new_holidays.append(
+                (date(year, 12, 31,), "New Years Day (Observed)"))
 
         # Special rules for XMas and XMas Eve
         christmas = date(year, 12, 25)
@@ -106,16 +115,16 @@ class UnitedStates(WesternCalendar):
                 new_holidays.remove(
                     (christmas_eve, "Christmas Day (Observed)")
                 )
-                new_holidays.append((date(year, 12, 23),
-                                     "Christmas Eve (Observed)"))
+                new_holidays.append(
+                    (date(year, 12, 23), "Christmas Eve (Observed)"))
             # You are observing the 26th (TUE)
             elif christmas.weekday() == MON:
                 # Remove the "fake" XMAS Eve shift, done before
                 new_holidays.remove(
                     (christmas, "Christmas Eve (Observed)")
                 )
-                new_holidays.append((date(year, 12, 26),
-                                     "Christmas Day (Observed)"))
+                new_holidays.append(
+                    (date(year, 12, 26), "Christmas Day (Observed)"))
         return holidays + new_holidays
 
     @staticmethod


Test patch:
diff --git a/workalendar/tests/test_usa.py b/workalendar/tests/test_usa.py
index c92146d..eeec024 100644
--- a/workalendar/tests/test_usa.py
+++ b/workalendar/tests/test_usa.py
@@ -1874,15 +1874,15 @@ class NormalShiftTestCase(UnitedStatesTest):
         self.assertEqual(holiday_dict[dec_26th], "Christmas Day (Observed)")
 
 
-class NormalShiftTestCaseExceptions(UnitedStatesTest):
+class ShiftExceptionsTestCase(UnitedStatesTest):
     # Using a fake calendar here
-    class NormalShiftUnitedStatesExceptions(UnitedStates):
+    class ShiftExceptionsCalendar(UnitedStates):
         "Normal Shift Fake United State calendar"
         shift_exceptions = (
             (7, 4),  # Month/Day == Fourth of July.
         )
 
-    cal_class = NormalShiftUnitedStatesExceptions
+    cal_class = ShiftExceptionsCalendar
 
     def test_shift_2015(self):
         # Test a normal shift on 4th of July.
@@ -1905,3 +1905,29 @@ class NormalShiftTestCaseExceptions(UnitedStatesTest):
         self.assertIn(fourth_july, holiday_dict)
         self.assertEqual(holiday_dict[fourth_july], "Independence Day")
         self.assertNotIn(observed, holiday_dict)
+
+
+class ShiftExceptionsNextYearTestCase(UnitedStatesTest):
+    class ShiftExceptionsCalendar(UnitedStates):
+        "Shift exception happens on jan 1st and XMas"
+        shift_exceptions = (
+            (1, 1),  # January 1st
+            (12, 25),  # Christmas Day
+        )
+
+    cal_class = ShiftExceptionsCalendar
+
+    def test_shift_2021(self):
+        # January 1st is a saturday in 2022
+        # As a consequence, Dec 31st should be a holiday.
+        # BUT here, January 1st is in the shift_exceptions,
+        # so 2021-12-31 should be a working day
+        holidays = self.cal.holidays_set(2021)
+        # XMas is a holiday
+        self.assertIn(date(2021, 12, 25), holidays)
+        # XMas eve is a working day
+        self.assertNotIn(date(2021, 12, 24), holidays)
+
+        # January 1st is a non-shift, Dec 31st should be a working day
+        self.assertNotIn(date(2021, 12, 31), holidays)
+        self.assertTrue(self.cal.is_working_day(date(2021, 12, 31)))