AtlatestRepositorybureau
1
;;; Bureau unit tests2
;;;3
;;; Tests formatting, ISO 8601 parsing, free-slots algorithm,4
;;; and JSON null safety.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 Parsing20
;; ============================================================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 day35
(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 instant41
;; (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 day48
(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-timestamp54
;; so DST transitions resolve correctly for the specific instant.55
(assert-equal (- ts1 (time-utc-offset-at ts1)) ts2))))58
;; ============================================================59
;; Email Formatting60
;; ============================================================62
(test-group "format-mailbox"64
(test "mailbox with role and unread"65
(let ((result (format-mailbox66
#{ 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-mailbox75
#{ name: "Projects" role: #f76
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-mailbox83
#{ name: "Custom" role: 'null84
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-summary92
#{ 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-summary103
(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-summary113
(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-summary122
#{ id: "em000"123
from: 'null124
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-summary132
#{ 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-full143
#{ id: "full1"144
from: #[#{ name: "Alice" email: "[email protected]" }]145
to: #[#{ name: "Bob" email: "[email protected]" }]146
cc: 'null147
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: 'null154
attachments: 'null155
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-full165
#{ 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: 'null173
bodyValues: #{}174
textBody: 'null175
htmlBody: 'null176
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-full184
#{ id: "full3"185
from: 'null186
to: 'null187
cc: 'null188
subject: "Minimal"189
receivedAt: ""190
keywords: 'null191
messageId: 'null192
bodyValues: 'null193
textBody: 'null194
htmlBody: 'null195
attachments: 'null196
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 Formatting204
;; ============================================================206
(test-group "format-calendar"208
(test "calendar with description and color"209
(let ((result (format-calendar210
#{ 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-calendar218
#{ name: "Personal" description: 'null219
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-identity227
#{ 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-identity235
#{ 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-end247
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 master256
;; (17:00 EET = 15:00 UTC on Jan 13 2026) must render Apr 28's257
;; occurrence at 17:00 EEST (14:00 UTC), not 18:00 (the UTC-naive258
;; walker's drift) and not 16:00 (the EET-offset rendering of the259
;; correct UTC observed on the broken sigil-stdlib v0.14.3).260
(let* ((ics (string-append261
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\n"262
"UID:[email protected]\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 master272
start: range-start273
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-datetime278
(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-start284
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-end296
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-start310
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 Algorithm326
;; ============================================================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 busy369
(assert-equal 1000 (car (car slots)))370
(assert-equal 1200 (cdr (car slots)))371
;; After busy372
(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 requirement381
(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 Validation395
;; ============================================================397
(test-group "credentials"399
(test "credentials-valid? with missing vars"400
;; When env vars aren't set, should return #f401
(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"))))