aboutsummaryrefslogtreecommitdiff
path: root/test/test_lectionary_ef.ml
diff options
context:
space:
mode:
Diffstat (limited to 'test/test_lectionary_ef.ml')
-rw-r--r--test/test_lectionary_ef.ml323
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) ]