aboutsummaryrefslogtreecommitdiff
path: root/test/test_template.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_template.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_template.ml')
-rw-r--r--test/test_template.ml165
1 files changed, 165 insertions, 0 deletions
diff --git a/test/test_template.ml b/test/test_template.ml
new file mode 100644
index 0000000..b83c378
--- /dev/null
+++ b/test/test_template.ml
@@ -0,0 +1,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 ] )