aboutsummaryrefslogtreecommitdiff
path: root/internal/calendar/temporal_ef_test.go
blob: d930cc70e344b39140b9f422e8bc25c73254afe9 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
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)
	}
}

// TestTemporalEFPassiontideWeeks pins that Passion week and Holy Week are
// numbered apart. They are two weeks with entirely different Masses, and
// efWeek used to give both 0 -- so every day of Holy Week took the slug of its
// Passion-week namesake and the two shared one set of readings.
//
// Good Friday is the case that shows why it matters: it was reading Passion
// Friday's Mass.
func TestTemporalEFPassiontideWeeks(t *testing.T) {
	easter := d("2026-04-05")
	for _, c := range []struct {
		off  int
		slug string
	}{
		{-13, "ef-passiontide-1-monday"}, // Passion week
		{-11, "ef-passiontide-1-wednesday"},
		{-6, "ef-passiontide-2-monday"}, // Holy Week
		{-2, "ef-passiontide-2-friday"}, // Good Friday
	} {
		day := easter.AddDate(0, 0, c.off)
		if got := temporalEF(day).Cel.Slug; got != c.slug {
			t.Errorf("Easter%+d (%s): slug = %q, want %q -- Passion week and Holy Week "+
				"must not share a slug", c.off, day.Format("2006-01-02"), got, c.slug)
		}
	}
}