aboutsummaryrefslogtreecommitdiff
path: root/test
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-26 09:15:08 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-26 09:15:08 +0200
commit155c2e55948dc179a45458d856808f7105878d1f (patch)
tree341c33054cc06d9d407b98797c96a5f7f880dcad /test
parent9a915291c3e76dd6cbaea92910ed9cfe3398062d (diff)
downloadcolitur-155c2e55948dc179a45458d856808f7105878d1f.tar.gz
colitur-155c2e55948dc179a45458d856808f7105878d1f.zip
feat(of): Lectionary_of -- reading-cycle arithmetic and the lectionary chain
Task 4 of Phases 3-5: the last Rite.t field OF still needed, `readings`. The cycle rules are CODE, cited to OLM (Ordo Lectionum Missae) 1981 Praenotanda -- verified against the real page image (docs/research/of/olm-1981.pdf pp.32-33/"XXXII-XXXIII"), not the document's own unreliable OCR text layer, per the design spec's own caveat. n. 66 (with its footnote 102's worked example, 1980=C/1981=A/ 1982=B/1983=C) gives the three-year Sunday cycle; n. 69 point 4 gives the two-year weekday cycle, Year I in odd label years, Year II in even. Both derive the "label year" the same way: one more than the civil year Advent I opened in, per n. 66's footnote 102 itself ("nempe a prima hebdomada Adventus, quae cadit in anno civili praecedente") -- so the cycle turns at Advent I, not 1 January, exactly where the task brief said to get it right. The design spec's own citation for the weekday rule, "Praenotanda n. 649", does not survive a direct check against the primary source (search finds "649" only in unrelated index/page-number entries); corrected here to n. 69, the paragraph the rule's own Latin text actually sits under, confirmed by the page image's own printed margin number. The data is bootstrapped from lectio's of-lectionary.ini (988 keys, niedziela.pl, harvest 2020-2025) by tools/bootstrap_lectionary_of.ml -- OCaml, not the brief's own suggested .py: tools/bootstrap_lectionary .ml already established this exact shape for EF, including a real safety net ([assert_reachable], sweeping Rite_of.Temporal_of.temporal AND the real merged sanctoral layer directly) that a second-language reimplementation would either duplicate or lose. Handles two real data anomalies found while building it: one malformed ini line (a Polish Septuagint-numbering annotation for Sirach 3, "Syr 3,2-6.12-14" against OLM/CEI/USA's shared Nova Vulgata, matching the design spec's own sec5 finding); and two Sunday-cycle-labelled bases (Annunciation, Immaculate Conception) where a scraping-year artifact left one of three cycle letters carrying a different day's Mass entirely -- resolved by a general, mechanical 2-of-3 majority rule, not a per-saint hand edit, and logged. Coverage measured in both directions, per the brief's own Step 1: - 1 of 365 days in civil year 2026 (25 December, the Christmas DAY Mass) has no Temporal_of-slug match anywhere in lectio's 988 keys -- only the Vigil is present. Named in the data file's own header. - 0 of 438 distinct lectio bases map to no colitur slug, after 19 are deliberately excluded (late-Advent/Christmas-season DATE-keyed duplicates lectio also carries in a WEEKDAY-keyed form colitur's own slugs actually match -- Lectionary.t has no date-keyed lookup at all, so the date-keyed family is transcription-inert, not silently dropped: named explicitly, not a residual). Lectionary_of.readings is three steps (no Commons indirection, unlike EF -- out of this task's scope): the observed celebration's own embedded citations; else, for a real sanctoral winner, its own slug looked up directly (flat, then both cycle letters); else the day's own temporal slug, same lookup. No preceding-Sunday fallback: OF's own two-year cycle assigns every Ordinary Time weekday its own reading by design, and no OF norm was found instructing otherwise. Tests (test/test_lectionary_of.ml, 12 cases): a cycle-arithmetic table straddling Advent 1980/1981/1982/2025 inside single civil years, reproducing OLM n.66's own worked example directly; SHA-256 pins on both the emitted file and lectio's own source (embedded in the provenance header); a pinned, NAMED 2026 coverage result (364 resolve, exactly {25 December} does not -- never a bare count); two resolution- chain spot checks (a plain ferial, and a saint's day beating the ferial it would otherwise fall on); and a real subprocess test that a missing lectio source file fails the generator loudly, non-zero exit, no output written -- the exact bug Task 1's own generator shipped and needed a review round to fix. dune test: exit 0, 856 tests. make check (COLITUR_EXHAUSTIVE_SWEEP=1, full 1583-9999 sweep): exit 0, 862 tests, ~342s, both run unpiped in the foreground. git diff --stat v1.0.0..HEAD -- lib/rites/rite_ef/ lib/kernel/ data/ef/ still empty.
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
+ ]