aboutsummaryrefslogtreecommitdiff
path: root/test/test_calendar_of_data.ml
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-25 23:14:13 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-25 23:14:13 +0200
commitd201028d71ad3f26d5ec0b9c12268475e0b70cfd (patch)
treead89916625d650ce8e3439ce18341a51447a0010 /test/test_calendar_of_data.ml
parenta2fba71d8cccc765c4a987df99fa780f5467514d (diff)
downloadcolitur-d201028d71ad3f26d5ec0b9c12268475e0b70cfd.tar.gz
colitur-d201028d71ad3f26d5ec0b9c12268475e0b70cfd.zip
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.
Diffstat (limited to 'test/test_calendar_of_data.ml')
-rw-r--r--test/test_calendar_of_data.ml207
1 files changed, 207 insertions, 0 deletions
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 ] )