(* Loads the bootstrapped data/ef/sanctoral.sexp (Task 10, tools/ bootstrap_sanctoral.ml) through Layer.load and checks it against counts independently derived from the source INI itself (grep -c '^\[', grep '^rank' | sort | uniq -c -- see the task report), plus two named spot-checks against the INI's own text so a passing count can't hide the wrong 322 entries having been converted (task brief's "live hazard"). *) module L = Colitur_kernel.Layer module Cel = Colitur_kernel.Celebration module Citation = Colitur_kernel.Citation module S = Colitur_kernel.Slug module DS = Colitur_kernel.Date_spec module Col = Colitur_kernel.Colour module Sub = Colitur_kernel.Subject module Lang = Colitur_kernel.Lang module Names = Colitur_kernel.Names module V = Rite_ef.Vocab_ef module PE = Rite_ef.Precedence_ef (* 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/ef/sanctoral.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 (* Counts re-derived 2026-08-12 (ef-rebootstrap) against the regenerated source INI directly (`grep -c '^\['` minus the [layer] header itself; `grep '^rank' | sort | uniq -c`) -- NOT copied from a `colitur day`/ bootstrap-tool run, per this project's own "derive the new value, don't transcribe it from output" pinning discipline. The source's SHA-256 moved 6a25e634... -> 1b303ef2..., correcting: 15 entries wrongly `rank = commemoration` that are really class-3 feasts (322 -> 327's own feast/ comm split moves 15 the other way before the net +5 below); 5 entries newly present (agnes-secundo, boniface-martyr, eusebius-confessor, evaristus, theodore -- all Commemoration_only), so 322 + 5 = 327 and 114 - 15 + 5 = 104. Class1 count (12) is untouched by this regeneration -- none of the 20 changed/added entries is Class1. *) let test_load_and_counts () = let l = load () in Alcotest.(check int) "327 entries" 327 (List.length l.L.entries); let commemoration_only = List.filter (fun e -> e.L.cel.Cel.status = Cel.Commemoration_only) l.L.entries in (* 102, not 104: ef-sanctoral-status promoted `ubaldus` (16 May) and `didacus` (13 November) to Feast. Both are ranked "III classis" outright in the Missal's own universal calendarium, with no commemoration rubric, and both have their own Mass entry in the Proprium taking its readings from a Common. They were the ONLY two defects found by auditing all 290 fixed-date entries against that calendarium -- see the register. *) Alcotest.(check int) "102 Commemoration_only" 102 (List.length commemoration_only); let class1 = List.filter (fun e -> e.L.cel.Cel.rank = V.Class1) l.L.entries in Alcotest.(check int) "12 Class1" 12 (List.length class1); let temporal_subjects = List.filter (fun e -> e.L.cel.Cel.subject = Sub.Temporal) l.L.entries in Alcotest.(check int) "no Subject.Temporal" 0 (List.length temporal_subjects); (* ef-rebootstrap fix round 1 (F8): a guard against WHOLESALE Polish-name loss, the exact defect lectio itself shipped once (a regeneration that dropped all 322 name.pl entries, the "SECOND Critical" the lectio side of this session's own work found and fixed -- register/ledger record). `parse_names`'s own `field_opt` change (this task's first commit) can legitimately drop to en-only for a genuinely pl-less source entry -- verified NOT to silently drop a PRESENT name -- but nothing before this assertion would have caught a regeneration that dropped pl WHOLESALE the way lectio's once did: `test_load_and_counts` checked entry/status/rank counts only, none of which move if every name.pl vanishes. Independently re-derived, not transcribed: `grep -c '^name.pl' tridentine-calendar.ini` = 322 (327 entries, 5 lack it -- the same 5 `parse_names` documents), cross-checked against `grep -c '(pl' data/ef/sanctoral.sexp` = 322 exactly. *) let with_pl = List.filter (fun e -> Names.find e.L.cel.Cel.names (Lang.of_string_exn "pl") <> None) l.L.entries in Alcotest.(check int) "322 of 327 entries carry a Polish name (5 legitimately do not)" 322 (List.length with_pl); (* Every slug already had to pass Slug.of_string during load (Slug.t_of_sexp is the validating parser -- an invalid slug would have failed the whole Layer.load with Error, never landing here silently). Re-checking is still worth doing explicitly, rather than resting entirely on "load didn't error", so a future loader change that weakens that guarantee has a test that would catch it directly. *) 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; (* All 322 dates are Date_spec.Fixed month/day pairs constructed via Date_spec.fixed, which already rejects an out-of-range day for that month against the LEAP-year maximum -- so every one must resolve in a leap year (2028: divisible by 4, not by 100). *) 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 (* Named spot-check 1: one of the 6 entries carrying an explicit `class` field in the source (INI lines: date=02-02, rank=class-2, colour=white, class=lord, name.en/name.pl as below). Exercises decision 1's non-default branch -- an entry where subject must NOT fall back to Saint. *) let test_spot_check_explicit_class () = let l = load () in let e = find l "purification-of-the-blessed-virgin-mary" in let cel = e.L.cel in Alcotest.(check bool) "date 02-02" true (e.L.date = mkdate 2 2); Alcotest.(check bool) "rank Class2" true (cel.Cel.rank = V.Class2); Alcotest.(check bool) "status Feast" true (cel.Cel.status = Cel.Feast); Alcotest.(check bool) "colour White" true (cel.Cel.colour = Col.White); Alcotest.(check bool) "subject Lord (from class = lord)" true (cel.Cel.subject = Sub.Lord); Alcotest.(check (option string)) "name.en" (Some "Purification of the Blessed Virgin Mary") (Names.find cel.Cel.names (Lang.of_string_exn "en")); Alcotest.(check (option string)) "name.pl" (Some "Oczyszczenie N. M. P.") (Names.find cel.Cel.names (Lang.of_string_exn "pl")); Alcotest.(check string) "layer tagged universal (Precedence_ef.universal_layer)" PE.universal_layer cel.Cel.layer (* Named spot-check 2: a `rank = commemoration` entry (INI lines: date=01-14, rank=commemoration, colour=white, no class, name.en/name.pl as below). Exercises decision 2 (Commemoration_only + inferred Class3) AND decision 1's default branch (no class field -> Saint) on the SAME entry. *) let test_spot_check_commemoration () = let l = load () in let e = find l "maur-abbot" in let cel = e.L.cel in Alcotest.(check bool) "date 01-15" true (e.L.date = mkdate 1 15); Alcotest.(check bool) "rank inferred Class3" true (cel.Cel.rank = V.Class3); Alcotest.(check bool) "status Commemoration_only" true (cel.Cel.status = Cel.Commemoration_only); Alcotest.(check bool) "colour White" true (cel.Cel.colour = Col.White); Alcotest.(check bool) "subject defaults to Saint (no class field)" true (cel.Cel.subject = Sub.Saint); Alcotest.(check (option string)) "name.en" (Some "St. Maur, Abbot") (Names.find cel.Cel.names (Lang.of_string_exn "en")); Alcotest.(check (option string)) "name.pl" (Some "św. Maura, Opata") (Names.find cel.Cel.names (Lang.of_string_exn "pl")) (* Task 3: sanctoral propers land on Celebration.citations, not a second lectionary-shaped file -- see the bootstrap tool's own reasoning. *) let test_sanctoral_carries_propers () = let l = load () in let e = find l "commemoration-of-the-baptism-of-the-lord" in let refs = List.map (fun c -> c.Citation.reference) e.L.cel.Cel.citations in Alcotest.(check (list string)) "13 January carries its proper Epistle and Gospel" [ "Isa 60:1-6"; "John 1:29-34" ] refs let test_commemoration_only_has_no_readings () = let l = load () in let bad = List.filter (fun e -> e.L.cel.Cel.status = Cel.Commemoration_only && e.L.cel.Cel.citations <> []) l.L.entries in Alcotest.(check int) "a commemoration contributes an oration, not a reading" 0 (List.length bad) (* RG 33's third omission trigger reads a hand-written table in [Precedence_ef.vigil_feast_table] pairing each vigil with the feast it precedes. That table is the one part of the rule no type can check: a slug that matches nothing in the data makes the rule silently inert for that entry, and a vigil ADDED to the data but not to the table is never omitted at all. Both directions are asserted here against the shipped file. The Ascension's vigil is excluded from the data-side checks because it is TEMPORAL (Temporal_ef builds it off an Easter offset) and correctly absent from the sanctoral layer -- asserted explicitly below rather than skipped, so that the exclusion cannot quietly grow to cover a real miss. *) let ascension_vigil = "ef-ascension-vigil" let test_vigil_feast_table_slugs_all_exist () = let l = load () in let mem slug = match Colitur_kernel.Slug.of_string slug with | Error e -> Alcotest.failf "%s: not a well-formed slug: %s" slug e | Ok s -> L.mem l s in List.iter (fun (vigil, feast) -> let feast = Colitur_kernel.Slug.to_string feast in if String.equal vigil ascension_vigil then begin (* BOTH halves of this pair are temporal -- Temporal_ef builds the vigil at Easter+38 and the feast at Easter+39 -- so neither belongs in the sanctoral layer. Asserted in the negative rather than skipped, so the exclusion cannot quietly widen to hide a real miss; the pair's positive witness is test_temporal_ef's own Ascension cases and the 2026-05-13 golden pin. *) Alcotest.(check bool) "ef-ascension-vigil is temporal, not sanctoral" false (mem vigil); Alcotest.(check bool) "ef-ascension is temporal, not sanctoral" false (mem feast) end else begin Alcotest.(check bool) (Printf.sprintf "%s is present in the shipped sanctoral" vigil) true (mem vigil); Alcotest.(check bool) (Printf.sprintf "%s (the feast %s precedes) is present in the shipped sanctoral" feast vigil) true (mem feast) end) PE.vigil_feast_table let test_vigil_feast_table_covers_every_shipped_vigil () = let l = load () in let tabled = List.map fst PE.vigil_feast_table in let shipped_vigils = l.L.entries |> List.filter (fun (e : V.rank L.entry) -> let slug = Colitur_kernel.Slug.to_string e.L.cel.Colitur_kernel.Celebration.slug in PE.is_vigil slug && PE.is_omissible_vigil e.L.cel.Colitur_kernel.Celebration.rank) |> List.map (fun (e : V.rank L.entry) -> Colitur_kernel.Slug.to_string e.L.cel.Colitur_kernel.Celebration.slug) |> List.sort compare in let missing = List.filter (fun s -> not (List.mem s tabled)) shipped_vigils in Alcotest.(check (list string)) "every II/III-class vigil in the shipped sanctoral is in vigil_feast_table -- a new one \ added to the data without a table row would never be omitted under RG 33" [] missing; (* Pinned so that a vigil VANISHING from the data is caught too: an empty [shipped_vigils] would satisfy the subset check above vacuously. *) Alcotest.(check int) "four sanctoral vigils are subject to RG 33" 4 (List.length shipped_vigils) let suite = ( "Sanctoral_ef (data/ef/sanctoral.sexp)", [ Alcotest.test_case "load succeeds and counts match the source INI" `Quick test_load_and_counts; Alcotest.test_case "spot-check: explicit class = lord (Purification of the BVM)" `Quick test_spot_check_explicit_class; Alcotest.test_case "spot-check: rank = commemoration (St. Maur, Abbot)" `Quick test_spot_check_commemoration; Alcotest.test_case "sanctoral entries carry their proper readings" `Quick test_sanctoral_carries_propers; Alcotest.test_case "Commemoration_only entries carry no readings" `Quick test_commemoration_only_has_no_readings; Alcotest.test_case "RG33: every vigil_feast_table slug exists in the shipped data" `Quick test_vigil_feast_table_slugs_all_exist; Alcotest.test_case "RG33: vigil_feast_table covers every shipped II/III-class vigil" `Quick test_vigil_feast_table_covers_every_shipped_vigil ] )