summaryrefslogtreecommitdiff
path: root/test/test_ics.ml
blob: fef20c78594bec279264b40293eb9bd413436f30 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
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)

(* W5: like [Emit_xml] and unlike [Emit_csv], DESCRIPTION is free text, not
   a fixed column -- a [Second] reading is a third joined line, not a
   breaking schema change. [second] is spliced BETWEEN [first]/[gospel]
   (reading order, OLM 1981 Praenotanda n. 69.1), matching
   [Emit_xml.day]'s own ordering. Asserts the exact line rather than a
   substring: short enough (69 octets incl. the "DESCRIPTION:" property
   name) to stay under RFC 5545's 75-octet fold threshold, so it is one
   physical line, not merely one logical property split across several. *)
let test_description_carries_second_reading_in_order () =
  let ls = lines (Ics.year (Test_view.three_citation_view ())) in
  let desc = List.find (fun l -> count_prefix "DESCRIPTION:" [ l ] = 1) ls in
  Alcotest.(check string) "Epistle, Second, Gospel, in that order"
    "DESCRIPTION:Epistle Rom 1:1-7\\nSecond 1 Cor 1:3-9\\nGospel John 1:1-14" desc

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 "description carries second reading in order" `Quick
        test_description_carries_second_reading_in_order;
      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 ] )