(* Task 2 (2026-08-25-colitur-of-phases-3-5): the post-2002 decree overlays. data/of/calendar-2002.sexp (Task 1) transcribes the 2002 typical edition and is never edited again -- every subsequent Calendarium Romanum Generale change is its own overlay file under data/of/amendments/, one per decree, per docs/research/of/PROVENANCE-amendments.md (13 decrees, 16 celebration-level changes -- the research inventory's own "15 items, numbered 1-15, where items 10-12 are three entries carried by a single decree" collapses to 13 distinct decrees once items 2/3, both dated 28 September 2002 but carrying DIFFERENT protocol numbers, are counted as the two separate juridical instruments they are). This suite asserts: every file parses; each has a pinned SHA-256; applying all 13 in their own decree-chronological order to the base calendar yields a pinned entry count; Overlay.merge reports NO diagnostic (a diagnostic would mean a directive was transcribed against a slug that does not exist in the base -- the exact silent failure one-file-per-decree exists to prevent); and Mary, Mother of the Church resolves to the Monday after Pentecost in three real, independently-computed years. *) module L = Colitur_kernel.Layer module O = Colitur_kernel.Overlay module Cel = Colitur_kernel.Celebration module S = Colitur_kernel.Slug module DS = Colitur_kernel.Date_spec module D = Colitur_kernel.Date module Computus = Colitur_kernel.Computus module Names = Colitur_kernel.Names module Lang = Colitur_kernel.Lang module V = Rite_of.Vocab_of let base_path = "../data/of/calendar-2002.sexp" let amendments_dir = "../data/of/amendments/" (* Decree-chronological order, matching docs/research/of/ PROVENANCE-amendments.md's own "Ordered by decree date" -- the numeric filename prefixes already encode this, spelled out here explicitly so the intended application order is visible in the test itself, not merely inferred from a directory listing. Each paired with its SHA-256, pinned 2026-08-26 against the shipped files themselves (`sha256sum data/of/amendments/*.sexp`), not copied from any tool's own stdout. *) let files = [ ("001-padre-pio.sexp", "8ddd92f1820a736c7a4f3c27b3181579d6280ed82f0983f80219d523782731f6"); ("002-juan-diego-cuauhtlatoatzin.sexp", "01000fe5615894ff82b89a099e1539d6556beccfe1d07d5664362b0126724b09"); ("003-our-lady-of-guadalupe.sexp", "a19100769786d110d462805cc4533867c9d56308f8a058f46b257b2b0d6cb3ef"); ("004-john-xxiii-john-paul-ii.sexp", "ae24ac41935600d0d11a81072bedce2ba9790b79e2f8192c5d4a08a9a7db4e6e"); ("005-mary-magdalene-rank.sexp", "08a19050c21391ee565bc4d5a3ba228e3162875188db4bbc3b7f4b066a60a0c1"); ("006-mary-mother-of-the-church.sexp", "8a1dda10c1014bc59c22779f3686c6cce0bf9ff1ec4e504cb912cf2c89319ff9"); ("007-paul-vi.sexp", "d7b442b6b6dd6c8e7aca5f7126f7b3e65104191ae84c158e07e9afd0f70edcfc"); ("008-our-lady-of-loreto.sexp", "772366fb6c04d1c779109d8b56fa1c0a71ff0129f92349564e1a353b253c2e13"); ("009-faustina-kowalska.sexp", "a6bf8a1ab8e3f4aa65bb22d4ff9ba863e5f65865180444f4b143f36aa99e45b3"); ("010-narek-avila-hildegard.sexp", "6ca11a7c2e02491b9b96349282c7b1d6fc28c9e4729f678bd54d11a70e43f568"); ("011-martha-mary-lazarus.sexp", "c4ede10b24f22def5fd557a3627d1ecaeb5de583f263372a67bccf9ab838f023"); ("012-teresa-of-calcutta.sexp", "5646597472f6170a2b56e1f1c4a2ef7e7917cb5c87eb5e3d1f98b8ee1aaae646"); ("013-john-henry-newman.sexp", "f6c12d1ead043a609b0086a130ae1a9fc680a8926fe77ccd97756ee747105d7c") ] let sha256_of_file path = let tmp = Filename.temp_file "colitur_amendments_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" let load_overlay 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 let load_base () = match L.load V.rank_of_sexp base_path with | Ok l -> l | Error e -> Alcotest.failf "%s: failed to load: %s" base_path e (* Every file parses. *) let test_files_parse () = List.iter (fun (name, _) -> let (_ : V.rank O.t) = load_overlay name in ()) files (* Each has a pinned SHA-256. *) let test_sha256_pinned () = List.iter (fun (name, expected) -> Alcotest.(check string) (name ^ " SHA-256") expected (sha256_of_file (amendments_dir ^ name))) files (* Applying all 13, in decree-chronological order, to the base calendar yields a pinned entry count, AND Overlay.merge reports no diagnostic -- the silent-no-op check the whole one-file-per-decree discipline exists to enforce (a diagnostic here would mean a directive was transcribed against a slug the base calendar does not actually have). *) let test_apply_in_order () = let base = load_base () in let overlays = List.map (fun (name, _) -> load_overlay name) files in let merged, diagnostics = O.merge base overlays in Alcotest.(check (list string)) "no diagnostics -- every directive matched something" [] (List.map O.diagnostic_to_string diagnostics); (* 208 base entries + 14 Adds (1+1+1+2+1+1+1+1+3+1+1, files 001/002/003/004/006/007/008/009/010x3/012/013) + 2 Edits (005, 011, no count change) = 222. *) Alcotest.(check int) "208 base + 14 adds = 222 entries" 222 (List.length merged.L.entries) (* Every new/edited slug this task introduces is actually present after the merge, and the two Edits did not accidentally become Adds (no "slug not present; edit ignored" diagnostic already ruled that out above, but a direct spot-check on the merged layer is cheap insurance against a slug typo that happened to also dodge the diagnostic, e.g. by matching a DIFFERENT, wrong existing slug). *) let test_new_slugs_present () = let base = load_base () in let overlays = List.map (fun (name, _) -> load_overlay name) files in let merged, _ = O.merge base overlays in let has slug = L.mem merged (S.of_string_exn slug) in List.iter (fun slug -> Alcotest.(check bool) (slug ^ " present after merge") true (has slug)) [ "pio-of-pietrelcina-padre-pio-priest"; "juan-diego"; "our-lady-of-guadalupe"; "john-xxiii-pope"; "john-paul-ii-pope"; "mary-mother-of-the-church"; "paul-vi-pope"; "our-lady-of-loreto"; "faustina-kowalska-virgin"; "gregory-of-narek-abbot-and-doctor"; "john-of-avila-priest-and-doctor"; "hildegard-of-bingen-virgin-and-doctor"; "teresa-of-calcutta-virgin"; "john-henry-newman-priest-and-doctor" ]; (* The two Edits kept their original slugs. *) match L.find merged (S.of_string_exn "mary-magdalene") with | None -> Alcotest.fail "mary-magdalene: slug lost after Edit" | Some e -> Alcotest.(check bool) "mary-magdalene is now Festum" true (e.L.cel.Cel.rank = V.Festum) let test_martha_mary_lazarus_renamed () = let base = load_base () in let overlays = List.map (fun (name, _) -> load_overlay name) files in let merged, _ = O.merge base overlays in match L.find merged (S.of_string_exn "martha") with | None -> Alcotest.fail "martha: slug lost after Edit" | Some e -> Alcotest.(check (option string)) "en name widened" (Some "Saints Martha, Mary and Lazarus") (Names.find e.L.cel.Cel.names (Lang.of_string_exn "en")); Alcotest.(check bool) "date unchanged, 29 July" true (e.L.date = (match DS.fixed ~month:7 ~day:29 with Ok d -> d | Error err -> failwith err)); Alcotest.(check bool) "rank unchanged, Memoria_obligatoria" true (e.L.cel.Cel.rank = V.Memoria_obligatoria) (* Mary, Mother of the Church resolves to the Monday after Pentecost -- Easter+50 -- in three real, independently-computed years, not merely trusted from the decree's own arithmetic. Pentecost is Easter+49 (the 50th day of the Paschal season inclusive of Easter); the day after is Easter+50. Checked structurally (weekday = Mon, and the resolved date is exactly 50 days after that year's real Gregorian Easter, computed via Colitur_kernel.Computus.gregorian_easter), not by hand-typing expected calendar dates. *) let test_mater_ecclesiae_monday_after_pentecost () = let base = load_base () in let overlays = List.map (fun (name, _) -> load_overlay name) files in let merged, _ = O.merge base overlays in let entry = match L.find merged (S.of_string_exn "mary-mother-of-the-church") with | Some e -> e | None -> Alcotest.fail "mary-mother-of-the-church: not found after merge" in Alcotest.(check bool) "date spec is Easter_offset 50" true (entry.L.date = DS.Easter_offset 50); Alcotest.(check bool) "Memoria_obligatoria" true (entry.L.cel.Cel.rank = V.Memoria_obligatoria); Alcotest.(check bool) "white" true (entry.L.cel.Cel.colour = Colitur_kernel.Colour.White); Alcotest.(check bool) "subject Bvm" true (entry.L.cel.Cel.subject = Colitur_kernel.Subject.Bvm); List.iter (fun year -> let easter = Computus.gregorian_easter year in let pentecost = D.add_days easter 49 in let expected = D.add_days easter 50 in Alcotest.(check bool) (Printf.sprintf "%d: Pentecost (Easter+49) is a Sunday" year) true (D.weekday pentecost = D.Sun); let resolved = match DS.resolve entry.L.date ~year ~easter with | Some d -> d | None -> Alcotest.failf "%d: Easter_offset 50 did not resolve" year in Alcotest.(check bool) (Printf.sprintf "%d: resolves to exactly Easter+50" year) true (resolved = expected); Alcotest.(check bool) (Printf.sprintf "%d: resolved date is a Monday" year) true (D.weekday resolved = D.Mon); Alcotest.(check bool) (Printf.sprintf "%d: resolved date is the day after Pentecost" year) true (resolved = D.add_days pentecost 1)) [ 2026; 2027; 2035 ] let suite = ( "Amendments_of (data/of/amendments/*.sexp)", [ Alcotest.test_case "every overlay file parses" `Quick test_files_parse; Alcotest.test_case "SHA-256 pinned per file" `Quick test_sha256_pinned; Alcotest.test_case "apply in decree-chronological order: 222 entries, no diagnostics" `Quick test_apply_in_order; Alcotest.test_case "every new/edited slug is present after merge" `Quick test_new_slugs_present; Alcotest.test_case "Martha/Mary/Lazarus: renamed in place, date/rank unchanged" `Quick test_martha_mary_lazarus_renamed; Alcotest.test_case "Mary, Mother of the Church: Monday after Pentecost, 3 real years" `Quick test_mater_ecclesiae_monday_after_pentecost ] )