Command-line and Emacs Calendar Client
1(* Test the Query module *)
2
3open Caledonia_lib
4
5(* Setup a fixed date for testing *)
6let fixed_date = Option.get @@ Ptime.of_date_time ((2025, 3, 27), ((0, 0, 0), 0))
7
8let setup_fixed_date () =
9 (Date.get_today := fun ?tz:_ () -> fixed_date);
10 fixed_date
11
12let calendar_dir_path = Filename.concat (Sys.getcwd ()) "calendar"
13
14let test_query_all ~fs () =
15 let calendar_dir =
16 Result.get_ok @@ Calendar_dir.create ~fs calendar_dir_path
17 in
18 let from =
19 Some (Option.get @@ Ptime.of_date_time ((2025, 01, 01), ((0, 0, 0), 0)))
20 in
21 let to_ = Option.get @@ Ptime.of_date_time ((2026, 01, 01), ((0, 0, 0), 0)) in
22 match Query.query ~fs calendar_dir ~from ~to_ () with
23 | Ok instances ->
24 Alcotest.(check int) "Should find events" 792 (List.length instances);
25 let test_event =
26 List.find_opt
27 (fun instance ->
28 Option.get @@ Event.get_summary instance.Recur.event = "Test Event")
29 instances
30 in
31 Alcotest.(check bool) "Should find Test Event" true (test_event <> None)
32 | Error _ -> Alcotest.fail "Error querying events"
33
34let test_recurrence_expansion ~fs () =
35 let calendar_dir =
36 Result.get_ok @@ Calendar_dir.create ~fs calendar_dir_path
37 in
38 let from =
39 Some (Option.get @@ Ptime.of_date_time ((2025, 3, 1), ((0, 0, 0), 0)))
40 in
41 let to_ =
42 Option.get @@ Ptime.of_date_time ((2025, 5, 31), ((23, 59, 59), 0))
43 in
44 match Query.query ~fs calendar_dir ~from ~to_ () with
45 | Ok instances ->
46 let recurring_instances =
47 List.filter
48 (fun instance ->
49 Option.get @@ Event.get_summary instance.Recur.event
50 = "Recurring Event")
51 instances
52 in
53 Alcotest.(check bool)
54 "Should find multiple recurring event instances" true
55 (List.length recurring_instances > 1)
56 | Error _ -> Alcotest.fail "Error querying events"
57
58let test_text_search ~fs () =
59 let calendar_dir =
60 Result.get_ok @@ Calendar_dir.create ~fs calendar_dir_path
61 in
62 let filter = Query.summary_contains "Test" in
63 let from =
64 Some (Option.get @@ Ptime.of_date_time ((2025, 01, 01), ((0, 0, 0), 0)))
65 in
66 let to_ = Option.get @@ Ptime.of_date_time ((2026, 01, 01), ((0, 0, 0), 0)) in
67 (match Query.query ~fs calendar_dir ~from ~to_ ~filter () with
68 | Ok instances ->
69 Alcotest.(check int)
70 "Should find event with 'Test' in summary" 2 (List.length instances)
71 | Error _ -> Alcotest.fail "Error querying events");
72 let filter = Query.location_contains "Weekly" in
73 (match Query.query ~fs calendar_dir ~from ~to_ ~filter () with
74 | Ok instances ->
75 Alcotest.(check int)
76 "Should find event with 'Weekly' in location" 10 (List.length instances)
77 | Error _ -> Alcotest.fail "Error querying events");
78 let filter =
79 Query.and_filter
80 [ Query.summary_contains "Test"; Query.description_contains "test" ]
81 in
82 (match Query.query ~fs calendar_dir ~from ~to_ ~filter () with
83 | Ok instances ->
84 Alcotest.(check int)
85 "Should find events matching combined and criteria" 2
86 (List.length instances)
87 | Error _ -> Alcotest.fail "Error querying events");
88 let filter =
89 Query.or_filter
90 [ Query.summary_contains "Test"; Query.location_contains "Weekly" ]
91 in
92 (match Query.query ~fs calendar_dir ~from ~to_ ~filter () with
93 | Ok instances ->
94 Alcotest.(check int)
95 "Should find events matching combined or criteria" 12
96 (List.length instances)
97 | Error _ -> Alcotest.fail "Error querying events");
98 ()
99
100let test_calendar_filter ~fs () =
101 let calendar_dir =
102 Result.get_ok @@ Calendar_dir.create ~fs calendar_dir_path
103 in
104 let from =
105 Some (Option.get @@ Ptime.of_date_time ((2025, 01, 01), ((0, 0, 0), 0)))
106 in
107 let to_ = Option.get @@ Ptime.of_date_time ((2026, 01, 01), ((0, 0, 0), 0)) in
108 let collection = Collection.Col "example" in
109 let filter = Query.in_collections [ collection ] in
110 (match Query.query ~fs calendar_dir ~from ~to_ ~filter () with
111 | Ok instances ->
112 let all_match_calendar =
113 List.for_all
114 (fun e ->
115 match Event.get_collection e.Recur.event with
116 | id -> id = collection)
117 instances
118 in
119 Alcotest.(check bool)
120 (Printf.sprintf "All events should be from calendar '%s'"
121 (match collection with Col str -> str))
122 true all_match_calendar;
123 Alcotest.(check int) "Should find events" 2 (List.length instances)
124 | Error _ -> Alcotest.fail "Error querying events");
125 let collections = [ Collection.Col "example"; Collection.Col "recurrence" ] in
126 let filter = Query.in_collections collections in
127 (match Query.query ~fs calendar_dir ~from ~to_ ~filter () with
128 | Ok instances ->
129 Alcotest.(check int) "Should find events" 792 (List.length instances)
130 | Error _ -> Alcotest.fail "Error querying events");
131 let filter =
132 Query.in_collections [ Collection.Col "non-existent-calendar" ]
133 in
134 (match Query.query ~fs calendar_dir ~from ~to_ ~filter () with
135 | Ok instances ->
136 Alcotest.(check int)
137 "Should find 0 events for non-existent calendar" 0
138 (List.length instances)
139 | Error _ -> Alcotest.fail "Error querying events");
140 ()
141
142let test_events =
143 (* Create a test event with specific text in all fields *)
144 let create_test_event ~collection ~summary ~description ~location ~start =
145 Event.create ~summary ~start
146 ?description:(if description = "" then None else Some description)
147 ?location:(if location = "" then None else Some location)
148 (Collection.Col collection)
149 in
150 [
151 (* Event with text in all fields *)
152 create_test_event ~collection:"search_test" ~summary:"Project Meeting"
153 ~description:"Weekly project status meeting with team"
154 ~location:"Conference Room A"
155 ~start:(`Datetime (`Utc fixed_date));
156 (* Event with mixed case to test case insensitivity *)
157 create_test_event ~collection:"search_test" ~summary:"IMPORTANT Meeting"
158 ~description:"Critical project review with stakeholders"
159 ~location:"Executive Suite"
160 ~start:(`Datetime (`Utc fixed_date));
161 (* Event with word fragments *)
162 create_test_event ~collection:"search_test" ~summary:"Conference Call"
163 ~description:"International conference preparation"
164 ~location:"Remote Meeting Room"
165 ~start:(`Datetime (`Utc fixed_date));
166 (* Event with unique text in each field *)
167 create_test_event ~collection:"search_test" ~summary:"Workshop on Testing"
168 ~description:"Quality Assurance techniques and practices"
169 ~location:"Training Center"
170 ~start:(`Datetime (`Utc fixed_date));
171 ]
172
173(* Test helper to verify if a list of events contains an event with a given summary *)
174let contains_summary events summary =
175 List.exists
176 (fun e -> String.equal (Option.get @@ Event.get_summary e) summary)
177 events
178
179let test_case_insensitive_search () =
180 (* Test lowercase query for an uppercase word *)
181 let lowercase_filter = Query.summary_contains "important" in
182 let matches =
183 List.filter (fun e -> Query.matches_filter e lowercase_filter) test_events
184 in
185 Alcotest.(check bool)
186 "Lowercase query should match uppercase text in summary" true
187 (contains_summary matches "IMPORTANT Meeting");
188 (* Test uppercase query for a lowercase word *)
189 let uppercase_filter = Query.description_contains "WEEKLY" in
190 let matches =
191 List.filter (fun e -> Query.matches_filter e uppercase_filter) test_events
192 in
193 Alcotest.(check bool)
194 "Uppercase query should match lowercase text in description" true
195 (contains_summary matches "Project Meeting")
196
197let test_partial_word_matching () =
198 (* Test searching for part of a word *)
199 let partial_filter = Query.summary_contains "Conf" in
200 (* Should match "Conference" *)
201 let matches =
202 List.filter (fun e -> Query.matches_filter e partial_filter) test_events
203 in
204 Alcotest.(check bool)
205 "Partial query should match full word in summary" true
206 (contains_summary matches "Conference Call");
207 (* Test another partial word in description *)
208 let partial_filter = Query.description_contains "nation" in
209 (* Should match "International" *)
210 let matches =
211 List.filter (fun e -> Query.matches_filter e partial_filter) test_events
212 in
213 Alcotest.(check bool)
214 "Partial query should match within word in description" true
215 (contains_summary matches "Conference Call");
216
217 Alcotest.(check bool)
218 "Partial query should match within word in description" true
219 (contains_summary matches "Conference Call")
220
221let test_boolean_logic () =
222 (* Test AND filter *)
223 let and_filter =
224 Query.and_filter
225 [ Query.summary_contains "Meeting"; Query.description_contains "project" ]
226 in
227 let matches =
228 List.filter (fun e -> Query.matches_filter e and_filter) test_events
229 in
230 Alcotest.(check int)
231 "AND filter should match events with both terms" 2
232 (* Two events have both "Meeting" in summary and "project" in description *)
233 (List.length matches);
234 (* Test OR filter *)
235 let or_filter =
236 Query.or_filter
237 [ Query.summary_contains "Workshop"; Query.summary_contains "Conference" ]
238 in
239 let matches =
240 List.filter (fun e -> Query.matches_filter e or_filter) test_events
241 in
242 Alcotest.(check int)
243 "OR filter should match events with either term"
244 2 (* One event has "Workshop", one has "Conference" *)
245 (List.length matches);
246
247 (* Test NOT filter *)
248 let not_filter = Query.not_filter (Query.summary_contains "Meeting") in
249 let matches =
250 List.filter (fun e -> Query.matches_filter e not_filter) test_events
251 in
252 Alcotest.(check int)
253 "NOT filter should match events without the term"
254 2 (* Two events don't have "Meeting" in the summary *)
255 (List.length matches);
256 (* Test complex combination: (Meeting AND project) OR Workshop BUT NOT Conference *)
257 let complex_filter =
258 Query.and_filter
259 [
260 Query.or_filter
261 [
262 Query.and_filter
263 [
264 Query.summary_contains "Meeting";
265 Query.description_contains "project";
266 ];
267 Query.summary_contains "Workshop";
268 ];
269 Query.not_filter (Query.summary_contains "Conference");
270 ]
271 in
272 let matches =
273 List.filter (fun e -> Query.matches_filter e complex_filter) test_events
274 in
275 Alcotest.(check int)
276 "Complex filter should match correctly"
277 3 (* Three events should match the complex criteria *)
278 (List.length matches)
279
280let test_cross_field_search () =
281 (* Search for a term that appears in multiple fields across different events *)
282 let term_filter =
283 Query.or_filter
284 [
285 Query.summary_contains "meeting";
286 Query.description_contains "meeting";
287 Query.location_contains "meeting";
288 ]
289 in
290 let matches =
291 List.filter (fun e -> Query.matches_filter e term_filter) test_events
292 in
293 Alcotest.(check int)
294 "Cross-field search should find all occurrences"
295 3 (* "meeting" appears in 3 events across different fields *)
296 (List.length matches);
297 (* Another test with a different term *)
298 let term_filter =
299 Query.or_filter
300 [
301 Query.summary_contains "conference";
302 Query.description_contains "conference";
303 Query.location_contains "conference";
304 ]
305 in
306 let matches =
307 List.filter (fun e -> Query.matches_filter e term_filter) test_events
308 in
309 Alcotest.(check int)
310 "Cross-field search should find all occurrences of 'conference'"
311 2 (* "conference" appears in 2 events across different fields *)
312 (List.length matches)
313
314let query_tests fs =
315 [
316 ("query all events", `Quick, test_query_all ~fs);
317 ("recurrence expansion", `Quick, test_recurrence_expansion ~fs);
318 ("text search", `Quick, test_text_search ~fs);
319 ("calendar filter", `Quick, test_calendar_filter ~fs);
320 ("case insensitive search", `Quick, test_case_insensitive_search);
321 ("partial word matching", `Quick, test_partial_word_matching);
322 ("boolean logic filters", `Quick, test_boolean_logic);
323 ("cross-field searching", `Quick, test_cross_field_search);
324 ]
325
326let () =
327 Eio_main.run @@ fun env ->
328 let fs = Eio.Stdenv.fs env in
329 let _ = setup_fixed_date () in
330 Alcotest.run "Query Tests" [ ("query", query_tests fs) ]