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 ] )