aboutsummaryrefslogtreecommitdiff
path: root/test
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-12 00:05:57 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-12 00:05:57 +0200
commit4624441d0bc9c1f28dea54606ab7999041d4b323 (patch)
treeaf9b9308c70fb5df6888990e7780e6e951d76644 /test
parent21dab521d6bfe8e4e7d04f761c11754059339ba2 (diff)
downloadcolitur-4624441d0bc9c1f28dea54606ab7999041d4b323.tar.gz
colitur-4624441d0bc9c1f28dea54606ab7999041d4b323.zip
data(ef): bootstrap the 1962 sanctoral from lectio
Convert lectio's tridentine-calendar.ini (322 entries) into data/ef/sanctoral.sexp via a validating OCaml converter, tools/ bootstrap_sanctoral.ml, rather than a hand-written script: every field is built through Slug.of_string, Colour.of_string and Vocab_ef.rank_of_string, so the emitted sexp is valid by construction. Two conversion decisions, both documented rather than buried: - subject defaults to Subject.Saint, overriding Celebration.make's kernel default of Subject.Temporal, for the 316 entries with no explicit class; - rank = commemoration maps to status = Commemoration_only with an inferred Class3 (not a citation -- it is what the 1960 reform reduced most simple feasts from), recorded as an open item in the rules register for the oracle to adjudicate. Every celebration is tagged layer = Precedence_ef.universal_layer, the provenance id RG 91's band classifier reads to tell the universal calendar from proper/indult data. The generated file carries a provenance header: source path, its SHA-256, and the UTC conversion date, so re-bootstrapping against a newer lectio is reproducible and diffable. Output is byte-identical across runs. test/test_sanctoral_ef.ml loads the file through Layer.load and checks the counts independently derived from the source INI (322 entries, 114 Commemoration_only, 12 Class1, no Subject.Temporal, every date resolves in a leap year), plus two named spot-checks against the INI's own text -- one entry with an explicit class field, one commemoration -- so a passing count cannot hide the wrong 322 entries having been converted.
Diffstat (limited to 'test')
-rw-r--r--test/dune1
-rw-r--r--test/test_colitur.ml2
-rw-r--r--test/test_sanctoral_ef.ml115
3 files changed, 117 insertions, 1 deletions
diff --git a/test/dune b/test/dune
index 07a4aae..bb8e474 100644
--- a/test/dune
+++ b/test/dune
@@ -1,6 +1,7 @@
(test
(name test_colitur)
(libraries colitur_kernel rite_ef alcotest qcheck qcheck-alcotest sexplib)
+ (deps ../data/ef/sanctoral.sexp)
(preprocess
(pps ppx_sexp_conv)))
diff --git a/test/test_colitur.ml b/test/test_colitur.ml
index a9cacd2..ba82fcd 100644
--- a/test/test_colitur.ml
+++ b/test/test_colitur.ml
@@ -3,4 +3,4 @@ let () =
Alcotest.run "colitur"
[ Test_date.suite; Test_computus.suite; Test_colour.suite; Test_slug.suite; Test_names.suite;
Test_overlay.suite; Test_temporal_ef.suite; Test_validate.suite; Test_precedence.suite;
- Test_calendar.suite; Test_precedence_ef.suite ]
+ Test_calendar.suite; Test_precedence_ef.suite; Test_sanctoral_ef.suite ]
diff --git a/test/test_sanctoral_ef.ml b/test/test_sanctoral_ef.ml
new file mode 100644
index 0000000..8fb178f
--- /dev/null
+++ b/test/test_sanctoral_ef.ml
@@ -0,0 +1,115 @@
+(* 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 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
+
+let test_load_and_counts () =
+ let l = load () in
+ Alcotest.(check int) "322 entries" 322 (List.length l.L.entries);
+ let commemoration_only =
+ List.filter (fun e -> e.L.cel.Cel.status = Cel.Commemoration_only) l.L.entries
+ in
+ Alcotest.(check int) "114 Commemoration_only" 114 (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);
+ (* 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 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"))
+
+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 ] )