summaryrefslogtreecommitdiff
path: root/test/test_temporal_ef.ml
blob: 8a0f38eb704ebccb181efafd596616f9e6959831 (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
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
      match Scanf.sscanf_opt slug "ef-time-after-pentecost-sunday-%d" (fun k -> k) with
      | None -> Alcotest.failf "slug %S did not match the expected ...-sunday-N pattern" slug
      | Some embedded ->
          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
module Sub = Colitur_kernel.Subject

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 subject_of dt = (office dt).Cel.subject
let temporal_week dt = (T.temporal dt).Colitur_kernel.Temporal.week
let la = Colitur_kernel.Lang.of_string_exn "la"
let en = Colitur_kernel.Lang.of_string_exn "en"
let name_la_of dt = Colitur_kernel.Names.find (office dt).Cel.names la

(* RG 17(b) (Caput III, "De Dominicis"), primary text (scan-verified,
   docs/research/rules-register.md §4): "festum S. Familiae Iesu, Mariae,
   Ioseph, celebrandum dominica prima post Epiphaniam" -- the Holy Family is
   celebrated on the first Sunday after Epiphany, EVERY year (unlike RG
   17(a)'s Holy Name, whose narrower 2-5 January window can be empty and
   carries an explicit "vel ea deficiente, die 2 ianuarii" fallback in the
   calendarium itself -- 7-13 January can never be empty of a Sunday, so
   Holy Family needs, and the calendarium carries, no such fallback).
   [T.holy_family_sunday] is [named]'s own [week_origin Time_after_epiphany]
   formula, restated for its own citation -- not re-derived a second time.

   Deliberately NOT part of [T.named]: [named] is checked before
   [T.sunday_slug]'s own Christmastide branch in [T.temporal]'s dispatch, but
   this feast's slug, rank and colour are EXACTLY what that branch already
   computes for the 7-13 January Sunday (register's own pre-existing note on
   [sunday_slug]: "the key stays lectio's") -- the only field that actually
   needed to change is [subject] (silently always [Temporal] before this),
   so [T.temporal] applies it as a targeted override inside that branch
   rather than duplicating slug/rank/colour a second time through [named]'s
   4-tuple. *)
let test_holy_family () =
  (* 2026: Epiphany (6 Jan) is a Tuesday, so Holy Family falls 11 January --
     an ordinary instance, no collision with the Baptism (13 Jan). *)
  Alcotest.(check string) "2026: slug is the ordinary I-post-Epiphany Sunday key"
    "ef-time-after-epiphany-sunday-1" (slug_of (d 2026 1 11));
  Alcotest.(check string) "2026: II class" "class-2" (rank_of (d 2026 1 11));
  Alcotest.(check string) "2026: white" "white" (colour_of (d 2026 1 11));
  Alcotest.(check bool) "2026: subject is Lord (RG 91 entry 14, \"Festa Domini II \
                          classis\" -- the mystery of the Holy Family touches Christ)"
    true
    (subject_of (d 2026 1 11) = Sub.Lord);
  (* fix round 1 (coordinator finding 3): a primary-sourced LATIN name,
     zero circularity (the calendarium's own January table and the Mass
     propers' own heading, both photographic scans, word for word:
     "Sanctae Familiae Iesu, Mariae, Ioseph"). No ENGLISH name -- deliberate
     (temporal_ef.ml's own comment), and every OTHER temporal-cycle
     candidate still carries no name of any kind. *)
  Alcotest.(check (option string)) "2026: Latin name from the calendarium/propers, verbatim"
    (Some "Sanctae Familiae Iesu, Mariae, Ioseph") (name_la_of (d 2026 1 11));
  Alcotest.(check (option string)) "2026: no English name (deliberate -- would read the oracle it is \
                                     compared against)"
    None (Colitur_kernel.Names.find (office (d 2026 1 11)).Cel.names en);
  Alcotest.(check (option string)) "2026: an ordinary neighbouring Sunday carries no Latin name either"
    None (name_la_of (d 2026 1 18));
  (* 2019: Epiphany itself is a Sunday, so the first Sunday AFTER it is the
     LATEST possible date, 13 January -- the one date Holy Family collides
     with the fixed Commemoration of the Baptism of the Lord (data/ef/
     sanctoral.sexp: "commemoration-of-the-baptism-of-the-lord", also 13
     Jan, II class, subject Lord). [T.temporal]'s own resolution of WHO wins
     that day is {!Rite_ef.Precedence_ef}'s business (band/disposition, see
     test_precedence_ef.ml), not this function's -- this only proves
     [T.temporal] itself still identifies 13 January 2019 as Holy Family's
     own date, unconditionally, regardless of what else might compete for
     the day once precedence is applied. *)
  Alcotest.(check bool) "2019: 6 January is a Sunday" true (D.weekday (d 2019 1 6) = D.Sun);
  Alcotest.(check string) "2019: Holy Family falls on the latest possible date, 13 Jan"
    "2019-01-13" (D.to_iso8601 (T.holy_family_sunday 2019));
  Alcotest.(check string) "2019: still the same slug" "ef-time-after-epiphany-sunday-1"
    (slug_of (T.holy_family_sunday 2019));
  Alcotest.(check bool) "2019: still subject Lord" true (subject_of (T.holy_family_sunday 2019) = Sub.Lord);
  (* Every possible weekday for 6 January exercised, proving the formula
     never leaves the 7-13 window empty (register's own citation above) --
     not merely the two years already checked above. *)
  List.iter
    (fun y ->
      let hf = T.holy_family_sunday y in
      Alcotest.(check bool) (Printf.sprintf "%d: Holy Family is a Sunday" y) true
        (D.weekday hf = D.Sun);
      Alcotest.(check bool) (Printf.sprintf "%d: Holy Family falls 7-13 January" y) true
        (D.month hf = 1 && D.day hf >= 7 && D.day hf <= 13))
    [ 2019; 2020; 2021; 2022; 2023; 2024; 2025; 2026; 2027 ];
  (* Negative control: an ORDINARY Sunday after Epiphany (the 2nd) stays
     [subject Temporal] -- the override is scoped to exactly one Sunday a
     year, not every Time-after-Epiphany-shaped Sunday. *)
  Alcotest.(check bool) "2026: the following Sunday (18 Jan, II after Epiphany) is NOT Lord"
    true
    (subject_of (d 2026 1 18) = Sub.Temporal)

(* Three of [test_holy_family]'s own exhaustive-weekday sample, pinned to
   their literal ISO dates and independently cross-checked against
   `date -d <iso> +%A` (the same discipline test_golden.ml's own header
   describes for its own weekday assertions) -- the earliest possible date
   (Epiphany a Saturday), the latest (Epiphany a Sunday), and one ordinary
   middle case, rather than trusting the structural "always 7-13" check
   above on its own without ever anchoring it to real calendar dates. *)
let test_holy_family_no_gap_year () =
  Alcotest.(check string) "2024 (6 Jan a Saturday): earliest possible date, 7 Jan" "2024-01-07"
    (D.to_iso8601 (T.holy_family_sunday 2024));
  Alcotest.(check string) "2019 (6 Jan a Sunday): latest possible date, 13 Jan" "2019-01-13"
    (D.to_iso8601 (T.holy_family_sunday 2019));
  Alcotest.(check string) "2025 (6 Jan a Monday): 12 Jan" "2025-01-12"
    (D.to_iso8601 (T.holy_family_sunday 2025))

(* Regression for a Task 14 Validate finding, reworked (register finding 4):
   [named] no longer carries its own week at all -- [temporal] computes it
   uniformly via [week] for every day, named or not -- so "a named day inside
   a numbered season run carries that run's week" now holds by construction
   rather than by a hand-set value on some branches and not others. These four
   sat inside a numbered run (Paschaltide week 6, Time after Pentecost weeks 1
   and 2 respectively) but carried [None] under the old convention-based
   version; each assertion below would have failed against it (first to fail:
   Ascension Vigil, expected [Some 6], got [None]). *)
let test_named_days_carry_their_week () =
  Alcotest.(check (option int)) "Ascension Vigil carries week 6" (Some 6) (temporal_week (d 2026 5 13));
  Alcotest.(check (option int)) "Ascension carries week 6" (Some 6) (temporal_week (d 2026 5 14));
  Alcotest.(check (option int)) "Corpus Christi carries week 1" (Some 1) (temporal_week (d 2026 6 4));
  Alcotest.(check (option int)) "Sacred Heart carries week 2" (Some 2) (temporal_week (d 2026 6 12))

(* The total replacement for the old property: with [week] removed from
   [named]'s own return type, "the day's week" has exactly one source, so this
   can be asserted for *every* day, not merely wherever [named] happened to
   supply an explicit value -- the old version's [| _ -> true] escape hatch
   for every unnamed and every None-week day is gone, and with it the reason
   that version could not detect an omission. *)
let prop_temporal_week_matches_week =
  QCheck.Test.make ~count:200
    ~name:"temporal's week always equals week, for every day of the year"
    (QCheck.int_range 1583 9998)
    (fun y ->
      let start = d y 1 1 in
      let stop = d (y + 1) 1 1 in
      let n = D.to_rata stop - D.to_rata start in
      let rec check i =
        i >= n
        ||
        let dt = D.add_days start i in
        temporal_week dt = T.week dt && check (i + 1)
      in
      check 0)

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))

