mirror of
https://git.pumpin.online/funkpower/typikon.git
synced 2026-10-22 18:57:13 +00:00
The years this calendar answers for are now 1 to 9999 and enforced at every constructor. Arithmetic saturates instead of wrapping, which in a release build had put a date in antiquity and produced an ISO string that was not ten characters long; a seven-day walk stops at the boundary rather than running past it. --days took any integer and aborted the process trying to allocate for it, as did --indent. Both are bounded now. --update-menaion parsed a day out of a fetched page with no bound on it and wrote keys like 01-4294967295 that no lookup could ever match, then wrote the file even when the parse had failed, in replace mode, which would have blanked a year of commemorations in a calendar that had been working. It bounds the day by the month and refuses to write when most of the year comes back empty. today() shelled out to date(1) and fell back to UTC without a word when it was absent, so a container reported the wrong day either side of midnight. It says so now. The UGCC loaded the Ruthenian menaion. That is the Metropolia of Pittsburgh's calendar of saints, and lending it to Kyiv is the same error as lending the law, in a release whose headline is that a church's law is never borrowed. Also: has_obligation was dead API about to be frozen, and two ptr::eq comparisons stood in for asking whether the discipline was overridden, which is what they meant.
709 lines
28 KiB
Rust
709 lines
28 KiB
Rust
//! 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, Some(Level::WineOil));
|
|
assert_eq!(at("2026-04-03", "ruthenian").binds, Some(Level::WineOil));
|
|
assert_eq!(at("2026-02-18", "ruthenian").binds, Some(Level::Dairy));
|
|
assert_eq!(at("2026-10-09", "ruthenian").binds, 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, 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.
|
|
if let Some(binds) = day.binds {
|
|
assert!(binds >= day.fast, "{} {}: binds {:?} > fast {:?}",
|
|
jur.key, day.date, 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);
|
|
}
|
|
|
|
// ───────────────────────── the delimited formats ──────────────────────────
|
|
|
|
#[test]
|
|
fn csv_has_a_header_and_one_row_per_day() {
|
|
use typikon::export::to_delimited;
|
|
let days = week(d("2026-01-01"), 5, resolve(Some("rocor")).unwrap());
|
|
let text = to_delimited(&days, ',', &[]);
|
|
let lines: Vec<&str> = text.trim_end().split('\n').collect();
|
|
assert_eq!(lines.len(), 6, "header plus five days");
|
|
assert!(lines[0].starts_with("date,weekday,"));
|
|
assert!(lines[1].starts_with("2026-01-01,Thursday,rocor,old,"));
|
|
}
|
|
|
|
#[test]
|
|
fn csv_quotes_the_fields_that_need_it() {
|
|
use typikon::export::to_delimited;
|
|
// Commemorations are full of commas, and joined with semicolons besides.
|
|
let days = week(d("2026-09-18"), 1, resolve(Some("rocor")).unwrap());
|
|
let text = to_delimited(&days, ',', &["title".to_string()]);
|
|
let row = text.trim_end().split('\n').nth(1).unwrap();
|
|
assert!(row.starts_with('"'), "a title with a comma must be quoted: {row}");
|
|
assert!(row.ends_with('"'));
|
|
// A quote inside a field doubles rather than escaping.
|
|
assert_eq!(row.matches("\"\"").count(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn tsv_uses_tabs_and_a_chosen_column_order() {
|
|
use typikon::export::to_delimited;
|
|
let days = week(d("2026-04-12"), 1, resolve(Some("new")).unwrap());
|
|
let cols = vec!["fast".to_string(), "date".to_string()];
|
|
let text = to_delimited(&days, '\t', &cols);
|
|
let lines: Vec<&str> = text.trim_end().split('\n').collect();
|
|
assert_eq!(lines[0], "fast\tdate");
|
|
assert_eq!(lines[1], "none\t2026-04-12");
|
|
}
|
|
|
|
#[test]
|
|
fn an_unknown_column_is_caught_before_anything_is_printed() {
|
|
use typikon::export::csv::unknown_column;
|
|
assert!(unknown_column(&["date".to_string(), "fast".to_string()]).is_none());
|
|
assert_eq!(
|
|
unknown_column(&["date".to_string(), "nonsense".to_string()]).map(String::as_str),
|
|
Some("nonsense")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn every_advertised_column_actually_renders() {
|
|
use typikon::export::{csv::COLUMNS, to_delimited};
|
|
let days = week(d("2026-02-16"), 1, resolve(Some("ruthenian")).unwrap());
|
|
let cols: Vec<String> = COLUMNS.iter().map(|c| c.to_string()).collect();
|
|
let text = to_delimited(&days, '\t', &cols);
|
|
let lines: Vec<&str> = text.trim_end().split('\n').collect();
|
|
// Every column produces a cell, even the empty ones.
|
|
assert_eq!(lines[0].split('\t').count(), COLUMNS.len());
|
|
assert_eq!(lines[1].split('\t').count(), COLUMNS.len());
|
|
}
|
|
|
|
// ───────────────────────── Greek and Slavic usage ─────────────────────────
|
|
|
|
#[test]
|
|
fn the_protection_is_a_great_feast_in_slavic_usage_only() {
|
|
use typikon::jurisdictions::{Jurisdiction, Tradition};
|
|
let base = *resolve(Some("goarch")).unwrap();
|
|
let slavic: &'static Jurisdiction =
|
|
Box::leak(Box::new(Jurisdiction { tradition: Tradition::Slavic, ..base }));
|
|
let greek: &'static Jurisdiction =
|
|
Box::leak(Box::new(Jurisdiction { tradition: Tradition::Greek, ..base }));
|
|
assert_eq!(describe(d("2026-10-01"), slavic).rank, Some(Rank::Great));
|
|
assert_eq!(describe(d("2026-10-01"), greek).rank, Some(Rank::Vigil));
|
|
}
|
|
|
|
#[test]
|
|
fn greek_usage_admits_fish_in_the_nativity_fast_where_slavic_does_not() {
|
|
// A Tuesday before 17 December.
|
|
assert_eq!(at("2026-12-01", "rocor").fast, Level::WineOil);
|
|
assert_eq!(at("2026-12-01", "goarch").fast, Level::Fish);
|
|
// Wednesday is a fast day in both.
|
|
assert!(at("2026-12-02", "goarch").fast <= Level::WineOil);
|
|
// And after the 17th the Greek relaxation stops.
|
|
assert_eq!(at("2026-12-22", "goarch").fast, Level::WineOil);
|
|
}
|
|
|
|
#[test]
|
|
fn a_jurisdiction_carries_the_usage_its_books_follow() {
|
|
use typikon::jurisdictions::Tradition;
|
|
for key in ["moscow", "rocor", "serbian", "oca", "ruthenian", "ukrainian"] {
|
|
assert_eq!(resolve(Some(key)).unwrap().tradition, Tradition::Slavic, "{key}");
|
|
}
|
|
for key in ["goarch", "greece", "constantinople", "antiochian", "cyprus"] {
|
|
assert_eq!(resolve(Some(key)).unwrap().tradition, Tradition::Greek, "{key}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn every_rank_has_a_mark_and_they_are_distinct() {
|
|
let signs: Vec<&str> = [Rank::Great, Rank::Vigil, Rank::Polyeleos]
|
|
.iter().map(|r| r.sign()).collect();
|
|
assert!(signs.iter().all(|s| !s.is_empty()));
|
|
assert_eq!(signs.iter().collect::<std::collections::HashSet<_>>().len(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn tradition_parses_the_names_people_use() {
|
|
use typikon::jurisdictions::Tradition;
|
|
assert_eq!(Tradition::parse("Slavic"), Some(Tradition::Slavic));
|
|
assert_eq!(Tradition::parse("russian"), Some(Tradition::Slavic));
|
|
assert_eq!(Tradition::parse("GREEK"), Some(Tradition::Greek));
|
|
assert_eq!(Tradition::parse("coptic"), None);
|
|
}
|
|
|
|
// ─────────────── the law that binds vs the discipline shown ───────────────
|
|
|
|
fn ruthenian_at_typikon() -> &'static typikon::jurisdictions::Jurisdiction {
|
|
use typikon::jurisdictions::{Jurisdiction, RuleKind};
|
|
let base = *resolve(Some("ruthenian")).unwrap();
|
|
Box::leak(Box::new(Jurisdiction { discipline: Some(RuleKind::Orthodox), ..base }))
|
|
}
|
|
|
|
#[test]
|
|
fn the_typikon_discipline_can_be_shown_without_losing_the_obligation() {
|
|
let jur = ruthenian_at_typikon();
|
|
// Clean Monday: the Typikon's total fast, still binding under particular law.
|
|
let clean = describe(d("2026-02-16"), jur);
|
|
assert_eq!(clean.fast, Level::Strict);
|
|
assert_eq!(clean.obligation, Some(Obligation::Obligatory));
|
|
assert_eq!(clean.binds, Some(Level::WineOil));
|
|
|
|
// A Wednesday of the Great Fast: xerophagy kept, meat alone binding.
|
|
let wed = describe(d("2026-02-18"), jur);
|
|
assert_eq!(wed.fast, Level::Xerophagy);
|
|
assert_eq!(wed.obligation, Some(Obligation::Obligatory));
|
|
assert_eq!(wed.binds, Some(Level::Dairy));
|
|
|
|
// A Thursday of the Great Fast: kept, and binding on no one.
|
|
let thu = describe(d("2026-02-19"), jur);
|
|
assert_eq!(thu.fast, Level::Xerophagy);
|
|
assert_eq!(thu.obligation, Some(Obligation::Customary));
|
|
assert_eq!(thu.binds, None);
|
|
}
|
|
|
|
#[test]
|
|
fn showing_the_typikon_never_invents_an_obligation() {
|
|
let jur = ruthenian_at_typikon();
|
|
let native = resolve(Some("ruthenian")).unwrap();
|
|
for n in 0..365 {
|
|
let day = describe(d("2026-01-01") + n, jur);
|
|
let plain = describe(d("2026-01-01") + n, native);
|
|
// Exactly the same days bind, whichever discipline is displayed.
|
|
assert_eq!(day.is_obligatory(), plain.is_obligatory(), "{}", day.date);
|
|
assert_eq!(day.binds, plain.binds, "{}", day.date);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_typikon_discipline_is_never_laxer_than_the_law_it_reports() {
|
|
let jur = ruthenian_at_typikon();
|
|
for n in 0..365 {
|
|
let day = describe(d("2026-01-01") + n, jur);
|
|
if let Some(binds) = day.binds {
|
|
assert!(binds >= day.fast, "{}: binds {:?} stricter than kept {:?}",
|
|
day.date, binds, day.fast);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_override_changes_nothing_for_a_jurisdiction_already_on_the_typikon() {
|
|
use typikon::jurisdictions::{Jurisdiction, RuleKind};
|
|
let base = *resolve(Some("rocor")).unwrap();
|
|
let forced: &'static Jurisdiction =
|
|
Box::leak(Box::new(Jurisdiction { discipline: Some(RuleKind::Orthodox), ..base }));
|
|
for n in 0..200 {
|
|
let a = describe(d("2026-01-01") + n, resolve(Some("rocor")).unwrap());
|
|
let b = describe(d("2026-01-01") + n, forced);
|
|
assert_eq!(a.fast, b.fast, "{}", a.date);
|
|
assert_eq!(a.obligation, b.obligation, "{}", a.date);
|
|
}
|
|
}
|
|
|
|
// ────────────────────────── what gets remembered ──────────────────────────
|
|
|
|
#[test]
|
|
fn the_config_round_trips_all_three_axes() {
|
|
use typikon::config::{self, Saved};
|
|
// A scratch XDG_CONFIG_HOME, so the test cannot touch a real one.
|
|
let dir = std::env::temp_dir().join(format!("typikon-test-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
// SAFETY: single-threaded test, and the variable is restored below.
|
|
let prev = std::env::var_os("XDG_CONFIG_HOME");
|
|
unsafe { std::env::set_var("XDG_CONFIG_HOME", &dir) };
|
|
|
|
config::write(&Saved {
|
|
jurisdiction: Some("ruthenian".into()),
|
|
tradition: Some("slavic".into()),
|
|
typikon: true,
|
|
})
|
|
.unwrap();
|
|
let back = config::saved();
|
|
assert_eq!(back.jurisdiction.as_deref(), Some("ruthenian"));
|
|
assert_eq!(back.tradition.as_deref(), Some("slavic"));
|
|
assert!(back.typikon);
|
|
|
|
// Remembering only a jurisdiction leaves the other two unset, rather than
|
|
// inventing a default that would disagree with the flags.
|
|
config::write(&Saved { jurisdiction: Some("rocor".into()), ..Default::default() }).unwrap();
|
|
let plain = config::saved();
|
|
assert_eq!(plain.jurisdiction.as_deref(), Some("rocor"));
|
|
assert_eq!(plain.tradition, None);
|
|
assert!(!plain.typikon);
|
|
|
|
unsafe {
|
|
match prev {
|
|
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
|
|
None => std::env::remove_var("XDG_CONFIG_HOME"),
|
|
}
|
|
}
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
// ─────────────── no church is told another church's law binds ─────────────
|
|
|
|
#[test]
|
|
fn only_the_church_whose_law_was_sourced_reports_an_obligation() {
|
|
use typikon::jurisdictions::{RuleKind, JURISDICTIONS};
|
|
// Ruthenian particular law is sourced from the Metropolia of Pittsburgh.
|
|
// No other jurisdiction may borrow it: telling a Melkite that fourteen
|
|
// days a year bind him under a law that was never his is the error the
|
|
// level/obligation split exists to prevent.
|
|
for jur in JURISDICTIONS.iter() {
|
|
if jur.rule == RuleKind::Ruthenian {
|
|
assert_eq!(jur.key, "ruthenian", "{} borrows another church's law", jur.key);
|
|
}
|
|
}
|
|
|
|
for key in ["melkite", "romanian-catholic", "ugcc"] {
|
|
let jur = resolve(Some(key)).unwrap();
|
|
let binding = week(d("2026-01-01"), 365, jur)
|
|
.into_iter()
|
|
.filter(|x| x.is_obligatory())
|
|
.count();
|
|
assert_eq!(binding, 0, "{key} claims {binding} binding days it has no source for");
|
|
// And the entry has to say why nothing binds.
|
|
assert!(jur.note.contains("not modelled"), "{key}: the note must say so");
|
|
}
|
|
|
|
// The one church whose law is sourced still reports it.
|
|
let ruthenian = resolve(Some("ruthenian")).unwrap();
|
|
let binding = week(d("2026-01-01"), 365, ruthenian)
|
|
.into_iter()
|
|
.filter(|x| x.is_obligatory())
|
|
.count();
|
|
assert_eq!(binding, 14);
|
|
}
|
|
|
|
// ───────────────────── the edges of the supported years ───────────────────
|
|
|
|
#[test]
|
|
fn dates_outside_the_supported_years_cannot_be_made() {
|
|
use typikon::calendars::{MAX_YEAR, MIN_YEAR};
|
|
assert!(Date::from_ymd(MIN_YEAR, 1, 1).is_some());
|
|
assert!(Date::from_ymd(MAX_YEAR, 12, 31).is_some());
|
|
assert!(Date::from_ymd(MIN_YEAR - 1, 12, 31).is_none());
|
|
assert!(Date::from_ymd(MAX_YEAR + 1, 1, 1).is_none());
|
|
assert!(Date::from_ymd(2026, 2, 30).is_none());
|
|
assert!(Date::from_jdn(i32::MAX).is_none());
|
|
assert!(Date::from_jdn(i32::MIN).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn a_walk_off_the_end_stops_rather_than_wrapping() {
|
|
let jur = resolve(Some("new")).unwrap();
|
|
// Every date emitted is a real one, and the year never leaves the range.
|
|
let days = week(d("9999-12-29"), 10, jur);
|
|
assert_eq!(days.len(), 3, "the walk should stop at 9999-12-31");
|
|
for day in &days {
|
|
assert_eq!(day.date.iso().len(), 10, "{}", day.date);
|
|
assert!(day.date.year() <= 9999);
|
|
}
|
|
// And the other end.
|
|
assert_eq!(week(d("0001-01-01") , 3, jur).len(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn date_arithmetic_saturates_instead_of_overflowing() {
|
|
// In a release build an unchecked add wraps silently, which put a date in
|
|
// antiquity and an ISO string that was not ten characters long.
|
|
assert_eq!(Date::MAX + 1, Date::MAX);
|
|
assert_eq!(Date::MIN - 1, Date::MIN);
|
|
assert_eq!(Date::MAX + i32::MAX, Date::MAX);
|
|
assert_eq!(Date::MIN - i32::MAX, Date::MIN);
|
|
assert!(Date::MAX.checked_add(1).is_none());
|
|
for d in [Date::MIN, Date::MAX] {
|
|
assert_eq!(d.iso().len(), 10);
|
|
}
|
|
}
|