aboutsummaryrefslogtreecommitdiff
path: root/test/test_sanctoral_ef.ml
blob: b237f25984a3007c6d6a875de1818e50396f78b4 (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
(* Loads the bootstrapped data/ef/sanctoral.sexp (Task 10, tools/
   bootstrap_sanctoral.ml) through Layer.load and checks it against counts
   independently derived from the source INI itself (grep -c '^\[',
   grep '^rank' | sort | uniq -c -- see the task report), plus two named
   spot-checks against the INI's own text so a passing count can't hide the
   wrong 322 entries having been converted (task brief's "live hazard"). *)

module L = Colitur_kernel.Layer
module Cel = Colitur_kernel.Celebration
module Citation = Colitur_kernel.Citation
module S = Colitur_kernel.Slug
module DS = Colitur_kernel.Date_spec
module Col = Colitur_kernel.Colour
module Sub = Colitur_kernel.Subject
module Lang = Colitur_kernel.Lang
module Names = Colitur_kernel.Names
module V = Rite_ef.Vocab_ef
module PE = Rite_ef.Precedence_ef

(* 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/ef/sanctoral.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

(* Counts re-derived 2026-08-12 (ef-rebootstrap) against the regenerated
   source INI directly (`grep -c '^\['` minus the [layer] header itself;
   `grep '^rank' | sort | uniq -c`) -- NOT copied from a `colitur day`/
   bootstrap-tool run, per this project's own "derive the new value, don't
   transcribe it from output" pinning discipline. The source's SHA-256 moved
   6a25e634... -> 1b303ef2..., correcting: 15 entries wrongly `rank =
   commemoration` that are really class-3 feasts (322 -> 327's own feast/
   comm split moves 15 the other way before the net +5 below); 5 entries
   newly present (agnes-secundo, boniface-martyr, eusebius-confessor,
   evaristus, theodore -- all Commemoration_only), so 322 + 5 = 327 and
   114 - 15 + 5 = 104. Class1 count (12) is untouched by this regeneration --
   none of the 20 changed/added entries is Class1. *)
let test_load_and_counts () =
  let l = load () in
  Alcotest.(check int) "327 entries" 327 (List.length l.L.entries);
  let commemoration_only =
    List.filter (fun e -> e.L.cel.Cel.status = Cel.Commemoration_only) l.L.entries
  in
  (* 102, not 104: ef-sanctoral-status promoted `ubaldus` (16 May) and
     `didacus` (13 November) to Feast. Both are ranked "III classis" outright
     in the Missal's own universal calendarium, with no commemoration rubric,
     and both have their own Mass entry in the Proprium taking its readings
     from a Common. They were the ONLY two defects found by auditing all 290
     fixed-date entries against that calendarium -- see the register. *)
  Alcotest.(check int) "102 Commemoration_only" 102 (List.length commemoration_only);
  let class1 = List.filter (fun e -> e.L.cel.Cel.rank = V.Class1) l.L.entries in
  Alcotest.(check int) "12 Class1" 12 (List.length class1);
  let temporal_subjects = List.filter (fun e -> e.L.cel.Cel.subject = Sub.Temporal) l.L.entries in
  Alcotest.(check int) "no Subject.Temporal" 0 (List.length temporal_subjects);
  (* ef-rebootstrap fix round 1 (F8): a guard against WHOLESALE Polish-name
     loss, the exact defect lectio itself shipped once (a regeneration that
     dropped all 322 name.pl entries, the "SECOND Critical" the lectio side
     of this session's own work found and fixed -- register/ledger record).
     `parse_names`'s own `field_opt` change (this task's first commit) can
     legitimately drop to en-only for a genuinely pl-less source entry --
     verified NOT to silently drop a PRESENT name -- but nothing before
     this assertion would have caught a regeneration that dropped pl
     WHOLESALE the way lectio's once did: `test_load_and_counts` checked
     entry/status/rank counts only, none of which move if every name.pl
     vanishes. Independently re-derived, not transcribed: `grep -c
     '^name.pl' tridentine-calendar.ini` = 322 (327 entries, 5 lack it --
     the same 5 `parse_names` documents), cross-checked against
     `grep -c '(pl' data/ef/sanctoral.sexp` = 322 exactly. *)
  let with_pl =
    List.filter
      (fun e -> Names.find e.L.cel.Cel.names (Lang.of_string_exn "pl") <> None)
      l.L.entries
  in
  Alcotest.(check int) "322 of 327 entries carry a Polish name (5 legitimately do not)" 322
    (List.length with_pl);
  (* Every slug already had to pass Slug.of_string during load (Slug.t_of_sexp
     is the validating parser -- an invalid slug would have failed the whole
     Layer.load with Error, never landing here silently). Re-checking is
     still worth doing explicitly, rather than resting entirely on "load
     didn't error", so a future loader change that weakens that guarantee
     has a test that would catch it directly. *)
  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;
  (* All 322 dates are Date_spec.Fixed month/day pairs constructed via
     Date_spec.fixed, which already rejects an out-of-range day for that
     month against the LEAP-year maximum -- so every one must resolve in a
     leap year (2028: divisible by 4, not by 100). *)
  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

(* Named spot-check 1: one of the 6 entries carrying an explicit `class`
   field in the source (INI lines: date=02-02, rank=class-2, colour=white,
   class=lord, name.en/name.pl as below). Exercises decision 1's non-default
   branch -- an entry where subject must NOT fall back to Saint. *)
let test_spot_check_explicit_class () =
  let l = load () in
  let e = find l "purification-of-the-blessed-virgin-mary" in
  let cel = e.L.cel in
  Alcotest.(check bool) "date 02-02" true (e.L.date = mkdate 2 2);
  Alcotest.(check bool) "rank Class2" true (cel.Cel.rank = V.Class2);
  Alcotest.(check bool) "status Feast" true (cel.Cel.status = Cel.Feast);
  Alcotest.(check bool) "colour White" true (cel.Cel.colour = Col.White);
  Alcotest.(check bool) "subject Lord (from class = lord)" true (cel.Cel.subject = Sub.Lord);
  Alcotest.(check (option string)) "name.en" (Some "Purification of the Blessed Virgin Mary")
    (Names.find cel.Cel.names (Lang.of_string_exn "en"));
  Alcotest.(check (option string)) "name.pl" (Some "Oczyszczenie N. M. P.")
    (Names.find cel.Cel.names (Lang.of_string_exn "pl"));
  Alcotest.(check string) "layer tagged universal (Precedence_ef.universal_layer)" PE.universal_layer
    cel.Cel.layer

(* Named spot-check 2: a `rank = commemoration` entry (INI lines: date=01-14,
   rank=commemoration, colour=white, no class, name.en/name.pl as below).
   Exercises decision 2 (Commemoration_only + inferred Class3) AND decision
   1's default branch (no class field -> Saint) on the SAME entry. *)
let test_spot_check_commemoration () =
  let l = load () in
  let e = find l "maur-abbot" in
  let cel = e.L.cel in
  Alcotest.(check bool) "date 01-15" true (e.L.date = mkdate 1 15);
  Alcotest.(check bool) "rank inferred Class3" true (cel.Cel.rank = V.Class3);
  Alcotest.(check bool) "status Commemoration_only" true (cel.Cel.status = Cel.Commemoration_only);
  Alcotest.(check bool) "colour White" true (cel.Cel.colour = Col.White);
  Alcotest.(check bool) "subject defaults to Saint (no class field)" true (cel.Cel.subject = Sub.Saint);
  Alcotest.(check (option string)) "name.en" (Some "St. Maur, Abbot")
    (Names.find cel.Cel.names (Lang.of_string_exn "en"));
  Alcotest.(check (option string)) "name.pl" (Some "św. Maura, Opata")
    (Names.find cel.Cel.names (Lang.of_string_exn "pl"))

(* Task 3: sanctoral propers land on Celebration.citations, not a second
   lectionary-shaped file -- see the bootstrap tool's own reasoning. *)
let test_sanctoral_carries_propers () =
  let l = load () in
  let e = find l "commemoration-of-the-baptism-of-the-lord" in
  let refs = List.map (fun c -> c.Citation.reference) e.L.cel.Cel.citations in
  Alcotest.(check (list string))
    "13 January carries its proper Epistle and Gospel"
    [ "Isa 60:1-6"; "John 1:29-34" ] refs

let test_commemoration_only_has_no_readings () =
  let l = load () in
  let bad =
    List.filter
      (fun e -> e.L.cel.Cel.status = Cel.Commemoration_only && e.L.cel.Cel.citations <> [])
      l.L.entries
  in
  Alcotest.(check int)
    "a commemoration contributes an oration, not a reading" 0 (List.length bad)

(* RG 33's third omission trigger reads a hand-written table in
   [Precedence_ef.vigil_feast_table] pairing each vigil with the feast it
   precedes. That table is the one part of the rule no type can check: a slug
   that matches nothing in the data makes the rule silently inert for that
   entry, and a vigil ADDED to the data but not to the table is never omitted
   at all. Both directions are asserted here against the shipped file.

   The Ascension's vigil is excluded from the data-side checks because it is
   TEMPORAL (Temporal_ef builds it off an Easter offset) and correctly absent
   from the sanctoral layer -- asserted explicitly below rather than skipped,
   so that the exclusion cannot quietly grow to cover a real miss. *)
let ascension_vigil = "ef-ascension-vigil"

let test_vigil_feast_table_slugs_all_exist () =
  let l = load () in
  let mem slug =
    match Colitur_kernel.Slug.of_string slug with
    | Error e -> Alcotest.failf "%s: not a well-formed slug: %s" slug e
    | Ok s -> L.mem l s
  in
  List.iter
    (fun (vigil, feast) ->
      let feast = Colitur_kernel.Slug.to_string feast in
      if String.equal vigil ascension_vigil then begin
        (* BOTH halves of this pair are temporal -- Temporal_ef builds the
           vigil at Easter+38 and the feast at Easter+39 -- so neither belongs
           in the sanctoral layer. Asserted in the negative rather than
           skipped, so the exclusion cannot quietly widen to hide a real miss;
           the pair's positive witness is test_temporal_ef's own Ascension
           cases and the 2026-05-13 golden pin. *)
        Alcotest.(check bool) "ef-ascension-vigil is temporal, not sanctoral" false (mem vigil);
        Alcotest.(check bool) "ef-ascension is temporal, not sanctoral" false (mem feast)
      end
      else begin
        Alcotest.(check bool)
          (Printf.sprintf "%s is present in the shipped sanctoral" vigil)
          true (mem vigil);
        Alcotest.(check bool)
          (Printf.sprintf "%s (the feast %s precedes) is present in the shipped sanctoral"
             feast vigil)
          true (mem feast)
      end)
    PE.vigil_feast_table

let test_vigil_feast_table_covers_every_shipped_vigil () =
  let l = load () in
  let tabled = List.map fst PE.vigil_feast_table in
  let shipped_vigils =
    l.L.entries
    |> List.filter (fun (e : V.rank L.entry) ->
           let slug = Colitur_kernel.Slug.to_string e.L.cel.Colitur_kernel.Celebration.slug in
           PE.is_vigil slug && PE.is_omissible_vigil e.L.cel.Colitur_kernel.Celebration.rank)
    |> List.map (fun (e : V.rank L.entry) ->
           Colitur_kernel.Slug.to_string e.L.cel.Colitur_kernel.Celebration.slug)
    |> List.sort compare
  in
  let missing = List.filter (fun s -> not (List.mem s tabled)) shipped_vigils in
  Alcotest.(check (list string))
    "every II/III-class vigil in the shipped sanctoral is in vigil_feast_table -- a new one \
     added to the data without a table row would never be omitted under RG 33"
    [] missing;
  (* Pinned so that a vigil VANISHING from the data is caught too: an empty
     [shipped_vigils] would satisfy the subset check above vacuously. *)
  Alcotest.(check int) "four sanctoral vigils are subject to RG 33" 4
    (List.length shipped_vigils)

let suite =
  ( "Sanctoral_ef (data/ef/sanctoral.sexp)",
    [ Alcotest.test_case "load succeeds and counts match the source INI" `Quick
        test_load_and_counts;
      Alcotest.test_case "spot-check: explicit class = lord (Purification of the BVM)" `Quick
        test_spot_check_explicit_class;
      Alcotest.test_case "spot-check: rank = commemoration (St. Maur, Abbot)" `Quick
        test_spot_check_commemoration;
      Alcotest.test_case "sanctoral entries carry their proper readings" `Quick
        test_sanctoral_carries_propers;
      Alcotest.test_case "Commemoration_only entries carry no readings" `Quick
        test_commemoration_only_has_no_readings;
      Alcotest.test_case "RG33: every vigil_feast_table slug exists in the shipped data" `Quick
        test_vigil_feast_table_slugs_all_exist;
      Alcotest.test_case "RG33: vigil_feast_table covers every shipped II/III-class vigil" `Quick
        test_vigil_feast_table_covers_every_shipped_vigil ] )