mirror of
https://git.pumpin.online/funkpower/typikon.git
synced 2026-10-23 11:07:16 +00:00
rebuild the engine in Rust
Python was never the house language here: the mw-* desktop surface it came
from is 39 bash scripts and was exactly one Python script, the one this was
extracted from. What it cost was distribution, a widget dependency that
needed pipx and a Python floor, and 24ms of interpreter startup before a line
of the program ran.
The port is verified rather than asserted. Both implementations were run over
199,446 day-records, 26 jurisdictions across 21 years, with every field of
every record required to match; the JSON, the iCalendar and 480 renderings of
the text output are identical too. examples/dump.rs is the harness.
What the type system actually buys, which is less than the usual claim and
more than nothing:
- Level is an ordered enum, strictest first, so reads as 'a is
stricter' and relaxed_to() is the one place a feast can lift a fast. No
rule can get the direction backwards.
- Date is a newtype over the Julian Day Number and JulianDate is a separate
type, so a civil date and an ecclesiastical one cannot be mixed up. That
confusion is the single most common way calendar code goes wrong.
- Octets is a newtype in the iCalendar folder, because the bug it replaced
counted characters against a limit RFC 5545 states in octets.
- Movables is a struct, not a map, so a mistyped key is a compile error
rather than a None at the far end of the program.
Of the six defects an adversarial review found in the Python, exactly one had
a shape a type system would have caught. The rest were logic, and the tests
came with them.
The menologion is compiled in with include_str!, so the binary answers on its
own: 930KB, no runtime, 1.2ms to start against Python's 36ms.
This commit is contained in:
@@ -1,368 +0,0 @@
|
||||
"""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"
|
||||
@@ -0,0 +1,408 @@
|
||||
//! Pinned against published calendars and against dates whose answer is not
|
||||
//! open to interpretation.
|
||||
|
||||
use typikon::calendars::{Date, JulianDate, Weekday};
|
||||
use typikon::day::{describe, week, year_of};
|
||||
use typikon::export::to_ics;
|
||||
use typikon::fasting::{Level, Obligation};
|
||||
use typikon::jurisdictions::{resolve, Calendar, MenologionKind, CALENDARS, JURISDICTIONS};
|
||||
use typikon::menologion::Rank;
|
||||
use typikon::paschalion::{gregorian_pascha, julian_pascha, Paschalion};
|
||||
use typikon::seasons::Season;
|
||||
|
||||
fn d(s: &str) -> Date {
|
||||
Date::parse_iso(s).expect("a real date")
|
||||
}
|
||||
|
||||
fn at(s: &str, j: &str) -> typikon::day::Day {
|
||||
describe(d(s), resolve(Some(j)).expect("a real jurisdiction"))
|
||||
}
|
||||
|
||||
// ───────────────────────────── the paschalion ─────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn julian_pascha_matches_the_published_dates() {
|
||||
for (year, expected) in [
|
||||
(2024, "2024-05-05"), (2025, "2025-04-20"), (2026, "2026-04-12"),
|
||||
(2027, "2027-05-02"), (2028, "2028-04-16"), (2030, "2030-04-28"),
|
||||
] {
|
||||
assert_eq!(julian_pascha(year).iso(), expected, "julian pascha {year}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gregorian_pascha_matches_the_published_dates() {
|
||||
for (year, expected) in [
|
||||
(2024, "2024-03-31"), (2025, "2025-04-20"), (2026, "2026-04-05"),
|
||||
(2027, "2027-03-28"), (2028, "2028-04-16"),
|
||||
] {
|
||||
assert_eq!(gregorian_pascha(year).iso(), expected, "gregorian pascha {year}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_two_paschalions_agree_in_2025_and_differ_by_a_week_in_2026() {
|
||||
assert_eq!(julian_pascha(2025), gregorian_pascha(2025));
|
||||
assert_eq!(julian_pascha(2026) - gregorian_pascha(2026), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_paschalion_invariants_hold_for_two_centuries() {
|
||||
for year in 1900..=2100 {
|
||||
let (j, g) = (julian_pascha(year), gregorian_pascha(year));
|
||||
assert_eq!(j.weekday(), Weekday::Sunday, "julian {year}");
|
||||
assert_eq!(g.weekday(), Weekday::Sunday, "gregorian {year}");
|
||||
assert!(
|
||||
Date::from_ymd(year, 4, 4).unwrap() <= j && j <= Date::from_ymd(year, 5, 8).unwrap(),
|
||||
"orthodox pascha {year} out of range: {j}"
|
||||
);
|
||||
assert!(
|
||||
Date::from_ymd(year, 3, 22).unwrap() <= g && g <= Date::from_ymd(year, 4, 25).unwrap(),
|
||||
"western pascha {year} out of range: {g}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────── calendar drift ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn every_civil_date_round_trips_through_the_julian_calendar() {
|
||||
let mut day = d("1800-01-01");
|
||||
let last = d("2200-12-31");
|
||||
while day <= last {
|
||||
let j = day.to_julian();
|
||||
assert_eq!(j.to_civil(), day, "round trip {day}");
|
||||
day = day + 1;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn julian_lags_thirteen_days_now() {
|
||||
assert_eq!(d("2026-09-18").julian_offset(), 13);
|
||||
let j = d("2026-09-18").to_julian();
|
||||
assert_eq!((j.year, j.month, j.day), (2026, 9, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_julian_offset_widens_the_day_after_a_julian_leap_day() {
|
||||
for (before, after) in [
|
||||
("1900-03-13", "1900-03-14"),
|
||||
("2100-03-14", "2100-03-15"),
|
||||
("2200-03-15", "2200-03-16"),
|
||||
] {
|
||||
assert_eq!(
|
||||
d(after).julian_offset(),
|
||||
d(before).julian_offset() + 1,
|
||||
"offset should widen at {after}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_calendar_nativity_falls_on_7_january() {
|
||||
assert_eq!(
|
||||
JulianDate { year: 2026, month: 12, day: 25 }.to_civil().iso(),
|
||||
"2027-01-07"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_and_new_disagree_about_the_day_not_the_date() {
|
||||
let old = at("2027-01-07", "rocor");
|
||||
let new = at("2026-12-25", "goarch");
|
||||
assert_eq!(old.church_date, "25 December");
|
||||
assert_eq!(new.church_date, "25 December");
|
||||
assert_eq!(old.feast, new.feast);
|
||||
assert_eq!(old.rank, Some(Rank::Great));
|
||||
assert_eq!(new.rank, Some(Rank::Great));
|
||||
}
|
||||
|
||||
// ───────────────────────── the Orthodox fast ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn great_fast_weekdays_are_xerophagy_and_weekends_wine_and_oil() {
|
||||
assert_eq!(at("2026-02-25", "new").fast, Level::Xerophagy);
|
||||
assert_eq!(at("2026-02-28", "new").fast, Level::WineOil);
|
||||
assert_eq!(at("2026-03-01", "new").fast, Level::WineOil);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_two_strictest_days_of_the_year() {
|
||||
assert_eq!(at("2026-02-23", "new").fast, Level::Strict);
|
||||
assert_eq!(at("2026-04-10", "new").fast, Level::Strict);
|
||||
// The one Saturday kept without oil.
|
||||
assert_eq!(at("2026-04-11", "new").fast, Level::Xerophagy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annunciation_and_palm_sunday_admit_fish_inside_the_great_fast() {
|
||||
let a = at("2026-03-25", "new");
|
||||
assert_eq!(a.season, Some(Season::GreatFast));
|
||||
assert_eq!(a.fast, Level::Fish);
|
||||
assert_eq!(at("2026-04-05", "new").fast, Level::Fish);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_annunciation_is_never_lost_to_great_friday() {
|
||||
for (when, j) in [
|
||||
("2016-03-25", "western"), ("2005-03-25", "western"),
|
||||
("2029-04-07", "rocor"), ("2018-04-07", "rocor"),
|
||||
] {
|
||||
let day = at(when, j);
|
||||
assert_eq!(day.church_date, "25 March", "{when}");
|
||||
assert!(day.feast.as_deref().unwrap_or("").contains("Annunciation"), "{when}");
|
||||
assert_eq!(day.rank, Some(Rank::Great), "{when}");
|
||||
assert_eq!(day.fast, Level::WineOil, "{when}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kyriopascha_and_the_annunciation_in_bright_week_stay_fast_free() {
|
||||
assert_eq!(at("2035-03-25", "western").fast, Level::None);
|
||||
assert_eq!(at("2075-04-07", "rocor").fast, Level::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palm_sunday_and_lazarus_saturday_are_still_the_fast() {
|
||||
for j in ["ruthenian", "ugcc", "melkite", "goarch", "rocor"] {
|
||||
for year in [2025, 2026, 2027] {
|
||||
let jur = resolve(Some(j)).unwrap();
|
||||
let (mv, _) = year_of(year, jur);
|
||||
for when in [mv.lazarus_saturday, mv.palm_sunday] {
|
||||
let day = describe(when, jur);
|
||||
assert!(day.season.is_some(), "{j} {when}: no season");
|
||||
assert_ne!(day.fast, Level::None, "{j} {when}");
|
||||
assert!(!day.reason.is_empty(), "{j} {when}: no reason");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ranked_saint_relaxes_a_weekday_of_a_fasting_season() {
|
||||
// The Nativity of the Forerunner, a great feast inside the Apostles' Fast.
|
||||
assert_eq!(at("2020-06-24", "goarch").fast, Level::Fish);
|
||||
// The Forty Martyrs of Sebaste, the textbook Lenten relaxation.
|
||||
assert_eq!(at("2022-03-09", "goarch").fast, Level::WineOil);
|
||||
// St Nicholas, a vigil saint on a Wednesday of the Nativity Fast.
|
||||
assert_eq!(at("2023-12-06", "goarch").fast, Level::WineOil);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_saint_brings_fish_into_the_great_fast_or_lifts_great_week() {
|
||||
for day in week(d("2022-03-07"), 40, resolve(Some("goarch")).unwrap()) {
|
||||
if day.church_date == "25 March" {
|
||||
continue;
|
||||
}
|
||||
if day.season == Some(Season::GreatFast) {
|
||||
assert!(day.fast >= Level::Strict, "{}", day.date);
|
||||
assert!(day.fast <= Level::WineOil, "{} got {:?}", day.date, day.fast);
|
||||
}
|
||||
if day.season == Some(Season::GreatWeek) {
|
||||
assert!(day.fast <= Level::WineOil, "{} got {:?}", day.date, day.fast);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_eves_relax_when_they_fall_on_a_weekend() {
|
||||
assert_eq!(at("2022-12-24", "goarch").fast, Level::WineOil);
|
||||
assert_eq!(at("2023-12-24", "goarch").fast, Level::WineOil);
|
||||
assert_eq!(at("2025-01-05", "goarch").fast, Level::WineOil);
|
||||
// A weekday eve is still xerophagy.
|
||||
assert_eq!(at("2024-12-24", "goarch").fast, Level::Xerophagy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cheesefare_week_keeps_dairy_on_wednesday_and_friday() {
|
||||
let day = at("2026-02-18", "new");
|
||||
assert_eq!(day.season, Some(Season::CheesefareWeek));
|
||||
assert_eq!(day.fast, Level::Dairy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fast_free_stretches() {
|
||||
assert_eq!(at("2026-04-17", "new").fast, Level::None); // Bright Friday
|
||||
assert_eq!(at("2026-01-02", "new").fast, Level::None); // Christmastide
|
||||
assert_eq!(at("2026-06-03", "new").fast, Level::None); // after Pentecost
|
||||
// Fast-free precisely because the Pharisee boasted of fasting twice a week.
|
||||
assert_eq!(at("2026-02-06", "new").fast, Level::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nativity_fast_tightens_for_its_last_five_days() {
|
||||
assert_eq!(at("2026-12-12", "new").fast, Level::Fish);
|
||||
assert_eq!(at("2026-12-19", "new").fast, Level::Fish);
|
||||
assert_eq!(at("2026-12-22", "new").fast, Level::WineOil);
|
||||
assert_eq!(at("2026-12-24", "new").fast, Level::Xerophagy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_wednesday_and_friday_are_xerophagy() {
|
||||
let day = at("2026-09-04", "new");
|
||||
assert!(day.season.is_none());
|
||||
assert!(day.rank.is_none());
|
||||
assert_eq!(day.fast, Level::Xerophagy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_polyeleos_saint_lifts_an_ordinary_friday_to_wine_and_oil() {
|
||||
let day = at("2026-10-09", "new");
|
||||
assert_eq!(day.weekday, Weekday::Friday);
|
||||
assert_eq!(day.rank, Some(Rank::Polyeleos));
|
||||
assert_eq!(day.fast, Level::WineOil);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_apostles_fast_is_empty_when_pascha_is_late() {
|
||||
let goarch: Vec<_> = week(d("2024-05-01"), 120, resolve(Some("goarch")).unwrap())
|
||||
.into_iter().filter(|x| x.season == Some(Season::Apostles)).collect();
|
||||
let rocor: Vec<_> = week(d("2024-05-01"), 120, resolve(Some("rocor")).unwrap())
|
||||
.into_iter().filter(|x| x.season == Some(Season::Apostles)).collect();
|
||||
assert!(goarch.is_empty(), "goarch 2024 should have no Apostles' Fast");
|
||||
assert!(!rocor.is_empty(), "rocor 2024 should still have one");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_orthodox_rule_never_reports_an_obligation() {
|
||||
for day in week(d("2026-01-01"), 365, resolve(Some("new")).unwrap()) {
|
||||
assert_ne!(day.obligation, Some(Obligation::Obligatory), "{}", day.date);
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────── Byzantine Catholic particular law ───────────────────
|
||||
|
||||
#[test]
|
||||
fn ruthenian_pascha_is_the_gregorian_one() {
|
||||
assert_eq!(at("2026-06-01", "ruthenian").pascha.iso(), "2026-04-05");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruthenian_clean_monday_is_16_february_2026() {
|
||||
assert!(at("2026-02-16", "ruthenian").reason.starts_with("the first day"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_great_fast_binds_under_ruthenian_law() {
|
||||
let obligatory: Vec<_> = week(d("2026-01-01"), 365, resolve(Some("ruthenian")).unwrap())
|
||||
.into_iter().filter(|x| x.is_obligatory()).collect();
|
||||
let dates: Vec<String> = obligatory.iter().map(|x| x.date.iso()).collect();
|
||||
assert!(dates.contains(&"2026-02-16".to_string()));
|
||||
assert!(dates.contains(&"2026-04-03".to_string()));
|
||||
for day in &obligatory {
|
||||
assert!(
|
||||
matches!(day.season, Some(Season::GreatFast) | Some(Season::GreatWeek)),
|
||||
"{} binds outside the Fast", day.date
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_voluntary_seasons_never_report_as_binding() {
|
||||
for when in ["2026-11-20", "2026-08-05"] {
|
||||
assert_eq!(at(when, "ruthenian").obligation, Some(Obligation::Customary));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn christmas_on_a_friday_does_not_ask_you_to_abstain() {
|
||||
let day = at("2026-12-25", "ruthenian");
|
||||
assert_eq!(day.weekday, Weekday::Friday);
|
||||
assert_eq!(day.fast, Level::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruthenian_strict_days_bind_strictly_and_the_rest_bind_to_meat() {
|
||||
assert_eq!(at("2026-02-16", "ruthenian").binds, Level::WineOil);
|
||||
assert_eq!(at("2026-04-03", "ruthenian").binds, Level::WineOil);
|
||||
assert_eq!(at("2026-02-18", "ruthenian").binds, Level::Dairy);
|
||||
assert_eq!(at("2026-10-09", "ruthenian").binds, Level::None);
|
||||
}
|
||||
|
||||
// ──────────────────────────── the menologion ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn every_day_of_a_leap_year_has_a_commemoration() {
|
||||
for day in week(d("2024-01-01"), 366, resolve(Some("new")).unwrap()) {
|
||||
assert!(!day.commemorations.is_empty(), "{}", day.date);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_great_feasts_are_all_present() {
|
||||
for (month, day) in [(9, 8), (9, 14), (11, 21), (12, 25), (1, 6), (2, 2), (3, 25), (8, 6), (8, 15)] {
|
||||
let x = describe(Date::from_ymd(2026, month, day).unwrap(), resolve(Some("goarch")).unwrap());
|
||||
assert_eq!(x.rank, Some(Rank::Great), "{month}-{day}");
|
||||
}
|
||||
for when in ["2026-04-05", "2026-05-21", "2026-05-31"] {
|
||||
assert_eq!(at(when, "goarch").rank, Some(Rank::Great), "{when}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_movable_feast_outranks_the_fixed_calendar() {
|
||||
assert!(at("2026-04-12", "new").feast.as_deref().unwrap_or("").contains("Pascha"));
|
||||
}
|
||||
|
||||
// ──────────────────────────────── output ──────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn ics_content_lines_stay_within_seventy_five_octets() {
|
||||
for j in ["goarch", "rocor", "ruthenian"] {
|
||||
let days = week(d("2026-01-01"), 365, resolve(Some(j)).unwrap());
|
||||
let text = to_ics(&days, "Typikon", "20260101T000000Z");
|
||||
for line in text.split("\r\n") {
|
||||
assert!(line.len() <= 75, "{j}: {} octets: {line}", line.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ics_is_well_formed() {
|
||||
let days = week(d("2026-04-10"), 7, resolve(Some("new")).unwrap());
|
||||
let text = to_ics(&days, "Typikon", "20260101T000000Z");
|
||||
assert!(text.starts_with("BEGIN:VCALENDAR"));
|
||||
assert!(text.trim_end().ends_with("END:VCALENDAR"));
|
||||
assert_eq!(text.matches("BEGIN:VEVENT").count(), text.matches("END:VEVENT").count());
|
||||
}
|
||||
|
||||
// ─────────────────────────── every jurisdiction ───────────────────────────
|
||||
|
||||
#[test]
|
||||
fn every_jurisdiction_holds_together_for_twenty_years() {
|
||||
for jur in JURISDICTIONS.iter().chain(CALENDARS.iter()) {
|
||||
for day in week(d("2020-01-01"), 366 * 21 / 4, jur) {
|
||||
assert!(!day.commemorations.is_empty(), "{} {}", jur.key, day.date);
|
||||
assert!(!day.title().is_empty(), "{} {}", jur.key, day.date);
|
||||
// `binds` may never be stricter than the level it sits under.
|
||||
assert!(day.binds >= day.fast, "{} {}: binds {:?} > fast {:?}",
|
||||
jur.key, day.date, day.binds, day.fast);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_calendar_name_resolves_without_naming_a_church() {
|
||||
assert_eq!(resolve(Some("old")).unwrap().menologion, MenologionKind::Julian);
|
||||
assert_eq!(resolve(Some("new")).unwrap().menologion, MenologionKind::RevisedJulian);
|
||||
assert_eq!(resolve(Some("western")).unwrap().paschalion, Paschalion::Gregorian);
|
||||
assert_eq!(resolve(None).unwrap().key, "new");
|
||||
assert!(resolve(Some("atlantis")).is_err());
|
||||
assert_eq!(resolve(Some("rocor")).unwrap().calendar(), Calendar::Old);
|
||||
}
|
||||
|
||||
// ─────────────────────── the levels are an ordered set ────────────────────
|
||||
|
||||
#[test]
|
||||
fn a_feast_can_only_relax_a_fast_never_tighten_it() {
|
||||
// Strictest to laxest, so `<` means stricter.
|
||||
assert!(Level::Strict < Level::Xerophagy);
|
||||
assert!(Level::Xerophagy < Level::WineOil);
|
||||
assert!(Level::Dairy < Level::None);
|
||||
// Relaxing to fish inside the Great Fast stops at wine and oil.
|
||||
assert_eq!(Level::Xerophagy.relaxed_to(Level::Fish, Level::WineOil), Level::WineOil);
|
||||
// A cap stricter than the day never tightens the day.
|
||||
assert_eq!(Level::Fish.relaxed_to(Level::WineOil, Level::WineOil), Level::Fish);
|
||||
// No rank means no change.
|
||||
assert_eq!(Level::Xerophagy.relaxed_to(Level::Xerophagy, Level::Fish), Level::Xerophagy);
|
||||
}
|
||||
Reference in New Issue
Block a user