summaryrefslogtreecommitdiff
path: root/test/test_lectionary_ef.ml
blob: bc2be8ca1dfa25df40b2a3e3872cc2f1745f11ae (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
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
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
open Colitur_kernel
open Rite_ef

(* Same pattern test_rite_ef.ml already uses: both [Calendar.year] and
   [Calendar.day] take the rite AND the sanctoral layer (Rite.t carries no
   layer of its own -- see rite_ef.mli's own note on why), so step 1's
   sanctoral-proper case needs the REAL data/ef/sanctoral.sexp +
   data/ef/adjustments.sexp loaded, not a bare [Rite_ef.context]. Relative
   to this test's own build directory (_build/default/test/); test/dune
   declares both as deps. *)
let sanctoral_path = "../data/ef/sanctoral.sexp"
let adjustments_path = "../data/ef/adjustments.sexp"
let lectionary_path = "../data/ef/lectionary.sexp"
let commons_path = "../data/ef/commons.sexp"

let real_layer () =
  let layer =
    match Layer.load Vocab_ef.rank_of_sexp sanctoral_path with
    | Ok l -> l
    | Error e -> Alcotest.failf "%s: failed to load: %s" sanctoral_path e
  in
  let overlay =
    match Overlay.load Vocab_ef.rank_of_sexp adjustments_path with
    | Ok o -> o
    | Error e -> Alcotest.failf "%s: failed to load: %s" adjustments_path e
  in
  let layer, diagnostics = Overlay.apply layer overlay in
  Alcotest.(check (list string)) "the committed overlay applies cleanly, no diagnostics" []
    (List.map Overlay.diagnostic_to_string diagnostics);
  layer

(* [Rite_ef.context] takes [~lectionary] (fix round 1, coordinator review):
   the module used to load data/ef/lectionary.sexp itself, as a side effect
   of being linked, which meant a bare `dune build` produced a `colitur`
   that died at startup on EVERY subcommand -- including ones (`easter`)
   that touch no lectionary data at all -- the moment that file was absent
   from the default build target. Caller-supplied now, same as the
   sanctoral layer above. *)
let real_lectionary () =
  match Lectionary.load lectionary_path with
  | Ok l -> l
  | Error e -> Alcotest.failf "%s: failed to load: %s" lectionary_path e

(* The Commons travel the same caller-supplied seam (Task 6). Note that
   [Commons.load]'s own validation runs here on the committed file: a
   duplicate common id, a duplicate assignment, an empty formulary, or an
   assignment naming a common that does not exist all come back as [Error]
   and fail every test in this file rather than degrading silently to "this
   saint has no Common". *)
let real_commons () =
  match Lectionary_ef.Commons.load commons_path with
  | Ok c -> c
  | Error e -> Alcotest.failf "%s: failed to load: %s" commons_path e

(* [Calendar.day] (not [year]): the liturgical year "opening in civil year y"
   is Advent-anchored (RG 61), so [Calendar.year _ _ 2030] covers Advent 2030
   through November 2031 -- it would never contain 13 January 2030, which
   belongs to the liturgical year that opened in Advent 2029. [Calendar.day]
   finds the containing liturgical year itself; see calendar.mli. *)
let day y m d =
  let date = match Date.make ~year:y ~month:m ~day:d with
    | Ok x -> x | Error e -> Alcotest.fail e in
  Calendar.day
    (Rite_ef.context ~lectionary:(real_lectionary ()) ~commons:(real_commons ()))
    (real_layer ()) date

(* Same resolution, with a caller-supplied Commons table instead of the
   committed one -- the seam the whole design rests on, and the only way to
   exercise step 4's guard, which is deliberately a no-op on shipped data. *)
let day_with ~commons y m d =
  let date = match Date.make ~year:y ~month:m ~day:d with
    | Ok x -> x | Error e -> Alcotest.fail e in
  Calendar.day
    (Rite_ef.context ~lectionary:(real_lectionary ()) ~commons)
    (real_layer ()) date

let refs (ld : _ Liturgical_day.t) =
  List.map (fun c -> c.Citation.reference) ld.citations

(* Chain step 1: the observed celebration's own proper wins.

   NOT 2030: that is the one pinned collision year (test_golden.ml,
   [test_holy_family_excludes_baptism_2030]) where 13 January is itself the
   Holy Family Sunday and RG 112(a) excludes the Baptism commemoration
   entirely -- [observed] there is [ef-time-after-epiphany-sunday-1], not
   this slug, so it is the wrong year to exercise step 1 against. 2026 is an
   ordinary year (13 January a Tuesday, no Sunday collision), where the
   fixed Commemoration of the Baptism of the Lord (Class2, subject Lord,
   data/ef/sanctoral.sexp) is observed outright. *)
let test_step1_sanctoral_proper () =
  Alcotest.(check (list string))
    "13 January 2026, Commemoration of the Baptism of the Lord"
    [ "Isa 60:1-6"; "John 1:29-34" ]
    (refs (day 2026 1 13))

(* Chain step 2: the day's own temporal slug has a proper (Lent has one daily). *)
let test_step2_lenten_feria_has_its_own () =
  Alcotest.(check (list string))
    "Monday of the 1st week of Lent is not the Sunday's Mass"
    [ "Ezech 34:11-16"; "Matt 25:31-46" ]
    (refs (day 2026 2 23))

(* Fix round 1 (coordinator review, Important finding 2): neither test above
   actually distinguishes step 1 from step 2 -- both survive swapping the
   chain order. 13 January 2026's temporal slug (ef-time-after-epiphany-1-
   tuesday) has no lectionary entry at all, so a swapped chain falls through
   to the same []-then-sanctoral answer; 23 February 2026's OBSERVED
   celebration IS the temporal office (ef-lent-1-monday carries no sanctoral
   entry of its own), so [observed.citations] and the temporal-slug lookup
   are the same lookup wearing two names -- order is a no-op either way.

   19 March 2026 (St Joseph) genuinely needs step 1 to run FIRST: the
   observed celebration (Joseph, Class1, a real sanctoral entry with its own
   citations) and the day's own temporal slug (ef-lent-4-thursday, ALSO a
   real lectionary entry, with different citations) disagree. Verified
   directly against the real data (not transcribed): both value pairs below
   were read off the actual resolved day and the actual
   data/ef/lectionary.sexp entry, not assumed. *)
let test_step1_wins_over_a_competing_step2_entry () =
  Alcotest.(check (list string))
    "19 March 2026: Joseph's own proper wins over Lent 4 Thursday's, which the temporal slug also has"
    [ "Ecclus 45:1-6"; "Matt 1:18-21" ]
    (refs (day 2026 3 19))

(* Fix round 1 (coordinator review): the mirror-image pin for step 2 -- a day
   whose OBSERVED celebration carries no citations of its own (Holy Family,
   synthesised by [Temporal_ef] itself, not sourced from
   data/ef/sanctoral.sexp, so [Celebration.citations] is empty) falls
   through to the temporal slug, and the temporal slug's own lectionary
   entry is genuinely Holy Family's Mass, not the Baptism's -- RG 112(a)
   (see test_golden.ml's own [test_holy_family_excludes_baptism_2030])
   excludes the Baptism from this day entirely, so there is no sanctoral
   citation anywhere to fall back to even in principle. Verified directly
   against the real data. *)
let test_step2_holy_family_reached_through_temporal_slug () =
  Alcotest.(check (list string))
    "13 January 2030: Holy Family reached via the temporal slug, the Baptism entirely absent"
    [ "Col 3:12-17"; "Luke 2:42-52" ]
    (refs (day 2030 1 13))

(* Chain step 3: a feria with no proper of its own says the preceding
   Sunday's Mass. *)
let test_step3_advent_feria_resumes_sunday () =
  Alcotest.(check (list string))
    "Monday after Advent I says Advent I's Mass"
    [ "Rom 13:11-14"; "Luke 21:25-33" ]
    (refs (day 2025 12 1))

(* Task 8, fix round 1 (coordinator review, Important 3(a)): this test used
   to pin 2025-12-29 (a Monday), reached by resuming its preceding Sunday's
   temporal identity. That date no longer exercises step 3 at all --
   tools/bootstrap_lectionary.ml's own [nativity_octave_entries] now gives
   the fixed Nativity-Octave slug (RG 91 entry 17, "ef-nativity-octave-
   day-5") its OWN direct citation ("Diebus infra octavam Nativitatis
   Domini", Tit. 3,4-7/Luc. 2,15-20), so 29 December resolves at STEP 2,
   unconditionally, regardless of weekday. Every civil day in 29-31
   December now resolves the same way -- there is no date left in that
   specific window this test could use to exercise step 3.

   REPLACED AGAIN, Task 9 (branch ef-lectionary, layer-4 oracle): 2028-01-05
   ("ef-christmas-1-wednesday") stopped exercising step 3 too, the SAME
   fate as the 2025-12-29 case this comment already documents -- the
   Missal's own Mass-propers "diebus ferialibus a 2 ad 5 ianuarii" rubric
   (CORRECTED, fix round 2: attributed to RG 17(a) here, which it is not --
   RG 17(a) fixes only WHEN the Holy Name is kept) now gives it a direct
   entry (tools/bootstrap_lectionary.ml's own
   [epiphanytide_opening_entries]), so it resolves at step 2. The three
   families this task's own fix closed (2-5 January, 7-12 January, Holy
   Family's own week) cover every NON-SATURDAY Christmastide feria in
   January now; the SATURDAYS of those same three families are
   DELIBERATELY left without a direct entry (RG 78's BVM Saturday Office
   wins there instead, {!Rite_ef.Temporal_ef.bvm_saturday_names} -- which
   itself carries no citations of its own, see task-6-report.md's own
   residual concern 4), so they are the closest remaining case: 2026-01-03
   ("ef-christmas-1-saturday") still has no citation of its own and
   genuinely resumes ITS preceding Sunday, 2025-12-28 -- confirmed a real
   Sunday that year, unaffected by any of this task's own fixes (still the
   historical "Sunday within the Octave of the Nativity", RG 69,
   unchanged by this task, see [colitur_keys]'s own CORRECTED note for why
   only the Holy-Name-Sunday alias was wrong, not this citation itself). *)
let test_step3_christmas_feria_resumes_sunday () =
  Alcotest.(check (list string))
    (* SUPERSEDED, not merely re-pinned (BVM Saturday Mass, 2026-08-17): this
       Saturday is an unoccupied IV-class Saturday, so it carries RG 78's own
       office -- and now RG 309(a)'s Mass for it, which answers before step 3's
       walkback is ever reached. The Nativity-Octave Sunday's Mass is no longer
       what this day says, and should not be: the day's office is Our Lady's.
       Mass II, "A Nativitate Domini usque ad Purificationem". *)
    "Saturday of Holy Name week says the BVM Saturday Mass, not the Sunday's"
    [ "Titus 3:4-7"; "Luke 2:15-20" ]
    (refs (day 2026 1 3))

(* Fix round 1 (coordinator review, Important finding 1): the original test
   here (2028-12-26) did not exercise step 3 at all -- 26 December is always
   Stephen (Class2, a real sanctoral entry with its own citations, fixed
   regardless of weekday), so that date resolves entirely at step 1 and never
   reaches step 2 or 3. Its justifying comment was also factually wrong: 24
   December's TEMPORAL slug is [ef-nativity-vigil], identical to its OBSERVED
   slug -- [Temporal_ef.named] hard-codes the Vigil for 24 December ahead of
   any Sunday computation, so there is no temporal/observed split on that
   date to exploit in the first place.

   2025-02-03 genuinely discriminates, verified directly against the real
   data (data/ef/sanctoral.sexp, data/ef/lectionary.sexp) and the real
   resolver, not transcribed: 2025-02-02 (a Sunday) is observed as the
   Purification of the BVM (RG 16(a): a Feast of the Lord takes the Sunday's
   place outright), with its own citations Mal 3:1-4 / Luke 2:22-32 -- but
   its TEMPORAL identity is [ef-time-after-epiphany-sunday-4], whose
   lectionary entry is the different Rom 13:8-10 / Matt 8:23-27. 2025-02-03
   (Monday) has no sanctoral office of its own and no entry for its own
   temporal slug ([ef-time-after-epiphany-4-monday], absent from
   data/ef/lectionary.sexp), so it reaches step 3 and must return the
   Sunday's TEMPORAL readings (Rom 13:8-10 / Matt 8:23-27). A "step 3 reads
   the observed office instead of the temporal one" bug would instead return
   the Purification's (Mal 3:1-4 / Luke 2:22-32) -- different, checkable
   values, not a bare length check. *)
let test_step3_uses_temporal_not_observed () =
  Alcotest.(check (list string))
    "3 February 2025 takes the preceding Sunday's TEMPORAL Mass (Time after Epiphany IV), not the Purification's"
    [ "Rom 13:8-10"; "Matt 8:23-27" ]
    (refs (day 2025 2 3))

(* Termination: a Sunday that reaches step 3 would consult itself. It must
   not: the guard is weekday <> Sun. *)
let test_step3_sunday_does_not_recurse () =
  let d = day 2026 6 14 in
  Alcotest.(check int) "a Sunday resolves without looping" 2 (List.length (refs d))

(* ---------------------------------------------------------------------- *)
(* Chain step 4: the Commons (Task 6).                                     *)
(*                                                                          *)
(* Step 4 EXECUTES SECOND, between step 1 and step 2, not last as the task  *)
(* brief sketched -- see lectionary_ef.ml's own branch comment for the      *)
(* measurement and the primary-source warrant. The step NUMBER is kept as   *)
(* the plan wrote it so that every "step 3" already recorded elsewhere      *)
(* still names the same branch.                                             *)
(*                                                                          *)
(* Every expected value below is quoted from data/ef/commons.sexp, which in *)
(* turn cites the Missal page it was read from. There is NO oracle for any  *)
(* of it: lectio has no readings for these feasts (that is why Task 6       *)
(* exists) and missalemeum's 2026-2027 window never observes one of them.   *)
(* These pins and the golden ones are the entire regression net.            *)
(* ---------------------------------------------------------------------- *)

(* 2038-03-06 observes Sts Perpetua and Felicity: a III-class feast that
   beats the Septuagesima II Saturday feria. The Missal sends 6 March to
   "Missa Me exspectaverunt, de Communi non Virginum I loco", whose printed
   Epistle and Gospel are these.

   This test is also the ORDERING pin, and it is the reason it uses explicit
   values rather than a length check: with step 4 placed last (the brief's
   sketch) this day resolves through step 2 to the feria's own
   "2 Cor. 11:19-33; 12:1-9" / "Luke 8:4-15" instead -- a green
   two-citation answer that a [List.length = 2] assertion would not
   distinguish from the right one. *)
let test_step4_commons_perpetua_and_felicity () =
  Alcotest.(check (list string))
    "6 March 2038: Sts Perpetua and Felicity take the Common of non-Virgins I"
    [ "Ecclus 51:1-8, 12"; "Matt 13:44-52" ]
    (refs (day 2038 3 6))

(* 2038-03-09, St Frances of Rome: "Missa Cognovi, de Communi non Virginum
   II loco". Same year, three days later, and a DIFFERENT Common -- so this
   pin also proves the assignment table is consulted per saint rather than
   one Common being handed to everything that reaches step 4. Its temporal
   slug (ef-septuagesima-3-tuesday) resolves to 1 Cor. 13:1-13 / Luke
   18:31-43, so the wrong-order failure is again a plausible-looking green. *)
let test_step4_commons_frances_of_rome () =
  Alcotest.(check (list string))
    "9 March 2038: St Frances of Rome takes the Common of non-Virgins II"
    [ "Prov 31:10-31"; "Matt 13:44-52" ]
    (refs (day 2038 3 9))

(* 2008-04-04, St Isidore: "Missa In medio, de Communi Doctorum". A
   Paschaltide witness, where step 2 would otherwise have supplied the
   Easter-week feria's 1 John 5:4-10 / John 20:19-31. *)
let test_step4_commons_isidore () =
  Alcotest.(check (list string))
    "4 April 2008: St Isidore takes the Common of Doctors"
    [ "2 Tim 4:1-8"; "Matt 5:13-19" ]
    (refs (day 2008 4 4))

(* 2005-04-05, St Vincent Ferrer: "Missa Os iusti, de Communi Confessoris
   non Pontificis I loco". *)
let test_step4_commons_vincent_ferrer () =
  Alcotest.(check (list string))
    "5 April 2005: St Vincent Ferrer takes the Common of a Confessor not a Bishop I"
    [ "Ecclus 31:8-11"; "Luke 12:35-40" ]
    (refs (day 2005 4 5))

(* Step 1 still wins over step 4 for a saint the Missal prints a full Mass
   for. St John of God (8 March) is the sharp case: his Epistle IS the
   Common of a Confessor not a Bishop's own (Ecclus 31:8-11), but the Missal
   assigns him a different GOSPEL (Mt 22:34-46, not that Common's Luke
   12:35-40), so a chain that reached for a Common here would be caught by
   the Gospel alone. He has no assignment in commons.sexp at all -- this
   asserts the proper arrived via adjustments.sexp and step 1. *)
let test_step1_proper_beats_any_common_john_of_god () =
  Alcotest.(check (list string))
    "8 March 2038: St John of God's own printed Mass, not the Common that shares its Epistle"
    [ "Ecclus 31:8-11"; "Matt 22:34-46" ]
    (refs (day 2038 3 8));
  Alcotest.(check bool)
    "and he is deliberately absent from the Commons assignment table" true
    (Lectionary_ef.commons_for ~commons:(real_commons ())
       (Slug.of_string_exn "john-of-god")
     = None)

(* The other two proper-Mass saints a real year ever observes. Both would
   otherwise silently emit their feria's Mass. *)
let test_step1_proper_thomas_aquinas () =
  Alcotest.(check (list string))
    "7 March 2011: St Thomas Aquinas's own proper"
    [ "Wis 7:7-14"; "Matt 5:13-19" ]
    (refs (day 2011 3 7))

let test_step1_proper_francis_of_paola () =
  Alcotest.(check (list string))
    "2 April 2008: St Francis of Paola's own proper"
    [ "Phil 3:7-12"; "Luke 12:32-34" ]
    (refs (day 2008 4 2))

(* A day whose observed office IS its temporal office is untouched by step 4
   on the SHIPPED data. 4 July 2026 is such a day, and the case most at risk:
   it is an RG 78 Saturday Office of the BVM, which deliberately REUSES the
   ordinary ferial slug (temporal_ef.ml's [bvm_saturday_names], "Slug"
   paragraph).

   This test alone does NOT pin the guard -- it passes with the guard
   removed, because no shipped assignment names a temporal slug. It is the
   baseline the next test needs, and it is labelled as such (fix round 1,
   coordinator review: its comment previously claimed to "pin the guard
   behaviourally", which was false -- the reviewer forced
   [sanctoral_office = true] and all 357 tests stayed green). *)
let test_step4_leaves_a_temporal_office_alone_on_shipped_data () =
  Alcotest.(check (list string))
    (* CORRECTED (BVM Saturday Mass, 2026-08-17). This pinned the GAP -- the
       office was Our Lady's while the Mass stayed the feria's -- which was the
       honest thing to assert while RG 309(a) was unimplemented. It is
       implemented now, so the day says Mass V, "A festo Ss. Trinitatis usque
       ad sabbatum ante dominicam I Adventus". *)
    "4 July 2026 (a BVM Saturday) says the BVM Saturday Mass"
    [ "Ecclus 24:14-16"; "Luke 11:27-28" ]
    (refs (day 2026 7 4))

(* THE GUARD, pinned. The guard is a no-op on shipped data by design, so the
   only way to exercise it is to build the table it defends against: a
   SYNTHETIC [Commons.t] that assigns a Common to a really-observed FERIAL
   slug. That is exactly the future-overlay mistake the guard exists to
   catch -- and note the blast radius it would have, since a ferial slug is
   shared by every year in which that week and weekday coincide, not by one
   date.

   Built through [Commons.of_tables], so it passes the same validation the
   committed file does -- this is a well-formed table with a wrong
   assignment, not malformed data caught by the loader.

   TEETH (verified, fix round 1): with the guard removed this returns the
   Common of Abbots' Ecclus 45:1-6 / Matt 19:27-29 and the assertion fails.
   The second check is what makes the first one mean something: it proves
   the hostile assignment really is present and resolvable, so the ferial
   Mass wins because the GUARD refused it, not because the lookup missed. *)
let test_step4_guard_refuses_a_common_assigned_to_a_ferial_slug () =
  let ferial_slug = Slug.of_string_exn "ef-time-after-pentecost-5-saturday" in
  let abbots = Slug.of_string_exn "common-of-abbots" in
  let hostile =
    match
      Lectionary_ef.Commons.of_tables
        ~commons:
          [ (abbots,
             [ { Citation.part = Citation.First; reference = "Ecclus 45:1-6" };
               { Citation.part = Citation.Gospel; reference = "Matt 19:27-29" } ]) ]
        ~assigned:[ (ferial_slug, abbots) ]
    with
    | Ok c -> c
    | Error e -> Alcotest.failf "synthetic commons failed to build: %s" e
  in
  Alcotest.(check bool)
    "the synthetic table really does resolve that ferial slug to a Common" true
    (Lectionary_ef.commons_for ~commons:hostile ferial_slug <> None);
  Alcotest.(check (list string))
    (* The point of this test is UNCHANGED and still worth asserting: step 4
       (the Common route) must refuse a temporal-origin office, so a Commons
       assignment cannot leak onto the BVM Saturday. What changed is only what
       answers instead -- formerly the feria's Mass via step 2, now RG 309(a)'s
       own via the BVM step. If step 4 ever did fire here it would produce a
       Common's readings, which are neither of these. *)
    "4 July 2026: step 4 still refuses a temporal office; the BVM Mass answers"
    [ "Ecclus 24:14-16"; "Luke 11:27-28" ]
    (refs (day_with ~commons:hostile 2026 7 4))

(* THE DATA ASSERTION, and the one that cannot drift: after Task 6, every
   sanctoral entry that can ever BE the observed office -- i.e. every
   [status Feast] entry -- either carries its own proper or has a Common
   assigned. Stated over the real loaded layer rather than as a hard-coded
   list of fifteen names, so that a future re-bootstrap adding a
   readingless class-3 feast fails here instead of silently emitting its
   feria's Mass.

   [Commemoration_only] entries are excluded deliberately and not as an
   oversight: they are never the observed celebration (Precedence never
   returns one as [observed]), and [readings] only ever consults
   [observed], so their empty citations are unreachable. 104 of the 119
   entries with no citations are of that kind. *)
let test_every_observable_sanctoral_feast_has_readings () =
  let commons = real_commons () in
  let layer = real_layer () in
  let gaps =
    List.filter_map
      (fun (entry : Vocab_ef.rank Layer.entry) ->
        let cel = entry.Layer.cel in
        if cel.Celebration.status <> Celebration.Feast then None
        else if cel.Celebration.citations <> [] then None
        else if Lectionary_ef.commons_for ~commons cel.Celebration.slug <> None then None
        else Some (Slug.to_string cel.Celebration.slug))
      layer.Layer.entries
  in
  Alcotest.(check (list string))
    "every sanctoral Feast has either a proper or an assigned Common" []
    (List.sort_uniq compare gaps)

(* The seven saints the Missal sends to a Common, and the exact Common each
   one is sent to -- quoted per saint, with its Missal citation, in
   data/ef/commons.sexp. Named explicitly (in addition to the generic
   invariant above) because three of them -- Benedict, Gregory the Great,
   Patrick -- are NEVER the observed office anywhere in 1583-9999 (12, 17
   and 21 March always fall to a Lenten or Passiontide feria that outranks a
   III-class feast), so no end-to-end pin can reach them and this table is
   their only coverage.

   The plan expected Benedict, Patrick and Gregory the Great to have PROPERS
   and Thomas Aquinas likewise; the Missal gives a proper only to Thomas
   Aquinas. All three reversals were verified on the page images
   (pp. 488, 492) as well as in both OCR text layers. *)
let test_step4_assignment_table () =
  let commons = real_commons () in
  let assigned =
    List.map
      (fun (saint, common) -> (Slug.to_string saint, Slug.to_string common))
      (Lectionary_ef.Commons.assignments commons)
  in
  Alcotest.(check (list (pair string string)))
    "the Commons assignment table, exactly"
    [ ("benedict", "common-of-abbots");
      ("didacus", "common-of-a-confessor-not-a-bishop-2");
      ("frances-rome", "common-of-non-virgins-2");
      ("gregory-the-great", "common-of-supreme-pontiffs");
      ("isidore-of-seville", "common-of-doctors");
      ("patrick", "common-of-a-confessor-bishop-1");
      (* ef-sanctoral-status: ubaldus and didacus were promoted from
         Commemoration_only to Feast on the Missal's own universal
         calendarium, which ranks both "III classis" outright with no
         commemoration rubric. Each takes its Mass from a Common with only
         its own Oratio proper, per its own rubric at its own date, so
         neither needed a proper authored -- only an assignment. ubaldus
         shares patrick's Common exactly ("Missa Statuit, de Communi
         Confessoris Pontificis I loco"). *)
      ("sts-felicitas-perpetua", "common-of-non-virgins-1");
      ("ubaldus", "common-of-a-confessor-bishop-1");
      ("vincent-ferrer", "common-of-a-confessor-not-a-bishop-1") ]
    assigned

(* The three Commons no real year reaches, resolved through [commons_for] so
   that the assignment AND the formulary behind it are both exercised. *)
let test_step4_unreachable_commons_still_resolve () =
  let commons = real_commons () in
  let for_saint s = Lectionary_ef.commons_for ~commons (Slug.of_string_exn s) in
  let refs_of = function
    | None -> [ "<no common>" ]
    | Some (_id, cs) -> List.map (fun c -> c.Citation.reference) cs
  in
  Alcotest.(check (list string))
    "St Benedict (21 March), Common of Abbots"
    [ "Ecclus 45:1-6"; "Matt 19:27-29" ]
    (refs_of (for_saint "benedict"));
  Alcotest.(check (list string))
    "St Gregory the Great (12 March), Common of Supreme Pontiffs"
    [ "1 Pet 5:1-4, 10-11"; "Matt 16:13-19" ]
    (refs_of (for_saint "gregory-the-great"));
  Alcotest.(check (list string))
    "St Patrick (17 March), Common of a Confessor Bishop I"
    [ "Ecclus 44:16-27; 45:3-20"; "Matt 25:14-23" ]
    (refs_of (for_saint "patrick"))

(* The five proper-Mass saints no real year reaches, asserted on the layer
   rather than end-to-end, for the same reason. *)
let test_step4_unreachable_propers_are_present () =
  let layer = real_layer () in
  let refs_of slug =
    match
      List.find_opt
        (fun (e : Vocab_ef.rank Layer.entry) ->
          Slug.equal e.Layer.cel.Celebration.slug (Slug.of_string_exn slug))
        layer.Layer.entries
    with
    | None -> [ "<absent from the layer>" ]
    | Some e -> List.map (fun c -> c.Citation.reference) e.Layer.cel.Celebration.citations
  in
  Alcotest.(check (list string))
    "Forty Holy Martyrs of Sebaste (10 March)"
    [ "Heb 11:33-39"; "Luke 6:17-23" ]
    (refs_of "forty-holy-martyrs-of-sebaste");
  Alcotest.(check (list string))
    "St Cyril of Jerusalem (18 March)"
    [ "Ecclus 39:6-14"; "Matt 10:23-28" ]
    (refs_of "cyril-of-jerusalem");
  (* The one genuine disagreement between the two printings: scan1's edition
     misprints this Gospel as "Luc. 2, 26-38" (confirmed on its own page
     image, so a typographical error, not OCR). Luke 1:26-38 is settled by
     scan2's edition, by the pericope text in both, and by the same edition
     citing the identical pericope correctly two pages later at the
     Annunciation. See data/ef/adjustments.sexp for the full account. *)
  Alcotest.(check (list string))
    "St Gabriel the Archangel (24 March)"
    [ "Dan 9:21-26"; "Luke 1:26-38" ]
    (refs_of "gabriel-the-archangel");
  (* Wis 10:10-17 here versus 10:10-14 for John of Capistrano below: a real
     difference, agreed by both scans and both page images. *)
  Alcotest.(check (list string))
    "St John Damascene (27 March)"
    [ "Wis 10:10-17"; "Luke 6:6-11" ]
    (refs_of "john-damascene");
  Alcotest.(check (list string))
    "St John of Capistrano (28 March)"
    [ "Wis 10:10-14"; "Luke 9:1-6" ]
    (refs_of "john-of-capistrano")

(* [Commons.load] must reject, not silently degrade, the four data defects
   that are indistinguishable downstream from "this saint has no Common".
   Written against strings rather than the committed file so the committed
   file stays clean. *)
let test_commons_load_rejects_bad_data () =
  (* [Filename.temp_file] + [Fun.protect], exactly as test_lectionary.ml's
     own [with_temp_file] does it (fix round 1, coordinator review: this
     used four FIXED names in the temp dir and removed none of them, so
     concurrent runs collided and the files leaked). *)
  let with_temp_file contents f =
    let path = Filename.temp_file "commons_test" ".sexp" in
    let oc = open_out path in
    output_string oc contents;
    close_out oc;
    Fun.protect ~finally:(fun () -> Sys.remove path) (fun () -> f path)
  in
  let err what contents =
    with_temp_file contents (fun path ->
        match Lectionary_ef.Commons.load path with
        | Ok _ -> Alcotest.failf "%s: expected Error, got Ok" what
        | Error e -> e)
  in
  let good_common = "(c-a (((part First) (reference \"A 1:1\")) ((part Gospel) (reference \"B 2:2\"))))" in
  let contains needle haystack =
    let n = String.length needle and h = String.length haystack in
    let rec go i = i + n <= h && (String.sub haystack i n = needle || go (i + 1)) in
    go 0
  in
  let check_msg label needle msg =
    Alcotest.(check bool) (Printf.sprintf "%s names the culprit (%s)" label needle) true
      (contains needle msg)
  in
  check_msg "duplicate common" "duplicate common"
    (err "duplicate common"
       (Printf.sprintf "((commons (%s %s)) (assigned ()))" good_common good_common));
  check_msg "duplicate assignment" "duplicate assignment"
    (err "duplicate assignment"
       (Printf.sprintf "((commons (%s)) (assigned ((s c-a) (s c-a))))" good_common));
  check_msg "empty formulary" "no citations"
    (err "empty formulary" "((commons ((c-a ()))) (assigned ()))");
  check_msg "unknown common" "unknown common"
    (err "unknown common"
       (Printf.sprintf "((commons (%s)) (assigned ((s c-missing))))" good_common));
  (* A missing file is an [Error] too, never an exception -- the contract
     [Lectionary.load] already makes and the reason neither is read at
     module-initialisation time. *)
  (match Lectionary_ef.Commons.load "../data/ef/no-such-commons.sexp" with
  | Ok _ -> Alcotest.fail "a missing commons file must not load"
  | Error _ -> ());
  (* [empty] is the identity: every lookup [None], nothing invented. *)
  Alcotest.(check bool) "Commons.empty resolves nothing" true
    (Lectionary_ef.commons_for ~commons:Lectionary_ef.Commons.empty
       (Slug.of_string_exn "benedict")
     = None)

(* ---------------------------------------------------------------------- *)
(* The formulary itself (Task 2): each step of the chain now reports HOW  *)
(* it resolved, not only what it resolved. One day per step -- the same   *)
(* dates this file already uses (and hand-verifies) elsewhere for the     *)
(* citations those days carry, so no new date needs independent checking. *)
(* ---------------------------------------------------------------------- *)

let formulary_cases =
  [ (* step 1: a saint with his own proper -- same date as
       [test_step1_proper_beats_any_common_john_of_god]. *)
    (2038, 3, 8, "john-of-god", Colitur_kernel.Mass_formulary.Proper);
    (* step 2: the day's own temporal slug -- same date as
       [test_step2_lenten_feria_has_its_own], Monday of Lent I. *)
    (2026, 2, 23, "ef-lent-1-monday", Colitur_kernel.Mass_formulary.Own_slug);
    (* step 3: a feria resuming the preceding Sunday. The task brief's own
       snippet pinned this date against week 9 ("ef-time-after-pentecost-
       sunday-9"); running the real resolver against 2026 shows 3 August
       2026 is Monday of week 10, resuming 2 August's "...sunday-10" --
       corrected per the brief's own "find the dates by running the current
       binary if they drift" instruction. *)
    (2026, 8, 3, "ef-time-after-pentecost-sunday-10",
     Colitur_kernel.Mass_formulary.Preceding_sunday);
    (* step 4: a saint sent to a Common -- same date as
       [test_step4_commons_perpetua_and_felicity]; [said] is the Common's
       OWN id (data/ef/commons.sexp), not the saint's slug. *)
    (2038, 3, 6, "common-of-non-virgins-1", Colitur_kernel.Mass_formulary.Common) ]

let test_formulary_reports_its_source () =
  List.iter
    (fun (y, m, d, expected_slug, expected_via) ->
      let day = day y m d in
      match day.Colitur_kernel.Liturgical_day.formulary with
      | None -> Alcotest.failf "%04d-%02d-%02d: no formulary" y m d
      | Some f ->
          Alcotest.(check string)
            (Printf.sprintf "%04d-%02d-%02d slug" y m d)
            expected_slug
            (Colitur_kernel.Slug.to_string f.Colitur_kernel.Mass_formulary.said);
          Alcotest.(check string)
            (Printf.sprintf "%04d-%02d-%02d source" y m d)
            (Colitur_kernel.Mass_formulary.source_to_string expected_via)
            (Colitur_kernel.Mass_formulary.source_to_string
               f.Colitur_kernel.Mass_formulary.via))
    formulary_cases

let suite =
  [ ("step 1: sanctoral proper", `Quick, test_step1_sanctoral_proper);
    ("step 2: own temporal proper", `Quick, test_step2_lenten_feria_has_its_own);
    ("step 1 wins over a competing step 2 entry", `Quick,
      test_step1_wins_over_a_competing_step2_entry);
    ("step 2: Holy Family reached through the temporal slug, Baptism absent", `Quick,
      test_step2_holy_family_reached_through_temporal_slug);
    ("step 3: Advent feria resumes the preceding Sunday", `Quick,
      test_step3_advent_feria_resumes_sunday);
    ("step 3: Christmas feria resumes the preceding Sunday", `Quick,
      test_step3_christmas_feria_resumes_sunday);
    ("step 3: 3 Feb 2025 takes the Sunday's temporal Mass, not the Purification's", `Quick,
      test_step3_uses_temporal_not_observed);
    ("step 3: a Sunday does not recurse into itself", `Quick,
      test_step3_sunday_does_not_recurse);
    ("step 4: Perpetua and Felicity take the Common of non-Virgins I", `Quick,
      test_step4_commons_perpetua_and_felicity);
    ("step 4: Frances of Rome takes the Common of non-Virgins II", `Quick,
      test_step4_commons_frances_of_rome);
    ("step 4: Isidore takes the Common of Doctors", `Quick, test_step4_commons_isidore);
    ("step 4: Vincent Ferrer takes the Common of a Confessor not a Bishop I", `Quick,
      test_step4_commons_vincent_ferrer);
    ("step 1 beats step 4: John of God's proper, not the Common sharing its Epistle", `Quick,
      test_step1_proper_beats_any_common_john_of_god);
    ("step 1: Thomas Aquinas's proper", `Quick, test_step1_proper_thomas_aquinas);
    ("step 1: Francis of Paola's proper", `Quick, test_step1_proper_francis_of_paola);
    ("step 4 leaves a temporal office alone on shipped data (BVM Saturday)", `Quick,
      test_step4_leaves_a_temporal_office_alone_on_shipped_data);
    ("step 4's GUARD refuses a Common wrongly assigned to a ferial slug", `Quick,
      test_step4_guard_refuses_a_common_assigned_to_a_ferial_slug);
    ("every observable sanctoral Feast has a proper or a Common", `Quick,
      test_every_observable_sanctoral_feast_has_readings);
    ("the Commons assignment table, exactly", `Quick, test_step4_assignment_table);
    ("the three Commons no real year reaches still resolve", `Quick,
      test_step4_unreachable_commons_still_resolve);
    ("the five propers no real year reaches are present", `Quick,
      test_step4_unreachable_propers_are_present);
    ("Commons.load rejects the four silent-degradation defects", `Quick,
      test_commons_load_rejects_bad_data);
    ("the formulary reports its own source, one day per step", `Quick,
      test_formulary_reports_its_source) ]