summaryrefslogtreecommitdiff
path: root/test/test_temporal_ef.ml
blob: 477651c7e77af3900b0ec27f35838601fc4291ba (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
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

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 temporal_week dt = (T.temporal dt).Colitur_kernel.Temporal.week

(* 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. *)

(* "Easter-derived" is discovered mechanically from [named] itself, not
   hand-copied from either [named]'s or [anchors]'s own source: scan a
   window of dates around a year's Easter and keep whatever [named] answers
   [Some] for. [named] returns [Some] only for its ~20 genuinely proper/named
   days -- ordinary Sundays and ferias are produced by other functions
   entirely, in [temporal]'s [None] branch -- so this cannot pick up an
   ordinary week's slug by accident regardless of window width. [-60, +75]
   safely isolates the Easter-relative half of [named] from its
   fixed-calendar half: exhaustively checked over 1583..2500, the nearest
   fixed named date to Easter (6 January, Epiphany) is never less than 75
   days before the EARLIEST possible Easter (22 March), so a 60-day backward
   reach cannot cross into it even in the closest year, while the window
   still comfortably covers [named]'s actual Easter-relative range (Ash
   Wednesday at Easter-46 the earliest, Sacred Heart at Easter+68 the
   latest). *)
let easter_relative_named_slugs y =
  let easter = Colitur_kernel.Computus.gregorian_easter y in
  List.filter_map
    (fun n -> match T.named (D.add_days easter n) with Some (_, slug, _, _) -> Some slug | None -> None)
    (List.init 136 (fun i -> i - 60))
  |> 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 Easter-derived
   slugs [anchors] fails to restate. [] means complete. *)
let missing_from_anchors ~named_easter_slugs ~anchors =
  List.filter (fun slug -> not (List.mem slug anchors)) named_easter_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 at an
   Easter-relative offset is missing from [anchors]. *)
let test_anchors_cover_easter_derived_named_days () =
  List.iter
    (fun y ->
      let missing =
        missing_from_anchors ~named_easter_slugs:(easter_relative_named_slugs y) ~anchors:(anchor_slugs y)
      in
      Alcotest.(check (list string))
        (Printf.sprintf "%d: every Easter-derived 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: [T.anchors]'s real slug set with one genuinely Easter-derived
   entry ("ef-ascension") struck out must fail [missing_from_anchors] the same
   way the real list passes it -- reproducing, in miniature, exactly what
   "deleting four entries leaves the whole suite green" (Plan 2, carried item
   5) looked like before this test existed. *)
let test_anchors_erosion_is_caught () =
  let y = 2026 in
  let named_easter_slugs = easter_relative_named_slugs y in
  Alcotest.(check bool) "sanity: ef-ascension is genuinely in the Easter-derived set" true
    (List.mem "ef-ascension" named_easter_slugs);
  let eroded_anchors = List.filter (fun s -> s <> "ef-ascension") (anchor_slugs y) in
  Alcotest.(check (list string)) "the erosion is caught: the missing entry is reported, and only it"
    [ "ef-ascension" ]
    (missing_from_anchors ~named_easter_slugs ~anchors:eroded_anchors)

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 "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 easter-derived named days" `Quick
      test_anchors_cover_easter_derived_named_days;
    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 ] )