(* Register finding 1 / controller finding A: Christmastide's ferial fallback
   used to collapse every day to literal week "0" (no numbered weeks in
   Christmastide), and because colitur's Christmastide spans 25 Dec - 13 Jan,
   the same weekday recurred and collided. 2026: Easter 5 Apr, Epiphany
   (6 Jan) is a Tuesday, so the actual first-Sunday-after-Epiphany origin is
   11 Jan -- exercising all four sub-stretches of the fix. *)
let test_christmastide_feria_slugs () =
  Alcotest.(check string) "26 Dec (Sat)" "ef-christmas-0-saturday" (slug_of (d 2026 12 26));
  Alcotest.(check string) "28 Dec (Mon)" "ef-christmas-0-monday" (slug_of (d 2026 12 28));
  Alcotest.(check string) "2 Jan (Fri)" "ef-christmas-1-friday" (slug_of (d 2026 1 2));
  Alcotest.(check string) "5 Jan (Mon)" "ef-christmas-1-monday" (slug_of (d 2026 1 5));
  (* 7-10 Jan precede the actual origin Sunday (11 Jan): colitur-only, not
     lectio's "week 1" key, precisely to avoid colliding with 12-13 Jan below. *)
  Alcotest.(check string) "7 Jan (Wed, before the origin)" "ef-christmas-2-wednesday" (slug_of (d 2026 1 7));
  Alcotest.(check string) "10 Jan (Sat, before the origin)" "ef-christmas-2-saturday" (slug_of (d 2026 1 10));
  (* 12-13 Jan are on/after the origin: genuinely week 1, lectio's key. *)
  Alcotest.(check string) "12 Jan (Mon, on/after the origin)" "ef-time-after-epiphany-1-monday"
    (slug_of (d 2026 1 12));
  Alcotest.(check string) "13 Jan (Tue, on/after the origin)" "ef-time-after-epiphany-1-tuesday"
    (slug_of (d 2026 1 13))

(* The general property behind the fix above -- no two dates in one
   liturgical year may share a slug -- moved to
   [Colitur_kernel.Validate]'s own ["slugs"] check (Plan 2 carried item 4),
   asserted outright with no resumed-Sunday exemption: Plan 2 verified zero
   duplicate slugs domain-wide, so the exemption this property used to carry
   protected nothing real. [Validate]'s own 200-sample property
   (test_validate.ml's [prop_invariants]) now covers every consumer,
   including this rite, over the same 1583..9998 domain this property used
   to sweep alone. *)

(* ---- Plan 2 carried item 5: the anchors list has no guard against its own
   erosion ----

   [Colitur_kernel.Validate] cannot own this completeness check: which of
   [named]'s entries are Easter-derived is knowledge only [named] itself
   has. [Rite.t] deliberately exposes just [temporal] (the merged result)
   and [anchors] (the independent restatement), never [named] -- so a
   rite-agnostic [Validate] has no ground truth to compare [anchors]
   against, short of inventing one. Two ways of inventing one were
   considered and rejected:

   - Hardcoding a specific Easter offset (Ash Wednesday = Easter-46, say)
     inside [Validate] would smuggle Western/Gregorian-Paschal-cycle
     knowledge into code the design intends to also serve a future
     Julian-reckoning rite (Byzantine, named explicitly as a future module
     in this project's own architecture note) -- for which neither that
     offset, nor even Gregorian Easter itself as the reference point
     ([Computus.gregorian_easter], not [julian_easter]), is the right one.
     Even [Computus]'s own [ash_wednesday]/[palm_sunday]/[ascension]/
     [pentecost] helpers are documented "(OF + EF)" -- i.e. already scoped
     to the two WESTERN forms, not to "any rite" the way [Validate] must
     stay.
   - Rediscovering "Easter-derived" structurally from [temporal] alone (scan
     near Easter, keep whatever recurs at the same offset across years with
     different Easters) is unsound for EF specifically: [Time_after_epiphany]
     onward, week numbering itself is computed from Easter-relative origins
     ([week_origin]), so almost every ORDINARY Sunday/feria slug in
     Septuagesima/Lent/Passiontide/Paschaltide/Time_after_pentecost is ALSO
     constant-offset-from-Easter across years -- structurally
     indistinguishable from a genuinely named day by that test alone. Rank
     does not separate them either: RG 91 entry 10 makes the privileged
     Easter/Pentecost octave FERIAS class 1 too, same as many named days.

   This guard is therefore entirely EF-specific and lives here, against
   [T.named] and [T.anchors] directly -- both accessible in this file, not
   through the [Rite.t] boundary.

   EXTENDED (final fix wave, item 3): the guard originally scanned only a
   60/+75-day window around Easter, so it caught erosion of an
   Easter-relative anchor (Ascension, say) but not of a FIXED-date one --
   Nativity, the Nativity vigil, the three Nativity octave days, the
   Circumcision, Epiphany -- nor of [christ_the_king], which is neither
   fixed nor Easter-relative (the last Sunday of October). Deleting any of
   those from [T.anchors] left the whole suite green: Plan 2's carried item
   5 was therefore only half-closed while being reported as closed, and the
   worst-covered anchor was exactly the one with the weakest citation
   (Christ the King -- oracle-backed, not yet primary-verified when this
   note was written; see docs/research/rules-register.md §4/§6, since
   primary-verified at RG 17(d) by this same fix wave). Fixed by widening
   the scan from an Easter-centred window to the WHOLE civil year: [named]
   only ever answers [Some] for its ~20 genuinely proper/named days
   (ordinary Sundays and ferias are produced entirely by [temporal]'s own
   [None] branch, never by [named]), so scanning every day of the year
   cannot pick up an ordinary week's slug by accident any more than the
   narrower window could -- it is simply no longer selective about WHICH
   kind of named day it is willing to notice. *)

(* Every slug [named] can produce for civil year [y] -- fixed-date AND
   Easter-relative alike -- discovered mechanically by asking [named] itself
   about every day of the year (366 days from 1 January, safely covering a
   leap year plus one day of spillover into the next, which duplicates
   rather than misleads: every slug [named] returns is a year-invariant
   string), not hand-copied from either [named]'s or [anchors]'s own
   source. *)
let named_slugs_for_year y =
  let jan1 = d y 1 1 in
  List.filter_map
    (fun i -> match T.named (D.add_days jan1 i) with Some (_, slug, _, _) -> Some slug | None -> None)
    (List.init 366 (fun i -> i))
  |> List.sort_uniq compare

let anchor_slugs y = List.map fst (T.anchors y) |> List.sort_uniq compare

(* The mechanism both tests below share: which of [named]'s slugs [anchors]
   fails to restate. [] means complete. *)
let missing_from_anchors ~named_slugs ~anchors =
  List.filter (fun slug -> not (List.mem slug anchors)) named_slugs

(* The real guard: for the domain's own Easter extremes (1598 earliest, 1666
   latest -- see test_validate.ml's own [extreme_years], corrected by this
   same task) plus an ordinary year, nothing [named] produces -- fixed-date
   or Easter-relative -- is missing from [anchors]. *)
let test_anchors_cover_all_named_days () =
  List.iter
    (fun y ->
      let missing =
        missing_from_anchors ~named_slugs:(named_slugs_for_year y) ~anchors:(anchor_slugs y)
      in
      Alcotest.(check (list string))
        (Printf.sprintf "%d: every named slug is restated in anchors" y)
        [] missing)
    [ 1598; 1666; 2026 ]

(* Proves the guard above actually has teeth, per this task's negative-fixture
   requirement, now over BOTH shapes of anchor: [T.anchors]'s real slug set
   with one Easter-relative entry ("ef-ascension") AND one fixed-date entry
   ("ef-nativity") struck out together must fail [missing_from_anchors] the
   same way the real list passes it -- reproducing, in miniature, exactly
   what "deleting ef-nativity, ef-epiphany and ef-christ-the-king leaves the
   whole suite green" (final fix wave, item 3) looked like before this test
   existed. *)
let test_anchors_erosion_is_caught () =
  let y = 2026 in
  let named_slugs = named_slugs_for_year y in
  Alcotest.(check bool) "sanity: ef-ascension is genuinely a named slug" true
    (List.mem "ef-ascension" named_slugs);
  Alcotest.(check bool) "sanity: ef-nativity is genuinely a named slug" true
    (List.mem "ef-nativity" named_slugs);
  let eroded_anchors =
    List.filter (fun s -> s <> "ef-ascension" && s <> "ef-nativity") (anchor_slugs y)
  in
  Alcotest.(check (list string))
    "the erosion is caught: both missing entries are reported, and only them"
    [ "ef-ascension"; "ef-nativity" ]
    (missing_from_anchors ~named_slugs ~anchors:eroded_anchors)

(* Holy Family's own anchor coverage, kept SEPARATE from the two tests above:
   it is genuinely NOT one of [T.named]'s outputs (test_holy_family's own
   comment explains why -- [T.temporal] applies it as a targeted override
   inside [T.sunday_slug]'s branch instead), so [named_slugs_for_year]'s scan
   cannot see it, and folding it into [test_anchors_cover_all_named_days]/
   [test_anchors_erosion_is_caught] would misrepresent what those two are
   actually proving ("everything [named] produces"). This proves the same
   two properties directly against [T.holy_family_sunday] instead: the entry
   is present, AND deleting it is caught -- {!Validate}'s own production
   anchor check (validate.ml, register/spec §5.7) calls [temporal], not
   [named], so it is unaffected by this distinction and already covers this
   entry regardless; this is this file's own unit-level guard against the
   same erosion. *)
let test_holy_family_anchor_present_and_erosion_is_caught () =
  let y = 2026 in
  let expected_slug = "ef-time-after-epiphany-sunday-1" in
  let expected_date = T.holy_family_sunday y in
  Alcotest.(check bool) "the anchor entry exists, at the right date" true
    (List.mem (expected_slug, expected_date) (T.anchors y));
  (* Erosion: with the entry struck out, [T.temporal] itself still puts
     [expected_slug] at [expected_date] (unaffected -- deleting an [anchors]
     row never touches [temporal]'s own computation, only what is CHECKED
     against it), so a reader who only trusted [anchors] would no longer be
     told to look there at all. *)
  let eroded = List.filter (fun (s, dt) -> not (String.equal s expected_slug && dt = expected_date)) (T.anchors y) in
  Alcotest.(check bool) "the erosion is caught: the entry no longer appears" false
    (List.mem (expected_slug, expected_date) eroded);
  Alcotest.(check string) "sanity: temporal itself is unaffected by the anchors-list erosion"
    expected_slug (slug_of expected_date)

let test_totality () =
  (* Every day of 2026 yields an office without raising. Not a slug
     re-validation -- Slug.t is a private string validated on construction,
     so round-tripping to_string/of_string on an existing value is a
     tautology (the same vacuous check Validate's own comment identifies and
     explains for the same reason). This still has value: it forces full
     evaluation of [temporal] across a whole year and would fail if any day
     in it raised. *)
  let jan1 = d 2026 1 1 in
  for i = 0 to 364 do
    ignore (T.temporal (D.add_days jan1 i))
  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 "holy family (RG 17(b))" `Quick test_holy_family;
    Alcotest.test_case "holy family: no gap year (RG 17(b))" `Quick test_holy_family_no_gap_year;
    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 "christmastide feria slugs" `Quick test_christmastide_feria_slugs;
    Alcotest.test_case "named days carry their week" `Quick test_named_days_carry_their_week;
    Alcotest.test_case "totality" `Quick test_totality;
    Alcotest.test_case "anchors cover all named days" `Quick
      test_anchors_cover_all_named_days;
    Alcotest.test_case "holy family anchor present, erosion caught" `Quick
      test_holy_family_anchor_present_and_erosion_is_caught;
    Alcotest.test_case "anchors erosion is caught" `Quick test_anchors_erosion_is_caught ]

let suite =
  ( "Rite_ef",
    [ Alcotest.test_case "vocab roundtrips" `Quick test_vocab_roundtrips;
      Alcotest.test_case "slug words" `Quick test_slug_words ]
    @ suite_extra
    @ List.map QCheck_alcotest.to_alcotest [ prop_temporal_week_matches_week ] )