aboutsummaryrefslogtreecommitdiff
path: root/test
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-21 22:39:12 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-21 22:39:12 +0200
commit384b0789c0f4d9beb936080a5592bb4aa6134295 (patch)
tree943842766c2dd09761e1d449cb3b1c56346e8dae /test
parent2ac3e3e8fcc66e20bf1687ae15f033d20b02216d (diff)
downloadcolitur-384b0789c0f4d9beb936080a5592bb4aa6134295.tar.gz
colitur-384b0789c0f4d9beb936080a5592bb4aa6134295.zip
feat(kernel,ef): the lectionary reports which Mass it said
Rite.readings now returns (Mass_formulary.t option * Citation.t list) instead of a bare citation list, and Liturgical_day.t carries the result as a new formulary field. Validate holds a rite that resolves a formulary at all to resolving one on every day, the same discipline it already applies to citations; the EF lectionary chain resolves Some on every day of every year 1583-9999, confirmed by a direct sweep over 2005-2050 as well as through Validate itself. Plan Tasks 2 and 3 are merged into this one commit on the coordinator's own instruction: Rite.readings' signature and the field that consumes it are one atomic edit, and the intermediate state does not compile on its own. Each of the four lectionary steps now builds its own Mass_formulary.t at the point it decides, not by re-deriving it afterwards from the citations it returns: step 1 tags Proper with the observed slug, step 2 tags Own_slug with the day's own temporal slug, step 3 tags Preceding_sunday with the resumed Sunday's temporal slug, and step 4 tags Common with the Common's own id -- Commons.find now returns that id alongside its citations rather than discarding it, since it is only ever in scope at the point the assignment is looked up. The RG 309(a) Saturday votive Mass of Our Lady, which answers between steps 4 and 2 rather than as one of the four numbered steps, is tagged Own_slug too: Mass_formulary.source has no dedicated constructor for it, and its own guard only ever fires when the observed celebration already is the day's own (reused ferial) temporal slug, which is exactly what Own_slug documents. Recorded as a judgement call in the task report, not a specified answer. test/cli.t's `emit --format sexp` line count is repinned (8472 to 8881): that command serializes Liturgical_day.t whole, so the new field grows its output. `colitur day` itself is untouched -- verified byte-identical against the pre-change binary across 1583, 1900, 2026, 2038 and 9999.
Diffstat (limited to 'test')
-rw-r--r--test/cli.t2
-rw-r--r--test/test_calendar.ml7
-rw-r--r--test/test_lectionary_ef.ml51
-rw-r--r--test/test_validate.ml109
4 files changed, 152 insertions, 17 deletions
diff --git a/test/cli.t b/test/cli.t
index 2e9a0e2..bdc19c8 100644
--- a/test/cli.t
+++ b/test/cli.t
@@ -452,7 +452,7 @@ CSV run rather than one per year:
sexp and xml are also available:
$ colitur emit --format sexp --from 2027 --to 2027 | wc -l
- 8472
+ 8881
$ colitur emit --format xml --from 2027 --to 2027 | head -2
<?xml version="1.0" encoding="UTF-8"?>
diff --git a/test/test_calendar.ml b/test/test_calendar.ml
index 132a466..599dfb2 100644
--- a/test/test_calendar.ml
+++ b/test/test_calendar.ml
@@ -99,9 +99,10 @@ module Fixture = struct
let rec search d = if (occupant d).Cel.rank = Lo then d else search (D.add_days d 1) in
search (D.add_days origin 1)
- (* No fixture here exercises citations -- readings is a harmless constant
- [], the same role [empty_layer] plays for the sanctoral side. *)
- let readings ~observed:_ ~temporal:_ ~date:_ ~temporal_at:_ = []
+ (* No fixture here exercises citations or the formulary -- readings is a
+ harmless constant [(None, [])], the same role [empty_layer] plays for
+ the sanctoral side. *)
+ let readings ~observed:_ ~temporal:_ ~date:_ ~temporal_at:_ = (None, [])
let rite : (season, rank) Rite.t =
{ Rite.id = "synthetic-calendar"; vocab; year_start; temporal; anchors = (fun _ -> []);
diff --git a/test/test_lectionary_ef.ml b/test/test_lectionary_ef.ml
index d56d187..bc2be8c 100644
--- a/test/test_lectionary_ef.ml
+++ b/test/test_lectionary_ef.ml
@@ -467,7 +467,7 @@ let test_step4_unreachable_commons_still_resolve () =
let for_saint s = Lectionary_ef.commons_for ~commons (Slug.of_string_exn s) in
let refs_of = function
| None -> [ "<no common>" ]
- | Some cs -> List.map (fun c -> c.Citation.reference) cs
+ | Some (_id, cs) -> List.map (fun c -> c.Citation.reference) cs
in
Alcotest.(check (list string))
"St Benedict (21 March), Common of Abbots"
@@ -580,6 +580,51 @@ let test_commons_load_rejects_bad_data () =
(Slug.of_string_exn "benedict")
= None)
+(* ---------------------------------------------------------------------- *)
+(* The formulary itself (Task 2): each step of the chain now reports HOW *)
+(* it resolved, not only what it resolved. One day per step -- the same *)
+(* dates this file already uses (and hand-verifies) elsewhere for the *)
+(* citations those days carry, so no new date needs independent checking. *)
+(* ---------------------------------------------------------------------- *)
+
+let formulary_cases =
+ [ (* step 1: a saint with his own proper -- same date as
+ [test_step1_proper_beats_any_common_john_of_god]. *)
+ (2038, 3, 8, "john-of-god", Colitur_kernel.Mass_formulary.Proper);
+ (* step 2: the day's own temporal slug -- same date as
+ [test_step2_lenten_feria_has_its_own], Monday of Lent I. *)
+ (2026, 2, 23, "ef-lent-1-monday", Colitur_kernel.Mass_formulary.Own_slug);
+ (* step 3: a feria resuming the preceding Sunday. The task brief's own
+ snippet pinned this date against week 9 ("ef-time-after-pentecost-
+ sunday-9"); running the real resolver against 2026 shows 3 August
+ 2026 is Monday of week 10, resuming 2 August's "...sunday-10" --
+ corrected per the brief's own "find the dates by running the current
+ binary if they drift" instruction. *)
+ (2026, 8, 3, "ef-time-after-pentecost-sunday-10",
+ Colitur_kernel.Mass_formulary.Preceding_sunday);
+ (* step 4: a saint sent to a Common -- same date as
+ [test_step4_commons_perpetua_and_felicity]; [said] is the Common's
+ OWN id (data/ef/commons.sexp), not the saint's slug. *)
+ (2038, 3, 6, "common-of-non-virgins-1", Colitur_kernel.Mass_formulary.Common) ]
+
+let test_formulary_reports_its_source () =
+ List.iter
+ (fun (y, m, d, expected_slug, expected_via) ->
+ let day = day y m d in
+ match day.Colitur_kernel.Liturgical_day.formulary with
+ | None -> Alcotest.failf "%04d-%02d-%02d: no formulary" y m d
+ | Some f ->
+ Alcotest.(check string)
+ (Printf.sprintf "%04d-%02d-%02d slug" y m d)
+ expected_slug
+ (Colitur_kernel.Slug.to_string f.Colitur_kernel.Mass_formulary.said);
+ Alcotest.(check string)
+ (Printf.sprintf "%04d-%02d-%02d source" y m d)
+ (Colitur_kernel.Mass_formulary.source_to_string expected_via)
+ (Colitur_kernel.Mass_formulary.source_to_string
+ f.Colitur_kernel.Mass_formulary.via))
+ formulary_cases
+
let suite =
[ ("step 1: sanctoral proper", `Quick, test_step1_sanctoral_proper);
("step 2: own temporal proper", `Quick, test_step2_lenten_feria_has_its_own);
@@ -618,4 +663,6 @@ let suite =
("the five propers no real year reaches are present", `Quick,
test_step4_unreachable_propers_are_present);
("Commons.load rejects the four silent-degradation defects", `Quick,
- test_commons_load_rejects_bad_data) ]
+ test_commons_load_rejects_bad_data);
+ ("the formulary reports its own source, one day per step", `Quick,
+ test_formulary_reports_its_source) ]
diff --git a/test/test_validate.ml b/test/test_validate.ml
index 8b3014a..7387971 100644
--- a/test/test_validate.ml
+++ b/test/test_validate.ml
@@ -300,15 +300,16 @@ module Synthetic = struct
defaults against the default empty [layer], since nothing ever contests
the temporal office there) so the resolution fixtures further down can
override them without duplicating every other field. *)
- (* Most fixtures here exercise no citations -- [readings] is a harmless
- constant [], the same role the other placeholder defaults above play,
- and {!Validate}'s own citation checks are gated on a rite producing SOME
- citation somewhere, so a constant [] leaves them entirely dormant. Task
+ (* Most fixtures here exercise no citations and no formulary -- [readings]
+ is a harmless constant [(None, [])], the same role the other
+ placeholder defaults above play, and {!Validate}'s own citation and
+ formulary checks are gated on a rite producing SOME citation/formulary
+ somewhere, so a constant [(None, [])] leaves them entirely dormant. Task
10 makes it overridable ([?readings] below) so the citation fixtures at
the end of this file can drive those checks directly, exactly as every
- other check here is driven -- rather than leaving two kernel checks with
- no committed proof that they can fire at all. *)
- let readings ~observed:_ ~temporal:_ ~date:_ ~temporal_at:_ = []
+ other check here is driven -- rather than leaving kernel checks with no
+ committed proof that they can fire at all. *)
+ let readings ~observed:_ ~temporal:_ ~date:_ ~temporal_at:_ = (None, [])
(* The shape {!Validate} accepts: exactly one First and one Gospel. The
references are deliberately nonsense -- these fixtures assert SHAPE,
@@ -317,6 +318,12 @@ module Synthetic = struct
[ { Citation.part = Citation.First; reference = "Synth 1:1" };
{ Citation.part = Citation.Gospel; reference = "Synth 2:2" } ]
+ (* The formulary equivalent of [well_formed_citations] above -- shape only,
+ never rubrically meaningful content. *)
+ let well_formed_formulary =
+ { Colitur_kernel.Mass_formulary.said = Slug.of_string_exn "syn-formulary";
+ via = Colitur_kernel.Mass_formulary.Own_slug }
+
let rite ?(vocab = vocab) ?(anchors = fun _ -> []) ?(season_runs = [ A; B ]) ?(rules = rules)
?(transfer_target = fun _ origin _ -> origin) ?(readings = readings) temporal :
(season, rank) Rite.t =
@@ -759,7 +766,7 @@ let test_citations_silent_without_a_lectionary () =
(* The positive: well-formed citations on every day report nothing. *)
let test_citations_clean_when_well_formed () =
- let readings ~observed:_ ~temporal:_ ~date:_ ~temporal_at:_ = well_formed_citations in
+ let readings ~observed:_ ~temporal:_ ~date:_ ~temporal_at:_ = (None, well_formed_citations) in
let fs = run ~readings good in
Alcotest.(check bool) "neither citation check fires when every day carries First + Gospel" false
(has_check "citations" fs || has_check "citations-unresolved" fs)
@@ -769,7 +776,7 @@ let test_citations_clean_when_well_formed () =
plausibly produce -- half a lookup succeeding. *)
let test_citations_fires_on_a_lone_epistle () =
let readings ~observed:_ ~temporal:_ ~date:_ ~temporal_at:_ =
- [ { Citation.part = Citation.First; reference = "Synth 1:1" } ]
+ (None, [ { Citation.part = Citation.First; reference = "Synth 1:1" } ])
in
Alcotest.(check bool) "citations check fires when a day carries an Epistle but no Gospel" true
(has_check "citations" (run ~readings good))
@@ -780,7 +787,7 @@ let test_citations_fires_on_a_lone_epistle () =
even though the day is otherwise a well-formed pair. *)
let test_citations_fires_on_an_out_of_scope_part () =
let readings ~observed:_ ~temporal:_ ~date:_ ~temporal_at:_ =
- { Citation.part = Citation.Tract; reference = "Synth 3:3" } :: well_formed_citations
+ (None, { Citation.part = Citation.Tract; reference = "Synth 3:3" } :: well_formed_citations)
in
Alcotest.(check bool) "citations check fires when a part outside First/Gospel appears" true
(has_check "citations" (run ~readings good))
@@ -792,7 +799,7 @@ let test_citations_fires_on_an_out_of_scope_part () =
this file singles out. *)
let test_citations_unresolved_fires_on_a_gap () =
let readings ~observed:_ ~temporal:_ ~date ~temporal_at:_ =
- if D.compare date target = 0 then [] else well_formed_citations
+ if D.compare date target = 0 then (None, []) else (None, well_formed_citations)
in
Alcotest.(check bool) "citations-unresolved fires when one day of the year resolves nothing" true
(has_check "citations-unresolved" (run ~readings good));
@@ -801,6 +808,79 @@ let test_citations_unresolved_fires_on_a_gap () =
Alcotest.(check bool) "the well-formedness check stays silent on a pure coverage gap" false
(has_check "citations" (run ~readings good))
+(* ---------------------------------------------------------------------- *)
+(* The formulary invariant (Task 3, celebrant-rubrics-phase1): the same *)
+(* negative-path discipline the citation checks above already hold *)
+(* themselves to, driven through the same synthetic fixture. Model on the *)
+(* citations trio above -- "same shape, its own name" is what {!Validate} *)
+(* itself now does, so the tests proving it can fire follow the same *)
+(* pattern. *)
+(* ---------------------------------------------------------------------- *)
+
+(* The gate: a rite that resolves no formulary at all (the default constant
+ [(None, [])]) must report nothing -- not "usually", not "on this year". *)
+let test_formulary_silent_without_a_lectionary () =
+ let fs = run good in
+ Alcotest.(check bool) "no formulary check fires for a rite with no readings at all" false
+ (has_check "formulary" fs)
+
+(* The positive: a formulary on every day reports nothing. *)
+let test_formulary_clean_when_well_formed () =
+ let readings ~observed:_ ~temporal:_ ~date:_ ~temporal_at:_ =
+ (Some well_formed_formulary, well_formed_citations)
+ in
+ Alcotest.(check bool) "formulary check stays silent when every day resolves one" false
+ (has_check "formulary" (run ~readings good))
+
+(* The coverage gap: a rite that resolves a formulary on most days but falls
+ through on one. On real EF data this has no witness at all (Task 3's own
+ [test_every_day_has_a_formulary] below confirms it directly), so this
+ fixture is the only thing that holds it honest. *)
+let test_formulary_fires_on_a_gap () =
+ let readings ~observed:_ ~temporal:_ ~date ~temporal_at:_ =
+ if D.compare date target = 0 then (None, [])
+ else (Some well_formed_formulary, well_formed_citations)
+ in
+ Alcotest.(check bool) "formulary check fires when one day of the year resolves none" true
+ (has_check "formulary" (run ~readings good))
+
+(* ---------------------------------------------------------------------- *)
+(* Direct real-EF-data coverage (Task 3 brief): every day of every year in *)
+(* the sample resolves a formulary, the same discipline the "citations" *)
+(* checks already hold EF to -- asserted directly against *)
+(* [Liturgical_day.t] rather than through [Validate.run]'s failure list, *)
+(* so a bug in [Validate]'s own gating could not hide this gap. *)
+(* ---------------------------------------------------------------------- *)
+
+(* 2005-2050: the same 46-year sample test_rite_ef.ml's own [sample_years]
+ uses, for the same reason -- non-trivial, deterministic, and already the
+ project's differential-testing window (CLAUDE.md). Defined locally
+ rather than shared: test executables in this project cross-reference
+ only [.suite] values (test_colitur.ml), never each other's internal
+ helpers. *)
+let sample_years =
+ let rec range a b = if a > b then [] else a :: range (a + 1) b in
+ range 2005 2050
+
+let year_of y = Colitur_kernel.Calendar.year real_ef_rite real_ef_layer y
+
+(* Every day of every year resolves a formulary, for the same reason
+ [Validate] already asserts exactly one First and one Gospel: a day that
+ says no Mass at all is a defect, not a gap. Mirrors the "citations"
+ check. *)
+let test_every_day_has_a_formulary () =
+ let missing = ref [] in
+ List.iter
+ (fun y ->
+ Array.iter
+ (fun (d : (_, _) Colitur_kernel.Liturgical_day.t) ->
+ if d.Colitur_kernel.Liturgical_day.formulary = None then
+ missing :=
+ Colitur_kernel.Date.to_iso8601 d.Colitur_kernel.Liturgical_day.date :: !missing)
+ (year_of y))
+ sample_years;
+ Alcotest.(check (list string)) "every day resolves a formulary" [] !missing
+
let suite =
( "Validate",
[ Alcotest.test_case "landmark years" `Quick test_landmark_years;
@@ -830,6 +910,13 @@ let suite =
test_citations_fires_on_an_out_of_scope_part;
Alcotest.test_case "citations-unresolved fires on a gap" `Quick
test_citations_unresolved_fires_on_a_gap;
+ Alcotest.test_case "formulary silent without a lectionary" `Quick
+ test_formulary_silent_without_a_lectionary;
+ Alcotest.test_case "formulary clean when well formed" `Quick
+ test_formulary_clean_when_well_formed;
+ Alcotest.test_case "formulary fires on a gap" `Quick test_formulary_fires_on_a_gap;
+ Alcotest.test_case "every day (2005-2050) resolves a formulary" `Quick
+ test_every_day_has_a_formulary;
Alcotest.test_case "lost fires on resolution exception" `Quick test_lost_fires_on_resolution_exception;
Alcotest.test_case "duplicated fires" `Quick test_duplicated_fires;
Alcotest.test_case "unconverged fires" `Quick test_unconverged_fires;