package calendar import ( "strings" "testing" "time" ) func TestTemporalEFSeasons(t *testing.T) { cases := []struct { date string season Season }{ {"2025-01-01", Christmas}, // Circumcision {"2025-01-20", TimeAfterEpiphany}, // after II Sunday after Epiphany {"2025-02-16", Septuagesima}, // Septuagesima Sunday (easter-63) {"2025-03-05", Lent}, // Ash Wednesday {"2025-04-06", Passiontide}, // Passion Sunday (easter-14) {"2025-04-13", Passiontide}, // Palm Sunday {"2025-04-20", Easter_}, // Easter {"2025-06-08", Easter_}, // Pentecost (still Paschaltide) {"2025-06-15", TimeAfterPentecost}, // Trinity {"2025-06-22", TimeAfterPentecost}, // II Sunday after Pentecost {"2025-10-26", TimeAfterPentecost}, // Christ the King {"2025-11-30", Advent}, // I Sunday of Advent {"2025-12-25", Christmas}, // Nativity } for _, c := range cases { got := temporalEF(d(c.date)) if got.Season != c.season { t.Errorf("temporalEF(%s).Season = %s, want %s", c.date, got.Season, c.season) } } } func TestTemporalEFChristTheKing(t *testing.T) { // EF: last Sunday of October (2025-10-26), not the last before Advent. if got := temporalEF(d("2025-10-26")); got.Cel.Slug != "ef-christ-the-king" { t.Errorf("2025-10-26 slug = %q want ef-christ-the-king", got.Cel.Slug) } if got := efChristTheKing(2025).Format("2006-01-02"); got != "2025-10-26" { t.Errorf("efChristTheKing(2025) = %s want 2025-10-26", got) } } // TestTemporalEFSundayRanks: RG 11-12 / RG 91 entry 6 -- Sundays of Advent, // Lent and Passiontide, and Low Sunday, are I class; all others (including // Septuagesima's own three Sundays and the ordinary Sundays after Epiphany/ // Pentecost) stay II class. Before the fix, only Advent I was I class; every // other Advent/Lent Sunday was left at the generic-Sunday II-class default. func TestTemporalEFSundayRanks(t *testing.T) { cases := []struct { date string want Rank }{ {"2025-11-30", RankClass1}, // Advent I {"2025-12-07", RankClass1}, // Advent II {"2025-12-14", RankClass1}, // Advent III (Gaudete) {"2025-12-21", RankClass1}, // Advent IV {"2025-03-09", RankClass1}, // Lent I {"2025-03-16", RankClass1}, // Lent II {"2025-03-23", RankClass1}, // Lent III {"2025-03-30", RankClass1}, // Lent IV (Laetare) {"2025-04-06", RankClass1}, // Passion Sunday {"2025-04-13", RankClass1}, // Palm Sunday {"2025-04-27", RankClass1}, // Low Sunday {"2025-02-16", RankClass2}, // Septuagesima Sunday -- NOT I class {"2025-02-23", RankClass2}, // Sexagesima Sunday -- NOT I class {"2025-03-02", RankClass2}, // Quinquagesima Sunday -- NOT I class {"2025-06-22", RankClass2}, // II Sunday after Pentecost -- ordinary II class } for _, c := range cases { got := temporalEF(d(c.date)) if got.Cel.Rank != c.want { t.Errorf("temporalEF(%s).Cel.Rank = %s, want %s (%s)", c.date, got.Cel.Rank, c.want, got.Cel.Slug) } } } // TestTemporalEFRoseSundays: RG 131 -- rose is permitted on Gaudete (Advent // III) and Laetare (Lent IV), for that Sunday's own Office and Mass only. // Before the fix, efColour had no Rose case at all. func TestTemporalEFRoseSundays(t *testing.T) { if got := temporalEF(d("2025-12-14")).Cel.Colour; got != Rose { t.Errorf("Gaudete (2025-12-14) colour = %s, want rose", got) } if got := temporalEF(d("2025-03-30")).Cel.Colour; got != Rose { t.Errorf("Laetare (2025-03-30) colour = %s, want rose", got) } // The Advent/Lent Sundays either side stay violet -- rose is an indult for // that one Sunday, not a season colour. if got := temporalEF(d("2025-12-07")).Cel.Colour; got != Violet { t.Errorf("Advent II (2025-12-07) colour = %s, want violet", got) } if got := temporalEF(d("2025-03-23")).Cel.Colour; got != Violet { t.Errorf("Lent III (2025-03-23) colour = %s, want violet", got) } } // TestTemporalEFHolyThursdayColour: RG 128(b)/RG 122 -- Holy Thursday's Mass // of Chrism and Mass in Cena Domini are white, a whole-day exception to // Passiontide's violet. Good Friday and Holy Saturday, either side, stay // violet (their own black/no-colour exceptions are a separate, unmodelled // gap -- see RG 132, precedence_ef.go's own doc comments). // TestTemporalEFTriduumColours pins all three days of the Sacred Triduum, // which take three different colours for three different reasons. // // Renamed from TestTemporalEFHolyThursdayColour, which asserted Good Friday // was violet "(unchanged)" -- encoding a gap this file's own comment used to // call "separate, unmodelled". It is modelled now, so the test that pinned // the gap had to move with it. A test asserting the absence of a feature // passes for exactly as long as the feature is absent, which is not the same // as being correct. func TestTemporalEFTriduumColours(t *testing.T) { easter := d("2025-04-20") holyThu := easter.AddDate(0, 0, -3) goodFri := easter.AddDate(0, 0, -2) holySat := easter.AddDate(0, 0, -1) // RG 128(b)'s named exception list, and RG 122 affirmatively. if got := temporalEF(holyThu).Cel.Colour; got != White { t.Errorf("Holy Thursday colour = %s, want white", got) } // RG 128(b) excepts the day from Passiontide's violet; RG 132 assigns // black. missalemeum's own colour set for the day is "bv", black first. if got := temporalEF(goodFri).Cel.Colour; got != Black { t.Errorf("Good Friday colour = %s, want black (RG 128(b) + RG 132)", got) } // Holy Saturday IS still violet: RG 128(b)'s exception for it covers only // the deacon at the Easter Preconium and the celebrant at the renewal of // baptismal promises, not the day, and this model carries one colour per // day. Asserted in the other direction on purpose, so a careless "the // whole Triduum is black" change fails here. if got := temporalEF(holySat).Cel.Colour; got != Violet { t.Errorf("Holy Saturday colour = %s, want violet (unchanged)", got) } } // TestTemporalEFEmberDayRanks: RG 91 entry 18 -- the Ember days of Lent (and, // unchanged, September and Advent) are II-class privileged ferias, not the // ordinary III-class Lenten ferias around them. Before the fix, efEmberSlug // had no Lent case at all, so Lent's Ember Wed/Fri/Sat fell through to the // ordinary III-class Lenten-feria rank. func TestTemporalEFEmberDayRanks(t *testing.T) { // Lent I Sunday 2025 = 2025-03-09; Ember Wed/Fri/Sat = +3/+5/+6. lentI := d("2025-03-09") for _, off := range []int{3, 5, 6} { day := lentI.AddDate(0, 0, off) got := temporalEF(day) if got.Cel.Rank != RankClass2 { t.Errorf("Lent Ember day %s rank = %s, want class-2 (%s)", day.Format("2006-01-02"), got.Cel.Rank, got.Cel.Slug) } } } // TestTemporalEFChristmastideBoundary pins RG 72's boundary and, just as // importantly, the SEPARATION it required. // // RG 72: "Tempus natalicium decurrit a I Vesperis Nativitatis Domini usque ad // diem 13 ianuarii INCLUSIVE." Christmas Time runs to 13 January, so 6-13 // January is the Epiphany section OF Christmas Time, not the beginning of Time // after Epiphany. RG 119(a) backs it from the colour side, white "usque ad // expletum tempus Epiphaniae". // // lectio ended Christmas Time on 5 January, so those eight days came out // green. The fix could not be the boundary constant alone: efSeason both named // the slugs and reported the season, so moving it renamed every slug in the // window and orphaned the ef-time-after-epiphany-sunday-1 lectionary key. The // season and the slug are now separate axes -- efSeason and efSlugSeason -- // and this test pins BOTH, because a future "simplification" that merges them // again would pass any test that checked only one. func TestTemporalEFChristmastideBoundary(t *testing.T) { for _, c := range []struct { date, season, slug string }{ // Epiphany itself is inside Christmas Time, not the start of what // follows it. Its slug is unchanged. {"2026-01-06", "christmas", "ef-epiphany"}, // The window: season christmas, slugs and week numbers still Epiphany's. {"2026-01-07", "christmas", "ef-time-after-epiphany-1-wednesday"}, {"2026-01-11", "christmas", "ef-time-after-epiphany-sunday-1"}, {"2026-01-12", "christmas", "ef-time-after-epiphany-1-monday"}, // 13 January is INCLUSIVE -- the last day of Christmas Time. It carries // the Commemoration of the Baptism, so only the season is asserted. {"2026-01-13", "christmas", ""}, // 14 January is the first day that is genuinely Time after Epiphany. {"2026-01-14", "time-after-epiphany", ""}, // The other end, unchanged: 5 January is Christmas Time by the old // boundary too, so a regression that moved the boundary the wrong way // would not show here -- which is why 14 January above is asserted. {"2026-01-05", "christmas", ""}, } { got := temporalEF(d(c.date)) if string(got.Season) != c.season { t.Errorf("%s: season = %q, want %q (RG 72: Christmas Time runs to 13 January inclusive)", c.date, got.Season, c.season) } if c.slug != "" && got.Cel.Slug != c.slug { t.Errorf("%s: slug = %q, want %q -- the SLUG season must stay Epiphany's here; "+ "if this fails with an ef-christmas-* slug, efSeason and efSlugSeason have been "+ "merged again and the lectionary keys are orphaned", c.date, got.Cel.Slug, c.slug) } } // The colour follows from the season, and is the reason any of this // matters: eight days a year were green that should be white. for _, day := range []string{"2026-01-07", "2026-01-09", "2026-01-12"} { if got := temporalEF(d(day)).Cel.Colour; got != White { t.Errorf("%s: colour = %s, want white (RG 119(a), following from the season)", day, got) } } } // TestTemporalEFEpiphanyWeekAnchor pins where the weeks after Epiphany are // counted from. They are numbered from the FIRST SUNDAY after Epiphany, not // from Epiphany itself -- the Sunday carries the Mass the week is named for // ("Dominica I post Epiphaniam"), while Epiphany is a feast inside Christmas // Time (RG 72), not the head of a numbered week. // // Counting from 6 January put every week one too high, all year until // Septuagesima cut the season short. func TestTemporalEFEpiphanyWeekAnchor(t *testing.T) { // 2026: Epiphany is a Tuesday, so the first Sunday after it is 11 January. for _, c := range []struct { date string week int }{ {"2026-01-14", 1}, // Wednesday of the week running from 11 January {"2026-01-17", 1}, // still that week {"2026-01-20", 2}, {"2026-01-27", 3}, } { if got := temporalEF(d(c.date)).Week; got != c.week { t.Errorf("%s: week = %d, want %d (weeks after Epiphany count from the "+ "first Sunday after it, not from 6 January)", c.date, got, c.week) } } // 2028: Epiphany falls ON a Thursday and 9 January is the Sunday; but the // year that matters for the off-by-one is one where Epiphany is itself a // Sunday, since then "the first Sunday AFTER Epiphany" is the following // week and a naive same-day anchor would be seven days out. y := 0 for c := 2005; c <= 2050; c++ { if time.Date(c, time.January, 6, 0, 0, 0, 0, time.UTC).Weekday() == time.Sunday { y = c break } } if y == 0 { t.Fatal("no year in 2005..2050 has Epiphany on a Sunday -- adjust this test") } anchor := firstSundayAfterEpiphany(y) if anchor.Day() == 6 { t.Errorf("%d: Epiphany is a Sunday and the anchor landed on it (%s); the "+ "first Sunday AFTER Epiphany is the next one", y, anchor.Format("2006-01-02")) } if got := temporalEF(anchor).Week; got != 1 { t.Errorf("%d: the first Sunday after Epiphany (%s) should be week 1, got %d", y, anchor.Format("2006-01-02"), got) } } // TestTemporalEFBVMSaturday pins RG 78, the votive Office of Our Lady on // Saturday: "In sabbatis, in quibus occurrit Officium de feria IV classis, fit // de sancta Maria in sabbato." A Saturday whose office would otherwise be a // IV-class feria keeps Our Lady's office instead, and it is white. // // Two things are asserted in opposite directions on purpose. The office // applies only where the feria really is IV class, so a Saturday in Lent or // Advent -- III class ferias, violet -- must NOT turn white; and the slug must // NOT change, because the day's readings are still the feria's and a bespoke // slug would recur many times a year. func TestTemporalEFBVMSaturday(t *testing.T) { // Ordinary per-annum Saturdays: IV class, so Our Lady's office, white. for _, day := range []string{"2026-06-20", "2026-06-27", "2026-07-04", "2026-10-31"} { got := temporalEF(d(day)) if got.Cel.Colour != White { t.Errorf("%s: colour = %s, want white (RG 78, Our Lady on Saturday)", day, got.Cel.Colour) } if got.Cel.Rank != RankClass4 { t.Errorf("%s: rank = %v, want class-4 -- the office does not change the rank", day, got.Cel.Rank) } if !strings.HasPrefix(got.Cel.Slug, "ef-time-after-pentecost-") { t.Errorf("%s: slug = %q, want the ferial slug unchanged -- the readings are "+ "still the feria's and a bespoke slug would recur many times a year", day, got.Cel.Slug) } } // Saturdays whose feria is NOT IV class keep their own colour. Lent and // Advent ferias are III class and violet; a fix that whitened every // Saturday would fail here. for _, c := range []struct { day string colour Colour }{ {"2026-03-07", Violet}, // Saturday in Lent {"2026-12-05", Violet}, // Saturday in Advent {"2026-04-04", Violet}, // Holy Saturday -- I class, privileged } { if got := temporalEF(d(c.day)).Cel.Colour; got != c.colour { t.Errorf("%s: colour = %s, want %s -- RG 78 applies only where the feria is IV class", c.day, got, c.colour) } } } // TestTemporalEFRogationDays pins RG 87's Minor Litanies: "Litaniae minores seu // Rogationes, per se, assignantur feriis II, III et IV ante festum Ascensionis // Domini." Easter+36 and +37 are that Monday and Tuesday; RG 128(d) makes them // violet, "for the procession and Mass of the Greater and Lesser Litanies" -- // a penitential note inside white Paschaltide. // // The Wednesday is asserted in the opposite direction on purpose. Easter+38 is // by construction the Vigil of the Ascension, II class, which wins the day; the // rubric asks for a COMMEMORATION there, which this function cannot express // since it returns one office per day. A change that extended the Rogation // branch to +38 to "complete" the rubric would silently displace the Vigil, and // this test is what stops it. func TestTemporalEFRogationDays(t *testing.T) { // 2027: Easter is 28 March, so the Rogation days are 3-5 May and the // Monday is unimpeded that year. easter := d("2027-03-28") for off, want := range map[int]string{36: "ef-rogation-monday", 37: "ef-rogation-tuesday"} { day := easter.AddDate(0, 0, off) got := temporalEF(day) if got.Cel.Slug != want { t.Errorf("Easter+%d (%s): slug = %q, want %q", off, day.Format("2006-01-02"), got.Cel.Slug, want) } if got.Cel.Colour != Violet { t.Errorf("Easter+%d (%s): colour = %s, want violet (RG 128(d))", off, day.Format("2006-01-02"), got.Cel.Colour) } } // Easter+38 is the Ascension's Vigil and must stay so. if got := temporalEF(easter.AddDate(0, 0, 38)); got.Cel.Slug != "ef-ascension-vigil" { t.Errorf("Easter+38: slug = %q, want ef-ascension-vigil -- the Rogation Wednesday's "+ "commemoration cannot be built here without displacing the Vigil", got.Cel.Slug) } }