Files
typikon/tests/test_typikon.py
T

369 lines
16 KiB
Python
Raw Normal View History

"""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, year_of
from typikon.export import to_ics, to_json
from typikon.fasting import (DAIRY, FISH, NONE, OBLIGATORY, STRICT, WINE_OIL,
XEROPHAGY, stricter)
# ───────────────────────────── 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"
# ─────────────────── regressions, each from a real defect ──────────────────
def test_the_annunciation_is_never_lost_to_great_friday():
"""It can fall on Great Friday or Great Saturday, and when it does the
feast lifts the total fast rather than disappearing into it."""
for when, jurisdiction in ((dt.date(2016, 3, 25), "western"),
(dt.date(2005, 3, 25), "western"),
(dt.date(2029, 4, 7), "rocor"),
(dt.date(2018, 4, 7), "rocor")):
d = describe(when, jurisdiction)
assert d.church_date == "25 March", when
assert "Annunciation" in d.feast, when
assert d.rank == "great", when
assert d.fast == WINE_OIL, (when, d.fast)
def test_kyriopascha_and_the_annunciation_in_bright_week_stay_fast_free():
assert describe(dt.date(2035, 3, 25), "western").fast == NONE
assert describe(dt.date(2075, 4, 7), "rocor").fast == NONE
def test_palm_sunday_and_lazarus_saturday_are_still_the_fast():
"""Both fall between the Forty Days and Great Week. Leaving that gap
outside every season made the Byzantine Catholic rule report no fast at
all on Palm Sunday."""
for jurisdiction in ("ruthenian", "ugcc", "melkite", "goarch", "rocor"):
for year in (2025, 2026, 2027):
mv = year_of(year, jurisdiction)
for when in (mv["lazarus_saturday"], mv["palm_sunday"]):
d = describe(when, jurisdiction)
assert d.season is not None, (jurisdiction, when)
assert d.fast != NONE, (jurisdiction, when, d.fast)
assert d.reason, (jurisdiction, when)
def test_a_ranked_saint_relaxes_a_weekday_of_a_fasting_season():
"""The rank is why the printed calendars mark so many Wednesdays in the
Nativity Fast for wine and oil."""
# The Nativity of the Forerunner, a great feast inside the Apostles' Fast.
assert describe(dt.date(2020, 6, 24), "goarch").fast == FISH
# The Forty Martyrs of Sebaste, the textbook relaxation of a Lenten weekday.
assert describe(dt.date(2022, 3, 9), "goarch").fast == WINE_OIL
# St Nicholas, a vigil saint on a Wednesday of the Nativity Fast.
assert describe(dt.date(2023, 12, 6), "goarch").fast == WINE_OIL
def test_no_saint_brings_fish_into_the_great_fast_or_lifts_great_week():
for d in week(dt.date(2022, 3, 7), 40, "goarch"):
if d.season == "Great Fast" and d.church_date != "25 March":
assert not stricter(FISH, d.fast) or d.fast != FISH, d.date
if d.season == "Great and Holy Week" and d.church_date != "25 March":
assert d.fast in (STRICT, XEROPHAGY, WINE_OIL), (d.date, d.fast)
def test_the_eves_relax_when_they_fall_on_a_weekend():
"""The Liturgy of St Basil is served with Vespers, so the eve is kept at
wine and oil rather than xerophagy."""
assert describe(dt.date(2022, 12, 24), "goarch").fast == WINE_OIL
assert describe(dt.date(2023, 12, 24), "goarch").fast == WINE_OIL
assert describe(dt.date(2025, 1, 5), "goarch").fast == WINE_OIL
# A weekday eve is still xerophagy.
assert describe(dt.date(2024, 12, 24), "goarch").fast == XEROPHAGY
def test_the_apostles_fast_is_empty_when_pascha_is_late():
"""A late Pascha can leave the fast with no days at all, which is correct
rather than a bug -- and the old calendar never has that problem."""
goarch = [d for d in week(dt.date(2024, 5, 1), 120, "goarch")
if d.season == "Apostles' Fast"]
rocor = [d for d in week(dt.date(2024, 5, 1), 120, "rocor")
if d.season == "Apostles' Fast"]
assert goarch == []
assert len(rocor) > 0
# ──────────────────────── invariants, not samples ──────────────────────────
def test_the_paschalion_invariants_hold_for_two_centuries():
for year in range(1900, 2101):
j, g = julian_pascha(year), gregorian_pascha(year)
assert j.weekday() == 6, year
assert g.weekday() == 6, year
assert dt.date(year, 4, 4) <= j <= dt.date(year, 5, 8), (year, j)
assert dt.date(year, 3, 22) <= g <= dt.date(year, 4, 25), (year, g)
def test_every_civil_date_round_trips_through_the_julian_calendar():
day, last = dt.date(1800, 1, 1), dt.date(2200, 12, 31)
while day <= last:
y, m, d = to_julian(day)
assert from_julian(y, m, d) == day, day
day += dt.timedelta(1)
def test_the_julian_offset_widens_the_day_after_a_julian_leap_day():
for before, after in ((dt.date(1900, 3, 13), dt.date(1900, 3, 14)),
(dt.date(2100, 3, 14), dt.date(2100, 3, 15)),
(dt.date(2200, 3, 15), dt.date(2200, 3, 16))):
assert julian_offset(after) == julian_offset(before) + 1, after
def test_ics_content_lines_stay_within_seventy_five_octets():
for jurisdiction in ("goarch", "rocor", "ruthenian"):
text = to_ics(week(dt.date(2026, 1, 1), 365, jurisdiction))
for line in text.split("\r\n"):
assert len(line.encode()) <= 75, (jurisdiction, line)
def test_every_jurisdiction_holds_together_for_twenty_years():
"""The regression net. Replaces a loop that asserted only truthiness and
so could not fail: "none" is a truthy string."""
from typikon import JURISDICTIONS
from typikon.fasting import LEVELS
for key in JURISDICTIONS:
for d in week(dt.date(2020, 1, 1), 366 * 21 // 4, key):
assert d.fast in LEVELS, (key, d.date, d.fast)
assert d.binds in LEVELS, (key, d.date, d.binds)
assert d.commemorations, (key, d.date)
assert not stricter(d.binds, d.fast), (key, d.date)
assert d.title, (key, d.date)
# ──────────────────────────────── the CLI ──────────────────────────────────
def test_the_cli_rejects_a_malformed_date_instead_of_traversing_a_traceback():
from typikon.cli import main
assert main(["-d", "tomorrow"]) == 2
assert main(["-d", "2026-02-30"]) == 2
assert main(["--year", "99999"]) == 2
assert main(["--jurisdiction", "atlantis"]) == 2
def test_the_cli_elides_on_a_word():
from typikon.cli import _elide
assert _elide("The Radiant and Life-giving Resurrection", 24) == "The Radiant and…"
assert _elide("Short", 24) == "Short"