aboutsummaryrefslogtreecommitdiff
path: root/internal/calendar
diff options
context:
space:
mode:
Diffstat (limited to 'internal/calendar')
-rw-r--r--internal/calendar/precedence_ef_repro_test.go45
-rw-r--r--internal/calendar/temporal_ef.go81
-rw-r--r--internal/calendar/temporal_ef_test.go92
3 files changed, 200 insertions, 18 deletions
diff --git a/internal/calendar/precedence_ef_repro_test.go b/internal/calendar/precedence_ef_repro_test.go
new file mode 100644
index 0000000..6c333d0
--- /dev/null
+++ b/internal/calendar/precedence_ef_repro_test.go
@@ -0,0 +1,45 @@
+package calendar_test
+
+// Reproduction tests for three named EF precedence defects (colitur's
+// differential/oracle audit against lectio's ~66-line EF precedence
+// approximation, internal/calendar/precedence_ef.go). Each test fails before
+// its fix and passes after -- see the report for the exact pre-fix failure
+// message. These exercise the REAL tridentine sanctoral data
+// (caldata.Tridentine()) through the public Compute entry point, since the
+// bugs are about how real fixed-date feasts interact with the temporal
+// calendar, not about precedenceEF's arithmetic in isolation (that is
+// covered separately, package-internal, in precedence_ef_test.go).
+
+import (
+ "testing"
+ "time"
+
+ "github.com/lukaszkasprzak/lectio/internal/caldata"
+ "github.com/lukaszkasprzak/lectio/internal/calendar"
+)
+
+func efCompute(date string) calendar.LiturgicalDay {
+ sel := calendar.DefaultSelection()
+ sel.Form = "old"
+ d, _ := time.Parse("2006-01-02", date)
+ return calendar.Compute(d.UTC(), sel, []calendar.Layer{caldata.Tridentine()})
+}
+
+// TestJosephYieldsToSundayOfLent: RG 91 entry 6 (Sundays of Lent, I class)
+// vs entry 11 (I-class feasts of the universal Church not above) -- the
+// Sunday holds; St Joseph (19 March) transfers to the next free day (RG 95,
+// RG 96), landing on the 20th. Before the fix (defect 4: Sunday ranks), Lent
+// Sundays were wrongly II class, so Joseph (I class) won outright every time
+// this collision occurred.
+func TestJosephYieldsToSundayOfLent(t *testing.T) {
+ for _, year := range []string{"2006", "2017", "2023", "2028", "2034", "2045"} {
+ sunday := efCompute(year + "-03-19")
+ if sunday.Observed.Slug == "joseph-spouse-of-the-bl-virgin-mary" {
+ t.Errorf("%s-03-19 observed = %q want the Lent Sunday (RG 91 entry 6 beats entry 11)", year, sunday.Observed.Slug)
+ }
+ next := efCompute(year + "-03-20")
+ if next.Observed.Slug != "joseph-spouse-of-the-bl-virgin-mary" {
+ t.Errorf("%s-03-20 observed = %q want joseph-spouse-of-the-bl-virgin-mary (transferred one day, RG 96)", year, next.Observed.Slug)
+ }
+ }
+}
diff --git a/internal/calendar/temporal_ef.go b/internal/calendar/temporal_ef.go
index 7420ebd..c649405 100644
--- a/internal/calendar/temporal_ef.go
+++ b/internal/calendar/temporal_ef.go
@@ -74,6 +74,16 @@ func efCel(season Season, slug string, col Colour, rank Rank, week int) temporal
}
}
+// efSunday is efCel plus the Sunday flag, for the temporal candidates that
+// are actual Sundays -- precedenceEF's tie-break needs to tell a Sunday (RG
+// 91 entry 15, wins its tie) apart from an equal-class privileged FERIA
+// (entry 18, the Ember/late-Advent days, which yields).
+func efSunday(season Season, slug string, col Colour, rank Rank, week int) temporalDay {
+ day := efCel(season, slug, col, rank, week)
+ day.Sunday = true
+ return day
+}
+
// temporalEF computes the 1962 temporal identity of date. Season is exact
// (oracle-validated); ranks/weeks are best-effort for display.
func temporalEF(date time.Time) temporalDay {
@@ -87,6 +97,15 @@ func temporalEF(date time.Time) temporalDay {
if !date.Before(easter.AddDate(0, 0, 50)) && !date.After(easter.AddDate(0, 0, 55)) {
col = Red
}
+ // Holy Thursday (the Mass of Chrism and the Mass in Cena Domini) is white,
+ // a whole-Mass exception to Passiontide's violet -- RG 128(b)'s own named
+ // exception list, and RG 122 stating the same fact affirmatively in the
+ // White section. Good Friday and Holy Saturday, either side, are NOT
+ // exceptions here (RG 132's black for Good Friday's liturgical action is a
+ // separate, unmodelled gap -- see precedence_ef.go's doc comments).
+ if sameDay(date, easter.AddDate(0, 0, -3)) {
+ col = White
+ }
sun := date.Weekday() == time.Sunday
// Major feasts of the Lord (I class): nice titles + colour.
@@ -129,8 +148,20 @@ func temporalEF(date time.Time) temporalDay {
if sun {
week := efWeek(date, season, y, easter)
rank := RankClass2
- if season == Advent && sameDay(date, adventStart(y)) {
- rank = RankClass1 // 1st Sunday of Advent
+ sundayColour := col
+ if season == Advent || season == Lent {
+ // RG 11-12 / RG 91 entry 6: every Sunday of Advent and every Sunday
+ // of Lent is I class -- not only Advent I, the sole case previously
+ // handled (Passiontide's own two Sundays, Passion and Palm, are
+ // already I class via the named-feast switch above; Low Sunday
+ // likewise).
+ rank = RankClass1
+ switch {
+ case season == Advent && week == 3:
+ sundayColour = Rose // Gaudete -- RG 131, that Sunday's Office/Mass only
+ case season == Lent && week == 4:
+ sundayColour = Rose // Laetare -- RG 131, that Sunday's Office/Mass only
+ }
}
// Resumed Sundays after Pentecost (1960 Rubrics): when Easter is early
// there are more than 24 Sundays after Pentecost. The LAST Sunday before
@@ -140,17 +171,17 @@ func temporalEF(date time.Time) temporalDay {
if season == TimeAfterPentecost {
lastSun := adventStart(y).AddDate(0, 0, -7)
if sameDay(date, lastSun) {
- return efCel(TimeAfterPentecost, "ef-time-after-pentecost-sunday-24", col, rank, 24)
+ return efSunday(TimeAfterPentecost, "ef-time-after-pentecost-sunday-24", col, rank, 24)
}
if week > 23 {
p := daysBetween(easter.AddDate(0, 0, 49), lastSun) / 7 // total Sundays after Pentecost
e := week - p + 7 // resumed Sunday after Epiphany
// Season stays time-after-pentecost (calendrical); the Epiphany
// slug only routes the readings to the resumed Mass.
- return efCel(TimeAfterPentecost, "ef-time-after-epiphany-sunday-"+strconv.Itoa(e), Green, rank, e)
+ return efSunday(TimeAfterPentecost, "ef-time-after-epiphany-sunday-"+strconv.Itoa(e), Green, rank, e)
}
}
- return efCel(season, "ef-"+string(season)+"-sunday-"+strconv.Itoa(week), col, rank, week)
+ return efSunday(season, "ef-"+string(season)+"-sunday-"+strconv.Itoa(week), sundayColour, rank, week)
}
// The days between Ash Wednesday and the 1st Sunday of Lent have their own
// proper Masses (not part of a numbered Lenten week).
@@ -166,6 +197,17 @@ func temporalEF(date time.Time) temporalDay {
// saint (e.g. the Conversion of St Paul, Jan 25) displaces them.
rank = RankClass3
}
+ if season == Advent && date.Month() == time.December && date.Day() >= 17 && date.Day() <= 23 {
+ // RG 91 entry 18: the late-Advent ferias (17-23 Dec) are II class
+ // privileged ferias, not the ordinary III class of the rest of Advent.
+ rank = RankClass2
+ }
+ if season == Christmas && date.Month() == time.December && date.Day() >= 26 {
+ // RG 67/68: "Dies infra octavam sunt II classis" -- days within the
+ // Octave of the Nativity (26-31 Dec) are II class; the octave day
+ // itself (1 Jan) is I class, already handled above as a named feast.
+ rank = RankClass2
+ }
if efPrivilegedFeria(date, easter) {
// The days of Holy Week and the privileged octaves of Easter and
// Pentecost are I class; no saint's feast is admitted (the feast is
@@ -173,12 +215,11 @@ func temporalEF(date time.Time) temporalDay {
// the temporal office win, matching the 1962 occurrence rules.
rank = RankClass1
}
- // The Ember Days of September and Advent (Wednesday, Friday, Saturday after
- // the third Sunday of the month/season) are II class privileged ferias in
- // violet; they displace a III class saint (only commemorated). The Lenten
- // Ember days are already III class Lenten ferias, and the Whitsun Ember days
- // fall inside the I class Pentecost octave handled above.
- if es, ok := efEmberSlug(date, y); ok {
+ // The Ember Days of September, Advent and Lent (Wednesday, Friday, Saturday
+ // after the anchor Sunday) are II class privileged ferias in violet; they
+ // displace a III class saint (only commemorated) -- RG 91 entry 18. The
+ // Whitsun Ember days fall inside the I class Pentecost octave handled above.
+ if es, ok := efEmberSlug(date, y, easter); ok {
return efCel(season, es, Violet, RankClass2, week)
}
// Unique ferial slug (season-week-weekday) so proper ferias (Lent, Advent,
@@ -197,22 +238,26 @@ func thirdSundayOfSeptember(y int) time.Time {
}
// efEmberSlug returns the Ember-day slug for date, if it is an Ember Wednesday,
-// Friday or Saturday of September or Advent (the day after the third Sunday +
-// 3/5/6). Returns ("", false) otherwise.
-func efEmberSlug(date time.Time, y int) (string, bool) {
+// Friday or Saturday of September, Advent or Lent (the day after the anchor
+// Sunday + 3/5/6: the third Sunday of September, Advent III, or Lent I --
+// MR1962, "De anno et eius partibus", Quatuor Tempora). Returns ("", false)
+// otherwise. The Whitsun Ember days are not listed here: they fall inside the
+// I class Pentecost octave, handled separately by efPrivilegedFeria.
+func efEmberSlug(date time.Time, y int, easter time.Time) (string, bool) {
for _, e := range []struct {
- third time.Time
+ anchor time.Time
season string
}{
{thirdSundayOfSeptember(y), "september"},
{adventStart(y).AddDate(0, 0, 14), "advent"}, // 3rd Sunday of Advent
+ {easter.AddDate(0, 0, -42), "lent"}, // 1st Sunday of Lent
} {
switch {
- case sameDay(date, e.third.AddDate(0, 0, 3)):
+ case sameDay(date, e.anchor.AddDate(0, 0, 3)):
return "ef-" + e.season + "-ember-wed", true
- case sameDay(date, e.third.AddDate(0, 0, 5)):
+ case sameDay(date, e.anchor.AddDate(0, 0, 5)):
return "ef-" + e.season + "-ember-fri", true
- case sameDay(date, e.third.AddDate(0, 0, 6)):
+ case sameDay(date, e.anchor.AddDate(0, 0, 6)):
return "ef-" + e.season + "-ember-sat", true
}
}
diff --git a/internal/calendar/temporal_ef_test.go b/internal/calendar/temporal_ef_test.go
index 6d632e2..8b1ffa5 100644
--- a/internal/calendar/temporal_ef_test.go
+++ b/internal/calendar/temporal_ef_test.go
@@ -38,3 +38,95 @@ func TestTemporalEFChristTheKing(t *testing.T) {
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).
+func TestTemporalEFHolyThursdayColour(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)
+ if got := temporalEF(holyThu).Cel.Colour; got != White {
+ t.Errorf("Holy Thursday colour = %s, want white", got)
+ }
+ if got := temporalEF(goodFri).Cel.Colour; got != Violet {
+ t.Errorf("Good Friday colour = %s, want violet (unchanged)", got)
+ }
+ 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)
+ }
+ }
+}