diff options
Diffstat (limited to 'test/test_lectionary_of.ml')
| -rw-r--r-- | test/test_lectionary_of.ml | 372 |
1 files changed, 372 insertions, 0 deletions
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 + ] |
