summaryrefslogtreecommitdiff
path: root/test/test_template.ml
blob: b83c378a06ce0097f3541098c5f7f6f3c4db769b (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
160
161
162
163
164
165
module T = Colitur_render.Template

let parse_ok s = match T.parse s with Ok ns -> ns | Error e -> Alcotest.failf "parse: %s" e

let test_plain_text () =
  Alcotest.(check bool) "text only" true (parse_ok "hello" = [ T.Text "hello" ])

let test_var () =
  Alcotest.(check bool) "var" true (parse_ok "{{name}}" = [ T.Var [ "name" ] ]);
  Alcotest.(check bool) "dotted" true (parse_ok "{{name.la}}" = [ T.Var [ "name"; "la" ] ]);
  Alcotest.(check bool) "spaces trimmed" true (parse_ok "{{  name.la  }}" = [ T.Var [ "name"; "la" ] ])

let test_section () =
  Alcotest.(check bool) "section" true
    (parse_ok "{{#days}}x{{/days}}" = [ T.Section ([ "days" ], [ T.Text "x" ]) ]);
  Alcotest.(check bool) "inverted" true
    (parse_ok "{{^days}}x{{/days}}" = [ T.Inverted ([ "days" ], [ T.Text "x" ]) ])

let test_nested_sections () =
  Alcotest.(check bool) "nested" true
    (parse_ok "{{#months}}{{#weeks}}w{{/weeks}}{{/months}}"
    = [ T.Section ([ "months" ], [ T.Section ([ "weeks" ], [ T.Text "w" ]) ]) ])

let test_comment_is_dropped () =
  Alcotest.(check bool) "comment" true (parse_ok "a{{! note }}b" = [ T.Text "a"; T.Text "b" ])

let test_unclosed_section_is_an_error () =
  match T.parse "{{#days}}x" with
  | Error _ -> ()
  | Ok _ -> Alcotest.fail "an unclosed section must be a parse error, not silently accepted"

let test_mismatched_close_is_an_error () =
  match T.parse "{{#days}}x{{/weeks}}" with
  | Error _ -> ()
  | Ok _ -> Alcotest.fail "a mismatched close tag must be a parse error"

let test_unterminated_tag_is_an_error () =
  match T.parse "{{name" with
  | Error _ -> ()
  | Ok _ -> Alcotest.fail "an unterminated tag must be a parse error"

(* The safety promise (spec section 5): a template is data, never a program.
   There is no raw/triple-brace form to opt out of escaping, and no partial. *)
let test_no_raw_or_partial_form () =
  (* {{{name}}} is not a triple-brace "unescaped" form -- there is no such
     form in this engine, so this assertion states what parsing it ACTUALLY
     produces rather than gesturing at a guarantee it cannot check: the
     lexer only recognises double braces, so it reads an ordinary var whose
     path is the literal string "{name", then a leftover "}" as text. The
     real guarantee against a raw form is structural, not this assertion:
     [node] has exactly four constructors -- Text, Var, Section, Inverted --
     and none of them is raw/unescaped. If [node] ever grows a fifth, raw
     constructor, this test is not what will catch it. *)
  Alcotest.(check bool) "triple brace parses as var + stray text" true
    (parse_ok "{{{name}}}" = [ T.Var [ "{name" ]; T.Text "}" ]);
  match T.parse "{{>partial}}" with
  | Error _ -> ()
  | Ok _ -> Alcotest.fail "partials must not parse: a template may not pull in another file"

(* No "current context" concept exists in this engine (sections push an
   object; lookup falls back outward), so a bare-dot or empty-sigil path has
   no meaning. Reject at parse time rather than inventing behaviour Task 3's
   renderer would otherwise have to define for a case nobody designed. *)
let test_empty_path_is_an_error () =
  let expect_error label s =
    match T.parse s with
    | Error _ -> ()
    | Ok _ -> Alcotest.failf "%s: an empty tag path must be a parse error" label
  in
  expect_error "{{.}}" "{{.}}";
  expect_error "{{ . }}" "{{ . }}";
  expect_error "{{#}}{{/}}" "{{#}}{{/}}";
  expect_error "{{^}}{{/}}" "{{^}}{{/}}"

let suite =
  ( "Template/parse",
    [ Alcotest.test_case "plain text" `Quick test_plain_text;
      Alcotest.test_case "vars" `Quick test_var;
      Alcotest.test_case "sections" `Quick test_section;
      Alcotest.test_case "nested sections" `Quick test_nested_sections;
      Alcotest.test_case "comments dropped" `Quick test_comment_is_dropped;
      Alcotest.test_case "unclosed section errors" `Quick test_unclosed_section_is_an_error;
      Alcotest.test_case "mismatched close errors" `Quick test_mismatched_close_is_an_error;
      Alcotest.test_case "unterminated tag errors" `Quick test_unterminated_tag_is_an_error;
      Alcotest.test_case "no raw or partial form" `Quick test_no_raw_or_partial_form;
      Alcotest.test_case "empty path errors" `Quick test_empty_path_is_an_error ] )

module E = Colitur_render.Escape

let render ?(flavour = E.None_) tpl v =
  match T.render_string ~flavour tpl v with Ok s -> s | Error e -> Alcotest.failf "render: %s" e

let obj kvs = T.Obj kvs

let test_render_var () =
  Alcotest.(check string) "var" "Hilary" (render "{{name}}" (obj [ ("name", T.Str "Hilary") ]));
  Alcotest.(check string) "dotted" "Hilarii"
    (render "{{name.la}}" (obj [ ("name", obj [ ("la", T.Str "Hilarii") ]) ]))

(* A missing key renders empty. This is the ONE silent case, and it is
   deliberate: a template written for a rite that does not set every optional
   field must still render (spec section 5). *)
let test_missing_key_is_empty () =
  Alcotest.(check string) "missing" "[]" (render "[{{nope}}]" (obj [ ("name", T.Str "x") ]))

let test_section_iterates () =
  let v = obj [ ("days", T.List [ obj [ ("dom", T.Str "1") ]; obj [ ("dom", T.Str "2") ] ]) ] in
  Alcotest.(check string) "iterate" "1|2|" (render "{{#days}}{{dom}}|{{/days}}" v)

let test_bool_section () =
  Alcotest.(check string) "true" "yes" (render "{{#f}}yes{{/f}}" (obj [ ("f", T.Bool true) ]));
  Alcotest.(check string) "false" "" (render "{{#f}}yes{{/f}}" (obj [ ("f", T.Bool false) ]));
  Alcotest.(check string) "inverted true" "" (render "{{^f}}no{{/f}}" (obj [ ("f", T.Bool true) ]));
  Alcotest.(check string) "inverted false" "no" (render "{{^f}}no{{/f}}" (obj [ ("f", T.Bool false) ]))

let test_empty_list_section_is_skipped () =
  Alcotest.(check string) "empty list" "" (render "{{#days}}x{{/days}}" (obj [ ("days", T.List []) ]));
  Alcotest.(check string) "inverted empty list" "none"
    (render "{{^days}}none{{/days}}" (obj [ ("days", T.List []) ]))

let test_outer_scope_visible_inside_section () =
  let v = obj [ ("year", T.Str "2027"); ("days", T.List [ obj [ ("dom", T.Str "1") ] ]) ] in
  Alcotest.(check string) "outer visible" "2027-1"
    (render "{{#days}}{{year}}-{{dom}}{{/days}}" v)

(* THE safety property (spec section 9.2): a data value can never escape its
   flavour. This is the test that must redden if any escape rule is broken. *)
let test_data_cannot_escape_flavour () =
  let nasty = obj [ ("name", T.Str "A & B \\ 50% {x} $y_z") ] in
  let out = render ~flavour:E.Latex "{{name}}" nasty in
  Alcotest.(check string) "fully escaped" "A \\& B \\textbackslash{} 50\\% \\{x\\} \\$y\\_z" out;
  let hout = render ~flavour:E.Html "{{name}}" (obj [ ("name", T.Str "<script>") ]) in
  Alcotest.(check string) "html escaped" "&lt;script&gt;" hout

(* Literal template text is NOT escaped -- it is the author's own markup. Only
   interpolated DATA is escaped. Getting this backwards would make every
   template render as visible source. *)
(* NOTE: the brief's own example here was "\textbf{{{name}}}", i.e. a literal
   "{" immediately followed by "{{name}}". That does not parse the way the
   brief assumes: this engine's lexer (Task 2, frozen) is greedy left-to-right
   and has no triple-brace special case (by design -- there is no raw form),
   so three consecutive "{" are read as an ordinary tag opener "{{" followed
   by a path that literally starts with "{" ("{name"), which resolves to
   nothing and renders empty, plus a stray literal "}" -- exactly what
   test_no_raw_or_partial_form above already documents for "{{{name}}}" on
   its own. Verified live: the brief's original line reddens with "\textbf}",
   not "\textbf{Hilary}". Rewritten below to put a character between the
   literal "{" and the tag's own "{{", which sidesteps the ambiguity while
   still proving the same property: literal backslash/braces from Text survive
   unescaped, where a wrongly-escaped Text node would turn "\textbf{" into
   "\textbackslash{}\{". *)
let test_literal_markup_is_not_escaped () =
  Alcotest.(check string) "literal kept" "\\textbf{Name: Hilary}"
    (render ~flavour:E.Latex "\\textbf{Name: {{name}}}" (obj [ ("name", T.Str "Hilary") ]))

let render_suite =
  ( "Template/render",
    [ Alcotest.test_case "vars" `Quick test_render_var;
      Alcotest.test_case "missing key is empty" `Quick test_missing_key_is_empty;
      Alcotest.test_case "section iterates" `Quick test_section_iterates;
      Alcotest.test_case "bool sections" `Quick test_bool_section;
      Alcotest.test_case "empty list sections" `Quick test_empty_list_section_is_skipped;
      Alcotest.test_case "outer scope visible" `Quick test_outer_scope_visible_inside_section;
      Alcotest.test_case "data cannot escape flavour" `Quick test_data_cannot_escape_flavour;
      Alcotest.test_case "literal markup unescaped" `Quick test_literal_markup_is_not_escaped ] )