mirror of
https://git.pumpin.online/funkpower/typikon.git
synced 2026-10-23 03:07:12 +00:00
231 lines
10 KiB
Python
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"
|