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
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
|
module B = Colitur_citation.Book
let s = Alcotest.string
let test_both_spellings_are_one_book () =
(* The books listed here arrive in more than one spelling and must
collapse onto one id. This is the whole reason the parser exists rather
than a regex. Most pairs are dotted/undotted; "Sir"/"Ecclus" and
"Apoc"/"Rev" are a modern spelling sitting inside otherwise-Vulgate
data -- see book.mli. *)
let same a b =
match B.of_token a, B.of_token b with
| Some x, Some y ->
Alcotest.(check s) (a ^ " = " ^ b) (B.to_string x) (B.to_string y)
| _ -> Alcotest.failf "%s or %s did not resolve" a b
in
same "Isa" "Isa.";
same "Matt" "Matt.";
same "1 Cor" "1 Cor.";
same "1 Pet" "1 Pet.";
same "1 Thess" "1 Thess.";
same "Eph" "Eph.";
same "3 Kgs." "3 Kings";
same "Sir" "Ecclus";
same "Apoc" "Rev";
same "2 Cor" "2 Cor.";
same "Col" "Col.";
same "Wis" "Wis.";
same "2 Tim" "2 Tim.";
same "Ex" "Exod";
same "Ezech" "Ezek";
same "Jas" "James"
let test_unknown_token_is_none () =
Alcotest.(check bool) "not a book" true (B.of_token "Nonesuch" = None)
let test_vulgate_is_identity () =
match B.of_token "3 Kings" with
| None -> Alcotest.fail "3 Kings did not resolve"
| Some k ->
Alcotest.(check s) "unmapped" "kings_3" (B.to_string (B.map B.vulgate k))
let test_modern_renumbers () =
let modern = B.tradition_of_fields [ ("kings_3", "kings_1") ] in
match B.of_token "3 Kings" with
| None -> Alcotest.fail "3 Kings did not resolve"
| Some k ->
Alcotest.(check s) "renumbered" "kings_1" (B.to_string (B.map modern k))
let test_tokens_has_no_duplicate_spelling () =
(* [of_token] resolves via [List.assoc_opt], which silently prefers the
first match on a duplicate key. A copy-paste collision in the table
would therefore mis-map a book in total silence, never an exception --
assert the invariant directly rather than trust it by inspection. *)
let spellings = List.map fst B.tokens in
let sorted = List.sort compare spellings in
let rec find_dup = function
| a :: (b :: _ as rest) -> if a = b then Some a else find_dup rest
| _ -> None
in
match find_dup sorted with
| None -> ()
| Some dup -> Alcotest.failf "duplicate spelling in Book.tokens: %S" dup
(* Read a whole file into a string. Test-only I/O; the library itself never
touches the filesystem. *)
let read_file path =
let ic = open_in_bin path in
let n = in_channel_length ic in
let content = really_input_string ic n in
close_in ic;
content
let starts_with_at content pos prefix =
let plen = String.length prefix in
pos + plen <= String.length content && String.sub content pos plen = prefix
(* Every [(reference ...)] payload in a data file, in file order. No
[Str]/regex -- a plain forward scan for the marker, then read to the
closing quote. Mirrors the coordinator's own survey command (grep -oh
over the reference marker, quote-delimited). *)
let references content =
let marker = "(reference \"" in
let mlen = String.length marker in
let len = String.length content in
let rec loop pos acc =
if pos >= len then List.rev acc
else if starts_with_at content pos marker then
let start = pos + mlen in
match String.index_from_opt content start '"' with
| None -> List.rev acc
| Some close ->
let payload = String.sub content start (close - start) in
loop (close + 1) (payload :: acc)
else loop (pos + 1) acc
in
loop 0 []
let is_alpha c = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')
let is_one_to_four c = c >= '1' && c <= '4'
(* The leading book token of one reference payload, e.g. ["2 Tim 4:1-8"] ->
["2 Tim"], ["Wis. 5:1-5"] -> ["Wis."]. Mirrors the coordinator's own
survey command's second stage:
`sed -E 's/^(([1-4] )?[A-Za-z]+\.?).*/\1/'`. *)
let book_token r =
let len = String.length r in
let start = if len >= 2 && is_one_to_four r.[0] && r.[1] = ' ' then 2 else 0 in
let i = ref start in
while !i < len && is_alpha r.[!i] do
incr i
done;
let stop = if !i < len && r.[!i] = '.' then !i + 1 else !i in
String.sub r 0 stop
let test_every_data_file_token_resolves () =
(* The check whose absence caused fix round 1: the brief surveyed only
the lectionary and missed 21 tokens, several common, living in
sanctoral.sexp and commons.sexp. Read all three files at test time and
re-derive the token set from them, rather than hardcoding a list, so
this keeps working when the data changes. *)
let files =
[ "../data/ef/lectionary.sexp"; "../data/ef/sanctoral.sexp"; "../data/ef/commons.sexp" ]
in
let tokens =
files
|> List.concat_map (fun f -> references (read_file f))
|> List.map book_token
|> List.sort_uniq compare
in
Alcotest.(check bool) "at least one token found" true (List.length tokens > 0);
let unresolved = List.filter (fun t -> B.of_token t = None) tokens in
match unresolved with
| [] -> ()
| _ ->
Alcotest.failf "unresolved book tokens in shipped data: %s"
(String.concat ", " unresolved)
let suite =
( "book",
[ Alcotest.test_case "both spellings one book" `Quick test_both_spellings_are_one_book;
Alcotest.test_case "unknown token" `Quick test_unknown_token_is_none;
Alcotest.test_case "vulgate identity" `Quick test_vulgate_is_identity;
Alcotest.test_case "modern renumbers" `Quick test_modern_renumbers;
Alcotest.test_case "tokens has no duplicate spelling" `Quick
test_tokens_has_no_duplicate_spelling;
Alcotest.test_case "every data file token resolves" `Quick
test_every_data_file_token_resolves ] )
module P = Colitur_citation.Parse
(* Render a parse back to a debug string so a test can assert shape
compactly: "book|chapter:v-v,v-v|chapter:v". *)
let show (t : P.t) =
let range (r : P.verse_range) =
match r.P.last with
| None -> string_of_int r.P.first
| Some l -> Printf.sprintf "%d-%d" r.P.first l
in
let part (p : P.part) =
Printf.sprintf "%d:%s" p.P.chapter
(String.concat "," (List.map range p.P.verses))
in
B.to_string t.P.book ^ "|" ^ String.concat "|" (List.map part t.P.parts)
let parses input expected () =
match P.parse input with
| Error e -> Alcotest.failf "%s did not parse: %s" input e
| Ok t -> Alcotest.(check s) input expected (show t)
let test_rejects_unknown_book () =
Alcotest.(check bool) "error" true (Result.is_error (P.parse "Nonesuch 1:1"))
let test_rejects_garbage () =
List.iter
(fun bad ->
Alcotest.(check bool) bad true (Result.is_error (P.parse bad)))
[ ""; "Luke"; "Luke :"; "Luke 1:"; "Luke abc:1" ]
let parse_suite =
[ ("simple", `Quick, parses "1 Cor 11:20-32" "corinthians_1|11:20-32");
("trailing period", `Quick, parses "1 John 3:13-18." "john_1|3:13-18");
("single verse", `Quick, parses "Luke 2:21" "luke|2:21");
("dotted spelling", `Quick, parses "Isa. 1:16-19" "isaiah|1:16-19");
("verse list", `Quick, parses "Acts 10:34, 42-48" "acts|10:34,42-48");
("new chapter", `Quick, parses "1 Cor. 9:24-27; 10:1-5"
"corinthians_1|9:24-27|10:1-5");
(* Rule 1: the second part names no chapter, so it inherits chapter 2. *)
("inherited chapter", `Quick, parses "Joel 2:23-24; 26-27" "joel|2:23-24|2:26-27");
(* Rule 2: comma separates chapter from verses in the second part. *)
("comma chapter", `Quick, parses "Mark 14:32-72; 15, 1-46"
"mark|14:32-72|15:1-46");
("four ranges", `Quick, parses "Dan 13:1-9, 15-17, 19-30, 33-62."
"daniel|13:1-9,15-17,19-30,33-62");
("mixed", `Quick, parses "Num 20:1, 3; 6-13." "numbers|20:1,3|20:6-13");
("trailing semicolon", `Quick, parses "1 Cor 1:18-25; 1:30;"
"corinthians_1|1:18-25|1:30");
("four parts", `Quick, parses "Eccli 24:5; 14:7; 14:9-11; 24:30-31"
"ecclesiasticus|24:5|14:7|14:9-11|24:30-31");
("modern name, vulgate id", `Quick, parses "Rev 12:1" "apocalypse|12:1");
("unknown book", `Quick, test_rejects_unknown_book);
("garbage", `Quick, test_rejects_garbage) ]
module R = Colitur_citation.Render
let names id form =
let n = B.to_string id in
match form with `Abbr -> (if n = "luke" then "Luc." else n)
| `Full -> (if n = "luke" then "Evangelium secundum Lucam" else n)
let render_with fields input expected () =
let style = R.style_of_fields fields in
match P.parse input with
| Error e -> Alcotest.failf "%s did not parse: %s" input e
| Ok t -> Alcotest.(check s) input expected (R.render style ~names t)
let test_unquotes_trailing_space () =
let st = R.style_of_fields [ ("part_sep", "\"; \"") ] in
Alcotest.(check s) "quotes stripped, space kept" "; " (R.part_sep st)
let test_bare_value_untouched () =
let st = R.style_of_fields [ ("range", "{first}-{last}") ] in
Alcotest.(check s) "no quotes" "{first}-{last}" (R.range st)
(* [subst] has no test pressure of its own anywhere else in the suite, so
these three isolate its contract directly through the only surface that
calls it: a [range]/[chapter_verse] template chosen so nothing else in
the pipeline (verse-list joining, book naming) can mask the result. *)
let test_subst_no_placeholder () =
(* A template with no "{" at all passes through completely unchanged. *)
render_with [ ("book", "abbr"); ("chapter_verse", "fixed-text") ] "Luke 2:21"
"Luc. fixed-text" ()
let test_subst_two_placeholders () =
(* Two DIFFERENT known placeholders, reordered relative to the record's
own field order ([last] before [first]) -- proves substitution is by
name, not by position. *)
render_with
[ ("book", "abbr"); ("range", "{last}~{first}") ]
"Luke 5:12-14" "Luc. 5:14~12" ()
let test_subst_unknown_placeholder_alone () =
(* A template that is NOTHING but an unrecognised placeholder: it must
survive byte-for-byte, proving [subst] never touches an unmatched
"{...}" run even when there is no surrounding literal text to anchor
on. *)
render_with [ ("book", "abbr"); ("range", "{nope}") ] "Luke 5:12-14"
"Luc. 5:{nope}" ()
(* [roman_numeral] is private to Render (not in the .mli, matching
[View.roman_numeral]'s own precedent -- tested indirectly, never
exposed). Reached through [render] with a [chapter_verse] template of
bare [{chapter_roman}], [book_sep] emptied and [names] returning "", so
the assertion isolates exactly the numeral and nothing else. *)
let roman_of n =
let luke = match B.of_token "Luke" with Some id -> id | None -> assert false in
let t : P.t = { P.book = luke; parts = [ { P.chapter = n; verses = [ { P.first = 1; last = None } ] } ] } in
let style = R.style_of_fields [ ("book_sep", ""); ("chapter_verse", "{chapter_roman}") ] in
R.render style ~names:(fun _ _ -> "") t
let test_roman_numeral_values () =
List.iter
(fun (n, expected) -> Alcotest.(check s) (string_of_int n) expected (roman_of n))
[ (1, "I"); (4, "IV"); (9, "IX"); (14, "XIV"); (40, "XL"); (150, "CL") ]
let test_roman_numeral_non_positive () =
(* The one input that could loop forever in a naive implementation: a
non-positive chapter returns the arabic form instead. Completing at
all is part of what this test proves. *)
Alcotest.(check s) "zero" "0" (roman_of 0);
Alcotest.(check s) "negative" "-3" (roman_of (-3))
let render_suite =
( "Citation/render",
[ Alcotest.test_case "latin default" `Quick
(render_with [ ("book", "abbr") ] "Luke 5:12-14" "Luc. 5:12-14");
Alcotest.test_case "full name" `Quick
(render_with [ ("book", "full") ] "Luke 5:12-14"
"Evangelium secundum Lucam 5:12-14");
Alcotest.test_case "comma style" `Quick
(render_with
[ ("book", "abbr"); ("chapter_verse", "{chapter}, {verses}") ]
"Luke 5:12-14" "Luc. 5, 12-14");
Alcotest.test_case "multi part" `Quick
(render_with [ ("book", "abbr"); ("part_sep", "\"; \"") ]
"Joel 2:23-24; 26-27" "joel 2:23-24; 2:26-27");
Alcotest.test_case "unquote" `Quick test_unquotes_trailing_space;
Alcotest.test_case "bare value" `Quick test_bare_value_untouched;
(* The Missal's own convention, scan-verified: "Matth. 11, 2" /
"Ioann. 1, 1" -- dotted abbreviation, comma, ARABIC chapter. *)
Alcotest.test_case "missal convention" `Quick
(render_with
[ ("book", "abbr"); ("chapter_verse", "{chapter}, {verses}") ]
"Luke 5:12-14" "Luc. 5, 12-14");
Alcotest.test_case "roman chapter" `Quick
(render_with
[ ("book", "abbr"); ("chapter_verse", "{chapter_roman}, {verses}") ]
"Luke 5:12-14" "Luc. V, 12-14");
(* U+00A0 between book and reference, for typeset output. *)
Alcotest.test_case "nbsp book sep" `Quick
(render_with [ ("book", "abbr"); ("book_sep", "\"\xc2\xa0\"") ]
"Luke 5:12-14" "Luc.\xc2\xa05:12-14");
Alcotest.test_case "unknown placeholder survives" `Quick
(render_with
[ ("book", "abbr"); ("chapter_verse", "{chapter}:{nope}") ]
"Luke 5:12-14" "Luc. 5:{nope}");
Alcotest.test_case "subst: no placeholder" `Quick test_subst_no_placeholder;
Alcotest.test_case "subst: two placeholders" `Quick test_subst_two_placeholders;
Alcotest.test_case "subst: unknown placeholder alone" `Quick
test_subst_unknown_placeholder_alone;
Alcotest.test_case "roman_numeral: table values" `Quick test_roman_numeral_values;
Alcotest.test_case "roman_numeral: non-positive" `Quick
test_roman_numeral_non_positive ] )
|