diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-19 11:48:30 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-19 11:48:30 +0200 |
| commit | 6762ce46af3cb12bc6ae37cda762c5d95add7903 (patch) | |
| tree | bc1d8c86050d0149ff961a9a4ff838f9c474ac2a /test/test_escape.ml | |
| parent | 897c274fd28402159ca6d45eedc1257b1ce98696 (diff) | |
| parent | 390bc6ac5196a946c473d0dbe7760fa41837c428 (diff) | |
| download | colitur-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_escape.ml')
| -rw-r--r-- | test/test_escape.ml | 119 |
1 files changed, 119 insertions, 0 deletions
diff --git a/test/test_escape.ml b/test/test_escape.ml new file mode 100644 index 0000000..35a9b9d --- /dev/null +++ b/test/test_escape.ml @@ -0,0 +1,119 @@ +module E = Colitur_render.Escape + +let check = Alcotest.(check string) + +let test_latex () = + check "ampersand" "\\&" (E.apply E.Latex "&"); + check "percent" "\\%" (E.apply E.Latex "%"); + check "underscore" "\\_" (E.apply E.Latex "_"); + check "braces" "\\{\\}" (E.apply E.Latex "{}"); + check "backslash first" "\\textbackslash{}" (E.apply E.Latex "\\"); + (* A real feast name that would otherwise break a .tex build. *) + check "real name" "Ss.mi Nominis Iesu \\& Mari\xc3\xa6" + (E.apply E.Latex "Ss.mi Nominis Iesu & Mari\xc3\xa6") + +let test_groff () = + check "backslash" "\\e" (E.apply E.Groff "\\"); + (* RG-irrelevant but groff-critical: a leading dot starts a request. *) + check "leading dot" "\\&.Ss" (E.apply E.Groff ".Ss"); + check "leading quote" "\\&'tis" (E.apply E.Groff "'tis"); + check "interior dot untouched" "Ss.mi" (E.apply E.Groff "Ss.mi") + +let test_html_xml () = + check "amp first" "&lt;" (E.apply E.Html "<"); + check "angles" "<b>" (E.apply E.Html "<b>"); + check "quote" """ (E.apply E.Html "\""); + check "xml same" "<b>" (E.apply E.Xml "<b>") + +let test_ics () = + check "comma" "\\," (E.apply E.Ics ","); + check "semicolon" "\\;" (E.apply E.Ics ";"); + check "backslash" "\\\\" (E.apply E.Ics "\\"); + check "newline" "\\n" (E.apply E.Ics "\n") + +let test_none_is_identity () = + check "none" "& < > \\ % {}" (E.apply E.None_ "& < > \\ % {}") + +let test_flavour_names () = + List.iter + (fun f -> Alcotest.(check bool) "roundtrip" true (E.of_string (E.to_string f) = Some f)) + [ E.Latex; E.Groff; E.Html; E.Xml; E.Ics; E.None_ ]; + Alcotest.(check bool) "tex" true (E.of_extension ".tex" = Some E.Latex); + Alcotest.(check bool) "ms" true (E.of_extension ".ms" = Some E.Groff); + Alcotest.(check bool) "mom" true (E.of_extension ".mom" = Some E.Groff); + Alcotest.(check bool) "html" true (E.of_extension ".html" = Some E.Html); + Alcotest.(check bool) "md is none" true (E.of_extension ".md" = Some E.None_); + Alcotest.(check bool) "adoc is none" true (E.of_extension ".adoc" = Some E.None_); + Alcotest.(check bool) "txt is none" true (E.of_extension ".txt" = Some E.None_); + (* Spec section 5: an unknown extension is an ERROR, never a silent fallback. *) + Alcotest.(check bool) "unknown is None" true (E.of_extension ".wat" = None) + +let test_fold_short_line_unchanged () = + check "short" "SUMMARY:Feast\r\n" (E.fold_ics "SUMMARY:Feast") + +let test_fold_long_line () = + let long = "DESCRIPTION:" ^ String.make 200 'x' in + let out = E.fold_ics long in + let lines = String.split_on_char '\n' out in + List.iter + (fun l -> + let l = if l <> "" && l.[String.length l - 1] = '\r' then String.sub l 0 (String.length l - 1) else l in + if String.length l > 75 then Alcotest.failf "line of %d octets exceeds 75" (String.length l)) + (List.filter (fun l -> l <> "") lines); + (* Continuation lines begin with exactly one space (RFC 5545 section 3.1). *) + match lines with + | _ :: second :: _ -> Alcotest.(check bool) "continuation starts with space" true (second.[0] = ' ') + | _ -> Alcotest.fail "expected the line to fold" + +(* RFC 5545 section 3.1 folds on OCTET boundaries; splitting mid-UTF-8 corrupts + Polish and Latin names, which is the whole reason this is not a template job. *) +let test_fold_never_splits_utf8 () = + let long = "SUMMARY:" ^ String.concat "" (List.init 40 (fun _ -> "\xc4\x99\xc5\x9b\xc4\x87")) in + let out = E.fold_ics long in + let chunks = + List.filter_map + (fun l -> + let l = if l <> "" && l.[String.length l - 1] = '\r' then String.sub l 0 (String.length l - 1) else l in + if l = "" then None else if l.[0] = ' ' then Some (String.sub l 1 (String.length l - 1)) else Some l) + (String.split_on_char '\n' out) + in + (* The named property: no chunk may START with a UTF-8 continuation byte + (0x80-0xBF) -- that would mean the previous fold cut mid-character. + Unfolding losslessly (below) cannot detect this on its own: concatenation + is insensitive to where the cuts fell, so a fold at ANY position still + round-trips. *) + List.iter + (fun c -> + if String.length c > 0 then + Alcotest.(check bool) "chunk does not start mid-UTF-8" true (Char.code c.[0] land 0xC0 <> 0x80)) + chunks; + let stripped = String.concat "" chunks in + check "unfolds to the original" long stripped + +(* B1 regression: 100 consecutive UTF-8 continuation bytes (0x80-0xBF) is not + producible by valid UTF-8 (whose longest continuation run is 3), but + fold_ics must stay TOTAL on arbitrary octet strings. A backoff loop with no + hard-cut fallback backs `cut` all the way down to `pos`, yielding a + zero-length chunk and recursing on the identical position forever. *) +let test_fold_pathological_input_terminates () = + let pathological = String.make 100 '\x80' in + let out = E.fold_ics pathological in + let lines = String.split_on_char '\n' out in + List.iter + (fun l -> + let l = if l <> "" && l.[String.length l - 1] = '\r' then String.sub l 0 (String.length l - 1) else l in + if String.length l > 75 then Alcotest.failf "line of %d octets exceeds 75" (String.length l)) + (List.filter (fun l -> l <> "") lines) + +let suite = + ( "Escape", + [ Alcotest.test_case "latex" `Quick test_latex; + Alcotest.test_case "groff" `Quick test_groff; + Alcotest.test_case "html/xml" `Quick test_html_xml; + Alcotest.test_case "ics" `Quick test_ics; + Alcotest.test_case "none is identity" `Quick test_none_is_identity; + Alcotest.test_case "flavour names" `Quick test_flavour_names; + Alcotest.test_case "fold: short unchanged" `Quick test_fold_short_line_unchanged; + Alcotest.test_case "fold: long line" `Quick test_fold_long_line; + Alcotest.test_case "fold: never splits utf8" `Quick test_fold_never_splits_utf8; + Alcotest.test_case "fold: pathological input terminates" `Quick test_fold_pathological_input_terminates ] ) |
