aboutsummaryrefslogtreecommitdiff
path: root/test
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-18 13:59:26 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-18 13:59:26 +0200
commit266b10d6cd2d44b4bccc36318054408ded80b8ce (patch)
treefa7e5bdd618902fd1bcfc34318395a181f531254 /test
parent150f8c550d2f9ebbac61d195eb342eb2b2ec6b95 (diff)
parent36daf47dde9b0c16dacef31163ea74effdd5e7f3 (diff)
downloadcolitur-266b10d6cd2d44b4bccc36318054408ded80b8ce.tar.gz
colitur-266b10d6cd2d44b4bccc36318054408ded80b8ce.zip
merge: a flat INI overlay front end
A convenience format for simple local calendars, transpiled to the existing S-expression form and verified against it before emitting.
Diffstat (limited to 'test')
-rw-r--r--test/cli.t19
-rw-r--r--test/test_colitur.ml2
-rw-r--r--test/test_overlay_ini.ml152
3 files changed, 172 insertions, 1 deletions
diff --git a/test/cli.t b/test/cli.t
index d735e69..d981d88 100644
--- a/test/cli.t
+++ b/test/cli.t
@@ -373,3 +373,22 @@ to empty and to the overlay's own id:
tiny.sexp: ok -- overlay tiny, 1 directive(s): 1 add, 0 suppress, 0 replace, 0 edit
every directive found its target
add tiny-feast
+
+`convert` turns the flat INI form into the S-expression one and verifies its
+own output before emitting it. The full pipeline, INI to a resolved day:
+
+ $ printf '[overlay]\nid = my-parish\n[our-patron]\ndate = 07-11\nrank = class-3\ncolour = white\nname.en = St Example\n' > p.ini
+ $ colitur convert p.ini > p.sexp
+ $ colitur check p.sexp
+ p.sexp: ok -- overlay my-parish, 1 directive(s): 1 add, 0 suppress, 0 replace, 0 edit
+ every directive found its target
+ add our-patron
+ $ colitur day 2026 --overlay p.sexp | grep '^2026-07-11'
+ 2026-07-11 saturday time-after-pentecost 6 our-patron class-3 white +pius-i
+
+What the INI form cannot express is refused by name, not dropped silently:
+
+ $ printf '[overlay]\nid = x\n[y]\nreplace = yes\n' > r.ini
+ $ colitur convert r.ini
+ colitur: r.ini: section [y]: replace is not expressible in the INI form -- it needs a whole entry, which is what the S-expression form is for (see colitur-overlay(5)). Suppress plus a fresh section is usually what you want instead.
+ [2]
diff --git a/test/test_colitur.ml b/test/test_colitur.ml
index 1e6b7ed..76ff685 100644
--- a/test/test_colitur.ml
+++ b/test/test_colitur.ml
@@ -2,7 +2,7 @@
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_overlay.suite; Test_overlay_ini.suite; Test_temporal_ef.suite; Test_validate.suite; Test_precedence.suite;
Test_calendar.suite; Test_precedence_ef.suite; Test_sanctoral_ef.suite; Test_rite_ef.suite;
Test_differential.suite; Test_oracle.suite; Test_oracle.suite_2038; Test_oracle.suite_2035; Test_golden.suite;
("lectionary", Test_lectionary.suite);
diff --git a/test/test_overlay_ini.ml b/test/test_overlay_ini.ml
new file mode 100644
index 0000000..65e681a
--- /dev/null
+++ b/test/test_overlay_ini.ml
@@ -0,0 +1,152 @@
+(* The INI overlay front end. Its contract is narrow and the tests follow it:
+ the flat form parses to exactly the Overlay.t the sexp form would, the
+ conversion PROVES that before emitting, and everything it cannot express is
+ refused by name rather than silently dropped. *)
+module OI = Colitur_kernel.Overlay_ini
+module O = Colitur_kernel.Overlay
+module L = Colitur_kernel.Layer
+module Cel = Colitur_kernel.Celebration
+module DS = Colitur_kernel.Date_spec
+module S = Colitur_kernel.Slug
+module Col = Colitur_kernel.Colour
+module Sub = Colitur_kernel.Subject
+module V = Rite_ef.Vocab_ef
+
+let parse = OI.parse ~rank_of_string:V.rank_of_string
+
+let convert =
+ OI.convert ~rank_of_string:V.rank_of_string ~rank_to_sexp:V.sexp_of_rank
+ ~rank_of_sexp:V.rank_of_sexp
+
+let ok_exn = function Ok v -> v | Error e -> Alcotest.failf "expected Ok, got: %s" e
+let err_exn = function Error e -> e | Ok _ -> Alcotest.fail "expected Error, got Ok"
+
+let minimal =
+ {|[overlay]
+id = my-parish
+[our-patron]
+date = 07-11
+rank = class-3
+colour = white
+name.en = St Example|}
+
+let test_minimal () =
+ let t = ok_exn (parse minimal) in
+ Alcotest.(check string) "id" "my-parish" t.O.id;
+ match t.O.directives with
+ | [ O.Add e ] ->
+ Alcotest.(check string) "slug" "our-patron" (S.to_string e.L.cel.Cel.slug);
+ Alcotest.(check bool) "colour" true (e.L.cel.Cel.colour = Col.White);
+ (* status and subject default rather than being required: a local feast
+ is overwhelmingly an ordinary saint's feast, and making the common
+ case silent is the whole point of this format. *)
+ Alcotest.(check bool) "status defaults to Feast" true (e.L.cel.Cel.status = Cel.Feast);
+ Alcotest.(check bool) "subject defaults to Saint" true (e.L.cel.Cel.subject = Sub.Saint);
+ (* layer defaults to the file's own id, exactly as the sexp form does *)
+ Alcotest.(check string) "layer" "my-parish" e.L.cel.Cel.layer
+ | _ -> Alcotest.fail "expected one Add"
+
+(* All three Date_spec shapes, since a flat string has to encode what the sexp
+ form spells out, and getting one wrong would be silent. *)
+let test_date_forms () =
+ let one date =
+ match (ok_exn (parse (Printf.sprintf "[overlay]\nid=x\n[s]\ndate=%s\nrank=class-3\ncolour=white\nname.en=N" date))).O.directives with
+ | [ O.Add e ] -> e.L.date
+ | _ -> Alcotest.fail "expected one Add"
+ in
+ Alcotest.(check bool) "MM-DD" true (one "07-11" = ok_exn (DS.fixed ~month:7 ~day:11));
+ Alcotest.(check bool) "easter+N" true (one "easter+60" = ok_exn (DS.easter_offset 60));
+ Alcotest.(check bool) "easter-N" true (one "easter-46" = ok_exn (DS.easter_offset (-46)));
+ Alcotest.(check bool) "nth weekday" true
+ (one "oct/sun/1" = ok_exn (DS.nth_weekday ~month:10 ~nth:1 ~weekday:Colitur_kernel.Date.Sun));
+ Alcotest.(check bool) "nth weekday, from the end" true
+ (one "oct/sun/-1" = ok_exn (DS.nth_weekday ~month:10 ~nth:(-1) ~weekday:Colitur_kernel.Date.Sun))
+
+let test_suppress_and_edit () =
+ let t = ok_exn (parse "[overlay]\nid=x\n[barbara]\nsuppress=yes\n[stanislaus]\nedit=yes\ncolour=red") in
+ match t.O.directives with
+ | [ O.Suppress s; O.Edit (e, [ O.Set_colour Col.Red ]) ] ->
+ Alcotest.(check string) "suppress" "barbara" (S.to_string s);
+ Alcotest.(check string) "edit" "stanislaus" (S.to_string e)
+ | _ -> Alcotest.fail "expected a Suppress then an Edit"
+
+(* THE contract of this module. convert must never hand back text that means
+ something other than the INI said -- a transpiler emitting valid-but-wrong
+ sexp is the failure a convenience format invites, and `colitur check` could
+ not catch it, since the output would parse cleanly and simply differ. *)
+let test_convert_round_trips () =
+ let text = ok_exn (convert minimal) in
+ let reparsed =
+ O.t_of_sexp V.rank_of_sexp (Sexplib.Sexp.of_string (Sexplib.Sexp.to_string (Sexplib.Sexp.of_string
+ (let i = String.index text '(' in String.sub text i (String.length text - i)))))
+ in
+ Alcotest.(check bool) "emitted sexp means exactly what the INI did" true
+ (reparsed = ok_exn (parse minimal))
+
+(* Every date form, through the full convert path, so the round-trip proof
+ covers the shapes most likely to be mis-rendered rather than just one. *)
+let test_convert_round_trips_every_date_form () =
+ List.iter
+ (fun d ->
+ let src = Printf.sprintf "[overlay]\nid=x\n[s]\ndate=%s\nrank=class-3\ncolour=white\nname.en=N" d in
+ match convert src with
+ | Ok _ -> ()
+ | Error e -> Alcotest.failf "convert failed to verify its own output for date %S: %s" d e)
+ [ "07-11"; "02-29"; "easter+60"; "easter-46"; "easter+0"; "oct/sun/1"; "oct/sun/-1"; "jan/mon/3" ]
+
+let test_errors_are_values_not_exceptions () =
+ let cases =
+ [ ("[s]\ndate=07-11\nrank=class-3\ncolour=white\nname.en=N", "no [overlay] section");
+ ("[overlay]\nid=x\n[s]\nrank=class-3\ncolour=white\nname.en=N", "date is required");
+ ("[overlay]\nid=x\n[s]\ndate=07-11\ncolour=white\nname.en=N", "rank is required");
+ ("[overlay]\nid=x\n[s]\ndate=07-11\nrank=class-3\nname.en=N", "colour is required");
+ ("[overlay]\nid=x\n[s]\ndate=07-11\nrank=class-3\ncolour=white", "name.<lang>");
+ (* A date that matches no form at all falls through to the three-forms
+ message. Note 99-99 does NOT belong here: it parses as MM-DD and gets
+ the more specific "month 99 out of range 1..12", which is the better
+ error and worth not regressing. *)
+ ("[overlay]\nid=x\n[s]\ndate=whenever\nrank=class-3\ncolour=white\nname.en=N", "three forms");
+ ("[overlay]\nid=x\n[s]\ndate=99-99\nrank=class-3\ncolour=white\nname.en=N", "month 99");
+ ("[overlay]\nid=x\n[s]\nreplace=yes", "not expressible") ]
+ in
+ List.iter
+ (fun (src, needle) ->
+ let e = err_exn (parse src) in
+ let contains hay n =
+ let nl = String.length n and hl = String.length hay in
+ let rec at i = i + nl <= hl && (String.sub hay i nl = n || at (i + 1)) in
+ at 0
+ in
+ Alcotest.(check bool)
+ (Printf.sprintf "error mentions %S (got: %s)" needle e)
+ true (contains e needle))
+ cases
+
+(* An INI overlay must reach the engine by exactly the path a hand-written sexp
+ does -- if the two diverged, this format would be a second semantics rather
+ than a front door. *)
+let test_same_result_as_handwritten_sexp () =
+ let from_ini = ok_exn (parse minimal) in
+ let handwritten =
+ {|((id my-parish)
+ (directives
+ ((Add ((date (Fixed (month 7) (day 11)))
+ (cel ((slug our-patron) (names ((en "St Example")))
+ (rank Class3) (status Feast) (colour White)
+ (subject Saint) (citations ()) (layer my-parish))))))))|}
+ in
+ let from_sexp = O.t_of_sexp V.rank_of_sexp (Sexplib.Sexp.of_string handwritten) in
+ Alcotest.(check bool) "identical Overlay.t" true (from_ini = from_sexp)
+
+let suite =
+ ( "Overlay_ini",
+ [ Alcotest.test_case "minimal file, with defaults" `Quick test_minimal;
+ Alcotest.test_case "all three date forms" `Quick test_date_forms;
+ Alcotest.test_case "suppress and edit" `Quick test_suppress_and_edit;
+ Alcotest.test_case "convert verifies its own output" `Quick test_convert_round_trips;
+ Alcotest.test_case "convert verifies every date form" `Quick
+ test_convert_round_trips_every_date_form;
+ Alcotest.test_case "errors are values, never exceptions" `Quick
+ test_errors_are_values_not_exceptions;
+ Alcotest.test_case "an INI overlay equals the hand-written sexp" `Quick
+ test_same_result_as_handwritten_sexp ] )