aboutsummaryrefslogtreecommitdiff
path: root/test/test_emit.ml
blob: 63bb3d0d02c4ec41a4d98991fc64bd0a02757797 (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
module V = Colitur_render.View
module Csv = Colitur_render.Emit_csv
module Json = Colitur_render.Emit_json
module Xml = Colitur_render.Emit_xml

let view_2027 () = Test_view.view_of 2027

let test_csv_header_and_rows () =
  let out = Csv.year (view_2027 ()) in
  let lines = String.split_on_char '\n' out |> List.filter (fun l -> l <> "") in
  Alcotest.(check int) "366 lines: header + 365 days" 366 (List.length lines);
  Alcotest.(check string) "header"
    "date,rite,season,week,slug,rank,colour,subject,name_la,name_en,first,gospel,comms"
    (List.hd lines);
  Alcotest.(check bool) "first row is 1 January" true
    (String.length (List.nth lines 1) > 10 && String.sub (List.nth lines 1) 0 10 = "2027-01-01")

(* RFC 4180: a field containing a comma, quote or newline is quoted, and an
   embedded quote is doubled. Feast names contain commas ("St. Joseph, Spouse
   of the Bl. Virgin Mary"), so this is live on real data, not hypothetical. *)
let test_csv_quotes_commas () =
  Alcotest.(check string) "comma quoted" "\"a,b\"" (Csv.escape_field "a,b");
  Alcotest.(check string) "quote doubled" "\"a\"\"b\"" (Csv.escape_field "a\"b");
  Alcotest.(check string) "plain unquoted" "ab" (Csv.escape_field "ab");
  let out = Csv.year (view_2027 ()) in
  let joseph =
    List.find
      (fun l -> String.length l > 10 && String.sub l 0 10 = "2027-03-19")
      (String.split_on_char '\n' out)
  in
  Alcotest.(check bool) "the comma in Joseph's name is quoted, not a field break" true
    (String.length (String.split_on_char ',' joseph |> List.hd) = 10)

let test_json_parses_back () =
  let out = Json.year (view_2027 ()) in
  Alcotest.(check bool) "starts as an object" true (out.[0] = '{');
  let count sub =
    let n = String.length sub in
    let rec go i acc =
      if i + n > String.length out then acc
      else go (i + 1) (if String.sub out i n = sub then acc + 1 else acc)
    in
    go 0 0
  in
  Alcotest.(check bool) "has a days array" true (count "\"days\":[" >= 1);
  (* Every ISO date the view produced appears in the JSON -- that is the
     property under test, not a specific occurrence count. The count is NOT
     1: the view deliberately offers both artefacts at every level (view.mli)
     -- the flat top-level [days], each month's own [days], and that month's
     [weeks] grid cell -- so an ordinary date's "iso" field appears 3 times.
     Verified against the real 2027 engine output (all 365 dates checked);
     the one exception is legitimate, not a bug: 2027-04-05 appears 6 times
     because the Annunciation (25 March, impeded by Holy Week) transfers to
     it under RG 96/98, and its own "to" target string is the identical
     literal, tripled by the same structural redundancy. *)
  Alcotest.(check int) "1 January appears (flat days + month days + week grid)" 3
    (count "\"2027-01-01\"");
  Alcotest.(check int) "31 December appears (flat days + month days + week grid)" 3
    (count "\"2027-12-31\"")

let test_json_escapes () =
  Alcotest.(check string) "quote" "\"a\\\"b\"" (Json.escape_string "a\"b");
  Alcotest.(check string) "backslash" "\"a\\\\b\"" (Json.escape_string "a\\b");
  Alcotest.(check string) "newline" "\"a\\nb\"" (Json.escape_string "a\nb");
  Alcotest.(check string) "tab" "\"a\\tb\"" (Json.escape_string "a\tb");
  (* Control characters below 0x20 must be \u-escaped (RFC 8259 section 7). *)
  Alcotest.(check string) "control" "\"a\\u0001b\"" (Json.escape_string "a\001b")

let test_utf8_passes_through_json () =
  Alcotest.(check string) "polish" "\"\xc5\x9awi\xc4\x99tej\""  (Json.escape_string "\xc5\x9awi\xc4\x99tej")

let suite =
  ( "Emit/csv+json",
    [ Alcotest.test_case "csv header and rows" `Quick test_csv_header_and_rows;
      Alcotest.test_case "csv quotes commas" `Quick test_csv_quotes_commas;
      Alcotest.test_case "json parses back" `Quick test_json_parses_back;
      Alcotest.test_case "json escapes" `Quick test_json_escapes;
      Alcotest.test_case "json passes utf8 through" `Quick test_utf8_passes_through_json ] )

(* Substring search shared by the two XML well-formedness checks below, in
   place of the brief's inline recursive finder -- same property, clearer to
   read. *)
let contains ~needle hay =
  let n = String.length needle and h = String.length hay in
  let rec go i = i + n <= h && (String.sub hay i n = needle || go (i + 1)) in
  go 0

let test_xml_shape () =
  let out = Xml.year (view_2027 ()) in
  Alcotest.(check bool) "declaration" true
    (String.length out > 5 && String.sub out 0 5 = "<?xml");
  Alcotest.(check bool) "root element" true
    (contains ~needle:"<calendar rite=\"ef\" year=\"2027\">" out)

(* Every '<' in the output must open a tag: an unescaped '<' inside a feast
   name is the failure that makes a whole feed unparseable. *)
let test_xml_tags_balance () =
  let out = Xml.year (view_2027 ()) in
  let opens = ref 0 and closes = ref 0 in
  String.iter (fun c -> if c = '<' then incr opens else if c = '>' then incr closes) out;
  Alcotest.(check int) "every < has a >" !opens !closes

let test_xml_escapes_data () =
  Alcotest.(check string) "amp" "a &amp; b" (Xml.escape "a & b");
  Alcotest.(check string) "angle" "&lt;x&gt;" (Xml.escape "<x>")

let xml_suite =
  ( "Emit/xml",
    [ Alcotest.test_case "shape" `Quick test_xml_shape;
      Alcotest.test_case "tags balance" `Quick test_xml_tags_balance;
      Alcotest.test_case "escapes data" `Quick test_xml_escapes_data ] )