(* 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 entries having been transcribed (the same "live hazard" test_sanctoral_ef.ml's own header names). Fix round 1 (2026-08-25): 206 -> 208. Two movable universal celebrations -- the Sacred Heart of Jesus and the Immaculate Heart of Mary -- are printed in the SAME 2002 table (as prose headings, not (kalends, day) rows) but were missed by the first pass; both are now produced as Date_spec.Easter_offset entries (68/69) by tools/extract_of_calendar.py's own MOVABLE_ENTRY_HEADINGS, the same mechanism EF's Rogation Wednesday already uses. See test_movable_entries below, which verifies the Easter+68/Easter+69 arithmetic against three REAL Easter dates rather than trusting the offset on its own say-so. 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" "1b4f2117133dea1c70989708d94e6f292f3ce23fce6221391191d6fcf6042d19" (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) "208 entries" 208 (List.length l.L.entries); let count pred = List.length (List.filter pred l.L.entries) in Alcotest.(check int) "9 Sollemnitas" 9 (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) "68 Memoria_obligatoria" 68 (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) "208 Feast (OF admits no Commemoration_only status)" 208 (count (fun e -> e.L.cel.Cel.status = Cel.Feast)); Alcotest.(check int) "every entry tagged Precedence_of.universal_layer" 208 (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" (* Fix round 1: the two movable entries (Sacred Heart of Jesus, Immaculate Heart of Mary) resolve to the right weekday AND date in three real years, computed from Colitur_kernel.Computus.gregorian_easter, not hand-typed dates -- so a wrong offset in the data would fail this even if it "looked" plausible. Also independently confirms the coordinator's own +68/+69 derivation (Pentecost = Easter+49, so the Friday/Saturday after the Second Sunday after Pentecost are Easter+68/+69) rather than trusting it. *) let test_movable_entries () = let l = load () in let heart = find l "sacred-heart-of-jesus" in Alcotest.(check bool) "Sacred Heart: date spec is Easter_offset 68" true (heart.L.date = DS.Easter_offset 68); Alcotest.(check bool) "Sacred Heart: Sollemnitas" true (heart.L.cel.Cel.rank = V.Sollemnitas); Alcotest.(check bool) "Sacred Heart: white" true (heart.L.cel.Cel.colour = Col.White); Alcotest.(check bool) "Sacred Heart: subject Lord" true (heart.L.cel.Cel.subject = Sub.Lord); let immaculate = find l "immaculate-heart-of-mary" in Alcotest.(check bool) "Immaculate Heart: date spec is Easter_offset 69" true (immaculate.L.date = DS.Easter_offset 69); Alcotest.(check bool) "Immaculate Heart: Memoria_obligatoria" true (immaculate.L.cel.Cel.rank = V.Memoria_obligatoria); Alcotest.(check bool) "Immaculate Heart: white" true (immaculate.L.cel.Cel.colour = Col.White); Alcotest.(check bool) "Immaculate Heart: subject Bvm" true (immaculate.L.cel.Cel.subject = Sub.Bvm); List.iter (fun year -> let easter = Colitur_kernel.Computus.gregorian_easter year in let resolve spec = match DS.resolve spec ~year ~easter with | Some d -> d | None -> Alcotest.failf "year %d: date spec did not resolve" year in let sacred_heart_date = resolve heart.L.date in let immaculate_heart_date = resolve immaculate.L.date in Alcotest.(check bool) (Printf.sprintf "%d: Sacred Heart (Easter+68) falls on a Friday" year) true (D.weekday sacred_heart_date = D.Fri); Alcotest.(check bool) (Printf.sprintf "%d: Immaculate Heart (Easter+69) falls on a Saturday" year) true (D.weekday immaculate_heart_date = D.Sat); (* Immaculate Heart is the day right after Sacred Heart, every year. *) Alcotest.(check bool) (Printf.sprintf "%d: Immaculate Heart is the day after Sacred Heart" year) true (immaculate_heart_date = D.add_days sacred_heart_date 1)) [ 2026; 2027; 2035 ] 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; Alcotest.test_case "fix round 1: Sacred Heart of Jesus (Easter+68) and Immaculate Heart of Mary (Easter+69)" `Quick test_movable_entries ] )