module N = Colitur_kernel.Names module L = Colitur_kernel.Lang module C = Colitur_kernel.Citation module DS = Colitur_kernel.Date_spec module D = Colitur_kernel.Date let lang s = L.of_string_exn s let test_names_basics () = let n = N.of_list [ (lang "pl", "Wielkanoc"); (lang "la", "Pascha") ] in Alcotest.(check (option string)) "find la" (Some "Pascha") (N.find n (lang "la")); Alcotest.(check (option string)) "find en" None (N.find n (lang "en")); Alcotest.(check (option string)) "fallback la then en" (Some "Pascha") (N.find_first n [ lang "en"; lang "la" ]); (* canonical order is by language code, so sexp output is byte-stable *) Alcotest.(check (list string)) "canonical order" [ "la"; "pl" ] (List.map (fun (l, _) -> L.to_string l) (N.to_list n)) let test_names_set_remove () = let n = N.of_list [ (lang "la", "Pascha") ] in let n = N.set n (lang "en") "Easter" in Alcotest.(check (option string)) "set en" (Some "Easter") (N.find n (lang "en")); let n = N.set n (lang "en") "Easter Sunday" in Alcotest.(check (option string)) "last writer wins" (Some "Easter Sunday") (N.find n (lang "en")); let n = N.remove n (lang "en") in Alcotest.(check (option string)) "removed" None (N.find n (lang "en")) let test_names_of_list_duplicates () = (* of_list with duplicate language: last entry wins *) let n = N.of_list [ (lang "en", "Easter"); (lang "en", "Easter Sunday") ] in Alcotest.(check (option string)) "of_list last wins" (Some "Easter Sunday") (N.find n (lang "en")) let test_names_sexp_canonical () = (* Two Names.t with different input order must serialize identically *) let n1 = N.of_list [ (lang "pl", "Wielkanoc"); (lang "la", "Pascha"); (lang "en", "Easter") ] in let n2 = N.of_list [ (lang "en", "Easter"); (lang "pl", "Wielkanoc"); (lang "la", "Pascha") ] in Alcotest.(check bool) "same sexp despite different input order" true (N.sexp_of_t n1 = N.sexp_of_t n2) let test_names_sexp_roundtrip () = let n = N.of_list [ (lang "la", "Pascha"); (lang "en", "Easter") ] in let sexp = N.sexp_of_t n in let n' = N.t_of_sexp sexp in Alcotest.(check bool) "sexp roundtrip" true (n = n') let test_citation () = let c = { C.part = C.Gospel; reference = "Jn 3:16" } in Alcotest.(check bool) "sexp roundtrip" true (C.t_of_sexp (C.sexp_of_t c) = c); Alcotest.(check string) "part string" "gospel" (C.part_to_string C.Gospel); Alcotest.(check bool) "part of_string" true (C.part_of_string "first" = Some C.First) let test_date_spec () = (match DS.fixed ~month:3 ~day:25 with | Ok ds -> ( match DS.resolve ds ~year: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) | 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)); Alcotest.(check bool) "reject month 13" true (Result.is_error (DS.fixed ~month:13 ~day:1)) let test_date_spec_sexp_roundtrip () = (match DS.fixed ~month:3 ~day:25 with | Ok ds -> let sexp = DS.sexp_of_t ds in let ds' = DS.t_of_sexp sexp in Alcotest.(check bool) "sexp roundtrip" true (ds = ds') | Error e -> Alcotest.failf "fixed: %s" e) let test_date_spec_sexp_validates () = (* Register finding 6: [ppx_sexp_conv]'s plain derived [t_of_sexp] would accept any in-range int pair for [Fixed], so [(Fixed(month 13)(day 1))] would silently deserialise into a spec that simply never resolves -- a saint quietly vanishing with no diagnostic. [t_of_sexp] now re-runs the value through [fixed], matching how [Slug] and [Lang] already validate on load. *) let bad = Sexplib.Sexp.of_string "(Fixed(month 13)(day 1))" in Alcotest.check_raises "month 13 rejected at load" (Sexplib0.Sexp_conv_error.Of_sexp_error (Failure "date_spec: month 13 out of range 1..12", bad)) (fun () -> ignore (DS.t_of_sexp bad)); let bad_day = Sexplib.Sexp.of_string "(Fixed(month 4)(day 31))" in Alcotest.check_raises "31 April rejected at load" (Sexplib0.Sexp_conv_error.Of_sexp_error (Failure "date_spec: day 31 out of range for month 4", bad_day)) (fun () -> ignore (DS.t_of_sexp bad_day)) module Cel = Colitur_kernel.Celebration module S = Colitur_kernel.Slug module Col = Colitur_kernel.Colour module Sub = Colitur_kernel.Subject (* A throwaway rank vocabulary, to exercise the parametric type. *) type demo_rank = High | Low [@@deriving sexp] let test_celebration () = let c = Cel.make ~slug:(S.of_string_exn "ef-easter-sunday") ~names:(N.of_list [ (lang "la", "Dominica Resurrectionis") ]) ~rank:High ~colour:Col.White ~subject:Sub.Lord ~layer:"temporal" () in Alcotest.(check string) "slug" "ef-easter-sunday" (S.to_string c.Cel.slug); Alcotest.(check bool) "default citations empty" true (c.Cel.citations = []); let sexp = Cel.sexp_of_t sexp_of_demo_rank c in Alcotest.(check bool) "sexp roundtrip" true (Cel.t_of_sexp demo_rank_of_sexp sexp = c) module Rec = Colitur_kernel.Record module Voc = Colitur_kernel.Vocab module Tmp = Colitur_kernel.Temporal type demo_season = Ordinary [@@deriving sexp] let demo_vocab : (demo_season, demo_rank) Voc.t = { seasons = [ Ordinary ]; season_to_string = (fun Ordinary -> "ordinary"); season_of_string = (function "ordinary" -> Some Ordinary | _ -> None); ranks = [ High; Low ]; rank_to_string = (function High -> "high" | Low -> "low"); rank_of_string = (function "high" -> Some High | "low" -> Some Low | _ -> None) } let test_record () = let date = match D.make ~year:2026 ~month:4 ~day:5 with | Ok d -> d | Error e -> Alcotest.failf "%s" e in let office = Cel.make ~slug:(S.of_string_exn "ef-easter-sunday") ~names:(N.of_list [ (lang "la", "Dominica Resurrectionis") ]) ~rank:High ~colour:Col.White ~subject:Sub.Lord ~layer:"temporal" () in let t = { Tmp.season = Ordinary; week = Some 1; weekday = D.Sun; office } in let r = Rec.of_temporal ~rite:"ef" demo_vocab date t in Alcotest.(check string) "date" "2026-04-05" r.Rec.date; Alcotest.(check string) "season" "ordinary" r.Rec.season; Alcotest.(check string) "week" "1" r.Rec.week; Alcotest.(check string) "weekday" "sunday" r.Rec.weekday; Alcotest.(check string) "rank" "high" r.Rec.rank; Alcotest.(check string) "colour" "white" r.Rec.colour; Alcotest.(check string) "subject" "lord" r.Rec.subject; Alcotest.(check (list string)) "names flattened" [ "la" ] (List.map fst r.Rec.names); (* A day outside a numbered week renders week as the empty string, not "0". *) let r' = Rec.of_temporal ~rite:"ef" demo_vocab date { t with Tmp.week = None } in Alcotest.(check string) "no week" "" r'.Rec.week; (* Schema is pinned: headers and to_row derive from a single columns list, so alignment cannot drift. Assert the column names in order. *) Alcotest.(check (list string)) "headers schema" [ "date"; "rite"; "season"; "week"; "weekday"; "slug"; "rank"; "colour"; "subject" ] Rec.headers; (* Register finding 14: with headers and to_row both derived from the same [columns] list, equal length holds by construction -- a length-only check here is vacuous, since it cannot fail without headers and to_row already being defined from different sources. Assert the row's actual values, in header order, instead. *) Alcotest.(check (list string)) "row values in header order" [ "2026-04-05"; "ef"; "ordinary"; "1"; "sunday"; "ef-easter-sunday"; "high"; "white"; "lord" ] (Rec.to_row r) let suite = ( "Names/Citation/DateSpec", [ Alcotest.test_case "names basics" `Quick test_names_basics; Alcotest.test_case "names set/remove" `Quick test_names_set_remove; Alcotest.test_case "names of_list duplicates" `Quick test_names_of_list_duplicates; Alcotest.test_case "names sexp canonical" `Quick test_names_sexp_canonical; Alcotest.test_case "names sexp roundtrip" `Quick test_names_sexp_roundtrip; Alcotest.test_case "citation" `Quick test_citation; 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 "celebration" `Quick test_celebration; Alcotest.test_case "record" `Quick test_record ] )