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