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
|
module V = Rite_ef.Vocab_ef
let test_vocab_roundtrips () =
List.iter
(fun s ->
Alcotest.(check bool) "season string roundtrip" true
(V.season_of_string (V.season_to_string s) = Some s))
V.seasons;
List.iter
(fun r ->
Alcotest.(check bool) "rank string roundtrip" true
(V.rank_of_string (V.rank_to_string r) = Some r))
V.ranks;
Alcotest.(check int) "eight seasons" 8 (List.length V.seasons);
Alcotest.(check int) "four ranks" 4 (List.length V.ranks)
(* Slug words are lectio's lectionary-key vocabulary, deliberately distinct from
the season names (spec §4.4, plan correction 2). *)
let test_slug_words () =
Alcotest.(check string) "paschaltide slugs as easter" "easter" (V.season_slug_word V.Paschaltide);
Alcotest.(check string) "christmastide slugs as christmas" "christmas"
(V.season_slug_word V.Christmastide);
Alcotest.(check string) "season name differs" "paschaltide" (V.season_to_string V.Paschaltide)
module T = Rite_ef.Temporal_ef
module D = Colitur_kernel.Date
let d y m dd = match D.make ~year:y ~month:m ~day:dd with
| Ok t -> t | Error e -> Alcotest.failf "%s" e
let season_str dt = V.season_to_string (T.season dt)
let test_advent_start () =
(* Advent 2026 begins Sunday 29 November 2026. *)
Alcotest.(check string) "advent 2026" "2026-11-29" (D.to_iso8601 (T.advent_start 2026));
Alcotest.(check bool) "always a Sunday" true (D.weekday (T.advent_start 2026) = D.Sun)
let test_seasons () =
(* Easter 2026 is 5 April. *)
Alcotest.(check string) "1 Dec 2026" "advent" (season_str (d 2026 12 1));
Alcotest.(check string) "24 Dec 2026" "advent" (season_str (d 2026 12 24));
Alcotest.(check string) "25 Dec 2026" "christmastide" (season_str (d 2026 12 25));
(* RG 72-73: Christmas Time runs to 13 January INCLUSIVE -- the deliberate
divergence from lectio, which starts time-after-epiphany at 6 January. *)
Alcotest.(check string) "6 Jan 2026" "christmastide" (season_str (d 2026 1 6));
Alcotest.(check string) "13 Jan 2026" "christmastide" (season_str (d 2026 1 13));
Alcotest.(check string) "14 Jan 2026" "time-after-epiphany" (season_str (d 2026 1 14));
Alcotest.(check string) "Septuagesima 1 Feb 2026" "septuagesima" (season_str (d 2026 2 1));
Alcotest.(check string) "Ash Wed 18 Feb 2026" "lent" (season_str (d 2026 2 18));
Alcotest.(check string) "Passion Sun 22 Mar 2026" "passiontide" (season_str (d 2026 3 22));
(* Holy Saturday stays in Passiontide: the Vigil is a night Mass (spec §4.2). *)
Alcotest.(check string) "Holy Sat 4 Apr 2026" "passiontide" (season_str (d 2026 4 4));
Alcotest.(check string) "Easter 5 Apr 2026" "paschaltide" (season_str (d 2026 4 5));
Alcotest.(check string) "Sat after Pentecost 30 May 2026" "paschaltide" (season_str (d 2026 5 30));
Alcotest.(check string) "Trinity 31 May 2026" "time-after-pentecost" (season_str (d 2026 5 31))
let named_slug dt = match T.named dt with
| Some (_, slug, _, _, _) -> slug
| None -> "<none>"
let test_named_feasts () =
(* Easter 2026 = 5 April. *)
Alcotest.(check string) "nativity" "ef-nativity" (named_slug (d 2026 12 25));
Alcotest.(check string) "nativity vigil" "ef-nativity-vigil" (named_slug (d 2026 12 24));
Alcotest.(check string) "circumcision" "ef-circumcision" (named_slug (d 2026 1 1));
Alcotest.(check string) "epiphany" "ef-epiphany" (named_slug (d 2026 1 6));
Alcotest.(check string) "ash wednesday" "ef-ash-wednesday" (named_slug (d 2026 2 18));
Alcotest.(check string) "passion sunday" "ef-passion-sunday" (named_slug (d 2026 3 22));
Alcotest.(check string) "palm sunday" "ef-palm-sunday" (named_slug (d 2026 3 29));
Alcotest.(check string) "easter" "ef-easter-sunday" (named_slug (d 2026 4 5));
Alcotest.(check string) "low sunday" "ef-low-sunday" (named_slug (d 2026 4 12));
Alcotest.(check string) "ascension vigil" "ef-ascension-vigil" (named_slug (d 2026 5 13));
Alcotest.(check string) "ascension" "ef-ascension" (named_slug (d 2026 5 14));
Alcotest.(check string) "pentecost vigil" "ef-pentecost-vigil" (named_slug (d 2026 5 23));
Alcotest.(check string) "pentecost" "ef-pentecost" (named_slug (d 2026 5 24));
Alcotest.(check string) "trinity" "ef-trinity" (named_slug (d 2026 5 31));
Alcotest.(check string) "corpus christi" "ef-corpus-christi" (named_slug (d 2026 6 4));
Alcotest.(check string) "sacred heart" "ef-sacred-heart" (named_slug (d 2026 6 12));
Alcotest.(check string) "an ordinary day is not named" "<none>" (named_slug (d 2026 7 15))
let test_christ_the_king () =
(* 1960 calendar: the LAST Sunday of October, not the OF's last before Advent. *)
Alcotest.(check string) "2026" "2026-10-25" (D.to_iso8601 (T.christ_the_king 2026));
Alcotest.(check bool) "always a Sunday" true (D.weekday (T.christ_the_king 2026) = D.Sun);
Alcotest.(check string) "slug" "ef-christ-the-king" (named_slug (T.christ_the_king 2026))
let test_nativity_octave () =
Alcotest.(check string) "29 Dec" "ef-nativity-octave-day-5" (named_slug (d 2026 12 29));
Alcotest.(check string) "31 Dec" "ef-nativity-octave-day-7" (named_slug (d 2026 12 31))
let sunday_slug_of dt = match T.sunday_slug dt with Some s -> s | None -> "<none>"
let test_week_numbers () =
Alcotest.(check (option int)) "Advent I 2026" (Some 1) (T.week (T.advent_start 2026));
Alcotest.(check (option int)) "Advent II 2026" (Some 2)
(T.week (D.add_days (T.advent_start 2026) 7));
(* Weeks are Sunday-aligned: the Saturday of week 1 is still week 1. *)
Alcotest.(check (option int)) "Advent I Saturday" (Some 1)
(T.week (D.add_days (T.advent_start 2026) 6));
(* Time after Pentecost counts from Pentecost, so Trinity is week 1. *)
Alcotest.(check (option int)) "Trinity 2026 is week 1" (Some 1) (T.week (d 2026 5 31));
(* Christmastide has no numbered weeks. *)
Alcotest.(check (option int)) "Christmastide has no week" None (T.week (d 2026 12 30));
(* Ash Wednesday is 4 days before the Lent I origin (22 Feb 2026): it belongs
to no numbered week. This is the only place in the system [floor_div]'s
negative branch fires -- guard against a regression to plain [/], which
would wrongly round this up to week 1. *)
Alcotest.(check (option int)) "Ash Wednesday has no week" None (T.week (d 2026 2 18))
let test_sunday_slugs () =
Alcotest.(check string) "Advent I" "ef-advent-sunday-1" (sunday_slug_of (T.advent_start 2026));
Alcotest.(check string) "Lent I 2026 (22 Feb)" "ef-lent-sunday-1" (sunday_slug_of (d 2026 2 22));
(* Slugs use lectio's season words: Paschaltide slugs as "easter". *)
Alcotest.(check string) "Paschaltide III 2026 (19 Apr)" "ef-easter-sunday-3"
(sunday_slug_of (d 2026 4 19));
(* The Sunday within the Octave of the Nativity keeps lectio's key. *)
Alcotest.(check string) "27 Dec 2026" "ef-christmas-sunday-0" (sunday_slug_of (d 2026 12 27));
(* The Sunday falling 7-13 Jan is the 1st Sunday after Epiphany. Its season is
Christmastide (RG 72-73) but its lectionary key stays lectio's. *)
Alcotest.(check string) "11 Jan 2026" "ef-time-after-epiphany-sunday-1"
(sunday_slug_of (d 2026 1 11))
(* Cross-check: on an ordinary (non-Last, non-resumed) Sunday after Pentecost,
the number embedded in [sunday_slug] must equal [week]. The two functions
independently compute Pentecost-relative week arithmetic; nothing else pins
them together, so a future rubric fix to one that is not mirrored in the
other would otherwise diverge silently. *)
let test_week_sunday_slug_agree () =
let easter = Colitur_kernel.Computus.gregorian_easter 2026 in
let pentecost = D.add_days easter 49 in
List.iter
(fun n ->
let dt = D.add_days pentecost (7 * n) in
let slug = sunday_slug_of dt in
let embedded = Scanf.sscanf slug "ef-time-after-pentecost-sunday-%d" (fun k -> k) in
Alcotest.(check (option int))
(Printf.sprintf "week %d after Pentecost matches slug" n)
(Some embedded) (T.week dt))
[ 1; 5; 10; 15; 20 ]
(* The resumed-Sunday tail: when Easter is early there are more than 23 Sundays
after Pentecost, and the surplus resume the Sundays after Epiphany that
Septuagesima cut short. 2038 has Easter on 25 April (the latest possible) and
1943-style early years have the most Sundays; 2035 (Easter 25 March) is an
early-Easter year with a long tail. *)
let test_resumed_sundays () =
let last_sunday_before_advent y = D.add_days (T.advent_start y) (-7) in
(* The last Sunday before Advent always keeps the 24th (Last) Mass. *)
Alcotest.(check string) "2035 last Sunday" "ef-time-after-pentecost-sunday-24"
(sunday_slug_of (last_sunday_before_advent 2035));
Alcotest.(check string) "2026 last Sunday" "ef-time-after-pentecost-sunday-24"
(sunday_slug_of (last_sunday_before_advent 2026));
(* In an early-Easter year the surplus Sundays route to Epiphany Masses. *)
let resumed =
List.filter_map
(fun n ->
let dt = D.add_days (T.advent_start 2035) (-7 - (7 * n)) in
let s = sunday_slug_of dt in
if String.length s >= 27 && String.sub s 0 27 = "ef-time-after-epiphany-sund" then Some s
else None)
[ 1; 2; 3; 4; 5 ]
in
Alcotest.(check bool) "2035 has resumed Epiphany Sundays" true (resumed <> [])
module Cel = Colitur_kernel.Celebration
module Sl = Colitur_kernel.Slug
module Colr = Colitur_kernel.Colour
let office dt = (T.temporal dt).Colitur_kernel.Temporal.office
let slug_of dt = Sl.to_string (office dt).Cel.slug
let rank_of dt = V.rank_to_string (office dt).Cel.rank
let colour_of dt = Colr.to_string (office dt).Cel.colour
let test_ferial_slugs_and_ranks () =
(* Ferias key on season-week-weekday, matching lectio's lectionary keys. *)
Alcotest.(check string) "Lent feria" "ef-lent-3-monday" (slug_of (d 2026 3 9));
(* RG 91 entry 22: Lent and Passiontide ferias are III class. *)
Alcotest.(check string) "Lent feria is III class" "class-3" (rank_of (d 2026 3 9));
(* RG 91 entry 25: Advent ferias to 16 December are III class ... *)
Alcotest.(check string) "Advent 15 Dec is III class" "class-3" (rank_of (d 2026 12 15));
(* ... entry 18: Advent 17-23 December are II class. lectio marks all Advent
ferias III class; this is a deliberate divergence. 21 Dec (not 18 Dec) is
used deliberately: 18 Dec 2026 is also the Advent Ember Friday, which
would independently be II class via the Ember rule and so would not
isolate this one. *)
Alcotest.(check string) "Advent 21 Dec is II class" "class-2" (rank_of (d 2026 12 21));
(* RG 91 entry 28: per annum ferias are IV class. *)
Alcotest.(check string) "per annum feria is IV class" "class-4" (rank_of (d 2026 7 15));
(* RG 91 entries 7 and 10: Holy Week and the privileged octaves are I class. *)
Alcotest.(check string) "Holy Monday is I class" "class-1" (rank_of (d 2026 3 30));
(* Good Friday: RG 91 entry 7 covers only Ash Wed and Mon-Wed of Holy Week --
Thursday-Saturday are the Sacred Triduum, entry 2, still I class. *)
Alcotest.(check string) "Good Friday is I class" "class-1" (rank_of (d 2026 4 3));
Alcotest.(check string) "Easter Monday is I class" "class-1" (rank_of (d 2026 4 6));
Alcotest.(check string) "Whit Monday is I class" "class-1" (rank_of (d 2026 5 25))
let test_after_ashes () =
Alcotest.(check string) "Thursday after Ash Wednesday" "ef-lent-after-ashes-thursday"
(slug_of (d 2026 2 19));
Alcotest.(check (option int)) "outside a numbered week" None
(T.temporal (d 2026 2 19)).Colitur_kernel.Temporal.week
let test_ember_days () =
(* September Ember days: Wed/Fri/Sat after the third Sunday of September.
Third Sunday of September 2026 = 20 September. *)
Alcotest.(check string) "September Ember Wed" "ef-september-ember-wed" (slug_of (d 2026 9 23));
Alcotest.(check string) "September Ember Sat" "ef-september-ember-sat" (slug_of (d 2026 9 26));
Alcotest.(check string) "September Ember is II class" "class-2" (rank_of (d 2026 9 23));
(* Advent Ember days: after Advent III (2026: 13 December). *)
Alcotest.(check string) "Advent Ember Wed" "ef-advent-ember-wed" (slug_of (d 2026 12 16));
(* Lenten Ember days: after Lent I (2026: 22 February). RG 91 entry 18 makes
them II class -- entry 22 excepts them from the III-class Lenten ferias.
lectio treats them as ordinary Lenten ferias. *)
Alcotest.(check string) "Lent Ember Wed" "ef-lent-ember-wed" (slug_of (d 2026 2 25));
Alcotest.(check string) "Lent Ember is II class" "class-2" (rank_of (d 2026 2 25));
(* Whitsun Ember days sit inside the I-class Pentecost octave. *)
Alcotest.(check string) "Whit Ember Wed" "ef-pentecost-ember-wed" (slug_of (d 2026 5 27));
Alcotest.(check string) "Whit Ember is I class" "class-1" (rank_of (d 2026 5 27));
(* Thursday is not an Ember day: the [day_of] guard matches only Wed/Fri/Sat
(3/5/6 days after the anchor), never day 4. 24 Sept 2026 is the Thursday
after the September anchor and must fall back to an ordinary IV-class
per annum feria, not an Ember slug. *)
Alcotest.(check string) "Thursday after the September anchor is not Ember" "class-4"
(rank_of (d 2026 9 24))
let test_rogations () =
(* RG 80/87: Minor Litanies on the Monday and Tuesday before Ascension. The
Wednesday is the Ascension vigil (see Task 11). *)
Alcotest.(check string) "Rogation Monday" "ef-rogation-monday" (slug_of (d 2026 5 11));
Alcotest.(check string) "Rogation Tuesday" "ef-rogation-tuesday" (slug_of (d 2026 5 12));
Alcotest.(check string) "Wednesday is the vigil" "ef-ascension-vigil" (slug_of (d 2026 5 13));
(* RG 88: "de Litaniis minoribus nihil fit in Officio" -- the Rogation
changes the Mass, not the Office, and no RG 91 table entry ranks these
days specially, so they keep the ordinary IV-class rank of an unprivileged
Paschaltide feria (entry 28's catch-all). *)
Alcotest.(check string) "Rogation Monday keeps the ordinary ferial rank" "class-4"
(rank_of (d 2026 5 11))
let test_colours () =
Alcotest.(check string) "Advent is violet" "violet" (colour_of (d 2026 12 1));
Alcotest.(check string) "per annum is green" "green" (colour_of (d 2026 7 15));
Alcotest.(check string) "Paschaltide is white" "white" (colour_of (d 2026 4 20));
Alcotest.(check string) "Pentecost octave is red" "red" (colour_of (d 2026 5 25));
(* Gaudete = Advent III, Laetare = Lent IV. *)
Alcotest.(check string) "Gaudete is rose" "rose" (colour_of (d 2026 12 13));
Alcotest.(check string) "Laetare is rose" "rose" (colour_of (d 2026 3 15))
let test_totality () =
(* Every day of 2026 yields an office, and every slug is well-formed. *)
let jan1 = d 2026 1 1 in
for i = 0 to 364 do
let dt = D.add_days jan1 i in
let s = slug_of dt in
match Sl.of_string s with
| Ok _ -> ()
| Error e -> Alcotest.failf "%s: %s" (D.to_iso8601 dt) e
done
let suite_extra =
[ Alcotest.test_case "advent start" `Quick test_advent_start;
Alcotest.test_case "seasons" `Quick test_seasons;
Alcotest.test_case "named feasts" `Quick test_named_feasts;
Alcotest.test_case "christ the king" `Quick test_christ_the_king;
Alcotest.test_case "nativity octave" `Quick test_nativity_octave;
Alcotest.test_case "week numbers" `Quick test_week_numbers;
Alcotest.test_case "sunday slugs" `Quick test_sunday_slugs;
Alcotest.test_case "week/sunday_slug agree" `Quick test_week_sunday_slug_agree;
Alcotest.test_case "resumed sundays" `Quick test_resumed_sundays;
Alcotest.test_case "ferial slugs and ranks" `Quick test_ferial_slugs_and_ranks;
Alcotest.test_case "after ashes" `Quick test_after_ashes;
Alcotest.test_case "ember days" `Quick test_ember_days;
Alcotest.test_case "rogations" `Quick test_rogations;
Alcotest.test_case "colours" `Quick test_colours;
Alcotest.test_case "totality" `Quick test_totality ]
let suite =
( "Rite_ef",
[ Alcotest.test_case "vocab roundtrips" `Quick test_vocab_roundtrips;
Alcotest.test_case "slug words" `Quick test_slug_words ]
@ suite_extra )
|