Files
typikon/tests/test_typikon.py
T
funkpower 11bbfddbe1 the Orthodox liturgical day, computed rather than fetched
Feasts, seasons and the fast for twenty-three jurisdictions, derived from
the paschalion and the fixed calendar so it needs no network and cannot go
stale.

The three things calendar code usually conflates are kept apart: the
paschalion that gives Pascha, the menologion the fixed feasts are read on,
and the fasting rule that is kept. Almost every Orthodox Church keeps the
Julian Paschalion; what divides them is the menologion, so one file of
commemorations indexed by ecclesiastical date serves old and new calendars
alike.

Six fasting levels rather than three, because the difference between
xerophagy and wine-and-oil is the difference the books actually make. Level
and obligation are carried separately: the Orthodox rule reports everything
as customary, and Ruthenian particular law reports both how a day is kept
and the far smaller minimum that binds.

The menologion covers all 366 days and is dedicated to the public domain.
Sets published by someone else are fetched, never redistributed.
2026-09-18 09:09:32 -04:00

231 lines
10 KiB
Python

"""Pinned against published calendars and against dates whose answer is not
open to interpretation."""
import datetime as dt
import pytest
from typikon import describe, gregorian_pascha, julian_pascha, resolve
from typikon.calendars import from_julian, julian_offset, to_julian
from typikon.day import week
from typikon.export import to_ics, to_json
from typikon.fasting import (DAIRY, FISH, NONE, OBLIGATORY, STRICT, WINE_OIL,
XEROPHAGY)
# ───────────────────────────── the paschalion ─────────────────────────────
@pytest.mark.parametrize("year,expected", [
(2024, "2024-05-05"), (2025, "2025-04-20"), (2026, "2026-04-12"),
(2027, "2027-05-02"), (2028, "2028-04-16"), (2030, "2030-04-28"),
])
def test_julian_pascha(year, expected):
assert julian_pascha(year).isoformat() == expected
@pytest.mark.parametrize("year,expected", [
(2024, "2024-03-31"), (2025, "2025-04-20"), (2026, "2026-04-05"),
(2027, "2027-03-28"), (2028, "2028-04-16"),
])
def test_gregorian_pascha(year, expected):
assert gregorian_pascha(year).isoformat() == expected
def test_the_two_paschalions_agree_in_2025_and_differ_in_2026():
assert julian_pascha(2025) == gregorian_pascha(2025)
assert (julian_pascha(2026) - gregorian_pascha(2026)).days == 7
# ──────────────────────────── calendar drift ──────────────────────────────
def test_julian_lags_thirteen_days_now_and_fourteen_after_2100():
assert julian_offset(dt.date(2026, 9, 18)) == 13
assert to_julian(dt.date(2026, 9, 18)) == (2026, 9, 5)
# Julian 2100 is a leap year and Gregorian 2100 is not, so the gap
# widens the day after the Julian 29 February — 15 March by the civil
# calendar. Nothing here hardcodes it.
assert julian_offset(dt.date(2100, 3, 14)) == 13
assert julian_offset(dt.date(2100, 3, 15)) == 14
def test_old_calendar_nativity_falls_on_7_january():
assert from_julian(2026, 12, 25) == dt.date(2027, 1, 7)
def test_old_and_new_disagree_about_the_day_not_the_date():
old = describe(dt.date(2027, 1, 7), "rocor")
new = describe(dt.date(2026, 12, 25), "goarch")
assert old.church_date == new.church_date == "25 December"
assert old.feast == new.feast
assert old.rank == new.rank == "great"
# ───────────────────────── the Orthodox fast ──────────────────────────────
def test_great_fast_weekdays_are_xerophagy_and_weekends_wine_and_oil():
# 2026: Orthodox Pascha 12 April, so Clean Monday is 23 February.
assert describe(dt.date(2026, 2, 25), "new").fast == XEROPHAGY # Wednesday
assert describe(dt.date(2026, 2, 28), "new").fast == WINE_OIL # Saturday
assert describe(dt.date(2026, 3, 1), "new").fast == WINE_OIL # Sunday
def test_the_two_strictest_days_of_the_year():
assert describe(dt.date(2026, 2, 23), "new").fast == STRICT # Clean Monday
assert describe(dt.date(2026, 4, 10), "new").fast == STRICT # Great Friday
# The one Saturday kept without oil.
assert describe(dt.date(2026, 4, 11), "new").fast == XEROPHAGY
def test_annunciation_and_palm_sunday_admit_fish_inside_the_great_fast():
ann = describe(dt.date(2026, 3, 25), "new")
assert ann.season == "Great Fast" and ann.fast == FISH
assert describe(dt.date(2026, 4, 5), "new").fast == FISH # Palm Sunday
def test_cheesefare_week_keeps_dairy_on_wednesday_and_friday():
d = describe(dt.date(2026, 2, 18), "new") # Wednesday
assert d.season == "Cheesefare Week" and d.fast == DAIRY
def test_the_fast_free_stretches():
assert describe(dt.date(2026, 4, 17), "new").fast == NONE # Bright Friday
assert describe(dt.date(2026, 1, 2), "new").fast == NONE # Christmastide
assert describe(dt.date(2026, 6, 3), "new").fast == NONE # week after Pentecost
# The week after the Publican and the Pharisee, kept fast-free precisely
# because the Pharisee boasted of fasting twice a week.
assert describe(dt.date(2026, 2, 6), "new").fast == NONE
def test_nativity_fast_tightens_for_its_last_five_days():
# A Saturday before 20 December admits fish; one after it does not.
assert describe(dt.date(2026, 12, 12), "new").fast == FISH
assert describe(dt.date(2026, 12, 26), "new").season != "Nativity Fast"
late = describe(dt.date(2026, 12, 19), "new")
assert late.fast == FISH
assert describe(dt.date(2026, 12, 22), "new").fast == WINE_OIL # Tuesday
assert describe(dt.date(2026, 12, 24), "new").fast == XEROPHAGY # the eve
def test_a_great_feast_lifts_an_ordinary_friday_to_fish():
d = describe(dt.date(2026, 8, 6), "goarch") # Transfiguration
assert d.weekday == "Thursday" and d.fast == FISH
# 2026-01-01, the Circumcision, is a Thursday; take a great feast on a
# Wednesday instead — the Meeting of our Lord, 2 February 2028.
m = describe(dt.date(2028, 2, 2), "goarch")
assert m.weekday == "Wednesday" and m.rank == "great" and m.fast == FISH
def test_ordinary_wednesday_and_friday_are_xerophagy():
d = describe(dt.date(2026, 9, 4), "new") # a Friday
assert d.season is None and d.rank is None and d.fast == XEROPHAGY
def test_a_polyeleos_saint_lifts_an_ordinary_friday_to_wine_and_oil():
# 9 October 2026 is a Friday and the Apostle James son of Alphaeus. The
# Typikon gives wine and oil for a saint of this rank, which is why the
# published calendars mark so many Fridays that way.
d = describe(dt.date(2026, 10, 9), "new")
assert d.weekday == "Friday" and d.rank == "polyeleos"
assert d.fast == WINE_OIL
def test_the_orthodox_rule_never_reports_an_obligation():
days = week(dt.date(2026, 1, 1), 365, "new")
assert not any(d.obligation == OBLIGATORY for d in days)
# ──────────────────── Byzantine Catholic particular law ───────────────────
def test_ruthenian_pascha_is_the_gregorian_one():
assert describe(dt.date(2026, 6, 1), "ruthenian").pascha == dt.date(2026, 4, 5)
def test_ruthenian_clean_monday_is_16_february_2026():
# As published by the Metropolitan Cantor Institute.
assert describe(dt.date(2026, 2, 16), "ruthenian").reason.startswith(
"the first day")
def test_only_four_kinds_of_day_bind_under_ruthenian_law():
obligatory = [d for d in week(dt.date(2026, 1, 1), 365, "ruthenian")
if d.obligation == OBLIGATORY]
# Clean Monday, Great Friday, and the Wednesdays and Fridays of the Fast.
assert dt.date(2026, 2, 16) in [d.date for d in obligatory]
assert dt.date(2026, 4, 3) in [d.date for d in obligatory]
assert all(d.season in ("Great Fast", "Great and Holy Week")
for d in obligatory)
def test_the_voluntary_seasons_never_report_as_binding():
for when in (dt.date(2026, 11, 20), dt.date(2026, 8, 5)):
assert describe(when, "ruthenian").obligation == "customary"
def test_christmas_on_a_friday_does_not_ask_you_to_abstain():
d = describe(dt.date(2026, 12, 25), "ruthenian")
assert d.weekday == "Friday" and d.fast == NONE
def test_ruthenian_strict_days_bind_strictly_and_the_rest_bind_to_meat():
assert describe(dt.date(2026, 2, 16), "ruthenian").binds == WINE_OIL
assert describe(dt.date(2026, 4, 3), "ruthenian").binds == WINE_OIL
assert describe(dt.date(2026, 2, 18), "ruthenian").binds == DAIRY
assert describe(dt.date(2026, 10, 9), "ruthenian").binds == NONE
# ──────────────────────────── the menologion ──────────────────────────────
def test_every_day_of_a_leap_year_has_a_commemoration():
for d in week(dt.date(2024, 1, 1), 366, "new"):
assert d.commemorations, d.date
def test_the_twelve_great_feasts_are_all_present():
fixed = [(9, 8), (9, 14), (11, 21), (12, 25), (1, 6), (2, 2), (3, 25),
(8, 6), (8, 15)]
for month, day in fixed:
d = describe(dt.date(2026, month, day), "goarch")
assert d.rank == "great", (month, day)
for when in (dt.date(2026, 4, 5), dt.date(2026, 5, 21), dt.date(2026, 5, 31)):
assert describe(when, "goarch").rank == "great", when
def test_a_movable_feast_outranks_the_fixed_calendar():
d = describe(dt.date(2026, 4, 12), "new")
assert "Pascha" in d.feast
# ──────────────────────────────── output ──────────────────────────────────
def test_json_round_trips():
import json
days = week(dt.date(2026, 4, 10), 3, "new")
parsed = json.loads(to_json(days))
assert len(parsed["days"]) == 3
assert parsed["days"][0]["fast"] == STRICT
def test_ics_is_well_formed_and_folds_long_lines():
text = to_ics(week(dt.date(2026, 4, 10), 7, "new"))
assert text.startswith("BEGIN:VCALENDAR")
assert text.rstrip().endswith("END:VCALENDAR")
assert text.count("BEGIN:VEVENT") == text.count("END:VEVENT")
for line in text.split("\r\n"):
assert len(line.encode()) < 200
# ─────────────────────────── every jurisdiction ───────────────────────────
@pytest.mark.parametrize("key", list(__import__("typikon").JURISDICTIONS))
def test_every_jurisdiction_reckons_a_whole_year_without_raising(key):
days = week(dt.date(2026, 1, 1), 365, key)
assert len(days) == 365
assert all(d.fast for d in days)
def test_a_calendar_name_resolves_without_naming_a_church():
assert resolve("old").menologion == "julian"
assert resolve("new").menologion == "revised_julian"
assert resolve("western").paschalion == "gregorian"
assert resolve(None).key == "new"