aboutsummaryrefslogtreecommitdiff
path: root/test/test_ics.ml
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-19 11:48:30 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-19 11:48:30 +0200
commit6762ce46af3cb12bc6ae37cda762c5d95add7903 (patch)
treebc1d8c86050d0149ff961a9a4ff838f9c474ac2a /test/test_ics.ml
parent897c274fd28402159ca6d45eedc1257b1ce98696 (diff)
parent390bc6ac5196a946c473d0dbe7760fa41837c428 (diff)
downloadcolitur-6762ce46af3cb12bc6ae37cda762c5d95add7903.tar.gz
colitur-6762ce46af3cb12bc6ae37cda762c5d95add7903.zip
feat: output, rendering and publishing
Gives colitur a publishable exit. Until now its only output was terminal rows; it can now print an ordo booklet and a wall calendar, publish an iCalendar feed people subscribe to, and serve a static JSON/XML API. lib/render escaping (six flavours + RFC 5545 folding), a deliberately logic-less template engine, the view model, and five emitters (CSV, JSON, XML, iCalendar, S-expression) CLI emit, table, render, publish -- all accepting --overlay templates ordo booklet in six flavours, wall grid in three schema day-v1.json and colitur-v1.xsd, the published contract man colitur-templates.5, plus colitur.1 updates The view model is why the engine can stay logic-less: a month grid needs leading blank cells, week bucketing and an in-month test, and a logic-less template can compute none of it. Shaping the data in OCaml keeps the engine safe for untrusted templates and makes the grid trivial. Formats split by whether correctness is mechanical. Presentation goes through templates; iCalendar and XML get dedicated emitters, because folding, exclusive DTEND, stable UIDs and schema fidelity are rules a template cannot enforce and each fails silently in a subscriber's client rather than loudly at generation. publish is deterministic and non-destructive: two runs produce a byte-identical tree, and --prune removes only files a previous run created, refusing any manifest entry that escapes the output directory. No new dependencies. The kernel and rite modules are untouched, and colitur day and colitur readings remain byte-identical.
Diffstat (limited to 'test/test_ics.ml')
-rw-r--r--test/test_ics.ml143
1 files changed, 143 insertions, 0 deletions
diff --git a/test/test_ics.ml b/test/test_ics.ml
new file mode 100644
index 0000000..7d1ca37
--- /dev/null
+++ b/test/test_ics.ml
@@ -0,0 +1,143 @@
+module Ics = Colitur_render.Emit_ics
+
+let out_2027 () = Ics.year (Test_view.view_of 2027)
+
+let lines s =
+ String.split_on_char '\n' s
+ |> List.map (fun l -> if l <> "" && l.[String.length l - 1] = '\r' then String.sub l 0 (String.length l - 1) else l)
+ |> List.filter (fun l -> l <> "")
+
+let count_prefix p ls =
+ List.length (List.filter (fun l -> String.length l >= String.length p && String.sub l 0 (String.length p) = p) ls)
+
+let test_envelope () =
+ let ls = lines (out_2027 ()) in
+ Alcotest.(check string) "first line" "BEGIN:VCALENDAR" (List.hd ls);
+ Alcotest.(check string) "last line" "END:VCALENDAR" (List.nth ls (List.length ls - 1));
+ Alcotest.(check int) "version" 1 (count_prefix "VERSION:2.0" ls);
+ Alcotest.(check int) "prodid" 1 (count_prefix "PRODID:" ls);
+ Alcotest.(check int) "365 events" 365 (count_prefix "BEGIN:VEVENT" ls)
+
+(* RFC 5545 section 3.6.1: for an all-day event DTEND is EXCLUSIVE. Getting
+ this wrong shows every event a day short in every client. *)
+let test_dtend_is_exclusive () =
+ let ls = lines (out_2027 ()) in
+ let starts = List.filter_map (fun l ->
+ if count_prefix "DTSTART;VALUE=DATE:" [l] = 1 then
+ Some (String.sub l (String.length "DTSTART;VALUE=DATE:") 8) else None) ls in
+ let ends = List.filter_map (fun l ->
+ if count_prefix "DTEND;VALUE=DATE:" [l] = 1 then
+ Some (String.sub l (String.length "DTEND;VALUE=DATE:") 8) else None) ls in
+ Alcotest.(check int) "same count" (List.length starts) (List.length ends);
+ Alcotest.(check string) "1 Jan starts" "20270101" (List.hd starts);
+ Alcotest.(check string) "1 Jan ends on the 2nd" "20270102" (List.hd ends);
+ Alcotest.(check string) "31 Dec ends on 1 Jan next" "20280101"
+ (List.nth ends (List.length ends - 1))
+
+let test_uids_unique_and_shaped () =
+ let ls = lines (out_2027 ()) in
+ let uids = List.filter_map (fun l ->
+ if count_prefix "UID:" [l] = 1 then Some (String.sub l 4 (String.length l - 4)) else None) ls in
+ Alcotest.(check int) "365 uids" 365 (List.length uids);
+ let sorted = List.sort_uniq String.compare uids in
+ Alcotest.(check int) "all unique" 365 (List.length sorted);
+ Alcotest.(check string) "shape" "20270101-ef@colitur" (List.hd uids)
+
+(* The rule that punishes silently, months later, in someone else's phone:
+ regenerating a feed must not duplicate every event. *)
+let test_output_is_byte_stable_across_runs () =
+ Alcotest.(check string) "two runs identical" (out_2027 ()) (out_2027 ())
+
+let test_every_line_folded_and_crlf () =
+ let raw = out_2027 () in
+ List.iter
+ (fun l ->
+ if String.length l > 75 then Alcotest.failf "line of %d octets: %s" (String.length l) l)
+ (lines raw);
+ (* Every physical line ends CRLF (RFC 5545 section 3.1). *)
+ let n = String.length raw in
+ let rec check i =
+ if i >= n then ()
+ else if raw.[i] = '\n' then (
+ if i = 0 || raw.[i - 1] <> '\r' then Alcotest.failf "bare LF at offset %d" i;
+ check (i + 1))
+ else check (i + 1)
+ in
+ check 0
+
+let test_no_rrule () =
+ Alcotest.(check int) "no RRULE" 0 (count_prefix "RRULE" (lines (out_2027 ())))
+
+let test_text_escaped () =
+ let ls = lines (Ics.year (Test_view.view_of 2035)) in
+ (* 2035-04-03 is St Joseph, whose English name contains a comma. *)
+ let joseph = List.find (fun l -> count_prefix "SUMMARY:St. Joseph" [l] = 1) ls in
+ Alcotest.(check bool) "comma escaped" true
+ (let re = "\\," in
+ let n = String.length re in
+ let rec f i = i + n <= String.length joseph && (String.sub joseph i n = re || f (i + 1)) in
+ f 0)
+
+let test_dtstamp_is_a_parameter () =
+ let a = Ics.year ~dtstamp:"20200101T000000Z" (Test_view.view_of 2027) in
+ let b = Ics.year ~dtstamp:"20210101T000000Z" (Test_view.view_of 2027) in
+ Alcotest.(check bool) "differs with the parameter" true (a <> b);
+ (* DTSTAMP is a per-VEVENT property (RFC 5545 section 3.8.7.2), not a
+ calendar-level one -- every one of the 365 events carries the same
+ default value, not just one line in the whole feed. *)
+ Alcotest.(check int) "default is deterministic" 365
+ (count_prefix "DTSTAMP:20270101T000000Z" (lines (out_2027 ())))
+
+(* F1 (fix round 1, 2026-08-19): 9999 is the domain's own last civil year.
+ Its last VEVENT's successor date (10000-01-01) falls outside the
+ 1583..9999 domain [Date.make] enforces (date.mli) -- [Date.add_days] is
+ itself unbounded, and [to_iso8601] pads but never truncates, so the naive
+ successor string would be 9 digits, not 8. RFC 5545 section 3.6.1 makes a
+ DATE-valued DTSTART with neither DTEND nor DURATION mean exactly one day,
+ so [dtend_of] omits the DTEND line entirely for that one event rather than
+ emit it malformed. *)
+let out_9999 () = Ics.year (Test_view.view_of 9999)
+
+let test_dtend_omitted_at_domain_end () =
+ let ls = Array.of_list (lines (out_9999 ())) in
+ let n = Array.length ls in
+ let idx = ref (-1) in
+ Array.iteri (fun i l -> if l = "DTSTART;VALUE=DATE:99991231" then idx := i) ls;
+ if !idx < 0 then Alcotest.fail "DTSTART;VALUE=DATE:99991231 not found";
+ let next_line = if !idx + 1 < n then ls.(!idx + 1) else "" in
+ Alcotest.(check bool) "no DTEND line follows DTSTART 99991231" true
+ (count_prefix "DTEND;VALUE=DATE:" [ next_line ] = 0);
+ Alcotest.(check bool) "SUMMARY follows DTSTART directly instead" true
+ (count_prefix "SUMMARY:" [ next_line ] = 1)
+
+(* The general form of F1's bug class: every DTEND value anywhere in the
+ feed must be an 8-digit YYYYMMDD, never 9 (a future domain-boundary
+ regression anywhere else in 1583..9999 would show up here too). *)
+let test_all_9999_dtends_are_eight_digits () =
+ let ls = lines (out_9999 ()) in
+ let prefix = "DTEND;VALUE=DATE:" in
+ let ends =
+ List.filter_map
+ (fun l ->
+ if count_prefix prefix [ l ] = 1 then
+ Some (String.sub l (String.length prefix) (String.length l - String.length prefix))
+ else None)
+ ls
+ in
+ Alcotest.(check bool) "at least one DTEND present in 9999" true (List.length ends > 0);
+ List.iter
+ (fun v -> if String.length v <> 8 then Alcotest.failf "DTEND value %S is %d digits, not 8" v (String.length v))
+ ends
+
+let suite =
+ ( "Emit/ics",
+ [ Alcotest.test_case "envelope" `Quick test_envelope;
+ Alcotest.test_case "DTEND exclusive" `Quick test_dtend_is_exclusive;
+ Alcotest.test_case "uids unique and shaped" `Quick test_uids_unique_and_shaped;
+ Alcotest.test_case "byte-stable across runs" `Quick test_output_is_byte_stable_across_runs;
+ Alcotest.test_case "folded and CRLF" `Quick test_every_line_folded_and_crlf;
+ Alcotest.test_case "no RRULE" `Quick test_no_rrule;
+ Alcotest.test_case "text escaped" `Quick test_text_escaped;
+ Alcotest.test_case "DTSTAMP is a parameter" `Quick test_dtstamp_is_a_parameter;
+ Alcotest.test_case "DTEND omitted at domain end" `Quick test_dtend_omitted_at_domain_end;
+ Alcotest.test_case "all 9999 DTENDs are eight digits" `Quick test_all_9999_dtends_are_eight_digits ] )