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 calendar_name = "example" in
107 let filter = Query.in_calendars [ calendar_name ] 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 ->
113 match Event.get_calendar_name e with id -> id = calendar_name)
114 events
115 in
116 Alcotest.(check bool)
117 (Printf.sprintf "All events should be from calendar '%s'" calendar_name)
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 calendar_names = [ "example"; "recurrence" ] in
122 let filter = Query.in_calendars calendar_names 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 = Query.in_calendars [ "non-existent-calendar" ] in
128 (match Query.query ~fs calendar_dir ~from ~to_ ~filter () with
129 | Ok events ->
130 Alcotest.(check int)
131 "Should find 0 events for non-existent calendar" 0 (List.length events)
132 | Error _ -> Alcotest.fail "Error querying events");
133 ()
134
135let test_events ~fs =
136 (* Create a test event with specific text in all fields *)
137 let create_test_event ~calendar_name ~summary ~description ~location ~start =
138 Event.create ~fs ~calendar_dir_path ~summary ~start
139 ?description:(if description = "" then None else Some description)
140 ?location:(if location = "" then None else Some location)
141 calendar_name
142 in
143 [
144 (* Event with text in all fields *)
145 Result.get_ok
146 @@ create_test_event ~calendar_name:"search_test" ~summary:"Project Meeting"
147 ~description:"Weekly project status meeting with team"
148 ~location:"Conference Room A"
149 ~start:(Icalendar.Params.empty, `Datetime (`Utc fixed_date));
150 (* Event with mixed case to test case insensitivity *)
151 Result.get_ok
152 @@ create_test_event ~calendar_name:"search_test"
153 ~summary:"IMPORTANT Meeting"
154 ~description:"Critical project review with stakeholders"
155 ~location:"Executive Suite"
156 ~start:(Icalendar.Params.empty, `Datetime (`Utc fixed_date));
157 (* Event with word fragments *)
158 Result.get_ok
159 @@ create_test_event ~calendar_name:"search_test" ~summary:"Conference Call"
160 ~description:"International conference preparation"
161 ~location:"Remote Meeting Room"
162 ~start:(Icalendar.Params.empty, `Datetime (`Utc fixed_date));
163 (* Event with unique text in each field *)
164 Result.get_ok
165 @@ create_test_event ~calendar_name:"search_test"
166 ~summary:"Workshop on Testing"
167 ~description:"Quality Assurance techniques and practices"
168 ~location:"Training Center"
169 ~start:(Icalendar.Params.empty, `Datetime (`Utc fixed_date));
170 ]
171
172(* Test helper to verify if a list of events contains an event with a given summary *)
173let contains_summary events summary =
174 List.exists
175 (fun e -> String.equal (Option.get @@ Event.get_summary e) summary)
176 events
177
178let test_case_insensitive_search ~fs () =
179 (* Test lowercase query for an uppercase word *)
180 let lowercase_filter = Query.summary_contains "important" in
181 let matches =
182 List.filter
183 (fun e -> Query.matches_filter e lowercase_filter)
184 (test_events ~fs)
185 in
186 Alcotest.(check bool)
187 "Lowercase query should match uppercase text in summary" true
188 (contains_summary matches "IMPORTANT Meeting");
189 (* Test uppercase query for a lowercase word *)
190 let uppercase_filter = Query.description_contains "WEEKLY" in
191 let matches =
192 List.filter
193 (fun e -> Query.matches_filter e uppercase_filter)
194 (test_events ~fs)
195 in
196 Alcotest.(check bool)
197 "Uppercase query should match lowercase text in description" true
198 (contains_summary matches "Project Meeting")
199
200let test_partial_word_matching ~fs () =
201 (* Test searching for part of a word *)
202 let partial_filter = Query.summary_contains "Conf" in
203 (* Should match "Conference" *)
204 let matches =
205 List.filter
206 (fun e -> Query.matches_filter e partial_filter)
207 (test_events ~fs)
208 in
209 Alcotest.(check bool)
210 "Partial query should match full word in summary" true
211 (contains_summary matches "Conference Call");
212 (* Test another partial word in description *)
213 let partial_filter = Query.description_contains "nation" in
214 (* Should match "International" *)
215 let matches =
216 List.filter
217 (fun e -> Query.matches_filter e partial_filter)
218 (test_events ~fs)
219 in
220 Alcotest.(check bool)
221 "Partial query should match within word in description" true
222 (contains_summary matches "Conference Call");
223
224 Alcotest.(check bool)
225 "Partial query should match within word in description" true
226 (contains_summary matches "Conference Call")
227
228let test_boolean_logic ~fs () =
229 (* Test AND filter *)
230 let and_filter =
231 Query.and_filter
232 [ Query.summary_contains "Meeting"; Query.description_contains "project" ]
233 in
234 let matches =
235 List.filter (fun e -> Query.matches_filter e and_filter) (test_events ~fs)
236 in
237 Alcotest.(check int)
238 "AND filter should match events with both terms" 2
239 (* Two events have both "Meeting" in summary and "project" in description *)
240 (List.length matches);
241 (* Test OR filter *)
242 let or_filter =
243 Query.or_filter
244 [ Query.summary_contains "Workshop"; Query.summary_contains "Conference" ]
245 in
246 let matches =
247 List.filter (fun e -> Query.matches_filter e or_filter) (test_events ~fs)
248 in
249 Alcotest.(check int)
250 "OR filter should match events with either term"
251 2 (* One event has "Workshop", one has "Conference" *)
252 (List.length matches);
253
254 (* Test NOT filter *)
255 let not_filter = Query.not_filter (Query.summary_contains "Meeting") in
256 let matches =
257 List.filter (fun e -> Query.matches_filter e not_filter) (test_events ~fs)
258 in
259 Alcotest.(check int)
260 "NOT filter should match events without the term"
261 2 (* Two events don't have "Meeting" in the summary *)
262 (List.length matches);
263 (* Test complex combination: (Meeting AND project) OR Workshop BUT NOT Conference *)
264 let complex_filter =
265 Query.and_filter
266 [
267 Query.or_filter
268 [
269 Query.and_filter
270 [
271 Query.summary_contains "Meeting";
272 Query.description_contains "project";
273 ];
274 Query.summary_contains "Workshop";
275 ];
276 Query.not_filter (Query.summary_contains "Conference");
277 ]
278 in
279 let matches =
280 List.filter
281 (fun e -> Query.matches_filter e complex_filter)
282 (test_events ~fs)
283 in
284 Alcotest.(check int)
285 "Complex filter should match correctly"
286 3 (* Three events should match the complex criteria *)
287 (List.length matches)
288
289let test_cross_field_search ~fs () =
290 (* Search for a term that appears in multiple fields across different events *)
291 let term_filter =
292 Query.or_filter
293 [
294 Query.summary_contains "meeting";
295 Query.description_contains "meeting";
296 Query.location_contains "meeting";
297 ]
298 in
299 let matches =
300 List.filter (fun e -> Query.matches_filter e term_filter) (test_events ~fs)
301 in
302 Alcotest.(check int)
303 "Cross-field search should find all occurrences"
304 3 (* "meeting" appears in 3 events across different fields *)
305 (List.length matches);
306 (* Another test with a different term *)
307 let term_filter =
308 Query.or_filter
309 [
310 Query.summary_contains "conference";
311 Query.description_contains "conference";
312 Query.location_contains "conference";
313 ]
314 in
315 let matches =
316 List.filter (fun e -> Query.matches_filter e term_filter) (test_events ~fs)
317 in
318 Alcotest.(check int)
319 "Cross-field search should find all occurrences of 'conference'"
320 2 (* "conference" appears in 2 events across different fields *)
321 (List.length matches)
322
323let query_tests fs =
324 [
325 ("query all events", `Quick, test_query_all ~fs);
326 ("recurrence expansion", `Quick, test_recurrence_expansion ~fs);
327 ("text search", `Quick, test_text_search ~fs);
328 ("calendar filter", `Quick, test_calendar_filter ~fs);
329 ("case insensitive search", `Quick, test_case_insensitive_search ~fs);
330 ("partial word matching", `Quick, test_partial_word_matching ~fs);
331 ("boolean logic filters", `Quick, test_boolean_logic ~fs);
332 ("cross-field searching", `Quick, test_cross_field_search ~fs);
333 ]
334
335let () =
336 Eio_main.run @@ fun env ->
337 let fs = Eio.Stdenv.fs env in
338 let _ = setup_fixed_date () in
339 Alcotest.run "Query Tests" [ ("query", query_tests fs) ]