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
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
|
(* Task 4 (2026-08-25-colitur-of-phases-3-5): the reading-cycle arithmetic
and the temporal + sanctoral lectionary resolution chain,
Rite_of.Lectionary_of.
No Rite_of.context exists yet (Task 5's own deliverable), so the
coverage test below builds its own APPROXIMATE day resolver directly
against Layer.on_date + Temporal_of.temporal, rather than through
Calendar.day/Colitur_kernel.Validate -- it does not run RG-style
occurrence/precedence resolution (Precedence_of.rules is Task 5's own
wiring point too), so on a date where more than one sanctoral entry
would occur it simply takes the layer's own canonically-sorted first
match. This is not a claim that the FULL calendar resolves this way; it
is the minimum needed to exercise Lectionary_of.readings' own
observed-vs-temporal branch honestly, over real (not synthetic) data. *)
module L = Colitur_kernel.Layer
module O = Colitur_kernel.Overlay
module Cel = Colitur_kernel.Celebration
module D = Colitur_kernel.Date
module Slug = Colitur_kernel.Slug
module Mass_formulary = Colitur_kernel.Mass_formulary
module Lectionary = Colitur_kernel.Lectionary
module Computus = Colitur_kernel.Computus
module V = Rite_of.Vocab_of
module T = Rite_of.Temporal_of
module Lect_of = Rite_of.Lectionary_of
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 iso = D.to_iso8601
(* ---- fixtures -------------------------------------------------------- *)
let lectionary_path = "../data/of/lectionary.sexp"
let calendar_path = "../data/of/calendar-2002.sexp"
let amendments_dir = "../data/of/amendments/"
let amendment_files =
[ "001-padre-pio.sexp"; "002-juan-diego-cuauhtlatoatzin.sexp"; "003-our-lady-of-guadalupe.sexp";
"004-john-xxiii-john-paul-ii.sexp"; "005-mary-magdalene-rank.sexp";
"006-mary-mother-of-the-church.sexp"; "007-paul-vi.sexp"; "008-our-lady-of-loreto.sexp";
"009-faustina-kowalska.sexp"; "010-narek-avila-hildegard.sexp"; "011-martha-mary-lazarus.sexp";
"012-teresa-of-calcutta.sexp"; "013-john-henry-newman.sexp" ]
let real_lectionary () =
match Lectionary.load lectionary_path with
| Ok l -> l
| Error e -> Alcotest.failf "%s: failed to load: %s" lectionary_path e
let real_sanctoral_layer () =
let base =
match L.load V.rank_of_sexp calendar_path with
| Ok l -> l
| Error e -> Alcotest.failf "%s: failed to load: %s" calendar_path e
in
let overlays =
List.map
(fun name ->
let path = amendments_dir ^ name in
match O.load V.rank_of_sexp path with
| Ok o -> o
| Error e -> Alcotest.failf "%s: failed to load: %s" path e)
amendment_files
in
let merged, diagnostics = O.merge base overlays in
Alcotest.(check (list string)) "the committed OF overlays apply cleanly, no diagnostics" []
(List.map O.diagnostic_to_string diagnostics);
merged
(* Approximate "observed" celebration for [date]: the sanctoral layer's own
(canonically-sorted) first match if any, else the day's own temporal
office -- see this file's own top-of-file comment for why this is a
deliberately simplified stand-in for real occurrence resolution. *)
let observed_on index date temporal =
match L.on_date index date with
| e :: _ -> e.L.cel
| [] -> temporal.Colitur_kernel.Temporal.office
let readings_on lectionary index date =
let temporal = T.temporal date in
let observed = observed_on index date temporal in
Lect_of.readings ~lectionary ~year_start:T.year_start ~observed ~temporal ~date ~temporal_at:T.temporal
(* ---- cycle arithmetic (OLM 1981 Praenotanda n.66/n.69 -- see
lectionary_of.mli's own citations, page-image verified) ---- *)
let sunday_letter c = Lect_of.sunday_cycle_letter c
let weekday_letter c = Lect_of.weekday_cycle_letter c
(* Every case here is derivable from OLM n.66's own footnote 102, whose
worked example ("annus 1980 est annus C, annus vero sequens, scilicet
annus 1981, est annus A, annus vero 1982 est annus B, et annus 1983 est
iterum annus C") is reproduced directly by testing dates EITHER SIDE of
Advent 1980/1981/1982 -- the task brief's own "straddle Advent I inside
a single civil year" requirement, exercised three times over, against
the primary source's own numbers, not invented ones. *)
let test_sunday_cycle_table () =
let cases =
[ (* Just before Advent 1980 (still the liturgical year opened Advent
1979, labelled 1980 -- OLM's own "1980 est annus C"), and Advent
1980 itself (opens the year labelled 1981 -- "1981, est annus A"):
both civil dates fall in the SAME civil year, 1980, on either side
of that year's own Advent I -- the boundary the brief names. *)
(D.add_days (T.advent_start 1980) (-1), "c", "before Advent 1980 (civil year 1980, label 1980)");
(T.advent_start 1980, "a", "Advent Sunday 1980 itself (civil year 1980, label 1981)");
(D.add_days (T.advent_start 1980) 3, "a", "a few days into Advent 1980 (still label 1981)");
(* Advent 1981 -> 1982 (A -> B). *)
(D.add_days (T.advent_start 1981) (-1), "a", "before Advent 1981 (label 1981)");
(T.advent_start 1981, "b", "Advent Sunday 1981 (label 1982)");
(* Advent 1982 -> 1983 (B -> C). *)
(D.add_days (T.advent_start 1982) (-1), "b", "before Advent 1982 (label 1982)");
(T.advent_start 1982, "c", "Advent Sunday 1982 (label 1983)");
(* A live-relevant pair: Advent 2025 (opens the "2026" liturgical
year, this task's own civil year) is label 2026, 2026 mod 3 = 1
-> A; the civil days immediately before it are still label 2025,
2025 mod 3 = 0 -> C. *)
(D.add_days (T.advent_start 2025) (-1), "c", "before Advent 2025 (label 2025)");
(T.advent_start 2025, "a", "Advent Sunday 2025 (label 2026)")
]
in
List.iter
(fun (date, expected, label) ->
Alcotest.(check string) (label ^ " (" ^ iso date ^ ")") expected
(sunday_letter (Lect_of.sunday_cycle ~year_start:T.year_start date)))
cases
(* OLM n.69 point 4: Year I odd label years, Year II even. Reuses the SAME
Advent-1980/1981/1982 boundaries as the Sunday-cycle table above (label
1980 even -> II, 1981 odd -> I, 1982 even -> II, 1983 odd -> I),
straddling Advent I within a single civil year exactly as the Sunday
table does. *)
let test_weekday_cycle_table () =
let cases =
[ (D.add_days (T.advent_start 1980) (-1), "ii", "before Advent 1980 (label 1980, even)");
(T.advent_start 1980, "i", "Advent Sunday 1980 (label 1981, odd)");
(D.add_days (T.advent_start 1981) (-1), "i", "before Advent 1981 (label 1981, odd)");
(T.advent_start 1981, "ii", "Advent Sunday 1981 (label 1982, even)");
(D.add_days (T.advent_start 1982) (-1), "ii", "before Advent 1982 (label 1982, even)");
(T.advent_start 1982, "i", "Advent Sunday 1982 (label 1983, odd)");
(D.add_days (T.advent_start 2025) (-1), "i", "before Advent 2025 (label 2025, odd)");
(T.advent_start 2025, "ii", "Advent Sunday 2025 (label 2026, even)")
]
in
List.iter
(fun (date, expected, label) ->
Alcotest.(check string) (label ^ " (" ^ iso date ^ ")") expected
(weekday_letter (Lect_of.weekday_cycle ~year_start:T.year_start date)))
cases
(* The two cycles are independent computations, not two views of the same
number -- Advent 1981 (label 1982) is Sunday-cycle B but weekday-cycle
II, a label that is even for one modulus and not reducible to the
other. *)
let test_cycles_are_independent () =
let date = T.advent_start 1981 in
Alcotest.(check string) "Sunday cycle B" "b" (sunday_letter (Lect_of.sunday_cycle ~year_start:T.year_start date));
Alcotest.(check string) "weekday cycle II" "ii"
(weekday_letter (Lect_of.weekday_cycle ~year_start:T.year_start date))
(* ---- SHA-256 pins ----------------------------------------------------- *)
let sha256_of_file path =
let tmp = Filename.temp_file "colitur_lectionary_of_sha256" ".txt" in
let cmd = Printf.sprintf "sha256sum %s > %s" (Filename.quote path) (Filename.quote tmp) in
(match Sys.command cmd with
| 0 -> ()
| n -> Alcotest.failf "sha256sum exited %d" n);
let ic = open_in tmp in
let line = input_line ic in
close_in ic;
Sys.remove tmp;
match String.split_on_char ' ' line with
| hash :: _ -> hash
| [] -> Alcotest.fail "sha256sum produced no output"
(* Pinned 2026-08-26 (FIX 1, O-Antiphon/Christmas-season date-keyed review)
against the shipped file itself, independently re-derived
(`sha256sum data/of/lectionary.sexp`), the same "derive the new value,
don't transcribe it from generator stdout" discipline
test_calendar_of_data.ml's own [test_sha256] already follows. *)
let test_sha256_pinned () =
(* Fix wave I8 (final-review.md, 2026-08-25-colitur-of-phases-3-5): the
hash changed again -- ONE genuine new entry this time ("of-nativity",
the Christmas Day Mass hand-authored from OLM 1981; see
test_coverage_2026's own citation), not header-only as the I2 change
just above was. Re-derived with `sha256sum data/of/lectionary.sexp`,
same discipline.
Fix wave I7 (final-review.md, 2026-08-25-colitur-of-phases-3-5,
2026-08-26): changed again -- {!Colitur_kernel.Rite.t.citation_shapes}
(kernel change) plus tools/bootstrap_lectionary_of.ml's own [cite] now
also extract a Second reading on every Sunday-cycle entry (183 new
citation fields; see data/of/lectionary.sexp's own header COVERAGE (5)
and test_citation_coverage_of.ml), and the header's "Epistle" wording
is fixed to "First". Re-derived the same way, same discipline. *)
Alcotest.(check string) "data/of/lectionary.sexp SHA-256"
"0d82069e879d8312943dee543aabc23208265534fcf7edd85c3d9137c2f4581d" (sha256_of_file lectionary_path)
(* lectio's OWN of-lectionary.ini SHA-256, pinned inside data/of/
lectionary.sexp's own provenance header (tools/bootstrap_lectionary_of
.ml's own emitted "; SHA-256: ..." line for the SOURCE, not the file
pinned above) -- checked here as a plain substring of the shipped file,
not by re-reading the sibling lectio repository (which need not be
present wherever this test runs; the point of pinning it in the header
is that this repo alone is then enough to know which snapshot produced
it). *)
let test_source_sha256_pinned_in_header () =
let ic = open_in lectionary_path in
let n = in_channel_length ic in
let content = really_input_string ic n in
close_in ic;
let needle = "SHA-256: f0b527aef58b93e6ccdfbeafe52b340ed2a9d7d72805bf5083e3da04b080123c" in
let contains haystack needle =
let hl = String.length haystack and nl = String.length needle in
let rec go i = i + nl <= hl && (String.sub haystack i nl = needle || go (i + 1)) in
go 0
in
Alcotest.(check bool) "lectio's of-lectionary.ini SHA-256 is pinned in the provenance header" true
(contains content needle)
(* A missing lectio snapshot must fail LOUDLY -- Task 1's own extractor
shipped exactly this bug (parse_lectio_ini swallowing FileNotFoundError,
silently producing a DIFFERENT file with a different SHA-256 and zero
`en` names) and needed a review round to fix; this task's own brief
names it explicitly as a trap not to repeat. Exercised here as a real
subprocess run, not merely by reading the generator's source: invokes
the built tools/bootstrap_lectionary_of.exe against a path that does
not exist and asserts BOTH a non-zero exit AND that no output file was
written (a generator that failed loudly but left a partial/stale
destination file behind would be only half fixed). *)
let test_missing_source_fails_loudly () =
let exe = "../tools/bootstrap_lectionary_of.exe" in
let bogus_src = Filename.temp_file "colitur_lectionary_of_missing" ".ini" in
Sys.remove bogus_src;
(* guaranteed not to exist, still a plausible-looking path *)
let dst = Filename.temp_file "colitur_lectionary_of_dst" ".sexp" in
Sys.remove dst;
let log = Filename.temp_file "colitur_lectionary_of_stderr" ".txt" in
let cmd =
Printf.sprintf "%s %s %s >%s 2>&1" (Filename.quote exe) (Filename.quote bogus_src) (Filename.quote dst)
(Filename.quote log)
in
let status = Sys.command cmd in
let ic = open_in log in
let n = in_channel_length ic in
let stderr_text = really_input_string ic n in
close_in ic;
Sys.remove log;
Alcotest.(check bool) "non-zero exit on a missing source file" true (status <> 0);
Alcotest.(check bool) "no output file written" false (Sys.file_exists dst);
Alcotest.(check bool) "error message names the missing path and explains why this matters" true
(let contains haystack needle =
let hl = String.length haystack and nl = String.length needle in
let rec go i = i + nl <= hl && (String.sub haystack i nl = needle || go (i + 1)) in
go 0
in
contains stderr_text "no such file" && contains stderr_text bogus_src)
(* ---- basic load ------------------------------------------------------- *)
let test_load () =
let l = real_lectionary () in
(* 771, not 770: fix wave I8 (final-review.md) added ONE hand-authored
entry, "of-nativity" (the Christmas Day Mass, absent from lectio's own
source entirely -- see this file's own [test_coverage_2026] and
tools/bootstrap_lectionary_of.ml's own [hand_authored] table for the
citation and full argument). *)
Alcotest.(check int) "771 entries" 771 (List.length (Lectionary.entries l))
(* ---- coverage: a full sample civil year, both directions, PINNED ------ *)
(* Civil year 2026 -- the same year this task's data file's own coverage
report uses. Iterates every day, resolving readings the same
approximate way [readings_on] does (see this file's own top-of-file
comment), and asserts BOTH the total resolved count AND the exact,
named set of unresolved dates -- never a silent zero (task brief's own
Step 4 requirement). *)
let all_days_of_2026 =
let days_in_month = [| 31; 28; 31; 30; 31; 30; 31; 31; 30; 31; 30; 31 |] in
List.concat
(List.init 12 (fun mi ->
let m = mi + 1 in
List.init days_in_month.(mi) (fun di -> d 2026 m (di + 1))))
let test_coverage_2026 () =
let lectionary = real_lectionary () in
let layer = real_sanctoral_layer () in
let index = L.index layer ~easter:Computus.gregorian_easter ~years:[ 2025; 2026 ] in
let unresolved =
List.filter_map
(fun date ->
match readings_on lectionary index date with
| None, [] -> Some (iso date)
| Some _, (_ :: _) -> None
| (Some _, []) | (None, _ :: _) ->
Alcotest.failf "%s: Mass_formulary.t and citations disagree on whether readings resolved"
(iso date))
all_days_of_2026
in
Alcotest.(check int) "365 days in 2026" 365 (List.length all_days_of_2026);
(* CLOSED, fix wave I8 (final-review.md, 2026-08-25-colitur-of-phases-3-5):
lectio's 988 keys never included a Christmas DAY Mass (only the
Vigil) -- tools/bootstrap_lectionary_of.ml's own [hand_authored] table
now injects it directly (Isa 52:7-10/John 1:1-18, OLM 1981's own "16
Ad Missam in die", docs/research/of/olm-1981-ocr.txt:4513-4520), so
the set that used to be pinned as {25 December} is now pinned as
EMPTY -- still a NAMED set, not a bare count collapsing to zero by
coincidence: a future regression reopening this (or any other) gap
still fails loudly by naming the date, not merely by a count
mismatch. *)
Alcotest.(check (list string)) "the unresolved-day set is empty" [] unresolved;
Alcotest.(check int) "365 of 365 days resolve readings" 365 (365 - List.length unresolved)
(* Same measurement, restated as "how many days resolve" rather than "how
many don't" -- redundant with the count above by construction, kept
because Step 4's own brief asks for a pinned resolved-count assertion
specifically, not only its complement. *)
let test_pinned_resolved_count () =
let lectionary = real_lectionary () in
let layer = real_sanctoral_layer () in
let index = L.index layer ~easter:Computus.gregorian_easter ~years:[ 2025; 2026 ] in
let resolved =
List.length
(List.filter
(fun date -> match readings_on lectionary index date with Some _, _ :: _ -> true | _ -> false)
all_days_of_2026)
in
Alcotest.(check int) "365 days resolve readings in 2026 (fix wave I8: was 364)" 365 resolved
(* ---- resolution chain: unit-level spot checks -------------------------- *)
(* Step 3: an ordinary Ordinary Time weekday with no sanctoral entry
resolves via its own temporal slug, cycle-selected. 16 June 2026 is a
Tuesday of Ordinary Time, in the SECOND (post-Pentecost) block, with no
calendar-2002.sexp/amendments entry (checked against the real merged
layer, not assumed, and clear of the three nearby movable solemnities
-- Corpus Christi 4 June, Sacred Heart 12 June, Immaculate Heart 13
June). The expected slug is read off Temporal_of.temporal itself, not
hand-computed: the second block's own week arithmetic (temporal_of.ml's
own [ordinary_time_week]) is counted BACKWARDS from Christ the King, not
forwards from the Baptism -- re-deriving that by hand here would just
duplicate the one calculation this test exists to exercise, not check
it independently. *)
let test_step3_temporal_ferial () =
let lectionary = real_lectionary () in
let layer = real_sanctoral_layer () in
let index = L.index layer ~easter:Computus.gregorian_easter ~years:[ 2025; 2026 ] in
let date = d 2026 6 16 in
Alcotest.(check bool) "16 June 2026: no sanctoral entry" true (L.on_date index date = []);
let expected_slug = (T.temporal date).Colitur_kernel.Temporal.office.Cel.slug in
match readings_on lectionary index date with
| Some fm, (_ :: _ as cs) ->
Alcotest.(check bool) "tagged Own_slug" true (fm.Mass_formulary.via = Mass_formulary.Own_slug);
Alcotest.(check (option string)) "said the temporal slug" (Some (Slug.to_string expected_slug))
(Option.map Slug.to_string fm.Mass_formulary.said);
Alcotest.(check int) "two citations (First + Gospel)" 2 (List.length cs)
| _ -> Alcotest.fail "16 June 2026 should resolve readings via step 3"
(* Step 2: a sanctoral saint who won the day resolves via his own slug, not
the temporal ferial's. 30 November 2026 is a Monday of Advent week 1
(which would otherwise be "of-advent-1-monday") but is also St Andrew
the Apostle's own fixed date. *)
let test_step2_sanctoral_wins () =
let lectionary = real_lectionary () in
let layer = real_sanctoral_layer () in
let index = L.index layer ~easter:Computus.gregorian_easter ~years:[ 2025; 2026 ] in
let date = d 2026 11 30 in
(match L.on_date index date with
| [ e ] -> Alcotest.(check string) "St Andrew's own entry" "andrew-the-apostle" (Slug.to_string e.L.cel.Cel.slug)
| es -> Alcotest.failf "expected exactly one sanctoral entry on 30 Nov 2026, got %d" (List.length es));
match readings_on lectionary index date with
| Some fm, (_ :: _ as cs) ->
Alcotest.(check bool) "tagged Proper" true (fm.Mass_formulary.via = Mass_formulary.Proper);
Alcotest.(check (option string)) "said Andrew's own slug" (Some "andrew-the-apostle")
(Option.map Slug.to_string fm.Mass_formulary.said);
(* "of-advent-1-monday" is FLAT, not cycle-lettered (OLM n.69 point 3:
Advent's own ferias do not change year to year -- confirmed by
this generator's own collapse, tools/bootstrap_lectionary_of.ml's
[collapse_weekday]), so there is exactly one temporal citation
set to compare against, not two. *)
Alcotest.(check bool) "NOT the temporal ferial's Advent citations" true
(cs <> Option.get (Lectionary.find lectionary (Slug.of_string_exn "of-advent-1-monday")))
| _ -> Alcotest.fail "30 November 2026 should resolve readings via step 2"
(* ---- date-keyed windows (OLM n. 69.3) -- FIX 1, 2026-08-26 review ----- *)
(* {!Lect_of.date_keyed_slug} itself, direct unit coverage of its own
boundaries: the O-Antiphon window (17-24 December), the Christmas-season
window (29-31 December, then 2-5 and 7 January), 6 January's own
deliberate exclusion (Temporal_of's own [named] fixes Epiphany there
unconditionally, so that date can never reach this function's caller as
a ferial), and the Sunday guard (a Sunday inside either window is a
NAMED office -- Advent 4, Holy Family, the Second Sunday after
Christmas -- never one of these ferial dates). *)
let test_date_keyed_slug_boundaries () =
let some_str dt = Option.map Slug.to_string (Lect_of.date_keyed_slug dt) in
Alcotest.(check (option string)) "16 December: outside the window" None (some_str (d 2026 12 16));
Alcotest.(check (option string)) "17 December: in the window" (Some "of-advent-dec-17") (some_str (d 2026 12 17));
Alcotest.(check (option string)) "24 December: in the window" (Some "of-advent-dec-24") (some_str (d 2029 12 24));
Alcotest.(check (option string)) "25 December: outside the window" None (some_str (d 2026 12 25));
Alcotest.(check (option string)) "28 December: outside the window" None (some_str (d 2026 12 28));
Alcotest.(check (option string)) "29 December: in the window" (Some "of-christmas-dec-29") (some_str (d 2026 12 29));
Alcotest.(check (option string)) "31 December: in the window" (Some "of-christmas-dec-31") (some_str (d 2029 12 31));
Alcotest.(check (option string)) "1 January: outside the window" None (some_str (d 2026 1 1));
Alcotest.(check (option string)) "2 January: in the window" (Some "of-christmas-jan-2") (some_str (d 2026 1 2));
Alcotest.(check (option string)) "5 January: in the window" (Some "of-christmas-jan-5") (some_str (d 2026 1 5));
Alcotest.(check (option string))
"6 January: deliberately excluded, Epiphany is fixed there unconditionally" None (some_str (d 2026 1 6));
Alcotest.(check (option string)) "7 January: in the window" (Some "of-christmas-jan-7") (some_str (d 2026 1 7));
Alcotest.(check (option string)) "8 January: outside the window" None (some_str (d 2026 1 8));
Alcotest.(check bool) "2028-12-24 is a Sunday, sanity check" true (D.weekday (d 2028 12 24) = D.Sun);
Alcotest.(check (option string)) "24 December on a Sunday (2028, Advent 4): guarded off" None
(some_str (d 2028 12 24));
Alcotest.(check bool) "2025-01-05 is a Sunday, sanity check" true (D.weekday (d 2025 1 5) = D.Sun);
Alcotest.(check (option string)) "5 January on a Sunday (2025): guarded off" None (some_str (d 2025 1 5))
(* The fix's own headline claim, through the REAL resolution chain, not
just the slug function in isolation: the same civil date resolves the
SAME citation across two years with a different weekday alignment,
where it used to drift. Verified by hand against the pre-fix binary
before this fix landed: 2024-12-17 served "Zephaniah 3:1-2,9-13/
Matth 21:28-32" (that year's own "of-advent-3-tuesday" content) while
2029-12-17 served "Numbers 24:2-7,15-17a/Matth 21:23-27" ("of-advent-3-
monday") -- two DIFFERENT citations for the identical calendar date.
2024 (Tuesday) and 2029 (Monday) are two different, non-Sunday
weekdays, so neither the sanctoral layer nor the Sunday guard is in
play -- both years reach {!Lect_of.readings}'s own step 3 through the
plain temporal-ferial branch, exactly like {!test_step3_temporal_ferial}
above. *)
let test_o_antiphon_no_drift () =
let check_year y expected_weekday_slug =
let lectionary = real_lectionary () in
let layer = real_sanctoral_layer () in
let index = L.index layer ~easter:Computus.gregorian_easter ~years:[ y - 1; y ] in
let date = d y 12 17 in
let expected_slug = (T.temporal date).Colitur_kernel.Temporal.office.Cel.slug in
Alcotest.(check string) (Printf.sprintf "%d-12-17: expected temporal slug" y) expected_weekday_slug
(Slug.to_string expected_slug);
match readings_on lectionary index date with
| Some fm, cs ->
Alcotest.(check bool) (Printf.sprintf "%d: tagged Own_slug" y) true
(fm.Mass_formulary.via = Mass_formulary.Own_slug);
Alcotest.(check (option string)) (Printf.sprintf "%d: said the temporal slug" y)
(Some (Slug.to_string expected_slug)) (Option.map Slug.to_string fm.Mass_formulary.said);
Alcotest.(check (list string)) (Printf.sprintf "%d: 17 December is Gen 49:2,8-10 / Matthew 1:1-17" y)
[ "Genesis 49:2,8-10"; "Matthew 1:1-17" ]
(List.map (fun (c : Colitur_kernel.Citation.t) -> c.reference) cs)
| None, _ -> Alcotest.failf "%d-12-17 should resolve readings" y
in
check_year 2024 "of-advent-3-tuesday";
check_year 2029 "of-advent-3-monday"
(* Same claim, the Christmas-season window, through a REALISTIC step 2 ->
step 3 path: 2 January carries the fixed memorial of Saints Basil and
Gregory Nazianzen (calendar-2002.sexp), which has no dedicated
lectionary entry of its own, so both years fall through to the day's
own ferial -- exactly the shape 2024-01-02's own CLI-verified pre-fix
drift took (that year served "1 John 3:22-4:6/Matt 4:12-17,23-25",
2029's weekday-keyed content, before this fix). 2024 (Tuesday) and 2025
(Thursday) are again two different non-Sunday weekdays. *)
let test_christmas_date_keyed_no_drift () =
let check_year y =
let lectionary = real_lectionary () in
let layer = real_sanctoral_layer () in
let index = L.index layer ~easter:Computus.gregorian_easter ~years:[ y - 1; y ] in
let date = d y 1 2 in
(match L.on_date index date with
| [ e ] ->
Alcotest.(check string) (Printf.sprintf "%d-01-02: Basil/Gregory's own entry" y)
"saints-basil-the-great-and-gregory-nazianzen-bishops-and-doctors" (Slug.to_string e.L.cel.Cel.slug)
| es -> Alcotest.failf "%d-01-02: expected exactly one sanctoral entry, got %d" y (List.length es));
match readings_on lectionary index date with
| Some fm, cs ->
Alcotest.(check bool) (Printf.sprintf "%d: tagged Own_slug (fell through to the ferial)" y) true
(fm.Mass_formulary.via = Mass_formulary.Own_slug);
Alcotest.(check (list string)) (Printf.sprintf "%d: 2 January is 1 John 2:22-28 / John 1:19-28" y)
[ "1 John 2:22-28"; "John 1:19-28" ]
(List.map (fun (c : Colitur_kernel.Citation.t) -> c.reference) cs)
| None, _ -> Alcotest.failf "%d-01-02 should resolve readings" y
in
check_year 2024;
check_year 2025
let suite =
[ Alcotest.test_case "Sunday cycle table (OLM n.66, straddles Advent I)" `Quick test_sunday_cycle_table;
Alcotest.test_case "weekday cycle table (OLM n.69.4, straddles Advent I)" `Quick test_weekday_cycle_table;
Alcotest.test_case "the two cycles are independent" `Quick test_cycles_are_independent;
Alcotest.test_case "data/of/lectionary.sexp SHA-256 pinned" `Quick test_sha256_pinned;
Alcotest.test_case "lectio source SHA-256 pinned in the header" `Quick test_source_sha256_pinned_in_header;
Alcotest.test_case "a missing lectio source fails loudly, not silently" `Quick test_missing_source_fails_loudly;
Alcotest.test_case "loads, 771 entries" `Quick test_load;
Alcotest.test_case "2026 coverage: nothing unresolved (fix wave I8)" `Quick test_coverage_2026;
Alcotest.test_case "2026 coverage: 365 days resolve" `Quick test_pinned_resolved_count;
Alcotest.test_case "step 3: temporal ferial fallback" `Quick test_step3_temporal_ferial;
Alcotest.test_case "step 2: sanctoral proper wins over the ferial" `Quick test_step2_sanctoral_wins;
Alcotest.test_case "date_keyed_slug boundaries (OLM n. 69.3)" `Quick test_date_keyed_slug_boundaries;
Alcotest.test_case "O-Antiphon date-keyed reading does not drift (17 December)" `Quick
test_o_antiphon_no_drift;
Alcotest.test_case "Christmas-season date-keyed reading does not drift (2 January)" `Quick
test_christmas_date_keyed_no_drift
]
|