aboutsummaryrefslogtreecommitdiff
path: root/test
diff options
context:
space:
mode:
Diffstat (limited to 'test')
-rw-r--r--test/test_calendar.ml5
-rw-r--r--test/test_golden.ml40
-rw-r--r--test/test_names.ml137
-rw-r--r--test/test_overlay.ml13
-rw-r--r--test/test_rite_ef.ml2
-rw-r--r--test/test_sanctoral_ef.ml2
-rw-r--r--test/test_validate.ml5
7 files changed, 192 insertions, 12 deletions
diff --git a/test/test_calendar.ml b/test/test_calendar.ml
index a533ab2..59624aa 100644
--- a/test/test_calendar.ml
+++ b/test/test_calendar.ml
@@ -105,6 +105,11 @@ module Fixture = struct
let rite : (season, rank) Rite.t =
{ Rite.id = "synthetic-calendar"; vocab; year_start; temporal; anchors = (fun _ -> []);
+ (* Not a Roman rite, but a Rite.t must supply SOME Easter now that
+ movable Date_spec variants exist. The Gregorian one is as good as
+ any for a fixture; nothing here is Easter-relative, so the value
+ is never actually read. *)
+ easter = Colitur_kernel.Computus.gregorian_easter;
rules; season_runs = [ A; B ]; transfer_target; readings }
let entry ~month ~day ~slug ~rank =
diff --git a/test/test_golden.ml b/test/test_golden.ml
index e9b3ec5..a073c52 100644
--- a/test/test_golden.ml
+++ b/test/test_golden.ml
@@ -1281,6 +1281,40 @@ let test_readings_displaced_sunday_feria_known_divergent () =
2026 10 26
"2026-10-26 slug=ef-time-after-pentecost-22-monday first=Col 1:12-20. gospel=John 18:33-37"
+
+(* ---- Rogation Wednesday (RG 87/88/89), movable-date-specs task ----
+
+ RG 87, verbatim (scan1.txt:691): "Litaniae minores seu Rogationes, per se,
+ assignantur feriis II, III et IV ante festum Ascensionis Domini" -- Monday,
+ Tuesday AND Wednesday. colitur built the first two as temporal offices from
+ the start; the Wednesday had no channel at all until Date_spec grew
+ [Easter_offset], because Easter+38 IS the Ascension Vigil by construction
+ and there is no (month, day) a Fixed spec could anchor to.
+
+ RG 88 keeps it out of the Office entirely, so the Vigil remains the
+ observed day and this is only ever a commemoration. RG 89 routes it through
+ the Major Litanies' rules -- but RG 109's closed list of privileged
+ commemorations names only "de Litaniis MAIORIBUS", never minoribus, so by
+ RG 107's closing rule it is ORDINARY. That asymmetry is the whole point of
+ pinning two years rather than one:
+
+ 2024 -- nothing else competes, so the single commemoration RG 111 allows on
+ a II-class day is the Rogation itself.
+ 2026 -- St Robert Bellarmine (III class) is impeded onto the same day and
+ takes that one slot instead: RG 113 orders admissions by RG 91's table, and
+ a Commemoration_only candidate has no row in it at all (band returns
+ [unclassified]), so a genuine feast outranks it.
+
+ Both weekdays independently checked with `date -d`. *)
+let test_rogation_wednesday_admitted_2024 () =
+ check ~msg:"2024: Rogation Wednesday is the one admitted commemoration" 2024 5 8
+ "2024-05-08 wednesday season=paschaltide week=6 slug=ef-ascension-vigil rank=class-2 colour=white subject=temporal name_la=- comms=[rogation-wednesday:ordinary] in=- out=[]"
+
+let test_rogation_wednesday_yields_to_a_feast_2026 () =
+ check ~msg:"2026: an impeded III-class feast takes the single slot instead" 2026 5 13
+ "2026-05-13 wednesday season=paschaltide week=6 slug=ef-ascension-vigil rank=class-2 colour=white \
+ subject=temporal name_la=- comms=[robert-bellarmine:ordinary] in=- out=[]"
+
let suite =
( "golden pins (known-tricky years)",
[ Alcotest.test_case "Easter extreme: 1598 earliest (22 Mar, Gauss-verified)" `Quick
@@ -1371,5 +1405,9 @@ let suite =
test_readings_step4_common_frances;
Alcotest.test_case
"readings: displaced-Sunday feria pins M26 shape 2(b)'s KNOWN-WRONG walkback (2026-10-26)" `Quick
- test_readings_displaced_sunday_feria_known_divergent
+ test_readings_displaced_sunday_feria_known_divergent;
+ Alcotest.test_case "RG87: Rogation Wednesday admitted (2024)" `Quick
+ test_rogation_wednesday_admitted_2024;
+ Alcotest.test_case "RG87/113: Rogation Wednesday yields to an impeded feast (2026)" `Quick
+ test_rogation_wednesday_yields_to_a_feast_2026
] )
diff --git a/test/test_names.ml b/test/test_names.ml
index cef7357..f3cdc54 100644
--- a/test/test_names.ml
+++ b/test/test_names.ml
@@ -2,6 +2,11 @@ module N = Colitur_kernel.Names
module L = Colitur_kernel.Lang
module C = Colitur_kernel.Citation
module DS = Colitur_kernel.Date_spec
+
+(* Task: Date_spec.resolve now takes the rite's own Easter (movable specs).
+ These fixtures are all [Fixed], so the value is irrelevant to them -- but it
+ must be supplied, and the Gregorian one is the honest choice here. *)
+let easter_of y = Colitur_kernel.Computus.gregorian_easter y
module D = Colitur_kernel.Date
let lang s = L.of_string_exn s
@@ -52,15 +57,15 @@ let test_citation () =
let test_date_spec () =
(match DS.fixed ~month:3 ~day:25 with
| Ok ds -> (
- match DS.resolve ds ~year:2026 with
+ match DS.resolve ds ~year:2026 ~easter:(easter_of 2026) with
| Some d -> Alcotest.(check string) "resolve" "2026-03-25" (D.to_iso8601 d)
| None -> Alcotest.fail "resolve returned None")
| Error e -> Alcotest.failf "fixed: %s" e);
(* Feb 29 is a legitimate fixed date that simply does not occur every year. *)
(match DS.fixed ~month:2 ~day:29 with
| Ok ds ->
- Alcotest.(check bool) "Feb 29 resolves in 2024" true (DS.resolve ds ~year:2024 <> None);
- Alcotest.(check bool) "Feb 29 absent in 2026" true (DS.resolve ds ~year:2026 = None)
+ Alcotest.(check bool) "Feb 29 resolves in 2024" true (DS.resolve ds ~year:2024 ~easter:(easter_of 2024) <> None);
+ Alcotest.(check bool) "Feb 29 absent in 2026" true (DS.resolve ds ~year:2026 ~easter:(easter_of 2026) = None)
| Error e -> Alcotest.failf "Feb 29 must be constructible: %s" e);
Alcotest.(check bool) "reject Feb 30" true (Result.is_error (DS.fixed ~month:2 ~day:30));
Alcotest.(check bool) "reject Apr 31" true (Result.is_error (DS.fixed ~month:4 ~day:31));
@@ -176,6 +181,118 @@ let test_record () =
[ "2026-04-05"; "ef"; "ordinary"; "1"; "sunday"; "ef-easter-sunday"; "high"; "white"; "lord" ]
(Rec.to_row r)
+
+(* ---- movable Date_spec variants (2026-08-17) ----
+ Every date literal below was checked against `date -d` before being typed,
+ the same rule test_golden.ml states for itself. Easter 2026 is 5 April. *)
+
+let easter_2026 = easter_of 2026
+
+let test_easter_offset_resolves () =
+ let ds = match DS.easter_offset 38 with Ok d -> d | Error e -> failwith e in
+ (* Easter 2026 = 5 April; +38 = 13 May, the Wednesday before Ascension --
+ the offset Rogation Wednesday needs (RG 87). *)
+ Alcotest.(check string) "Easter+38 in 2026" "2026-05-13"
+ (match DS.resolve ds ~year:2026 ~easter:easter_2026 with Some d -> D.to_iso8601 d | None -> "none")
+
+let test_easter_offset_zero_is_easter () =
+ let ds = match DS.easter_offset 0 with Ok d -> d | Error e -> failwith e in
+ Alcotest.(check string) "Easter+0 is Easter itself" "2026-04-05"
+ (match DS.resolve ds ~year:2026 ~easter:easter_2026 with Some d -> D.to_iso8601 d | None -> "none")
+
+let test_easter_offset_negative () =
+ let ds = match DS.easter_offset (-46) with Ok d -> d | Error e -> failwith e in
+ (* Ash Wednesday 2026 is 18 February, Easter-46. *)
+ Alcotest.(check string) "Easter-46 is Ash Wednesday" "2026-02-18"
+ (match DS.resolve ds ~year:2026 ~easter:easter_2026 with Some d -> D.to_iso8601 d | None -> "none")
+
+let test_nth_weekday_first_sunday_october () =
+ let ds = match DS.nth_weekday ~month:10 ~nth:1 ~weekday:D.Sun with Ok d -> d | Error e -> failwith e in
+ (* 1 October 2026 is a Thursday, so the first Sunday is the 4th. *)
+ Alcotest.(check string) "first Sunday of October 2026" "2026-10-04"
+ (match DS.resolve ds ~year:2026 ~easter:easter_2026 with Some d -> D.to_iso8601 d | None -> "none")
+
+let test_nth_weekday_last_sunday_october () =
+ let ds = match DS.nth_weekday ~month:10 ~nth:(-1) ~weekday:D.Sun with Ok d -> d | Error e -> failwith e in
+ Alcotest.(check string) "last Sunday of October 2026" "2026-10-25"
+ (match DS.resolve ds ~year:2026 ~easter:easter_2026 with Some d -> D.to_iso8601 d | None -> "none")
+
+let test_nth_weekday_absent_fifth () =
+ let ds = match DS.nth_weekday ~month:10 ~nth:5 ~weekday:D.Sun with Ok d -> d | Error e -> failwith e in
+ (* October 2026 has four Sundays (4, 11, 18, 25). A fifth is not an error --
+ it simply does not occur, the same contract 29 February has. *)
+ Alcotest.(check bool) "a fifth Sunday the month lacks resolves to None" true
+ (DS.resolve ds ~year:2026 ~easter:easter_2026 = None)
+
+let test_nth_weekday_february_leap_edge () =
+ let ds = match DS.nth_weekday ~month:2 ~nth:(-1) ~weekday:D.Sat with Ok d -> d | Error e -> failwith e in
+ (* 2024 is a leap year; 29 February is a Thursday, so the last Saturday is
+ the 24th. Exercises the month-length probe against Date.make rather than
+ a duplicated leap rule. *)
+ Alcotest.(check string) "last Saturday of February 2024" "2024-02-24"
+ (match DS.resolve ds ~year:2024 ~easter:(easter_of 2024) with Some d -> D.to_iso8601 d | None -> "none")
+
+let test_movable_constructors_reject_nonsense () =
+ Alcotest.(check bool) "nth = 0 rejected" true (Result.is_error (DS.nth_weekday ~month:10 ~nth:0 ~weekday:D.Sun));
+ Alcotest.(check bool) "nth = 6 rejected" true (Result.is_error (DS.nth_weekday ~month:10 ~nth:6 ~weekday:D.Sun));
+ Alcotest.(check bool) "nth = -6 rejected" true (Result.is_error (DS.nth_weekday ~month:10 ~nth:(-6) ~weekday:D.Sun));
+ Alcotest.(check bool) "month 13 rejected" true (Result.is_error (DS.nth_weekday ~month:13 ~nth:1 ~weekday:D.Sun));
+ Alcotest.(check bool) "offset 400 rejected" true (Result.is_error (DS.easter_offset 400));
+ Alcotest.(check bool) "offset -400 rejected" true (Result.is_error (DS.easter_offset (-400)))
+
+(* The failure this guards is INVISIBLE: an unvalidated spec deserialises into
+ something that silently never resolves, and a celebration vanishes with no
+ diagnostic anywhere. Date_spec quarantines the derived parser in [Repr] and
+ re-validates in a hand-written [t_of_sexp] for exactly that reason; the two
+ new variants are held to it too, or the guarantee is only partial. *)
+let test_movable_sexp_parser_rejects_invalid () =
+ List.iter
+ (fun s ->
+ Alcotest.(check bool)
+ (Printf.sprintf "rejects %s" s)
+ true
+ (try
+ ignore (DS.t_of_sexp (Sexplib.Sexp.of_string s));
+ false
+ with _ -> true))
+ [ "(Nth_weekday (month 10) (nth 0) (weekday Sun))";
+ "(Nth_weekday (month 13) (nth 1) (weekday Sun))";
+ "(Nth_weekday (month 10) (nth 9) (weekday Sun))";
+ "(Easter_offset 100000)";
+ "(Easter_offset -100000)" ]
+
+let test_movable_sexp_roundtrip () =
+ List.iter
+ (fun ds -> Alcotest.(check bool) "sexp round-trips" true (DS.t_of_sexp (DS.sexp_of_t ds) = ds))
+ [ (match DS.fixed ~month:6 ~day:30 with Ok d -> d | Error e -> failwith e);
+ (match DS.easter_offset 38 with Ok d -> d | Error e -> failwith e);
+ (match DS.easter_offset (-63) with Ok d -> d | Error e -> failwith e);
+ (match DS.nth_weekday ~month:10 ~nth:1 ~weekday:D.Sun with Ok d -> d | Error e -> failwith e);
+ (match DS.nth_weekday ~month:10 ~nth:(-1) ~weekday:D.Sat with Ok d -> d | Error e -> failwith e) ]
+
+(* Year-independent properties: the confidence-past-2050 mechanism. *)
+let prop_nth_weekday_lands_correctly =
+ QCheck.Test.make ~count:500 ~name:"Nth_weekday resolves into its own month with its own weekday"
+ QCheck.(triple (int_range 1583 9998) (int_range 1 12) (int_range 1 5))
+ (fun (year, month, nth) ->
+ match DS.nth_weekday ~month ~nth ~weekday:D.Sun with
+ | Error _ -> false
+ | Ok ds -> (
+ match DS.resolve ds ~year ~easter:(easter_of year) with
+ | None -> true (* a 5th Sunday the month lacks: legitimate *)
+ | Some d -> D.month d = month && D.weekday d = D.Sun))
+
+let prop_easter_offset_is_exactly_that_offset =
+ QCheck.Test.make ~count:500 ~name:"Easter_offset n resolves exactly n days from Easter"
+ QCheck.(pair (int_range 1600 9900) (int_range (-60) 200))
+ (fun (year, n) ->
+ match DS.easter_offset n with
+ | Error _ -> false
+ | Ok ds -> (
+ match DS.resolve ds ~year ~easter:(easter_of year) with
+ | None -> false
+ | Some d -> D.to_rata d - D.to_rata (easter_of year) = n))
+
let suite =
( "Names/Citation/DateSpec",
[ Alcotest.test_case "names basics" `Quick test_names_basics;
@@ -187,6 +304,18 @@ let suite =
Alcotest.test_case "date_spec" `Quick test_date_spec;
Alcotest.test_case "date_spec sexp roundtrip" `Quick test_date_spec_sexp_roundtrip;
Alcotest.test_case "date_spec sexp validates" `Quick test_date_spec_sexp_validates;
+ Alcotest.test_case "Easter_offset resolves" `Quick test_easter_offset_resolves;
+ Alcotest.test_case "Easter_offset 0 is Easter" `Quick test_easter_offset_zero_is_easter;
+ Alcotest.test_case "Easter_offset negative" `Quick test_easter_offset_negative;
+ Alcotest.test_case "Nth_weekday first Sunday" `Quick test_nth_weekday_first_sunday_october;
+ Alcotest.test_case "Nth_weekday last Sunday" `Quick test_nth_weekday_last_sunday_october;
+ Alcotest.test_case "Nth_weekday absent fifth" `Quick test_nth_weekday_absent_fifth;
+ Alcotest.test_case "Nth_weekday February leap edge" `Quick test_nth_weekday_february_leap_edge;
+ Alcotest.test_case "movable constructors reject nonsense" `Quick test_movable_constructors_reject_nonsense;
+ Alcotest.test_case "movable sexp parser rejects invalid" `Quick test_movable_sexp_parser_rejects_invalid;
+ Alcotest.test_case "movable sexp roundtrip" `Quick test_movable_sexp_roundtrip;
Alcotest.test_case "celebration" `Quick test_celebration;
Alcotest.test_case "celebration status" `Quick test_celebration_status;
- Alcotest.test_case "record" `Quick test_record ] )
+ Alcotest.test_case "record" `Quick test_record ]
+ @ List.map QCheck_alcotest.to_alcotest
+ [ prop_nth_weekday_lands_correctly; prop_easter_offset_is_exactly_that_offset ] )
diff --git a/test/test_overlay.ml b/test/test_overlay.ml
index b5edd5a..f3d1aee 100644
--- a/test/test_overlay.ml
+++ b/test/test_overlay.ml
@@ -1,4 +1,7 @@
module L = Colitur_kernel.Layer
+
+let mkdate y m d =
+ match Colitur_kernel.Date.make ~year:y ~month:m ~day:d with Ok x -> x | Error e -> failwith e
module Cel = Colitur_kernel.Celebration
module S = Colitur_kernel.Slug
module DS = Colitur_kernel.Date_spec
@@ -47,9 +50,9 @@ let test_layer_basics () =
(List.map (fun e -> S.to_string e.L.cel.Cel.slug) l.L.entries)
let test_layer_index () =
- let idx = L.index_by_date (base ()) in
- Alcotest.(check int) "Jan 14 has one" 1 (List.length (L.on_date idx ~month:1 ~day:14));
- Alcotest.(check int) "Jan 20 has none" 0 (List.length (L.on_date idx ~month:1 ~day:20))
+ let idx = L.index (base ()) ~easter:Colitur_kernel.Computus.gregorian_easter ~years:[ 2026 ] in
+ Alcotest.(check int) "Jan 14 has one" 1 (List.length (L.on_date idx (mkdate 2026 1 14)));
+ Alcotest.(check int) "Jan 20 has none" 0 (List.length (L.on_date idx (mkdate 2026 1 20)))
(* Three entries sharing a date, inserted deliberately out of canonical
(by-slug) order: this is what actually pins accumulation (the bucket must
@@ -61,8 +64,8 @@ let test_layer_index_same_date () =
L.of_entries ~id:"base" ~name:"Test same-date"
[ entry ~month:3 ~day:17 "zeta"; entry ~month:3 ~day:17 "alpha"; entry ~month:3 ~day:17 "mu" ]
in
- let idx = L.index_by_date l in
- let bucket = L.on_date idx ~month:3 ~day:17 in
+ let idx = L.index l ~easter:Colitur_kernel.Computus.gregorian_easter ~years:[ 2026 ] in
+ let bucket = L.on_date idx (mkdate 2026 3 17) in
Alcotest.(check int) "three entries share Mar 17" 3 (List.length bucket);
Alcotest.(check (list string)) "bucket sorted by slug" [ "alpha"; "mu"; "zeta" ]
(List.map (fun e -> S.to_string e.L.cel.Cel.slug) bucket)
diff --git a/test/test_rite_ef.ml b/test/test_rite_ef.ml
index a014977..4da6d73 100644
--- a/test/test_rite_ef.ml
+++ b/test/test_rite_ef.ml
@@ -261,7 +261,7 @@ let test_barbara_added () =
| None -> Alcotest.fail "slug barbara not found in the overlaid layer (ef-sanctoral-audit Add missing?)"
| Some e ->
Alcotest.(check bool) "barbara: 4 December" true
- (match e.Layer.date with Date_spec.Fixed { month = 12; day = 4 } -> true | Date_spec.Fixed _ -> false);
+ (match e.Layer.date with Date_spec.Fixed { month = 12; day = 4 } -> true | _ -> false);
Alcotest.(check bool) "barbara: Commemoration_only (no separate feast row in the calendarium, a bare \
\"Commemoratio\" nested under Peter Chrysologus)"
true (e.Layer.cel.Cel.status = Cel.Commemoration_only);
diff --git a/test/test_sanctoral_ef.ml b/test/test_sanctoral_ef.ml
index 2024dc2..ebf96dc 100644
--- a/test/test_sanctoral_ef.ml
+++ b/test/test_sanctoral_ef.ml
@@ -91,7 +91,7 @@ let test_load_and_counts () =
leap year (2028: divisible by 4, not by 100). *)
List.iter
(fun e ->
- match DS.resolve e.L.date ~year:2028 with
+ 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
diff --git a/test/test_validate.ml b/test/test_validate.ml
index 1a960df..4d5458f 100644
--- a/test/test_validate.ml
+++ b/test/test_validate.ml
@@ -321,6 +321,11 @@ module Synthetic = struct
?(transfer_target = fun _ origin _ -> origin) ?(readings = readings) temporal :
(season, rank) Rite.t =
{ Rite.id = "synthetic"; vocab; year_start; temporal; anchors; rules; season_runs;
+ (* Not a Roman rite, but a Rite.t must supply SOME Easter now that
+ movable Date_spec variants exist. The Gregorian one is as good as
+ any for a fixture; nothing here is Easter-relative, so the value
+ is never actually read. *)
+ easter = Colitur_kernel.Computus.gregorian_easter;
transfer_target; readings }
(* Empty by default: every check built before Task 12 exercises the