summaryrefslogtreecommitdiff
path: root/test
diff options
context:
space:
mode:
Diffstat (limited to 'test')
-rw-r--r--test/dune2
-rw-r--r--test/test_colitur.ml1
-rw-r--r--test/test_lectionary_of.ml372
3 files changed, 375 insertions, 0 deletions
diff --git a/test/dune b/test/dune
index c5bfe07..50bdee2 100644
--- a/test/dune
+++ b/test/dune
@@ -29,6 +29,8 @@
../data/of/expected-divergences-litcal.sexp
../data/ef/lectionary.sexp
../data/ef/commons.sexp
+ ../data/of/lectionary.sexp
+ ../tools/bootstrap_lectionary_of.exe
../data/ef/examples/diocesan-example.sexp
fixtures/lectio-ef-2005-2050.txt
fixtures/missalemeum-ef-2026-2027.txt
diff --git a/test/test_colitur.ml b/test/test_colitur.ml
index 3e16566..30e649e 100644
--- a/test/test_colitur.ml
+++ b/test/test_colitur.ml
@@ -27,6 +27,7 @@ let () =
Test_golden.suite;
("lectionary", Test_lectionary.suite);
("lectionary-ef", Test_lectionary_ef.suite);
+ ("lectionary-of", Test_lectionary_of.suite);
Test_escape.suite;
Test_template.suite;
Test_template.render_suite;
diff --git a/test/test_lectionary_of.ml b/test/test_lectionary_of.ml
new file mode 100644
index 0000000..6fb48a0
--- /dev/null
+++ b/test/test_lectionary_of.ml
@@ -0,0 +1,372 @@
+(* Task 4 (2026-08-25-colitur-of-phases-3-5): the reading-cycle arithmetic
+ and the temporal + sanctoral lectionary resolution chain,
+ Rite_of.Lectionary_of.
+
+ No Rite_of.context exists yet (Task 5's own deliverable), so the
+ coverage test below builds its own APPROXIMATE day resolver directly
+ against Layer.on_date + Temporal_of.temporal, rather than through
+ Calendar.day/Colitur_kernel.Validate -- it does not run RG-style
+ occurrence/precedence resolution (Precedence_of.rules is Task 5's own
+ wiring point too), so on a date where more than one sanctoral entry
+ would occur it simply takes the layer's own canonically-sorted first
+ match. This is not a claim that the FULL calendar resolves this way; it
+ is the minimum needed to exercise Lectionary_of.readings' own
+ observed-vs-temporal branch honestly, over real (not synthetic) data. *)
+
+module L = Colitur_kernel.Layer
+module O = Colitur_kernel.Overlay
+module Cel = Colitur_kernel.Celebration
+module D = Colitur_kernel.Date
+module Slug = Colitur_kernel.Slug
+module Mass_formulary = Colitur_kernel.Mass_formulary
+module Lectionary = Colitur_kernel.Lectionary
+module Computus = Colitur_kernel.Computus
+module V = Rite_of.Vocab_of
+module T = Rite_of.Temporal_of
+module Lect_of = Rite_of.Lectionary_of
+
+let d y m dd = match D.make ~year:y ~month:m ~day:dd with Ok t -> t | Error e -> Alcotest.failf "%s" e
+let iso = D.to_iso8601
+
+(* ---- fixtures -------------------------------------------------------- *)
+
+let lectionary_path = "../data/of/lectionary.sexp"
+let calendar_path = "../data/of/calendar-2002.sexp"
+let amendments_dir = "../data/of/amendments/"
+
+let amendment_files =
+ [ "001-padre-pio.sexp"; "002-juan-diego-cuauhtlatoatzin.sexp"; "003-our-lady-of-guadalupe.sexp";
+ "004-john-xxiii-john-paul-ii.sexp"; "005-mary-magdalene-rank.sexp";
+ "006-mary-mother-of-the-church.sexp"; "007-paul-vi.sexp"; "008-our-lady-of-loreto.sexp";
+ "009-faustina-kowalska.sexp"; "010-narek-avila-hildegard.sexp"; "011-martha-mary-lazarus.sexp";
+ "012-teresa-of-calcutta.sexp"; "013-john-henry-newman.sexp" ]
+
+let real_lectionary () =
+ match Lectionary.load lectionary_path with
+ | Ok l -> l
+ | Error e -> Alcotest.failf "%s: failed to load: %s" lectionary_path e
+
+let real_sanctoral_layer () =
+ let base =
+ match L.load V.rank_of_sexp calendar_path with
+ | Ok l -> l
+ | Error e -> Alcotest.failf "%s: failed to load: %s" calendar_path e
+ in
+ let overlays =
+ List.map
+ (fun name ->
+ let path = amendments_dir ^ name in
+ match O.load V.rank_of_sexp path with
+ | Ok o -> o
+ | Error e -> Alcotest.failf "%s: failed to load: %s" path e)
+ amendment_files
+ in
+ let merged, diagnostics = O.merge base overlays in
+ Alcotest.(check (list string)) "the committed OF overlays apply cleanly, no diagnostics" []
+ (List.map O.diagnostic_to_string diagnostics);
+ merged
+
+(* Approximate "observed" celebration for [date]: the sanctoral layer's own
+ (canonically-sorted) first match if any, else the day's own temporal
+ office -- see this file's own top-of-file comment for why this is a
+ deliberately simplified stand-in for real occurrence resolution. *)
+let observed_on index date temporal =
+ match L.on_date index date with
+ | e :: _ -> e.L.cel
+ | [] -> temporal.Colitur_kernel.Temporal.office
+
+let readings_on lectionary index date =
+ let temporal = T.temporal date in
+ let observed = observed_on index date temporal in
+ Lect_of.readings ~lectionary ~year_start:T.year_start ~observed ~temporal ~date ~temporal_at:T.temporal
+
+(* ---- cycle arithmetic (OLM 1981 Praenotanda n.66/n.69 -- see
+ lectionary_of.mli's own citations, page-image verified) ---- *)
+
+let sunday_letter c = Lect_of.sunday_cycle_letter c
+let weekday_letter c = Lect_of.weekday_cycle_letter c
+
+(* Every case here is derivable from OLM n.66's own footnote 102, whose
+ worked example ("annus 1980 est annus C, annus vero sequens, scilicet
+ annus 1981, est annus A, annus vero 1982 est annus B, et annus 1983 est
+ iterum annus C") is reproduced directly by testing dates EITHER SIDE of
+ Advent 1980/1981/1982 -- the task brief's own "straddle Advent I inside
+ a single civil year" requirement, exercised three times over, against
+ the primary source's own numbers, not invented ones. *)
+let test_sunday_cycle_table () =
+ let cases =
+ [ (* Just before Advent 1980 (still the liturgical year opened Advent
+ 1979, labelled 1980 -- OLM's own "1980 est annus C"), and Advent
+ 1980 itself (opens the year labelled 1981 -- "1981, est annus A"):
+ both civil dates fall in the SAME civil year, 1980, on either side
+ of that year's own Advent I -- the boundary the brief names. *)
+ (D.add_days (T.advent_start 1980) (-1), "c", "before Advent 1980 (civil year 1980, label 1980)");
+ (T.advent_start 1980, "a", "Advent Sunday 1980 itself (civil year 1980, label 1981)");
+ (D.add_days (T.advent_start 1980) 3, "a", "a few days into Advent 1980 (still label 1981)");
+ (* Advent 1981 -> 1982 (A -> B). *)
+ (D.add_days (T.advent_start 1981) (-1), "a", "before Advent 1981 (label 1981)");
+ (T.advent_start 1981, "b", "Advent Sunday 1981 (label 1982)");
+ (* Advent 1982 -> 1983 (B -> C). *)
+ (D.add_days (T.advent_start 1982) (-1), "b", "before Advent 1982 (label 1982)");
+ (T.advent_start 1982, "c", "Advent Sunday 1982 (label 1983)");
+ (* A live-relevant pair: Advent 2025 (opens the "2026" liturgical
+ year, this task's own civil year) is label 2026, 2026 mod 3 = 1
+ -> A; the civil days immediately before it are still label 2025,
+ 2025 mod 3 = 0 -> C. *)
+ (D.add_days (T.advent_start 2025) (-1), "c", "before Advent 2025 (label 2025)");
+ (T.advent_start 2025, "a", "Advent Sunday 2025 (label 2026)")
+ ]
+ in
+ List.iter
+ (fun (date, expected, label) ->
+ Alcotest.(check string) (label ^ " (" ^ iso date ^ ")") expected
+ (sunday_letter (Lect_of.sunday_cycle ~year_start:T.year_start date)))
+ cases
+
+(* OLM n.69 point 4: Year I odd label years, Year II even. Reuses the SAME
+ Advent-1980/1981/1982 boundaries as the Sunday-cycle table above (label
+ 1980 even -> II, 1981 odd -> I, 1982 even -> II, 1983 odd -> I),
+ straddling Advent I within a single civil year exactly as the Sunday
+ table does. *)
+let test_weekday_cycle_table () =
+ let cases =
+ [ (D.add_days (T.advent_start 1980) (-1), "ii", "before Advent 1980 (label 1980, even)");
+ (T.advent_start 1980, "i", "Advent Sunday 1980 (label 1981, odd)");
+ (D.add_days (T.advent_start 1981) (-1), "i", "before Advent 1981 (label 1981, odd)");
+ (T.advent_start 1981, "ii", "Advent Sunday 1981 (label 1982, even)");
+ (D.add_days (T.advent_start 1982) (-1), "ii", "before Advent 1982 (label 1982, even)");
+ (T.advent_start 1982, "i", "Advent Sunday 1982 (label 1983, odd)");
+ (D.add_days (T.advent_start 2025) (-1), "i", "before Advent 2025 (label 2025, odd)");
+ (T.advent_start 2025, "ii", "Advent Sunday 2025 (label 2026, even)")
+ ]
+ in
+ List.iter
+ (fun (date, expected, label) ->
+ Alcotest.(check string) (label ^ " (" ^ iso date ^ ")") expected
+ (weekday_letter (Lect_of.weekday_cycle ~year_start:T.year_start date)))
+ cases
+
+(* The two cycles are independent computations, not two views of the same
+ number -- Advent 1981 (label 1982) is Sunday-cycle B but weekday-cycle
+ II, a label that is even for one modulus and not reducible to the
+ other. *)
+let test_cycles_are_independent () =
+ let date = T.advent_start 1981 in
+ Alcotest.(check string) "Sunday cycle B" "b" (sunday_letter (Lect_of.sunday_cycle ~year_start:T.year_start date));
+ Alcotest.(check string) "weekday cycle II" "ii"
+ (weekday_letter (Lect_of.weekday_cycle ~year_start:T.year_start date))
+
+(* ---- SHA-256 pins ----------------------------------------------------- *)
+
+let sha256_of_file path =
+ let tmp = Filename.temp_file "colitur_lectionary_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-26 against the shipped file itself, independently
+ re-derived (`sha256sum data/of/lectionary.sexp`), the same "derive the
+ new value, don't transcribe it from generator stdout" discipline
+ test_calendar_of_data.ml's own [test_sha256] already follows. *)
+let test_sha256_pinned () =
+ Alcotest.(check string) "data/of/lectionary.sexp SHA-256"
+ "e7fd109bc30baf8a5531a614995e986959214424c80f4d444fa2b772538decde" (sha256_of_file lectionary_path)
+
+(* lectio's OWN of-lectionary.ini SHA-256, pinned inside data/of/
+ lectionary.sexp's own provenance header (tools/bootstrap_lectionary_of
+ .ml's own emitted "; SHA-256: ..." line for the SOURCE, not the file
+ pinned above) -- checked here as a plain substring of the shipped file,
+ not by re-reading the sibling lectio repository (which need not be
+ present wherever this test runs; the point of pinning it in the header
+ is that this repo alone is then enough to know which snapshot produced
+ it). *)
+let test_source_sha256_pinned_in_header () =
+ let ic = open_in lectionary_path in
+ let n = in_channel_length ic in
+ let content = really_input_string ic n in
+ close_in ic;
+ let needle = "SHA-256: f0b527aef58b93e6ccdfbeafe52b340ed2a9d7d72805bf5083e3da04b080123c" in
+ let contains haystack needle =
+ let hl = String.length haystack and nl = String.length needle in
+ let rec go i = i + nl <= hl && (String.sub haystack i nl = needle || go (i + 1)) in
+ go 0
+ in
+ Alcotest.(check bool) "lectio's of-lectionary.ini SHA-256 is pinned in the provenance header" true
+ (contains content needle)
+
+(* A missing lectio snapshot must fail LOUDLY -- Task 1's own extractor
+ shipped exactly this bug (parse_lectio_ini swallowing FileNotFoundError,
+ silently producing a DIFFERENT file with a different SHA-256 and zero
+ `en` names) and needed a review round to fix; this task's own brief
+ names it explicitly as a trap not to repeat. Exercised here as a real
+ subprocess run, not merely by reading the generator's source: invokes
+ the built tools/bootstrap_lectionary_of.exe against a path that does
+ not exist and asserts BOTH a non-zero exit AND that no output file was
+ written (a generator that failed loudly but left a partial/stale
+ destination file behind would be only half fixed). *)
+let test_missing_source_fails_loudly () =
+ let exe = "../tools/bootstrap_lectionary_of.exe" in
+ let bogus_src = Filename.temp_file "colitur_lectionary_of_missing" ".ini" in
+ Sys.remove bogus_src;
+ (* guaranteed not to exist, still a plausible-looking path *)
+ let dst = Filename.temp_file "colitur_lectionary_of_dst" ".sexp" in
+ Sys.remove dst;
+ let log = Filename.temp_file "colitur_lectionary_of_stderr" ".txt" in
+ let cmd =
+ Printf.sprintf "%s %s %s >%s 2>&1" (Filename.quote exe) (Filename.quote bogus_src) (Filename.quote dst)
+ (Filename.quote log)
+ in
+ let status = Sys.command cmd in
+ let ic = open_in log in
+ let n = in_channel_length ic in
+ let stderr_text = really_input_string ic n in
+ close_in ic;
+ Sys.remove log;
+ Alcotest.(check bool) "non-zero exit on a missing source file" true (status <> 0);
+ Alcotest.(check bool) "no output file written" false (Sys.file_exists dst);
+ Alcotest.(check bool) "error message names the missing path and explains why this matters" true
+ (let contains haystack needle =
+ let hl = String.length haystack and nl = String.length needle in
+ let rec go i = i + nl <= hl && (String.sub haystack i nl = needle || go (i + 1)) in
+ go 0
+ in
+ contains stderr_text "no such file" && contains stderr_text bogus_src)
+
+(* ---- basic load ------------------------------------------------------- *)
+
+let test_load () =
+ let l = real_lectionary () in
+ Alcotest.(check int) "754 entries" 754 (List.length (Lectionary.entries l))
+
+(* ---- coverage: a full sample civil year, both directions, PINNED ------ *)
+
+(* Civil year 2026 -- the same year this task's data file's own coverage
+ report uses. Iterates every day, resolving readings the same
+ approximate way [readings_on] does (see this file's own top-of-file
+ comment), and asserts BOTH the total resolved count AND the exact,
+ named set of unresolved dates -- never a silent zero (task brief's own
+ Step 4 requirement). *)
+let all_days_of_2026 =
+ let days_in_month = [| 31; 28; 31; 30; 31; 30; 31; 31; 30; 31; 30; 31 |] in
+ List.concat
+ (List.init 12 (fun mi ->
+ let m = mi + 1 in
+ List.init days_in_month.(mi) (fun di -> d 2026 m (di + 1))))
+
+let test_coverage_2026 () =
+ let lectionary = real_lectionary () in
+ let layer = real_sanctoral_layer () in
+ let index = L.index layer ~easter:Computus.gregorian_easter ~years:[ 2025; 2026 ] in
+ let unresolved =
+ List.filter_map
+ (fun date ->
+ match readings_on lectionary index date with
+ | None, [] -> Some (iso date)
+ | Some _, (_ :: _) -> None
+ | (Some _, []) | (None, _ :: _) ->
+ Alcotest.failf "%s: Mass_formulary.t and citations disagree on whether readings resolved"
+ (iso date))
+ all_days_of_2026
+ in
+ Alcotest.(check int) "365 days in 2026" 365 (List.length all_days_of_2026);
+ (* The ONE genuine gap this task's own bootstrap found and named: lectio's
+ 988 keys never include a Christmas DAY Mass (only the Vigil) -- see
+ data/of/lectionary.sexp's own provenance header and
+ tools/bootstrap_lectionary_of.ml's own [named_overrides] comment on
+ "christmas". Pinned as a NAMED set, not a bare count: a silent zero
+ here is exactly the failure mode the task brief warns against. *)
+ Alcotest.(check (list string)) "the unresolved-day set is exactly {25 December}" [ "2026-12-25" ] unresolved;
+ Alcotest.(check int) "364 of 365 days resolve readings" 364 (365 - List.length unresolved)
+
+(* Same measurement, restated as "how many days resolve" rather than "how
+ many don't" -- redundant with the count above by construction, kept
+ because Step 4's own brief asks for a pinned resolved-count assertion
+ specifically, not only its complement. *)
+let test_pinned_resolved_count () =
+ let lectionary = real_lectionary () in
+ let layer = real_sanctoral_layer () in
+ let index = L.index layer ~easter:Computus.gregorian_easter ~years:[ 2025; 2026 ] in
+ let resolved =
+ List.length
+ (List.filter
+ (fun date -> match readings_on lectionary index date with Some _, _ :: _ -> true | _ -> false)
+ all_days_of_2026)
+ in
+ Alcotest.(check int) "364 days resolve readings in 2026" 364 resolved
+
+(* ---- resolution chain: unit-level spot checks -------------------------- *)
+
+(* Step 3: an ordinary Ordinary Time weekday with no sanctoral entry
+ resolves via its own temporal slug, cycle-selected. 16 June 2026 is a
+ Tuesday of Ordinary Time, in the SECOND (post-Pentecost) block, with no
+ calendar-2002.sexp/amendments entry (checked against the real merged
+ layer, not assumed, and clear of the three nearby movable solemnities
+ -- Corpus Christi 4 June, Sacred Heart 12 June, Immaculate Heart 13
+ June). The expected slug is read off Temporal_of.temporal itself, not
+ hand-computed: the second block's own week arithmetic (temporal_of.ml's
+ own [ordinary_time_week]) is counted BACKWARDS from Christ the King, not
+ forwards from the Baptism -- re-deriving that by hand here would just
+ duplicate the one calculation this test exists to exercise, not check
+ it independently. *)
+let test_step3_temporal_ferial () =
+ let lectionary = real_lectionary () in
+ let layer = real_sanctoral_layer () in
+ let index = L.index layer ~easter:Computus.gregorian_easter ~years:[ 2025; 2026 ] in
+ let date = d 2026 6 16 in
+ Alcotest.(check bool) "16 June 2026: no sanctoral entry" true (L.on_date index date = []);
+ let expected_slug = (T.temporal date).Colitur_kernel.Temporal.office.Cel.slug in
+ match readings_on lectionary index date with
+ | Some fm, (_ :: _ as cs) ->
+ Alcotest.(check bool) "tagged Own_slug" true (fm.Mass_formulary.via = Mass_formulary.Own_slug);
+ Alcotest.(check (option string)) "said the temporal slug" (Some (Slug.to_string expected_slug))
+ (Option.map Slug.to_string fm.Mass_formulary.said);
+ Alcotest.(check int) "two citations (First + Gospel)" 2 (List.length cs)
+ | _ -> Alcotest.fail "16 June 2026 should resolve readings via step 3"
+
+(* Step 2: a sanctoral saint who won the day resolves via his own slug, not
+ the temporal ferial's. 30 November 2026 is a Monday of Advent week 1
+ (which would otherwise be "of-advent-1-monday") but is also St Andrew
+ the Apostle's own fixed date. *)
+let test_step2_sanctoral_wins () =
+ let lectionary = real_lectionary () in
+ let layer = real_sanctoral_layer () in
+ let index = L.index layer ~easter:Computus.gregorian_easter ~years:[ 2025; 2026 ] in
+ let date = d 2026 11 30 in
+ (match L.on_date index date with
+ | [ e ] -> Alcotest.(check string) "St Andrew's own entry" "andrew-the-apostle" (Slug.to_string e.L.cel.Cel.slug)
+ | es -> Alcotest.failf "expected exactly one sanctoral entry on 30 Nov 2026, got %d" (List.length es));
+ match readings_on lectionary index date with
+ | Some fm, (_ :: _ as cs) ->
+ Alcotest.(check bool) "tagged Proper" true (fm.Mass_formulary.via = Mass_formulary.Proper);
+ Alcotest.(check (option string)) "said Andrew's own slug" (Some "andrew-the-apostle")
+ (Option.map Slug.to_string fm.Mass_formulary.said);
+ (* "of-advent-1-monday" is FLAT, not cycle-lettered (OLM n.69 point 3:
+ Advent's own ferias do not change year to year -- confirmed by
+ this generator's own collapse, tools/bootstrap_lectionary_of.ml's
+ [collapse_weekday]), so there is exactly one temporal citation
+ set to compare against, not two. *)
+ Alcotest.(check bool) "NOT the temporal ferial's Advent citations" true
+ (cs <> Option.get (Lectionary.find lectionary (Slug.of_string_exn "of-advent-1-monday")))
+ | _ -> Alcotest.fail "30 November 2026 should resolve readings via step 2"
+
+let suite =
+ [ Alcotest.test_case "Sunday cycle table (OLM n.66, straddles Advent I)" `Quick test_sunday_cycle_table;
+ Alcotest.test_case "weekday cycle table (OLM n.69.4, straddles Advent I)" `Quick test_weekday_cycle_table;
+ Alcotest.test_case "the two cycles are independent" `Quick test_cycles_are_independent;
+ Alcotest.test_case "data/of/lectionary.sexp SHA-256 pinned" `Quick test_sha256_pinned;
+ Alcotest.test_case "lectio source SHA-256 pinned in the header" `Quick test_source_sha256_pinned_in_header;
+ Alcotest.test_case "a missing lectio source fails loudly, not silently" `Quick test_missing_source_fails_loudly;
+ Alcotest.test_case "loads, 754 entries" `Quick test_load;
+ Alcotest.test_case "2026 coverage: exactly {25 December} unresolved" `Quick test_coverage_2026;
+ Alcotest.test_case "2026 coverage: 364 days resolve" `Quick test_pinned_resolved_count;
+ Alcotest.test_case "step 3: temporal ferial fallback" `Quick test_step3_temporal_ferial;
+ Alcotest.test_case "step 2: sanctoral proper wins over the ferial" `Quick test_step2_sanctoral_wins
+ ]