mirror of
https://git.pumpin.online/funkpower/typikon.git
synced 2026-10-22 18:57:13 +00:00
An adversarial pass found the rules throwing away the feast rank that
describe() computes, in exactly the seasons where it decides the answer.
- A saint of vigil or polyeleos rank now lifts a weekday of a fast, to wine
and oil inside the Great Fast and as far as fish in the three lesser
ones. This is why the printed calendars mark so many Wednesdays in the
Nativity Fast; 74 days across 2020-2030 were being reported stricter than
any published calendar keeps them, St Nicholas and the Forty Martyrs of
Sebaste among them.
- The Annunciation was tested after Great Friday and Great Saturday, so on
the years it lands there it vanished into them entirely -- the Great Feast
of the Incarnation demoted to a bullet, reported as a total fast, and its
rank overwritten with the lesser movable one the rules then read. It is
now tested first and lifts those days to wine and oil. Five such years
this century on the Gregorian paschalion, seven on the Julian.
- Lazarus Saturday and Palm Sunday belonged to no season at all, sitting
between the Forty Days and Great Week. Any rule keying on the season fell
through, so Byzantine Catholic jurisdictions reported a meat glyph and
'no fast' on Palm Sunday.
- The eves of the Nativity and Theophany are relaxed to wine and oil when
they fall on a weekend, which ruthenian.py already implemented and this
module did not.
Also: iCalendar folded on characters where RFC 5545 counts octets, so an
emoji put lines up to 80 octets over a 75-octet limit; a malformed --date
printed a traceback; the overlaid menologion was rebuilt for every single day
asked about; the Nativity of the Forerunner was ranked below its class.
Fourteen new tests, each one able to fail. The old sweep asserted only
all(d.fast), and 'none' is a truthy string.
369 lines
16 KiB
Python
369 lines
16 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, 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"
|