diff options
| -rw-r--r-- | internal/calendar/precedence_ef_repro_test.go | 45 | ||||
| -rw-r--r-- | internal/calendar/temporal_ef.go | 81 | ||||
| -rw-r--r-- | internal/calendar/temporal_ef_test.go | 92 |
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) + } + } +} |
