AtlatestRepositorybureau

bureau / tree / testtest-bureau.sgl

1;;; Bureau unit tests
2;;;
3;;; Tests formatting, ISO 8601 parsing, free-slots algorithm,
4;;; and JSON null safety.
5
6(import (sigil test)
7 (sigil string)
8 (sigil json)
9 (sigil math)
10 (sigil time)
11 (sigil caldav)
12 (bureau format)
13 (bureau calendar)
14 (bureau config)
15 (bureau email))
18;; ============================================================
19;; ISO 8601 Parsing
20;; ============================================================
22(test-group "parse-iso8601"
24 (test "date only"
25 (let ((ts (parse-iso8601 "2026-03-12")))
26 (assert-true (number? ts))
27 (assert-equal 2026 (time-year ts))
28 (assert-equal 3 (time-month ts))
29 (assert-equal 12 (time-day ts))))
31 (test "datetime with Z"
32 (let ((ts (parse-iso8601 "2026-03-12T09:00:00Z")))
33 (assert-true (number? ts))
34 ;; 09:00 UTC = 32400 seconds into the day
35 (assert-equal 32400 (- ts (parse-iso8601 "2026-03-12")))))
37 (test "datetime without Z treated as local time"
38 (let ((ts1 (parse-iso8601 "2026-03-12T09:30:00"))
39 (ts2 (parse-iso8601 "2026-03-12T09:30:00Z")))
40 ;; Local time differs from UTC by the timezone offset at that instant
41 ;; (use -at variant so DST resolves correctly for the specific date)
42 (assert-equal (- ts2 (time-utc-offset-at ts2)) ts1)))
44 (test "datetime without seconds"
45 (let ((ts (parse-iso8601 "2026-03-12T14:00Z")))
46 (assert-true (number? ts))
47 ;; 14:00 UTC = 50400 seconds into the day
48 (assert-equal 50400 (- ts (parse-iso8601 "2026-03-12")))))
50 (test "round-trip consistency"
51 (let* ((ts1 (parse-iso8601 "2026-06-15T08:30:00Z"))
52 (ts2 (parse-iso8601 "2026-06-15T08:30:00")))
53 ;; With Z is UTC, without Z is local time. Use offset-at-timestamp
54 ;; so DST transitions resolve correctly for the specific instant.
55 (assert-equal (- ts1 (time-utc-offset-at ts1)) ts2))))
58;; ============================================================
59;; Email Formatting
60;; ============================================================
62(test-group "format-mailbox"
64 (test "mailbox with role and unread"
65 (let ((result (format-mailbox
66 #{ name: "Inbox" role: "inbox"
67 totalEmails: 150 unreadEmails: 5 })))
68 (assert-true (string-contains? result "Inbox"))
69 (assert-true (string-contains? result "(inbox)"))
70 (assert-true (string-contains? result "150 total"))
71 (assert-true (string-contains? result "5 unread"))))
73 (test "mailbox without role"
74 (let ((result (format-mailbox
75 #{ name: "Projects" role: #f
76 totalEmails: 42 unreadEmails: 0 })))
77 (assert-true (string-contains? result "Projects"))
78 (assert-true (string-contains? result "42 total"))
79 (assert-false (string-contains? result "unread"))))
81 (test "mailbox with null role"
82 (let ((result (format-mailbox
83 #{ name: "Custom" role: 'null
84 totalEmails: 10 unreadEmails: 0 })))
85 (assert-true (string-contains? result "Custom"))
86 (assert-false (string-contains? result "null")))))
88(test-group "format-email-summary"
90 (test "basic email summary"
91 (let ((result (format-email-summary
92 #{ id: "em123"
93 from: #[#{ name: "Alice" email: "[email protected]" }]
94 subject: "Project update"
95 receivedAt: "2026-03-12T14:30:00Z"
96 keywords: #{} })))
97 (assert-true (string-contains? result "[em123]"))
98 (assert-true (string-contains? result "Alice"))
99 (assert-true (string-contains? result "Project update"))))
101 (test "unread flagged email"
102 (let ((result (format-email-summary
103 (dict id: "em456"
104 from: #[#{ name: "" email: "[email protected]" }]
105 subject: "Urgent"
106 receivedAt: "2026-03-12T10:00:00Z"
107 keywords: (dict-set #{} (string->keyword "$flagged") #t)))))
108 (assert-true (string-contains? result "unread"))
109 (assert-true (string-contains? result "flagged"))))
111 (test "read email has no unread tag"
112 (let ((result (format-email-summary
113 (dict id: "em789"
114 from: #[#{ name: "Carol" email: "[email protected]" }]
115 subject: "FYI"
116 receivedAt: "2026-03-12T12:00:00Z"
117 keywords: (dict-set #{} (string->keyword "$seen") #t)))))
118 (assert-false (string-contains? result "unread"))))
120 (test "null from field"
121 (let ((result (format-email-summary
122 #{ id: "em000"
123 from: 'null
124 subject: "No sender"
125 receivedAt: "2026-03-12T12:00:00Z"
126 keywords: #{} })))
127 (assert-true (string-contains? result "[em000]"))
128 (assert-true (string-contains? result "No sender"))))
130 (test "null keywords"
131 (let ((result (format-email-summary
132 #{ id: "em111"
133 from: #[#{ email: "[email protected]" }]
134 subject: "Test"
135 receivedAt: "2026-03-12T12:00:00Z"
136 keywords: 'null })))
137 (assert-true (string-contains? result "Test")))))
139(test-group "format-email-full"
141 (test "full email with body"
142 (let ((result (format-email-full
143 #{ id: "full1"
144 from: #[#{ name: "Alice" email: "[email protected]" }]
145 to: #[#{ name: "Bob" email: "[email protected]" }]
146 cc: 'null
147 subject: "Meeting notes"
148 receivedAt: "2026-03-12T14:30:00Z"
149 keywords: #{ "$seen" #t }
150 messageId: #["[email protected]"]
151 bodyValues: #{ part0: #{ value: "Here are the notes." } }
152 textBody: #[#{ partId: "part0" }]
153 htmlBody: 'null
154 attachments: 'null
155 preview: "Here are the notes." })))
156 (assert-true (string-contains? result "ID: full1"))
157 (assert-true (string-contains? result "From: Alice"))
158 (assert-true (string-contains? result "To: Bob"))
159 (assert-false (string-contains? result "CC:"))
160 (assert-true (string-contains? result "Meeting notes"))
161 (assert-true (string-contains? result "Here are the notes."))))
163 (test "full email with cc and attachments"
164 (let ((result (format-email-full
165 #{ id: "full2"
166 from: #[#{ email: "[email protected]" }]
167 to: #[#{ email: "[email protected]" }]
168 cc: #[#{ email: "[email protected]" }]
169 subject: "With attachment"
170 receivedAt: "2026-03-12T14:30:00Z"
171 keywords: #{}
172 messageId: 'null
173 bodyValues: #{}
174 textBody: 'null
175 htmlBody: 'null
176 attachments: #[#{ name: "report.pdf" type: "application/pdf" size: 12345 }]
177 preview: "See attached." })))
178 (assert-true (string-contains? result "CC: [email protected]"))
179 (assert-true (string-contains? result "report.pdf"))
180 (assert-true (string-contains? result "12345 bytes"))))
182 (test "full email with all null optional fields"
183 (let ((result (format-email-full
184 #{ id: "full3"
185 from: 'null
186 to: 'null
187 cc: 'null
188 subject: "Minimal"
189 receivedAt: ""
190 keywords: 'null
191 messageId: 'null
192 bodyValues: 'null
193 textBody: 'null
194 htmlBody: 'null
195 attachments: 'null
196 preview: "Preview text" })))
197 (assert-true (string-contains? result "ID: full3"))
198 (assert-true (string-contains? result "Minimal"))
199 (assert-true (string-contains? result "Preview text")))))
202;; ============================================================
203;; Calendar Formatting
204;; ============================================================
206(test-group "format-calendar"
208 (test "calendar with description and color"
209 (let ((result (format-calendar
210 #{ name: "Work" description: "Work calendar"
211 color: "#0000FF" href: "/cal/work/" })))
212 (assert-true (string-contains? result "Work"))
213 (assert-true (string-contains? result "Work calendar"))
214 (assert-true (string-contains? result "#0000FF"))))
216 (test "calendar with null description"
217 (let ((result (format-calendar
218 #{ name: "Personal" description: 'null
219 color: 'null href: "/cal/personal/" })))
220 (assert-true (string-contains? result "Personal"))
221 (assert-false (string-contains? result "null")))))
223(test-group "format-identity"
225 (test "identity with name"
226 (let ((result (format-identity
227 #{ id: "id1" name: "David Wilson"
228 email: "[email protected]" })))
229 (assert-true (string-contains? result "David Wilson"))
230 (assert-true (string-contains? result "[email protected]"))
231 (assert-true (string-contains? result "[id: id1]"))))
233 (test "identity without name"
234 (let ((result (format-identity
235 #{ id: "id2" name: ""
236 email: "[email protected]" })))
237 (assert-true (string-contains? result "[email protected]"))
238 (assert-false (string-contains? result "<")))))
240(test-group "format-event-summary"
242 (test "timed event with location"
243 (let* ((ts-start (parse-iso8601 "2026-03-14T09:00:00Z"))
244 (ts-end (parse-iso8601 "2026-03-14T10:00:00Z"))
245 (event (ical-event summary: "Team standup"
246 dtstart: ts-start dtend: ts-end
247 location: "Room 42"
248 all-day?: #f)))
249 (let ((result (format-event-summary event)))
250 (assert-true (string-contains? result "Team standup"))
251 (assert-true (string-contains? result "Room 42")))))
253 (test "TZID-tagged recurring event keeps wall-clock across DST"
254 ;; Regression for the CodeSignal Simulation/Team-coffee miscount.
255 ;; A weekly meeting with TZID=Europe/Athens and a winter master
256 ;; (17:00 EET = 15:00 UTC on Jan 13 2026) must render Apr 28's
257 ;; occurrence at 17:00 EEST (14:00 UTC), not 18:00 (the UTC-naive
258 ;; walker's drift) and not 16:00 (the EET-offset rendering of the
259 ;; correct UTC observed on the broken sigil-stdlib v0.14.3).
260 (let* ((ics (string-append
261 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\n"
263 "DTSTART;TZID=Europe/Athens:20260113T170000\r\n"
264 "DTEND;TZID=Europe/Athens:20260113T180000\r\n"
265 "SUMMARY:Simulation - Estimation/Planning\r\n"
266 "RRULE:FREQ=WEEKLY;BYDAY=TU\r\n"
267 "END:VEVENT\r\nEND:VCALENDAR"))
268 (master (car (ical-parse ics)))
269 (range-start (parse-ical-datetime "20260428T000000Z"))
270 (range-end (parse-ical-datetime "20260429T000000Z"))
271 (occurrences (ical-expand-recurrence master
272 start: range-start
273 end: range-end)))
274 (assert-equal 1 (length occurrences))
275 ;; 14:00 UTC is the wall-clock anchor 17:00 in EEST (UTC+3).
276 (assert-equal "20260428T140000Z"
277 (format-ical-datetime
278 (ical-event-dtstart (car occurrences))))))
280 (test "event without location"
281 (let* ((ts-start (parse-iso8601 "2026-03-14T14:00:00Z"))
282 (event (ical-event summary: "Focus time"
283 dtstart: ts-start
284 all-day?: #f)))
285 (let ((result (format-event-summary event)))
286 (assert-true (string-contains? result "Focus time"))
287 (assert-false (string-contains? result "| |"))))))
289(test-group "format-event-full"
291 (test "full event details"
292 (let* ((ts-start (parse-iso8601 "2026-03-14T09:00:00Z"))
293 (ts-end (parse-iso8601 "2026-03-14T10:00:00Z"))
294 (event (ical-event summary: "Sprint review"
295 dtstart: ts-start dtend: ts-end
296 location: "Conference Room A"
297 description: "Review sprint goals"
298 status: "CONFIRMED"
299 all-day?: #f)))
300 (let ((result (format-event-full event)))
301 (assert-true (string-contains? result "Summary: Sprint review"))
302 (assert-true (string-contains? result "Location: Conference Room A"))
303 (assert-true (string-contains? result "Status: CONFIRMED"))
304 (assert-true (string-contains? result "Review sprint goals")))))
306 (test "event with recurrence"
307 (let* ((ts-start (parse-iso8601 "2026-03-14T08:00:00Z"))
308 (event (ical-event summary: "Daily standup"
309 dtstart: ts-start
310 rrule: "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"
311 all-day?: #f)))
312 (let ((result (format-event-full event)))
313 (assert-true (string-contains? result "Recurrence: FREQ=WEEKLY"))))))
315(test-group "format-free-slot"
317 (test "slot duration in minutes"
318 (let* ((start (parse-iso8601 "2026-03-14T10:00:00Z"))
319 (end (parse-iso8601 "2026-03-14T11:30:00Z"))
320 (result (format-free-slot start end)))
321 (assert-true (string-contains? result "90 min free")))))
324;; ============================================================
325;; Free Slots Algorithm
326;; ============================================================
328(test-group "merge-intervals"
330 (test "empty list"
331 (assert-equal '() (merge-intervals '())))
333 (test "single interval"
334 (assert-equal '((100 . 200))
335 (merge-intervals '((100 . 200)))))
337 (test "non-overlapping"
338 (assert-equal '((100 . 200) (300 . 400))
339 (merge-intervals '((100 . 200) (300 . 400)))))
341 (test "overlapping intervals"
342 (assert-equal '((100 . 400))
343 (merge-intervals '((100 . 300) (200 . 400)))))
345 (test "adjacent intervals"
346 (assert-equal '((100 . 400))
347 (merge-intervals '((100 . 200) (200 . 400)))))
349 (test "contained interval"
350 (assert-equal '((100 . 500))
351 (merge-intervals '((100 . 500) (200 . 300)))))
353 (test "multiple overlapping"
354 (assert-equal '((100 . 600))
355 (merge-intervals '((100 . 300) (200 . 400) (350 . 600))))))
357(test-group "find-free-slots"
359 (test "no busy intervals"
360 (let ((slots (find-free-slots '() 1000 2000 0 #f)))
361 (assert-equal 1 (length slots))
362 (assert-equal 1000 (car (car slots)))
363 (assert-equal 2000 (cdr (car slots)))))
365 (test "one busy interval in middle"
366 (let ((slots (find-free-slots '((1200 . 1400)) 1000 2000 0 #f)))
367 (assert-equal 2 (length slots))
368 ;; Before busy
369 (assert-equal 1000 (car (car slots)))
370 (assert-equal 1200 (cdr (car slots)))
371 ;; After busy
372 (assert-equal 1400 (car (cadr slots)))
373 (assert-equal 2000 (cdr (cadr slots)))))
375 (test "fully busy"
376 (let ((slots (find-free-slots '((1000 . 2000)) 1000 2000 0 #f)))
377 (assert-equal 0 (length slots))))
379 (test "minimum duration filtering"
380 ;; 100-unit gap but 500 min-duration requirement
381 (let ((slots (find-free-slots '((1100 . 1900)) 1000 2000 500 #f)))
382 (assert-equal 0 (length slots))))
384 (test "overlapping busy intervals"
385 (let ((slots (find-free-slots '((1100 . 1300) (1200 . 1500)) 1000 2000 0 #f)))
386 (assert-equal 2 (length slots))
387 (assert-equal 1000 (car (car slots)))
388 (assert-equal 1100 (cdr (car slots)))
389 (assert-equal 1500 (car (cadr slots)))
390 (assert-equal 2000 (cdr (cadr slots))))))
393;; ============================================================
394;; Credential Validation
395;; ============================================================
397(test-group "credentials"
399 (test "credentials-valid? with missing vars"
400 ;; When env vars aren't set, should return #f
401 (assert-true (boolean? (bureau-credentials-valid?)))))
404;; ============================================================
405;; ID Parsing (batch operations)
406;; ============================================================
408(test-group "parse-id-list"
410 (test "simple comma-separated IDs"
411 (assert-equal '("id1" "id2" "id3")
412 (parse-id-list "id1,id2,id3")))
414 (test "IDs with spaces"
415 (assert-equal '("id1" "id2" "id3")
416 (parse-id-list "id1, id2, id3")))
418 (test "trailing comma filtered"
419 (assert-equal '("id1" "id2")
420 (parse-id-list "id1,id2,")))
422 (test "leading comma filtered"
423 (assert-equal '("id1" "id2")
424 (parse-id-list ",id1,id2")))
426 (test "double comma filtered"
427 (assert-equal '("id1" "id2")
428 (parse-id-list "id1,,id2")))
430 (test "whitespace-only entries filtered"
431 (assert-equal '("id1" "id2")
432 (parse-id-list "id1, ,id2")))
434 (test "single ID"
435 (assert-equal '("id1")
436 (parse-id-list "id1")))
438 (test "empty string raises error"
439 (assert-error (parse-id-list "")))
441 (test "only commas raises error"
442 (assert-error (parse-id-list ",,,"))))
445;; ============================================================
446;; Date Normalization (JMAP UTCDate)
447;; ============================================================
449(test-group "normalize-utc-date"
451 (test "date only"
452 (assert-equal "2026-04-01T00:00:00Z"
453 (normalize-utc-date "2026-04-01")))
455 (test "full UTC datetime passes through"
456 (assert-equal "2026-04-01T18:30:00Z"
457 (normalize-utc-date "2026-04-01T18:30:00Z")))
459 (test "datetime without Z gets Z appended"
460 (assert-equal "2026-04-01T18:30:00Z"
461 (normalize-utc-date "2026-04-01T18:30:00")))
463 (test "partial time (no seconds) gets :00 added"
464 (assert-equal "2026-04-01T18:30:00Z"
465 (normalize-utc-date "2026-04-01T18:30")))
467 (test "partial time with Z"
468 (assert-equal "2026-04-01T14:00:00Z"
469 (normalize-utc-date "2026-04-01T14:00Z")))
471 (test "timezone offset +HH:MM stripped"
472 (assert-equal "2026-04-01T18:30:00Z"
473 (normalize-utc-date "2026-04-01T18:30:00+03:00")))
475 (test "timezone offset -HH:MM stripped"
476 (assert-equal "2026-04-01T18:30:00Z"
477 (normalize-utc-date "2026-04-01T18:30:00-05:00")))
479 (test "fractional seconds stripped"
480 (assert-equal "2026-04-01T18:30:00Z"
481 (normalize-utc-date "2026-04-01T18:30:00.123Z")))
483 (test "fractional seconds with offset stripped"
484 (assert-equal "2026-04-01T18:30:00Z"
485 (normalize-utc-date "2026-04-01T18:30:00.000+03:00"))))