aboutsummaryrefslogtreecommitdiff
path: root/test/test_calendar_of_data.ml
blob: 9261ebca347db32a55e6100af95def74e623f744 (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
(* Task 1 (2026-08-25-colitur-of-phases-3-5): loads data/of/calendar-2002.sexp
   -- the General Roman Calendar transcribed from the 2002 Missale Romanum by
   tools/extract_of_calendar.py -- through Layer.load and checks it against
   counts independently re-derived from the shipped file itself (grep -c
   '(slug', grep -oP rank/status/colour/subject | sort | uniq -c -- see
   .superpowers/sdd/2026-08-25-colitur-of-phases-3-5/task-1-report.md), plus
   named spot-checks read straight off the Missal's own printed page so a
   passing count can't hide the wrong entries having been transcribed
   (the same "live hazard" test_sanctoral_ef.ml's own header names).

   Fix round 1 (2026-08-25): 206 -> 208. Two movable universal celebrations
   -- the Sacred Heart of Jesus and the Immaculate Heart of Mary -- are
   printed in the SAME 2002 table (as prose headings, not (kalends, day)
   rows) but were missed by the first pass; both are now produced as
   Date_spec.Easter_offset entries (68/69) by
   tools/extract_of_calendar.py's own MOVABLE_ENTRY_HEADINGS, the same
   mechanism EF's Rogation Wednesday already uses. See
   test_movable_entries below, which verifies the Easter+68/Easter+69
   arithmetic against three REAL Easter dates rather than trusting the
   offset on its own say-so.

   SOURCE DISCIPLINE, asserted structurally here, not merely stated in the
   tool's own header: every entry's rank is one of the four Vocab_of.rank
   grades NEVER Feria (that rank is temporal-only, see vocab_of.mli), and
   every entry's layer is Precedence_of.universal_layer. *)

module L = Colitur_kernel.Layer
module Cel = Colitur_kernel.Celebration
module S = Colitur_kernel.Slug
module DS = Colitur_kernel.Date_spec
module D = Colitur_kernel.Date
module Col = Colitur_kernel.Colour
module Sub = Colitur_kernel.Subject
module Lang = Colitur_kernel.Lang
module Names = Colitur_kernel.Names
module V = Rite_of.Vocab_of
module PO = Rite_of.Precedence_of
module T = Rite_of.Temporal_of

(* Relative to this test's own build directory (_build/default/test/); made
   available there because test/dune declares it as a dep of the test
   stanza. *)
let path = "../data/of/calendar-2002.sexp"

let load () =
  match L.load V.rank_of_sexp path with
  | Ok l -> l
  | Error e -> Alcotest.failf "%s: failed to load: %s" path e

let mkdate m d = match DS.fixed ~month:m ~day:d with Ok t -> t | Error e -> failwith e

let sha256_of_file path =
  let tmp = Filename.temp_file "colitur_calendar_of_sha256" ".txt" in
  let cmd = Printf.sprintf "sha256sum %s > %s" (Filename.quote path) (Filename.quote tmp) in
  (match Sys.command cmd with
   | 0 -> ()
   | n -> Alcotest.failf "sha256sum exited %d" n);
  let ic = open_in tmp in
  let line = input_line ic in
  close_in ic;
  Sys.remove tmp;
  match String.split_on_char ' ' line with
  | hash :: _ -> hash
  | [] -> Alcotest.fail "sha256sum produced no output"

(* Pinned 2026-08-25 against the shipped file itself, independently
   re-derived (`sha256sum data/of/calendar-2002.sexp`) -- not copied from
   the tool's own stdout run, per this project's "derive the new value,
   don't transcribe it from output" pinning discipline. *)
let test_sha256 () =
  Alcotest.(check string) "data/of/calendar-2002.sexp SHA-256"
    "91479d8b5d92d8e35c2ae34f239d5623a2a59cca2f02d51a8cbbad52edd46b3a" (sha256_of_file path)

(* Counts independently re-derived from the shipped file
   (`grep -c '((slug'`; `grep -oP '(?<=\(rank )[A-Za-z_]+' | sort | uniq -c`).
   208 = 209 raw FIXED-date table rows minus the 3 dates (1 Jan, 6 Jan, 25
   Dec) already computed by Temporal_of.named (see
   test_excludes_temporal_of_dates below, which closes the loop on that
   exclusion) PLUS the 2 movable entries fix round 1 added (Sacred Heart
   of Jesus, Immaculate Heart of Mary -- see test_movable_entries): 209 -
   3 + 2 = 208. *)
let test_load_and_counts () =
  let l = load () in
  Alcotest.(check int) "208 entries" 208 (List.length l.L.entries);
  let count pred = List.length (List.filter pred l.L.entries) in
  Alcotest.(check int) "9 Sollemnitas" 9 (count (fun e -> e.L.cel.Cel.rank = V.Sollemnitas));
  Alcotest.(check int) "23 Festum" 23 (count (fun e -> e.L.cel.Cel.rank = V.Festum));
  Alcotest.(check int) "68 Memoria_obligatoria" 68
    (count (fun e -> e.L.cel.Cel.rank = V.Memoria_obligatoria));
  Alcotest.(check int) "108 Memoria_ad_libitum" 108
    (count (fun e -> e.L.cel.Cel.rank = V.Memoria_ad_libitum));
  Alcotest.(check int) "0 Feria (temporal-only rank, never a calendar entry)" 0
    (count (fun e -> e.L.cel.Cel.rank = V.Feria));
  Alcotest.(check int) "208 Feast (OF admits no Commemoration_only status)" 208
    (count (fun e -> e.L.cel.Cel.status = Cel.Feast));
  Alcotest.(check int) "every entry tagged Precedence_of.universal_layer" 208
    (count (fun e -> e.L.cel.Cel.layer = PO.universal_layer));
  (* Fix round 2: classify_subject's "maria"/"b.m.v" SUBSTRING heuristic
     over-matched 12 saints who merely carry "Maria" in their own name
     (Maximilian Mary Kolbe, John Mary Vianney, Mary Magdalene, ...) as
     Bvm -- replaced with an explicit BVM_DATES table of the 13 genuinely
     Marian entries (12 fixed + the movable Immaculate Heart of Mary).
     Pinned here so a regression to the old substring rule -- or a new one
     with the same failure shape -- reddens immediately rather than only
     showing up if/when a future Subject.Bvm-keyed rule is added. *)
  Alcotest.(check int) "13 Bvm (12 fixed + Immaculate Heart of Mary)" 13
    (count (fun e -> e.L.cel.Cel.subject = Sub.Bvm));
  Alcotest.(check int) "9 Lord" 9 (count (fun e -> e.L.cel.Cel.subject = Sub.Lord));
  Alcotest.(check int) "186 Saint" 186 (count (fun e -> e.L.cel.Cel.subject = Sub.Saint))

let test_slugs_unique () =
  let l = load () in
  let slugs = List.map (fun e -> S.to_string e.L.cel.Cel.slug) l.L.entries in
  let sorted = List.sort_uniq compare slugs in
  Alcotest.(check int) "no duplicate slugs" (List.length slugs) (List.length sorted)

let test_slugs_revalidate () =
  let l = load () in
  List.iter
    (fun e ->
      match S.of_string (S.to_string e.L.cel.Cel.slug) with
      | Ok _ -> ()
      | Error msg -> Alcotest.failf "slug %s failed re-validation: %s" (S.to_string e.L.cel.Cel.slug) msg)
    l.L.entries

(* Every FIXED-date entry's month/day pair already went through DS.fixed,
   which rejects an out-of-range day against the leap-year maximum, so
   resolving every entry (fixed AND the 2 movable Easter_offset ones, via
   DS.resolve's own Easter_offset branch) in a real leap year also proves
   no fixed entry claims a nonexistent day (e.g. 30 February) and that both
   movable entries resolve at all. Not "every date is a Fixed month/day"
   (stale since fix round 1 -- 2 of 208 are Easter_offset). *)
let test_dates_resolve_in_a_leap_year () =
  let l = load () in
  List.iter
    (fun e ->
      match DS.resolve e.L.date ~year:2028 ~easter:(Colitur_kernel.Computus.gregorian_easter 2028) with
      | Some _ -> ()
      | None -> Alcotest.failf "slug %s: date does not resolve in leap year 2028" (S.to_string e.L.cel.Cel.slug))
    l.L.entries

let find l slug =
  match L.find l (S.of_string_exn slug) with
  | Some e -> e
  | None -> Alcotest.failf "slug %s not found in %s" slug path

(* Brief step 4's own named spot-checks, read straight off the Missal's
   printed Calendarium Romanum Generale page. *)

let test_spot_check_conversion_of_paul () =
  let e = find (load ()) "the-conversion-of-saint-paul-apostle" in
  Alcotest.(check bool) "25 Jan" true (e.L.date = mkdate 1 25);
  Alcotest.(check bool) "Festum" true (e.L.cel.Cel.rank = V.Festum);
  (* IGMR 346(a)'s own explicit white exception for this date, despite the
     title's "apostoli" which would otherwise trigger the apostle/martyr-red
     default -- see tools/extract_of_calendar.py's WHITE_APOSTLE_EXCEPTIONS. *)
  Alcotest.(check bool) "white (IGMR 346(a) explicit exception)" true (e.L.cel.Cel.colour = Col.White)

let test_spot_check_chair_of_peter () =
  let e = find (load ()) "chair-of-saint-peter-apostle" in
  Alcotest.(check bool) "22 Feb" true (e.L.date = mkdate 2 22);
  Alcotest.(check bool) "Festum" true (e.L.cel.Cel.rank = V.Festum);
  Alcotest.(check bool) "white (IGMR 346(a) explicit exception)" true (e.L.cel.Cel.colour = Col.White)

let test_spot_check_presentation () =
  let e = find (load ()) "presentation-of-the-lord" in
  Alcotest.(check bool) "2 Feb" true (e.L.date = mkdate 2 2);
  Alcotest.(check bool) "Festum" true (e.L.cel.Cel.rank = V.Festum);
  Alcotest.(check bool) "subject Lord" true (e.L.cel.Cel.subject = Sub.Lord)

let test_spot_check_francis_de_sales () =
  let e = find (load ()) "francis-de-sales-bishop-and-doctor" in
  Alcotest.(check bool) "24 Jan" true (e.L.date = mkdate 1 24);
  Alcotest.(check bool) "Memoria_obligatoria (printed grade word: Memoria)" true
    (e.L.cel.Cel.rank = V.Memoria_obligatoria)

let test_spot_check_angela_merici () =
  let e = find (load ()) "angela-merici-virgin" in
  Alcotest.(check bool) "27 Jan" true (e.L.date = mkdate 1 27);
  Alcotest.(check bool) "blank grade -> Memoria_ad_libitum (calendar's own footnote)" true
    (e.L.cel.Cel.rank = V.Memoria_ad_libitum)

(* 1 January (Mary, Mother of God) IS "Sollemnitas" on the Missal's own
   printed page, the brief's sixth named spot-check -- but it is
   DELIBERATELY ABSENT from this file: Phase 1's Temporal_of.named already
   computes it as a temporal-cycle office (m=1, dd=1), and shipping it here
   too would create two competing candidates for the same day (see
   tools/extract_of_calendar.py's own module docstring, point 2, and this
   file's SKIP_DATES table). Asserted both ways, so the exclusion is
   verified rather than merely claimed: absent from the data, present and
   correctly graded in the code that actually covers it. The same holds for
   6 January (Epiphany) and 25 December (the Nativity). *)
let test_excludes_temporal_of_dates () =
  let l = load () in
  let has_date m d =
    List.exists (fun e -> e.L.date = mkdate m d) l.L.entries
  in
  Alcotest.(check bool) "1 Jan absent from calendar-2002.sexp" false (has_date 1 1);
  Alcotest.(check bool) "6 Jan absent from calendar-2002.sexp" false (has_date 1 6);
  Alcotest.(check bool) "25 Dec absent from calendar-2002.sexp" false (has_date 12 25);
  let mkd m d = match D.make ~year:2026 ~month:m ~day:d with Ok d -> d | Error e -> failwith e in
  (match T.named (mkd 1 1) with
   | Some (_, _, colour, rank) ->
     Alcotest.(check bool) "1 Jan: Temporal_of.named yields Sollemnitas" true (rank = V.Sollemnitas);
     Alcotest.(check bool) "1 Jan: Temporal_of.named yields White" true (colour = Col.White)
   | None -> Alcotest.fail "1 Jan: Temporal_of.named returned None -- the office this file \
                             excludes on that basis does not actually exist in code");
  (match T.named (mkd 1 6) with
   | Some (_, _, _, rank) -> Alcotest.(check bool) "6 Jan: Sollemnitas" true (rank = V.Sollemnitas)
   | None -> Alcotest.fail "6 Jan: Temporal_of.named returned None");
  match T.named (mkd 12 25) with
  | Some (_, _, _, rank) -> Alcotest.(check bool) "25 Dec: Sollemnitas" true (rank = V.Sollemnitas)
  | None -> Alcotest.fail "25 Dec: Temporal_of.named returned None"

(* Fix round 1: the two movable entries (Sacred Heart of Jesus, Immaculate
   Heart of Mary) resolve to the right weekday AND date in three real
   years, computed from Colitur_kernel.Computus.gregorian_easter, not
   hand-typed dates -- so a wrong offset in the data would fail this even
   if it "looked" plausible. Also independently confirms the coordinator's
   own +68/+69 derivation (Pentecost = Easter+49, so the Friday/Saturday
   after the Second Sunday after Pentecost are Easter+68/+69) rather than
   trusting it. *)
let test_movable_entries () =
  let l = load () in
  let heart = find l "sacred-heart-of-jesus" in
  Alcotest.(check bool) "Sacred Heart: date spec is Easter_offset 68" true
    (heart.L.date = DS.Easter_offset 68);
  Alcotest.(check bool) "Sacred Heart: Sollemnitas" true
    (heart.L.cel.Cel.rank = V.Sollemnitas);
  Alcotest.(check bool) "Sacred Heart: white" true (heart.L.cel.Cel.colour = Col.White);
  Alcotest.(check bool) "Sacred Heart: subject Lord" true (heart.L.cel.Cel.subject = Sub.Lord);
  let immaculate = find l "immaculate-heart-of-mary" in
  Alcotest.(check bool) "Immaculate Heart: date spec is Easter_offset 69" true
    (immaculate.L.date = DS.Easter_offset 69);
  Alcotest.(check bool) "Immaculate Heart: Memoria_obligatoria" true
    (immaculate.L.cel.Cel.rank = V.Memoria_obligatoria);
  Alcotest.(check bool) "Immaculate Heart: white" true (immaculate.L.cel.Cel.colour = Col.White);
  Alcotest.(check bool) "Immaculate Heart: subject Bvm" true (immaculate.L.cel.Cel.subject = Sub.Bvm);
  List.iter
    (fun year ->
      let easter = Colitur_kernel.Computus.gregorian_easter year in
      let resolve spec =
        match DS.resolve spec ~year ~easter with
        | Some d -> d
        | None -> Alcotest.failf "year %d: date spec did not resolve" year
      in
      let sacred_heart_date = resolve heart.L.date in
      let immaculate_heart_date = resolve immaculate.L.date in
      Alcotest.(check bool)
        (Printf.sprintf "%d: Sacred Heart (Easter+68) falls on a Friday" year)
        true (D.weekday sacred_heart_date = D.Fri);
      Alcotest.(check bool)
        (Printf.sprintf "%d: Immaculate Heart (Easter+69) falls on a Saturday" year)
        true (D.weekday immaculate_heart_date = D.Sat);
      (* Immaculate Heart is the day right after Sacred Heart, every year. *)
      Alcotest.(check bool)
        (Printf.sprintf "%d: Immaculate Heart is the day after Sacred Heart" year)
        true (immaculate_heart_date = D.add_days sacred_heart_date 1))
    [ 2026; 2027; 2035 ]

let suite =
  ( "Calendar_of (data/of/calendar-2002.sexp)",
    [ Alcotest.test_case "SHA-256 pinned" `Quick test_sha256;
      Alcotest.test_case "load succeeds and counts match the shipped file" `Quick
        test_load_and_counts;
      Alcotest.test_case "slugs are unique" `Quick test_slugs_unique;
      Alcotest.test_case "slugs re-validate" `Quick test_slugs_revalidate;
      Alcotest.test_case "every date resolves in a leap year" `Quick
        test_dates_resolve_in_a_leap_year;
      Alcotest.test_case "spot-check: 25 Jan Festum, Conversion of Paul" `Quick
        test_spot_check_conversion_of_paul;
      Alcotest.test_case "spot-check: 22 Feb Festum, Chair of Peter" `Quick
        test_spot_check_chair_of_peter;
      Alcotest.test_case "spot-check: 2 Feb Festum, Presentation" `Quick
        test_spot_check_presentation;
      Alcotest.test_case "spot-check: 24 Jan Memoria, Francis de Sales" `Quick
        test_spot_check_francis_de_sales;
      Alcotest.test_case "spot-check: 27 Jan blank -> Memoria_ad_libitum, Angela Merici" `Quick
        test_spot_check_angela_merici;
      Alcotest.test_case
        "1/6 Jan and 25 Dec deliberately excluded -- Temporal_of.named covers them" `Quick
        test_excludes_temporal_of_dates;
      Alcotest.test_case
        "fix round 1: Sacred Heart of Jesus (Easter+68) and Immaculate Heart of Mary          (Easter+69)" `Quick
        test_movable_entries ] )