open Colitur_kernel open Rite_ef (* Same pattern test_rite_ef.ml already uses: both [Calendar.year] and [Calendar.day] take the rite AND the sanctoral layer (Rite.t carries no layer of its own -- see rite_ef.mli's own note on why), so step 1's sanctoral-proper case needs the REAL data/ef/sanctoral.sexp + data/ef/adjustments.sexp loaded, not a bare [Rite_ef.context]. Relative to this test's own build directory (_build/default/test/); test/dune declares both as deps. *) 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 = match Layer.load Vocab_ef.rank_of_sexp sanctoral_path with | Ok l -> l | Error e -> Alcotest.failf "%s: failed to load: %s" sanctoral_path e in let overlay = match Overlay.load Vocab_ef.rank_of_sexp adjustments_path with | Ok o -> o | Error e -> Alcotest.failf "%s: failed to load: %s" adjustments_path e in let layer, diagnostics = Overlay.apply layer overlay in Alcotest.(check (list string)) "the committed overlay applies cleanly, no diagnostics" [] (List.map Overlay.diagnostic_to_string diagnostics); layer (* [Rite_ef.context] takes [~lectionary] (fix round 1, coordinator review): the module used to load data/ef/lectionary.sexp itself, as a side effect of being linked, which meant a bare `dune build` produced a `colitur` that died at startup on EVERY subcommand -- including ones (`easter`) that touch no lectionary data at all -- the moment that file was absent from the default build target. Caller-supplied now, same as the sanctoral layer above. *) let real_lectionary () = match Lectionary.load lectionary_path with | 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 belongs to the liturgical year that opened in Advent 2029. [Calendar.day] finds the containing liturgical year itself; see calendar.mli. *) 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 ()) ~commons:(real_commons ())) (real_layer ()) date (* Same resolution, with a caller-supplied Commons table instead of the committed one -- the seam the whole design rests on, and the only way to exercise step 4's guard, which is deliberately a no-op on shipped data. *) let day_with ~commons 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 ()) ~commons) (real_layer ()) date let refs (ld : _ Liturgical_day.t) = List.map (fun c -> c.Citation.reference) ld.citations (* Chain step 1: the observed celebration's own proper wins. NOT 2030: that is the one pinned collision year (test_golden.ml, [test_holy_family_excludes_baptism_2030]) where 13 January is itself the Holy Family Sunday and RG 112(a) excludes the Baptism commemoration entirely -- [observed] there is [ef-time-after-epiphany-sunday-1], not this slug, so it is the wrong year to exercise step 1 against. 2026 is an ordinary year (13 January a Tuesday, no Sunday collision), where the fixed Commemoration of the Baptism of the Lord (Class2, subject Lord, data/ef/sanctoral.sexp) is observed outright. *) let test_step1_sanctoral_proper () = Alcotest.(check (list string)) "13 January 2026, Commemoration of the Baptism of the Lord" [ "Isa 60:1-6"; "John 1:29-34" ] (refs (day 2026 1 13)) (* Chain step 2: the day's own temporal slug has a proper (Lent has one daily). *) let test_step2_lenten_feria_has_its_own () = Alcotest.(check (list string)) "Monday of the 1st week of Lent is not the Sunday's Mass" [ "Ezech 34:11-16"; "Matt 25:31-46" ] (refs (day 2026 2 23)) (* Fix round 1 (coordinator review, Important finding 2): neither test above actually distinguishes step 1 from step 2 -- both survive swapping the chain order. 13 January 2026's temporal slug (ef-time-after-epiphany-1- tuesday) has no lectionary entry at all, so a swapped chain falls through to the same []-then-sanctoral answer; 23 February 2026's OBSERVED celebration IS the temporal office (ef-lent-1-monday carries no sanctoral entry of its own), so [observed.citations] and the temporal-slug lookup are the same lookup wearing two names -- order is a no-op either way. 19 March 2026 (St Joseph) genuinely needs step 1 to run FIRST: the observed celebration (Joseph, Class1, a real sanctoral entry with its own citations) and the day's own temporal slug (ef-lent-4-thursday, ALSO a real lectionary entry, with different citations) disagree. Verified directly against the real data (not transcribed): both value pairs below were read off the actual resolved day and the actual data/ef/lectionary.sexp entry, not assumed. *) let test_step1_wins_over_a_competing_step2_entry () = Alcotest.(check (list string)) "19 March 2026: Joseph's own proper wins over Lent 4 Thursday's, which the temporal slug also has" [ "Ecclus 45:1-6"; "Matt 1:18-21" ] (refs (day 2026 3 19)) (* Fix round 1 (coordinator review): the mirror-image pin for step 2 -- a day whose OBSERVED celebration carries no citations of its own (Holy Family, synthesised by [Temporal_ef] itself, not sourced from data/ef/sanctoral.sexp, so [Celebration.citations] is empty) falls through to the temporal slug, and the temporal slug's own lectionary entry is genuinely Holy Family's Mass, not the Baptism's -- RG 112(a) (see test_golden.ml's own [test_holy_family_excludes_baptism_2030]) excludes the Baptism from this day entirely, so there is no sanctoral citation anywhere to fall back to even in principle. Verified directly against the real data. *) let test_step2_holy_family_reached_through_temporal_slug () = Alcotest.(check (list string)) "13 January 2030: Holy Family reached via the temporal slug, the Baptism entirely absent" [ "Col 3:12-17"; "Luke 2:42-52" ] (refs (day 2030 1 13)) (* Chain step 3: a feria with no proper of its own says the preceding Sunday's Mass. *) let test_step3_advent_feria_resumes_sunday () = Alcotest.(check (list string)) "Monday after Advent I says Advent I's Mass" [ "Rom 13:11-14"; "Luke 21:25-33" ] (refs (day 2025 12 1)) (* Task 8, fix round 1 (coordinator review, Important 3(a)): this test used to pin 2025-12-29 (a Monday), reached by resuming its preceding Sunday's temporal identity. That date no longer exercises step 3 at all -- tools/bootstrap_lectionary.ml's own [nativity_octave_entries] now gives the fixed Nativity-Octave slug (RG 91 entry 17, "ef-nativity-octave- day-5") its OWN direct citation ("Diebus infra octavam Nativitatis Domini", Tit. 3,4-7/Luc. 2,15-20), so 29 December resolves at STEP 2, unconditionally, regardless of weekday. Every civil day in 29-31 December now resolves the same way -- there is no date left in that specific window this test could use to exercise step 3. REPLACED AGAIN, Task 9 (branch ef-lectionary, layer-4 oracle): 2028-01-05 ("ef-christmas-1-wednesday") stopped exercising step 3 too, the SAME fate as the 2025-12-29 case this comment already documents -- the Missal's own Mass-propers "diebus ferialibus a 2 ad 5 ianuarii" rubric (CORRECTED, fix round 2: attributed to RG 17(a) here, which it is not -- RG 17(a) fixes only WHEN the Holy Name is kept) now gives it a direct entry (tools/bootstrap_lectionary.ml's own [epiphanytide_opening_entries]), so it resolves at step 2. The three families this task's own fix closed (2-5 January, 7-12 January, Holy Family's own week) cover every NON-SATURDAY Christmastide feria in January now; the SATURDAYS of those same three families are DELIBERATELY left without a direct entry (RG 78's BVM Saturday Office wins there instead, {!Rite_ef.Temporal_ef.bvm_saturday_names} -- which itself carries no citations of its own, see task-6-report.md's own residual concern 4), so they are the closest remaining case: 2026-01-03 ("ef-christmas-1-saturday") still has no citation of its own and genuinely resumes ITS preceding Sunday, 2025-12-28 -- confirmed a real Sunday that year, unaffected by any of this task's own fixes (still the historical "Sunday within the Octave of the Nativity", RG 69, unchanged by this task, see [colitur_keys]'s own CORRECTED note for why only the Holy-Name-Sunday alias was wrong, not this citation itself). *) let test_step3_christmas_feria_resumes_sunday () = Alcotest.(check (list string)) (* SUPERSEDED, not merely re-pinned (BVM Saturday Mass, 2026-08-17): this Saturday is an unoccupied IV-class Saturday, so it carries RG 78's own office -- and now RG 309(a)'s Mass for it, which answers before step 3's walkback is ever reached. The Nativity-Octave Sunday's Mass is no longer what this day says, and should not be: the day's office is Our Lady's. Mass II, "A Nativitate Domini usque ad Purificationem". *) "Saturday of Holy Name week says the BVM Saturday Mass, not the Sunday's" [ "Titus 3:4-7"; "Luke 2:15-20" ] (refs (day 2026 1 3)) (* Fix round 1 (coordinator review, Important finding 1): the original test here (2028-12-26) did not exercise step 3 at all -- 26 December is always Stephen (Class2, a real sanctoral entry with its own citations, fixed regardless of weekday), so that date resolves entirely at step 1 and never reaches step 2 or 3. Its justifying comment was also factually wrong: 24 December's TEMPORAL slug is [ef-nativity-vigil], identical to its OBSERVED slug -- [Temporal_ef.named] hard-codes the Vigil for 24 December ahead of any Sunday computation, so there is no temporal/observed split on that date to exploit in the first place. 2025-02-03 genuinely discriminates, verified directly against the real data (data/ef/sanctoral.sexp, data/ef/lectionary.sexp) and the real resolver, not transcribed: 2025-02-02 (a Sunday) is observed as the Purification of the BVM (RG 16(a): a Feast of the Lord takes the Sunday's place outright), with its own citations Mal 3:1-4 / Luke 2:22-32 -- but its TEMPORAL identity is [ef-time-after-epiphany-sunday-4], whose lectionary entry is the different Rom 13:8-10 / Matt 8:23-27. 2025-02-03 (Monday) has no sanctoral office of its own and no entry for its own temporal slug ([ef-time-after-epiphany-4-monday], absent from data/ef/lectionary.sexp), so it reaches step 3 and must return the Sunday's TEMPORAL readings (Rom 13:8-10 / Matt 8:23-27). A "step 3 reads the observed office instead of the temporal one" bug would instead return the Purification's (Mal 3:1-4 / Luke 2:22-32) -- different, checkable values, not a bare length check. *) let test_step3_uses_temporal_not_observed () = Alcotest.(check (list string)) "3 February 2025 takes the preceding Sunday's TEMPORAL Mass (Time after Epiphany IV), not the Purification's" [ "Rom 13:8-10"; "Matt 8:23-27" ] (refs (day 2025 2 3)) (* Termination: a Sunday that reaches step 3 would consult itself. It must not: the guard is weekday <> Sun. *) 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)) (* A day whose observed office IS its temporal office is untouched by step 4 on the SHIPPED data. 4 July 2026 is such a day, and the case most at risk: it is an RG 78 Saturday Office of the BVM, which deliberately REUSES the ordinary ferial slug (temporal_ef.ml's [bvm_saturday_names], "Slug" paragraph). This test alone does NOT pin the guard -- it passes with the guard removed, because no shipped assignment names a temporal slug. It is the baseline the next test needs, and it is labelled as such (fix round 1, coordinator review: its comment previously claimed to "pin the guard behaviourally", which was false -- the reviewer forced [sanctoral_office = true] and all 357 tests stayed green). *) let test_step4_leaves_a_temporal_office_alone_on_shipped_data () = Alcotest.(check (list string)) (* CORRECTED (BVM Saturday Mass, 2026-08-17). This pinned the GAP -- the office was Our Lady's while the Mass stayed the feria's -- which was the honest thing to assert while RG 309(a) was unimplemented. It is implemented now, so the day says Mass V, "A festo Ss. Trinitatis usque ad sabbatum ante dominicam I Adventus". *) "4 July 2026 (a BVM Saturday) says the BVM Saturday Mass" [ "Ecclus 24:14-16"; "Luke 11:27-28" ] (refs (day 2026 7 4)) (* THE GUARD, pinned. The guard is a no-op on shipped data by design, so the only way to exercise it is to build the table it defends against: a SYNTHETIC [Commons.t] that assigns a Common to a really-observed FERIAL slug. That is exactly the future-overlay mistake the guard exists to catch -- and note the blast radius it would have, since a ferial slug is shared by every year in which that week and weekday coincide, not by one date. Built through [Commons.of_tables], so it passes the same validation the committed file does -- this is a well-formed table with a wrong assignment, not malformed data caught by the loader. TEETH (verified, fix round 1): with the guard removed this returns the Common of Abbots' Ecclus 45:1-6 / Matt 19:27-29 and the assertion fails. The second check is what makes the first one mean something: it proves the hostile assignment really is present and resolvable, so the ferial Mass wins because the GUARD refused it, not because the lookup missed. *) let test_step4_guard_refuses_a_common_assigned_to_a_ferial_slug () = let ferial_slug = Slug.of_string_exn "ef-time-after-pentecost-5-saturday" in let abbots = Slug.of_string_exn "common-of-abbots" in let hostile = match Lectionary_ef.Commons.of_tables ~commons: [ (abbots, [ { Citation.part = Citation.First; reference = "Ecclus 45:1-6" }; { Citation.part = Citation.Gospel; reference = "Matt 19:27-29" } ]) ] ~assigned:[ (ferial_slug, abbots) ] with | Ok c -> c | Error e -> Alcotest.failf "synthetic commons failed to build: %s" e in Alcotest.(check bool) "the synthetic table really does resolve that ferial slug to a Common" true (Lectionary_ef.commons_for ~commons:hostile ferial_slug <> None); Alcotest.(check (list string)) (* The point of this test is UNCHANGED and still worth asserting: step 4 (the Common route) must refuse a temporal-origin office, so a Commons assignment cannot leak onto the BVM Saturday. What changed is only what answers instead -- formerly the feria's Mass via step 2, now RG 309(a)'s own via the BVM step. If step 4 ever did fire here it would produce a Common's readings, which are neither of these. *) "4 July 2026: step 4 still refuses a temporal office; the BVM Mass answers" [ "Ecclus 24:14-16"; "Luke 11:27-28" ] (refs (day_with ~commons:hostile 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"); ("didacus", "common-of-a-confessor-not-a-bishop-2"); ("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"); (* ef-sanctoral-status: ubaldus and didacus were promoted from Commemoration_only to Feast on the Missal's own universal calendarium, which ranks both "III classis" outright with no commemoration rubric. Each takes its Mass from a Common with only its own Oratio proper, per its own rubric at its own date, so neither needed a proper authored -- only an assignment. ubaldus shares patrick's Common exactly ("Missa Statuit, de Communi Confessoris Pontificis I loco"). *) ("sts-felicitas-perpetua", "common-of-non-virgins-1"); ("ubaldus", "common-of-a-confessor-bishop-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 -> [ "" ] | 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 -> [ "" ] | 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 () = (* [Filename.temp_file] + [Fun.protect], exactly as test_lectionary.ml's own [with_temp_file] does it (fix round 1, coordinator review: this used four FIXED names in the temp dir and removed none of them, so concurrent runs collided and the files leaked). *) let with_temp_file contents f = let path = Filename.temp_file "commons_test" ".sexp" in let oc = open_out path in output_string oc contents; close_out oc; Fun.protect ~finally:(fun () -> Sys.remove path) (fun () -> f path) in let err what contents = with_temp_file contents (fun path -> match Lectionary_ef.Commons.load path with | Ok _ -> Alcotest.failf "%s: expected Error, got Ok" what | 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 "duplicate common" (Printf.sprintf "((commons (%s %s)) (assigned ()))" good_common good_common)); check_msg "duplicate assignment" "duplicate assignment" (err "duplicate assignment" (Printf.sprintf "((commons (%s)) (assigned ((s c-a) (s c-a))))" good_common)); check_msg "empty formulary" "no citations" (err "empty formulary" "((commons ((c-a ()))) (assigned ()))"); check_msg "unknown common" "unknown common" (err "unknown common" (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); ("step 1 wins over a competing step 2 entry", `Quick, test_step1_wins_over_a_competing_step2_entry); ("step 2: Holy Family reached through the temporal slug, Baptism absent", `Quick, test_step2_holy_family_reached_through_temporal_slug); ("step 3: Advent feria resumes the preceding Sunday", `Quick, test_step3_advent_feria_resumes_sunday); ("step 3: Christmas feria resumes the preceding Sunday", `Quick, test_step3_christmas_feria_resumes_sunday); ("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); ("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 leaves a temporal office alone on shipped data (BVM Saturday)", `Quick, test_step4_leaves_a_temporal_office_alone_on_shipped_data); ("step 4's GUARD refuses a Common wrongly assigned to a ferial slug", `Quick, test_step4_guard_refuses_a_common_assigned_to_a_ferial_slug); ("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) ]