diff options
Diffstat (limited to 'test/test_lectionary_ef.ml')
| -rw-r--r-- | test/test_lectionary_ef.ml | 323 |
1 files changed, 321 insertions, 2 deletions
diff --git a/test/test_lectionary_ef.ml b/test/test_lectionary_ef.ml index 8af57f6..e28476d 100644 --- a/test/test_lectionary_ef.ml +++ b/test/test_lectionary_ef.ml @@ -11,6 +11,7 @@ open Rite_ef let sanctoral_path = "../data/ef/sanctoral.sexp" let adjustments_path = "../data/ef/adjustments.sexp" let lectionary_path = "../data/ef/lectionary.sexp" +let commons_path = "../data/ef/commons.sexp" let real_layer () = let layer = @@ -40,6 +41,17 @@ let real_lectionary () = | Ok l -> l | Error e -> Alcotest.failf "%s: failed to load: %s" lectionary_path e +(* The Commons travel the same caller-supplied seam (Task 6). Note that + [Commons.load]'s own validation runs here on the committed file: a + duplicate common id, a duplicate assignment, an empty formulary, or an + assignment naming a common that does not exist all come back as [Error] + and fail every test in this file rather than degrading silently to "this + saint has no Common". *) +let real_commons () = + match Lectionary_ef.Commons.load commons_path with + | Ok c -> c + | Error e -> Alcotest.failf "%s: failed to load: %s" commons_path e + (* [Calendar.day] (not [year]): the liturgical year "opening in civil year y" is Advent-anchored (RG 61), so [Calendar.year _ _ 2030] covers Advent 2030 through November 2031 -- it would never contain 13 January 2030, which @@ -48,7 +60,9 @@ let real_lectionary () = let day y m d = let date = match Date.make ~year:y ~month:m ~day:d with | Ok x -> x | Error e -> Alcotest.fail e in - Calendar.day (Rite_ef.context ~lectionary:(real_lectionary ())) (real_layer ()) date + Calendar.day + (Rite_ef.context ~lectionary:(real_lectionary ()) ~commons:(real_commons ())) + (real_layer ()) date let refs (ld : _ Liturgical_day.t) = List.map (fun c -> c.Citation.reference) ld.citations @@ -164,6 +178,289 @@ let test_step3_sunday_does_not_recurse () = let d = day 2026 6 14 in Alcotest.(check int) "a Sunday resolves without looping" 2 (List.length (refs d)) +(* ---------------------------------------------------------------------- *) +(* Chain step 4: the Commons (Task 6). *) +(* *) +(* Step 4 EXECUTES SECOND, between step 1 and step 2, not last as the task *) +(* brief sketched -- see lectionary_ef.ml's own branch comment for the *) +(* measurement and the primary-source warrant. The step NUMBER is kept as *) +(* the plan wrote it so that every "step 3" already recorded elsewhere *) +(* still names the same branch. *) +(* *) +(* Every expected value below is quoted from data/ef/commons.sexp, which in *) +(* turn cites the Missal page it was read from. There is NO oracle for any *) +(* of it: lectio has no readings for these feasts (that is why Task 6 *) +(* exists) and missalemeum's 2026-2027 window never observes one of them. *) +(* These pins and the golden ones are the entire regression net. *) +(* ---------------------------------------------------------------------- *) + +(* 2038-03-06 observes Sts Perpetua and Felicity: a III-class feast that + beats the Septuagesima II Saturday feria. The Missal sends 6 March to + "Missa Me exspectaverunt, de Communi non Virginum I loco", whose printed + Epistle and Gospel are these. + + This test is also the ORDERING pin, and it is the reason it uses explicit + values rather than a length check: with step 4 placed last (the brief's + sketch) this day resolves through step 2 to the feria's own + "2 Cor. 11:19-33; 12:1-9" / "Luke 8:4-15" instead -- a green + two-citation answer that a [List.length = 2] assertion would not + distinguish from the right one. *) +let test_step4_commons_perpetua_and_felicity () = + Alcotest.(check (list string)) + "6 March 2038: Sts Perpetua and Felicity take the Common of non-Virgins I" + [ "Ecclus 51:1-8, 12"; "Matt 13:44-52" ] + (refs (day 2038 3 6)) + +(* 2038-03-09, St Frances of Rome: "Missa Cognovi, de Communi non Virginum + II loco". Same year, three days later, and a DIFFERENT Common -- so this + pin also proves the assignment table is consulted per saint rather than + one Common being handed to everything that reaches step 4. Its temporal + slug (ef-septuagesima-3-tuesday) resolves to 1 Cor. 13:1-13 / Luke + 18:31-43, so the wrong-order failure is again a plausible-looking green. *) +let test_step4_commons_frances_of_rome () = + Alcotest.(check (list string)) + "9 March 2038: St Frances of Rome takes the Common of non-Virgins II" + [ "Prov 31:10-31"; "Matt 13:44-52" ] + (refs (day 2038 3 9)) + +(* 2008-04-04, St Isidore: "Missa In medio, de Communi Doctorum". A + Paschaltide witness, where step 2 would otherwise have supplied the + Easter-week feria's 1 John 5:4-10 / John 20:19-31. *) +let test_step4_commons_isidore () = + Alcotest.(check (list string)) + "4 April 2008: St Isidore takes the Common of Doctors" + [ "2 Tim 4:1-8"; "Matt 5:13-19" ] + (refs (day 2008 4 4)) + +(* 2005-04-05, St Vincent Ferrer: "Missa Os iusti, de Communi Confessoris + non Pontificis I loco". *) +let test_step4_commons_vincent_ferrer () = + Alcotest.(check (list string)) + "5 April 2005: St Vincent Ferrer takes the Common of a Confessor not a Bishop I" + [ "Ecclus 31:8-11"; "Luke 12:35-40" ] + (refs (day 2005 4 5)) + +(* Step 1 still wins over step 4 for a saint the Missal prints a full Mass + for. St John of God (8 March) is the sharp case: his Epistle IS the + Common of a Confessor not a Bishop's own (Ecclus 31:8-11), but the Missal + assigns him a different GOSPEL (Mt 22:34-46, not that Common's Luke + 12:35-40), so a chain that reached for a Common here would be caught by + the Gospel alone. He has no assignment in commons.sexp at all -- this + asserts the proper arrived via adjustments.sexp and step 1. *) +let test_step1_proper_beats_any_common_john_of_god () = + Alcotest.(check (list string)) + "8 March 2038: St John of God's own printed Mass, not the Common that shares its Epistle" + [ "Ecclus 31:8-11"; "Matt 22:34-46" ] + (refs (day 2038 3 8)); + Alcotest.(check bool) + "and he is deliberately absent from the Commons assignment table" true + (Lectionary_ef.commons_for ~commons:(real_commons ()) + (Slug.of_string_exn "john-of-god") + = None) + +(* The other two proper-Mass saints a real year ever observes. Both would + otherwise silently emit their feria's Mass. *) +let test_step1_proper_thomas_aquinas () = + Alcotest.(check (list string)) + "7 March 2011: St Thomas Aquinas's own proper" + [ "Wis 7:7-14"; "Matt 5:13-19" ] + (refs (day 2011 3 7)) + +let test_step1_proper_francis_of_paola () = + Alcotest.(check (list string)) + "2 April 2008: St Francis of Paola's own proper" + [ "Phil 3:7-12"; "Luke 12:32-34" ] + (refs (day 2008 4 2)) + +(* Step 4 must never divert a day whose observed office IS its temporal + office. The guard in [readings] makes that structural; this pins it + behaviourally on the case most at risk -- the RG 78 Saturday Office of + the BVM, which deliberately REUSES the ordinary ferial slug (see + temporal_ef.ml's [bvm_saturday_names], "Slug" paragraph). If that shared + slug were ever assigned a Common, every feria sharing it would change + Mass. 4 July 2026 is such a Saturday; it keeps its own ferial Mass. *) +let test_step4_never_diverts_a_temporal_office () = + Alcotest.(check (list string)) + "4 July 2026 (a BVM Saturday) keeps its ferial Mass, unaffected by step 4" + [ "1 Pet 3:8-15."; "Matt 5:20-24." ] + (refs (day 2026 7 4)) + +(* THE DATA ASSERTION, and the one that cannot drift: after Task 6, every + sanctoral entry that can ever BE the observed office -- i.e. every + [status Feast] entry -- either carries its own proper or has a Common + assigned. Stated over the real loaded layer rather than as a hard-coded + list of fifteen names, so that a future re-bootstrap adding a + readingless class-3 feast fails here instead of silently emitting its + feria's Mass. + + [Commemoration_only] entries are excluded deliberately and not as an + oversight: they are never the observed celebration (Precedence never + returns one as [observed]), and [readings] only ever consults + [observed], so their empty citations are unreachable. 104 of the 119 + entries with no citations are of that kind. *) +let test_every_observable_sanctoral_feast_has_readings () = + let commons = real_commons () in + let layer = real_layer () in + let gaps = + List.filter_map + (fun (entry : Vocab_ef.rank Layer.entry) -> + let cel = entry.Layer.cel in + if cel.Celebration.status <> Celebration.Feast then None + else if cel.Celebration.citations <> [] then None + else if Lectionary_ef.commons_for ~commons cel.Celebration.slug <> None then None + else Some (Slug.to_string cel.Celebration.slug)) + layer.Layer.entries + in + Alcotest.(check (list string)) + "every sanctoral Feast has either a proper or an assigned Common" [] + (List.sort_uniq compare gaps) + +(* The seven saints the Missal sends to a Common, and the exact Common each + one is sent to -- quoted per saint, with its Missal citation, in + data/ef/commons.sexp. Named explicitly (in addition to the generic + invariant above) because three of them -- Benedict, Gregory the Great, + Patrick -- are NEVER the observed office anywhere in 1583-9999 (12, 17 + and 21 March always fall to a Lenten or Passiontide feria that outranks a + III-class feast), so no end-to-end pin can reach them and this table is + their only coverage. + + The plan expected Benedict, Patrick and Gregory the Great to have PROPERS + and Thomas Aquinas likewise; the Missal gives a proper only to Thomas + Aquinas. All three reversals were verified on the page images + (pp. 488, 492) as well as in both OCR text layers. *) +let test_step4_assignment_table () = + let commons = real_commons () in + let assigned = + List.map + (fun (saint, common) -> (Slug.to_string saint, Slug.to_string common)) + (Lectionary_ef.Commons.assignments commons) + in + Alcotest.(check (list (pair string string))) + "the Commons assignment table, exactly" + [ ("benedict", "common-of-abbots"); + ("frances-rome", "common-of-non-virgins-2"); + ("gregory-the-great", "common-of-supreme-pontiffs"); + ("isidore-of-seville", "common-of-doctors"); + ("patrick", "common-of-a-confessor-bishop-1"); + ("sts-felicitas-perpetua", "common-of-non-virgins-1"); + ("vincent-ferrer", "common-of-a-confessor-not-a-bishop-1") ] + assigned + +(* The three Commons no real year reaches, resolved through [commons_for] so + that the assignment AND the formulary behind it are both exercised. *) +let test_step4_unreachable_commons_still_resolve () = + let commons = real_commons () in + let for_saint s = Lectionary_ef.commons_for ~commons (Slug.of_string_exn s) in + let refs_of = function + | None -> [ "<no common>" ] + | Some cs -> List.map (fun c -> c.Citation.reference) cs + in + Alcotest.(check (list string)) + "St Benedict (21 March), Common of Abbots" + [ "Ecclus 45:1-6"; "Matt 19:27-29" ] + (refs_of (for_saint "benedict")); + Alcotest.(check (list string)) + "St Gregory the Great (12 March), Common of Supreme Pontiffs" + [ "1 Pet 5:1-4, 10-11"; "Matt 16:13-19" ] + (refs_of (for_saint "gregory-the-great")); + Alcotest.(check (list string)) + "St Patrick (17 March), Common of a Confessor Bishop I" + [ "Ecclus 44:16-27; 45:3-20"; "Matt 25:14-23" ] + (refs_of (for_saint "patrick")) + +(* The five proper-Mass saints no real year reaches, asserted on the layer + rather than end-to-end, for the same reason. *) +let test_step4_unreachable_propers_are_present () = + let layer = real_layer () in + let refs_of slug = + match + List.find_opt + (fun (e : Vocab_ef.rank Layer.entry) -> + Slug.equal e.Layer.cel.Celebration.slug (Slug.of_string_exn slug)) + layer.Layer.entries + with + | None -> [ "<absent from the layer>" ] + | Some e -> List.map (fun c -> c.Citation.reference) e.Layer.cel.Celebration.citations + in + Alcotest.(check (list string)) + "Forty Holy Martyrs of Sebaste (10 March)" + [ "Heb 11:33-39"; "Luke 6:17-23" ] + (refs_of "forty-holy-martyrs-of-sebaste"); + Alcotest.(check (list string)) + "St Cyril of Jerusalem (18 March)" + [ "Ecclus 39:6-14"; "Matt 10:23-28" ] + (refs_of "cyril-of-jerusalem"); + (* The one genuine disagreement between the two printings: scan1's edition + misprints this Gospel as "Luc. 2, 26-38" (confirmed on its own page + image, so a typographical error, not OCR). Luke 1:26-38 is settled by + scan2's edition, by the pericope text in both, and by the same edition + citing the identical pericope correctly two pages later at the + Annunciation. See data/ef/adjustments.sexp for the full account. *) + Alcotest.(check (list string)) + "St Gabriel the Archangel (24 March)" + [ "Dan 9:21-26"; "Luke 1:26-38" ] + (refs_of "gabriel-the-archangel"); + (* Wis 10:10-17 here versus 10:10-14 for John of Capistrano below: a real + difference, agreed by both scans and both page images. *) + Alcotest.(check (list string)) + "St John Damascene (27 March)" + [ "Wis 10:10-17"; "Luke 6:6-11" ] + (refs_of "john-damascene"); + Alcotest.(check (list string)) + "St John of Capistrano (28 March)" + [ "Wis 10:10-14"; "Luke 9:1-6" ] + (refs_of "john-of-capistrano") + +(* [Commons.load] must reject, not silently degrade, the four data defects + that are indistinguishable downstream from "this saint has no Common". + Written against strings rather than the committed file so the committed + file stays clean. *) +let test_commons_load_rejects_bad_data () = + let write name contents = + let path = Filename.concat (Filename.get_temp_dir_name ()) name in + let oc = open_out path in + output_string oc contents; + close_out oc; + path + in + let err name contents = + match Lectionary_ef.Commons.load (write name contents) with + | Ok _ -> Alcotest.failf "%s: expected Error, got Ok" name + | Error e -> e + in + let good_common = "(c-a (((part First) (reference \"A 1:1\")) ((part Gospel) (reference \"B 2:2\"))))" in + let contains needle haystack = + let n = String.length needle and h = String.length haystack in + let rec go i = i + n <= h && (String.sub haystack i n = needle || go (i + 1)) in + go 0 + in + let check_msg label needle msg = + Alcotest.(check bool) (Printf.sprintf "%s names the culprit (%s)" label needle) true + (contains needle msg) + in + check_msg "duplicate common" "duplicate common" + (err "commons-dup-common.sexp" + (Printf.sprintf "((commons (%s %s)) (assigned ()))" good_common good_common)); + check_msg "duplicate assignment" "duplicate assignment" + (err "commons-dup-assign.sexp" + (Printf.sprintf "((commons (%s)) (assigned ((s c-a) (s c-a))))" good_common)); + check_msg "empty formulary" "no citations" + (err "commons-empty.sexp" "((commons ((c-a ()))) (assigned ()))"); + check_msg "unknown common" "unknown common" + (err "commons-unknown.sexp" + (Printf.sprintf "((commons (%s)) (assigned ((s c-missing))))" good_common)); + (* A missing file is an [Error] too, never an exception -- the contract + [Lectionary.load] already makes and the reason neither is read at + module-initialisation time. *) + (match Lectionary_ef.Commons.load "../data/ef/no-such-commons.sexp" with + | Ok _ -> Alcotest.fail "a missing commons file must not load" + | Error _ -> ()); + (* [empty] is the identity: every lookup [None], nothing invented. *) + Alcotest.(check bool) "Commons.empty resolves nothing" true + (Lectionary_ef.commons_for ~commons:Lectionary_ef.Commons.empty + (Slug.of_string_exn "benedict") + = None) + let suite = [ ("step 1: sanctoral proper", `Quick, test_step1_sanctoral_proper); ("step 2: own temporal proper", `Quick, test_step2_lenten_feria_has_its_own); @@ -178,4 +475,26 @@ let suite = ("step 3: 3 Feb 2025 takes the Sunday's temporal Mass, not the Purification's", `Quick, test_step3_uses_temporal_not_observed); ("step 3: a Sunday does not recurse into itself", `Quick, - test_step3_sunday_does_not_recurse) ] + test_step3_sunday_does_not_recurse); + ("step 4: Perpetua and Felicity take the Common of non-Virgins I", `Quick, + test_step4_commons_perpetua_and_felicity); + ("step 4: Frances of Rome takes the Common of non-Virgins II", `Quick, + test_step4_commons_frances_of_rome); + ("step 4: Isidore takes the Common of Doctors", `Quick, test_step4_commons_isidore); + ("step 4: Vincent Ferrer takes the Common of a Confessor not a Bishop I", `Quick, + test_step4_commons_vincent_ferrer); + ("step 1 beats step 4: John of God's proper, not the Common sharing its Epistle", `Quick, + test_step1_proper_beats_any_common_john_of_god); + ("step 1: Thomas Aquinas's proper", `Quick, test_step1_proper_thomas_aquinas); + ("step 1: Francis of Paola's proper", `Quick, test_step1_proper_francis_of_paola); + ("step 4 never diverts a temporal office (BVM Saturday)", `Quick, + test_step4_never_diverts_a_temporal_office); + ("every observable sanctoral Feast has a proper or a Common", `Quick, + test_every_observable_sanctoral_feast_has_readings); + ("the Commons assignment table, exactly", `Quick, test_step4_assignment_table); + ("the three Commons no real year reaches still resolve", `Quick, + test_step4_unreachable_commons_still_resolve); + ("the five propers no real year reaches are present", `Quick, + test_step4_unreachable_propers_are_present); + ("Commons.load rejects the four silent-degradation defects", `Quick, + test_commons_load_rejects_bad_data) ] |
