From d201028d71ad3f26d5ec0b9c12268475e0b70cfd Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Tue, 25 Aug 2026 23:14:13 +0200 Subject: feat(of): transcribe the 2002 General Roman Calendar Extracted from the Latin typical edition, which is the authority; lectio's roman-calendar.ini is a cross-check only and every divergence is adjudicated against the Missal in the provenance header. A blank grade column means Memoria ad libitum, per the calendar's own footnote, rather than a missing value. 206 entries; 1 January, 6 January and 25 December are deliberately excluded because Temporal_of.named already computes them, and shipping them here too would create a duplicate candidate for the same day. Colour is derived from IGMR n. 346, cited per class in the extractor; the lectio cross-check surfaces one rank divergence (22 July, Mary Magdalene -- lectio reflects the 2016 post-2002 elevation decree, out of scope here) and 17 colour divergences, all adjudicated in the provenance header. --- test/test_calendar_of_data.ml | 207 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 test/test_calendar_of_data.ml (limited to 'test/test_calendar_of_data.ml') diff --git a/test/test_calendar_of_data.ml b/test/test_calendar_of_data.ml new file mode 100644 index 0000000..01a757e --- /dev/null +++ b/test/test_calendar_of_data.ml @@ -0,0 +1,207 @@ +(* Task 1 (2026-08-25-colitur-of-phases-3-5): loads data/of/calendar-2002.sexp + -- the General Roman Calendar transcribed from the 2002 Missale Romanum by + tools/extract_of_calendar.py -- through Layer.load and checks it against + counts independently re-derived from the shipped file itself (grep -c + '(slug', grep -oP rank/status/colour/subject | sort | uniq -c -- see + .superpowers/sdd/2026-08-25-colitur-of-phases-3-5/task-1-report.md), plus + named spot-checks read straight off the Missal's own printed page so a + passing count can't hide the wrong 206 entries having been transcribed + (the same "live hazard" test_sanctoral_ef.ml's own header names). + + SOURCE DISCIPLINE, asserted structurally here, not merely stated in the + tool's own header: every entry's rank is one of the four Vocab_of.rank + grades NEVER Feria (that rank is temporal-only, see vocab_of.mli), and + every entry's layer is Precedence_of.universal_layer. *) + +module L = Colitur_kernel.Layer +module Cel = Colitur_kernel.Celebration +module S = Colitur_kernel.Slug +module DS = Colitur_kernel.Date_spec +module D = Colitur_kernel.Date +module Col = Colitur_kernel.Colour +module Sub = Colitur_kernel.Subject +module Lang = Colitur_kernel.Lang +module Names = Colitur_kernel.Names +module V = Rite_of.Vocab_of +module PO = Rite_of.Precedence_of +module T = Rite_of.Temporal_of + +(* Relative to this test's own build directory (_build/default/test/); made + available there because test/dune declares it as a dep of the test + stanza. *) +let path = "../data/of/calendar-2002.sexp" + +let load () = + match L.load V.rank_of_sexp path with + | Ok l -> l + | Error e -> Alcotest.failf "%s: failed to load: %s" path e + +let mkdate m d = match DS.fixed ~month:m ~day:d with Ok t -> t | Error e -> failwith e + +let sha256_of_file path = + let tmp = Filename.temp_file "colitur_calendar_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-25 against the shipped file itself, independently + re-derived (`sha256sum data/of/calendar-2002.sexp`) -- not copied from + the tool's own stdout run, per this project's "derive the new value, + don't transcribe it from output" pinning discipline. *) +let test_sha256 () = + Alcotest.(check string) "data/of/calendar-2002.sexp SHA-256" + "b2bf3fa9d27f5bc6e47225c477c158f629eaab6c280e0b69fa04c8a55db9f612" (sha256_of_file path) + +(* Counts independently re-derived from the shipped file + (`grep -c '(slug'`; `grep -oP '(?<=\(rank )[A-Za-z_]+' | sort | uniq -c`). + 206 = 209 raw table rows minus the 3 dates (1 Jan, 6 Jan, 25 Dec) already + computed by Temporal_of.named -- see test_excludes_temporal_of_dates + below, which closes the loop on that exclusion rather than merely + asserting the count moved. *) +let test_load_and_counts () = + let l = load () in + Alcotest.(check int) "206 entries" 206 (List.length l.L.entries); + let count pred = List.length (List.filter pred l.L.entries) in + Alcotest.(check int) "8 Sollemnitas" 8 (count (fun e -> e.L.cel.Cel.rank = V.Sollemnitas)); + Alcotest.(check int) "23 Festum" 23 (count (fun e -> e.L.cel.Cel.rank = V.Festum)); + Alcotest.(check int) "67 Memoria_obligatoria" 67 + (count (fun e -> e.L.cel.Cel.rank = V.Memoria_obligatoria)); + Alcotest.(check int) "108 Memoria_ad_libitum" 108 + (count (fun e -> e.L.cel.Cel.rank = V.Memoria_ad_libitum)); + Alcotest.(check int) "0 Feria (temporal-only rank, never a calendar entry)" 0 + (count (fun e -> e.L.cel.Cel.rank = V.Feria)); + Alcotest.(check int) "206 Feast (OF admits no Commemoration_only status)" 206 + (count (fun e -> e.L.cel.Cel.status = Cel.Feast)); + Alcotest.(check int) "every entry tagged Precedence_of.universal_layer" 206 + (count (fun e -> e.L.cel.Cel.layer = PO.universal_layer)) + +let test_slugs_unique () = + let l = load () in + let slugs = List.map (fun e -> S.to_string e.L.cel.Cel.slug) l.L.entries in + let sorted = List.sort_uniq compare slugs in + Alcotest.(check int) "no duplicate slugs" (List.length slugs) (List.length sorted) + +let test_slugs_revalidate () = + let l = load () in + List.iter + (fun e -> + match S.of_string (S.to_string e.L.cel.Cel.slug) with + | Ok _ -> () + | Error msg -> Alcotest.failf "slug %s failed re-validation: %s" (S.to_string e.L.cel.Cel.slug) msg) + l.L.entries + +(* Every date is a Fixed month/day; DS.fixed already rejects an out-of-range + day against the leap-year maximum, so this also proves no entry claims a + nonexistent day (e.g. 30 February). *) +let test_dates_resolve_in_a_leap_year () = + let l = load () in + List.iter + (fun e -> + match DS.resolve e.L.date ~year:2028 ~easter:(Colitur_kernel.Computus.gregorian_easter 2028) with + | Some _ -> () + | None -> Alcotest.failf "slug %s: date does not resolve in leap year 2028" (S.to_string e.L.cel.Cel.slug)) + l.L.entries + +let find l slug = + match L.find l (S.of_string_exn slug) with + | Some e -> e + | None -> Alcotest.failf "slug %s not found in %s" slug path + +(* Brief step 4's own named spot-checks, read straight off the Missal's + printed Calendarium Romanum Generale page. *) + +let test_spot_check_conversion_of_paul () = + let e = find (load ()) "the-conversion-of-saint-paul-apostle" in + Alcotest.(check bool) "25 Jan" true (e.L.date = mkdate 1 25); + Alcotest.(check bool) "Festum" true (e.L.cel.Cel.rank = V.Festum); + (* IGMR 346(a)'s own explicit white exception for this date, despite the + title's "apostoli" which would otherwise trigger the apostle/martyr-red + default -- see tools/extract_of_calendar.py's WHITE_APOSTLE_EXCEPTIONS. *) + Alcotest.(check bool) "white (IGMR 346(a) explicit exception)" true (e.L.cel.Cel.colour = Col.White) + +let test_spot_check_chair_of_peter () = + let e = find (load ()) "chair-of-saint-peter-apostle" in + Alcotest.(check bool) "22 Feb" true (e.L.date = mkdate 2 22); + Alcotest.(check bool) "Festum" true (e.L.cel.Cel.rank = V.Festum); + Alcotest.(check bool) "white (IGMR 346(a) explicit exception)" true (e.L.cel.Cel.colour = Col.White) + +let test_spot_check_presentation () = + let e = find (load ()) "presentation-of-the-lord" in + Alcotest.(check bool) "2 Feb" true (e.L.date = mkdate 2 2); + Alcotest.(check bool) "Festum" true (e.L.cel.Cel.rank = V.Festum); + Alcotest.(check bool) "subject Lord" true (e.L.cel.Cel.subject = Sub.Lord) + +let test_spot_check_francis_de_sales () = + let e = find (load ()) "francis-de-sales-bishop-and-doctor" in + Alcotest.(check bool) "24 Jan" true (e.L.date = mkdate 1 24); + Alcotest.(check bool) "Memoria_obligatoria (printed grade word: Memoria)" true + (e.L.cel.Cel.rank = V.Memoria_obligatoria) + +let test_spot_check_angela_merici () = + let e = find (load ()) "angela-merici-virgin" in + Alcotest.(check bool) "27 Jan" true (e.L.date = mkdate 1 27); + Alcotest.(check bool) "blank grade -> Memoria_ad_libitum (calendar's own footnote)" true + (e.L.cel.Cel.rank = V.Memoria_ad_libitum) + +(* 1 January (Mary, Mother of God) IS "Sollemnitas" on the Missal's own + printed page, the brief's sixth named spot-check -- but it is + DELIBERATELY ABSENT from this file: Phase 1's Temporal_of.named already + computes it as a temporal-cycle office (m=1, dd=1), and shipping it here + too would create two competing candidates for the same day (see + tools/extract_of_calendar.py's own module docstring, point 2, and this + file's SKIP_DATES table). Asserted both ways, so the exclusion is + verified rather than merely claimed: absent from the data, present and + correctly graded in the code that actually covers it. The same holds for + 6 January (Epiphany) and 25 December (the Nativity). *) +let test_excludes_temporal_of_dates () = + let l = load () in + let has_date m d = + List.exists (fun e -> e.L.date = mkdate m d) l.L.entries + in + Alcotest.(check bool) "1 Jan absent from calendar-2002.sexp" false (has_date 1 1); + Alcotest.(check bool) "6 Jan absent from calendar-2002.sexp" false (has_date 1 6); + Alcotest.(check bool) "25 Dec absent from calendar-2002.sexp" false (has_date 12 25); + let mkd m d = match D.make ~year:2026 ~month:m ~day:d with Ok d -> d | Error e -> failwith e in + (match T.named (mkd 1 1) with + | Some (_, _, colour, rank) -> + Alcotest.(check bool) "1 Jan: Temporal_of.named yields Sollemnitas" true (rank = V.Sollemnitas); + Alcotest.(check bool) "1 Jan: Temporal_of.named yields White" true (colour = Col.White) + | None -> Alcotest.fail "1 Jan: Temporal_of.named returned None -- the office this file \ + excludes on that basis does not actually exist in code"); + (match T.named (mkd 1 6) with + | Some (_, _, _, rank) -> Alcotest.(check bool) "6 Jan: Sollemnitas" true (rank = V.Sollemnitas) + | None -> Alcotest.fail "6 Jan: Temporal_of.named returned None"); + match T.named (mkd 12 25) with + | Some (_, _, _, rank) -> Alcotest.(check bool) "25 Dec: Sollemnitas" true (rank = V.Sollemnitas) + | None -> Alcotest.fail "25 Dec: Temporal_of.named returned None" + +let suite = + ( "Calendar_of (data/of/calendar-2002.sexp)", + [ Alcotest.test_case "SHA-256 pinned" `Quick test_sha256; + Alcotest.test_case "load succeeds and counts match the shipped file" `Quick + test_load_and_counts; + Alcotest.test_case "slugs are unique" `Quick test_slugs_unique; + Alcotest.test_case "slugs re-validate" `Quick test_slugs_revalidate; + Alcotest.test_case "every date resolves in a leap year" `Quick + test_dates_resolve_in_a_leap_year; + Alcotest.test_case "spot-check: 25 Jan Festum, Conversion of Paul" `Quick + test_spot_check_conversion_of_paul; + Alcotest.test_case "spot-check: 22 Feb Festum, Chair of Peter" `Quick + test_spot_check_chair_of_peter; + Alcotest.test_case "spot-check: 2 Feb Festum, Presentation" `Quick + test_spot_check_presentation; + Alcotest.test_case "spot-check: 24 Jan Memoria, Francis de Sales" `Quick + test_spot_check_francis_de_sales; + Alcotest.test_case "spot-check: 27 Jan blank -> Memoria_ad_libitum, Angela Merici" `Quick + test_spot_check_angela_merici; + Alcotest.test_case + "1/6 Jan and 25 Dec deliberately excluded -- Temporal_of.named covers them" `Quick + test_excludes_temporal_of_dates ] ) -- cgit v1.3