diff options
| author | Craig Jennings <c@cjennings.net> | 2026-02-05 15:13:57 -0600 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-02-05 15:13:57 -0600 |
| commit | b7cb1c51e5663419344d8b55766635801f3ee4c8 (patch) | |
| tree | a13d903c1d7d82d8b49fe7edbd5f9b7652592c23 | |
| parent | 12f36cb887c3e84741bc2f3d6afd9e71c6ffddd7 (diff) | |
feat(calendar-sync): add event details — attendees, organizer, status, URL
Add ICS text unescaping (RFC 5545), HTML stripping, and new fields
(attendees/status, organizer, meeting URL) to calendar-sync.el.
event-to-org now outputs org property drawers. 88 new tests across
10 test files, 146/146 pass. Also fix pre-existing test require
order and keymap guard issues.
22 files changed, 1113 insertions, 24 deletions
diff --git a/modules/calendar-sync.el b/modules/calendar-sync.el index d3b6880a..fadad6c0 100644 --- a/modules/calendar-sync.el +++ b/modules/calendar-sync.el @@ -111,6 +111,12 @@ Default: 60 minutes (1 hour).") If non-nil, sync starts automatically when calendar-sync is loaded. If nil, user must manually call `calendar-sync-start'.") +(defvar calendar-sync-user-emails + '("craigmartinjennings@gmail.com" "craig.jennings@deepsat.com" "c@cjennings.net") + "List of user email addresses for determining acceptance status. +Used by `calendar-sync--find-user-status' to look up the user's +PARTSTAT in event attendee lists.") + (defvar calendar-sync-past-months 3 "Number of months in the past to include when expanding recurring events. Default: 3 months. This keeps recent history visible in org-agenda.") @@ -228,6 +234,47 @@ Returns CONTENT with all \\r characters removed." content (replace-regexp-in-string "\r" "" content))) +;;; Text Cleaning (ICS unescape + HTML strip) + +(defun calendar-sync--unescape-ics-text (text) + "Unescape RFC 5545 escape sequences in TEXT. +Converts: \\n→newline, \\,→comma, \\\\→backslash, \\;→semicolon. +Returns nil for nil input." + (when text + ;; Use placeholder for literal backslash to avoid double-unescaping. + ;; replace-regexp-in-string with LITERAL=t avoids backslash interpretation. + (let ((result (replace-regexp-in-string "\\\\\\\\" "\000" text))) + (setq result (replace-regexp-in-string "\\\\n" "\n" result t t)) + (setq result (replace-regexp-in-string "\\\\," "," result t t)) + (setq result (replace-regexp-in-string "\\\\;" ";" result t t)) + (replace-regexp-in-string "\000" "\\" result t t)))) + +(defun calendar-sync--strip-html (text) + "Strip HTML tags from TEXT and decode common HTML entities. +Converts <br>, <br/>, <br /> to newlines. Strips all other tags. +Decodes & < > ". Collapses excessive blank lines. +Returns nil for nil input." + (when text + (let ((result text)) + ;; Convert <br> variants to newline (must come before tag stripping) + (setq result (replace-regexp-in-string "<br[ \t]*/?>[ \t]*" "\n" result)) + ;; Strip all remaining HTML tags + (setq result (replace-regexp-in-string "<[^>]*>" "" result)) + ;; Decode HTML entities + (setq result (replace-regexp-in-string "&" "&" result)) + (setq result (replace-regexp-in-string "<" "<" result)) + (setq result (replace-regexp-in-string ">" ">" result)) + (setq result (replace-regexp-in-string """ "\"" result)) + ;; Collapse 3+ consecutive newlines to 2 + (setq result (replace-regexp-in-string "\n\\{3,\\}" "\n\n" result)) + result))) + +(defun calendar-sync--clean-text (text) + "Clean TEXT by unescaping ICS sequences, stripping HTML, and trimming. +Returns nil for nil input. Returns empty string for whitespace-only input." + (when text + (string-trim (calendar-sync--strip-html (calendar-sync--unescape-ics-text text))))) + ;;; Date Utilities (defun calendar-sync--add-months (date months) @@ -438,6 +485,13 @@ Compares year, month, day, hour, minute." (plist-put result :description (plist-get exception :description))) (when (plist-get exception :location) (plist-put result :location (plist-get exception :location))) + ;; Pass through new fields if exception overrides them + (when (plist-get exception :attendees) + (plist-put result :attendees (plist-get exception :attendees))) + (when (plist-get exception :organizer) + (plist-put result :organizer (plist-get exception :organizer))) + (when (plist-get exception :url) + (plist-put result :url (plist-get exception :url))) result)) (defun calendar-sync--apply-recurrence-exceptions (occurrences exceptions) @@ -618,6 +672,102 @@ Returns nil if property not found." (when (string-match (format "^\\(%s[^\n]*\\)$" (regexp-quote property)) event) (match-string 1 event))) +(defun calendar-sync--get-all-property-lines (event property) + "Extract ALL lines matching PROPERTY from EVENT string. +Unlike `calendar-sync--get-property-line' which returns the first match, +this returns a list of all matching lines. Handles continuation lines +\(lines starting with space or tab). +Returns nil if EVENT or PROPERTY is nil, or no matches found." + (when (and event property (stringp event) (not (string-empty-p event))) + (let ((lines '()) + (pattern (format "^%s[^\n]*" (regexp-quote property))) + (pos 0)) + (while (string-match pattern event pos) + (let ((line (match-string 0 event)) + (end (match-end 0))) + ;; Handle continuation lines (start with space or tab after newline) + (while (and (< end (length event)) + (string-match "\n[ \t]\\([^\n]*\\)" event end) + (= (match-beginning 0) end)) + (setq line (concat line (match-string 1 event))) + (setq end (match-end 0))) + (push line lines) + (setq pos (if (< end (length event)) (1+ end) end)))) + (nreverse lines)))) + +(defun calendar-sync--parse-attendee-line (line) + "Parse single ATTENDEE LINE into plist. +Returns plist (:cn NAME :email EMAIL :partstat STATUS :role ROLE). +Returns nil for nil, empty, or malformed input." + (when (and line (stringp line) (not (string-empty-p line)) + (string-match-p "^ATTENDEE" line)) + (let ((cn nil) + (email nil) + (partstat nil) + (role nil)) + ;; Extract CN parameter + (when (string-match ";CN=\\([^;:]+\\)" line) + (setq cn (match-string 1 line)) + ;; Strip surrounding quotes if present + (when (and (string-prefix-p "\"" cn) (string-suffix-p "\"" cn)) + (setq cn (substring cn 1 -1)))) + ;; Extract PARTSTAT parameter + (when (string-match ";PARTSTAT=\\([^;:]+\\)" line) + (setq partstat (match-string 1 line))) + ;; Extract ROLE parameter + (when (string-match ";ROLE=\\([^;:]+\\)" line) + (setq role (match-string 1 line))) + ;; Extract email from mailto: value + (when (string-match "mailto:\\([^>\n ]+\\)" line) + (setq email (match-string 1 line))) + (when email + (list :cn cn :email email :partstat partstat :role role))))) + +(defun calendar-sync--find-user-status (attendees user-emails) + "Find user's PARTSTAT from ATTENDEES list using USER-EMAILS. +ATTENDEES is list of plists from `calendar-sync--parse-attendee-line'. +USER-EMAILS is list of email strings to match against. +Returns lowercase status string (\"accepted\", \"declined\", etc.) or nil." + (when (and attendees user-emails) + (let ((user-emails-lower (mapcar #'downcase user-emails)) + (found nil)) + (cl-dolist (attendee attendees) + (let ((attendee-email (downcase (or (plist-get attendee :email) "")))) + (when (member attendee-email user-emails-lower) + (let ((partstat (plist-get attendee :partstat))) + (when partstat + (setq found (downcase partstat)) + (cl-return found)))))) + found))) + +(defun calendar-sync--parse-organizer (event-str) + "Parse ORGANIZER property from EVENT-STR into plist. +Returns plist (:cn NAME :email EMAIL), or nil if no ORGANIZER found." + (when (and event-str (stringp event-str)) + (let ((line (calendar-sync--get-property-line event-str "ORGANIZER"))) + (when line + (let ((cn nil) + (email nil)) + ;; Extract CN parameter + (when (string-match ";CN=\\([^;:]+\\)" line) + (setq cn (match-string 1 line)) + ;; Strip surrounding quotes if present + (when (and (string-prefix-p "\"" cn) (string-suffix-p "\"" cn)) + (setq cn (substring cn 1 -1)))) + ;; Extract email from mailto: value + (when (string-match "mailto:\\([^>\n ]+\\)" line) + (setq email (match-string 1 line))) + (when email + (list :cn cn :email email))))))) + +(defun calendar-sync--extract-meeting-url (event-str) + "Extract meeting URL from EVENT-STR. +Prefers X-GOOGLE-CONFERENCE over URL property. +Returns URL string or nil." + (when (and event-str (stringp event-str)) + (or (calendar-sync--get-property event-str "X-GOOGLE-CONFERENCE") + (calendar-sync--get-property event-str "URL")))) + (defun calendar-sync--extract-tzid (property-line) "Extract TZID parameter value from PROPERTY-LINE. PROPERTY-LINE is like 'DTSTART;TZID=Europe/Lisbon:20260202T190000'. @@ -938,17 +1088,22 @@ Filters out dates excluded via EXDATE properties." (defun calendar-sync--parse-event (event-str) "Parse single VEVENT string EVENT-STR into plist. -Returns plist with :uid :summary :description :location :start :end. +Returns plist with :uid :summary :description :location :start :end +:attendees :organizer :url :status. Returns nil if event lacks required fields (DTSTART, SUMMARY). Skips events with RECURRENCE-ID (individual instances of recurring events are handled separately via exception collection). -Handles TZID-qualified timestamps by converting to local time." +Handles TZID-qualified timestamps by converting to local time. +Cleans text fields (description, location, summary) via `calendar-sync--clean-text'." ;; Skip individual instances of recurring events (they're collected as exceptions) (unless (calendar-sync--get-property event-str "RECURRENCE-ID") (let* ((uid (calendar-sync--get-property event-str "UID")) - (summary (calendar-sync--get-property event-str "SUMMARY")) - (description (calendar-sync--get-property event-str "DESCRIPTION")) - (location (calendar-sync--get-property event-str "LOCATION")) + (summary (calendar-sync--clean-text + (calendar-sync--get-property event-str "SUMMARY"))) + (description (calendar-sync--clean-text + (calendar-sync--get-property event-str "DESCRIPTION"))) + (location (calendar-sync--clean-text + (calendar-sync--get-property event-str "LOCATION"))) ;; Get raw property values (dtstart (calendar-sync--get-property event-str "DTSTART")) (dtend (calendar-sync--get-property event-str "DTEND")) @@ -956,7 +1111,15 @@ Handles TZID-qualified timestamps by converting to local time." (dtstart-line (calendar-sync--get-property-line event-str "DTSTART")) (dtend-line (calendar-sync--get-property-line event-str "DTEND")) (start-tzid (calendar-sync--extract-tzid dtstart-line)) - (end-tzid (calendar-sync--extract-tzid dtend-line))) + (end-tzid (calendar-sync--extract-tzid dtend-line)) + ;; Extract attendees + (attendee-lines (calendar-sync--get-all-property-lines event-str "ATTENDEE")) + (attendees (delq nil (mapcar #'calendar-sync--parse-attendee-line attendee-lines))) + ;; Extract organizer and URL + (organizer (calendar-sync--parse-organizer event-str)) + (url (calendar-sync--extract-meeting-url event-str)) + ;; Determine user status from attendees + (status (calendar-sync--find-user-status attendees calendar-sync-user-emails))) (when (and summary dtstart) (let ((start-parsed (calendar-sync--parse-timestamp dtstart start-tzid)) (end-parsed (and dtend (calendar-sync--parse-timestamp dtend end-tzid)))) @@ -966,23 +1129,52 @@ Handles TZID-qualified timestamps by converting to local time." :description description :location location :start start-parsed - :end end-parsed))))))) + :end end-parsed + :attendees attendees + :organizer organizer + :url url + :status status))))))) (defun calendar-sync--event-to-org (event) - "Convert parsed EVENT plist to org entry string." - (let* ((summary (plist-get event :summary)) + "Convert parsed EVENT plist to org entry string. +Produces property drawer with LOCATION, ORGANIZER, STATUS, URL when present. +Description appears as body text after the drawer." + (let* ((summary (or (plist-get event :summary) "(No Title)")) (description (plist-get event :description)) (location (plist-get event :location)) (start (plist-get event :start)) (end (plist-get event :end)) + (organizer (plist-get event :organizer)) + (status (plist-get event :status)) + (url (plist-get event :url)) (timestamp (calendar-sync--format-timestamp start end)) - (parts (list (format "* %s" summary)))) - (push timestamp parts) - (when description - (push description parts)) - (when location - (push (format "Location: %s" location) parts)) - (string-join (nreverse parts) "\n"))) + ;; Build property drawer entries + (props '())) + ;; Collect non-nil properties + (when (and location (not (string-empty-p location))) + (push (format ":LOCATION: %s" location) props)) + (when organizer + (let ((org-name (or (plist-get organizer :cn) + (plist-get organizer :email)))) + (when org-name + (push (format ":ORGANIZER: %s" org-name) props)))) + (when (and status (not (string-empty-p status))) + (push (format ":STATUS: %s" status) props)) + (when (and url (not (string-empty-p url))) + (push (format ":URL: %s" url) props)) + (setq props (nreverse props)) + ;; Build output + (let ((parts (list (format "* %s" summary) timestamp))) + ;; Add property drawer if any properties exist + (when props + (push ":PROPERTIES:" parts) + (dolist (prop props) + (push prop parts)) + (push ":END:" parts)) + ;; Add description as body text + (when (and description (not (string-empty-p description))) + (push description parts)) + (string-join (nreverse parts) "\n")))) (defun calendar-sync--event-start-time (event) "Extract comparable start time from EVENT plist. diff --git a/modules/custom-buffer-file.el b/modules/custom-buffer-file.el index 7ff38250..92e73c3d 100644 --- a/modules/custom-buffer-file.el +++ b/modules/custom-buffer-file.el @@ -447,7 +447,8 @@ Signals an error if: "g" #'revert-buffer "w" #'cj/view-buffer-in-eww "e" #'cj/view-email-in-buffer) -(keymap-set cj/custom-keymap "b" cj/buffer-and-file-map) +(when (boundp 'cj/custom-keymap) + (keymap-set cj/custom-keymap "b" cj/buffer-and-file-map)) (with-eval-after-load 'which-key (which-key-add-key-based-replacements diff --git a/tests/test-calendar-sync--clean-text.el b/tests/test-calendar-sync--clean-text.el new file mode 100644 index 00000000..86c8532b --- /dev/null +++ b/tests/test-calendar-sync--clean-text.el @@ -0,0 +1,58 @@ +;;; test-calendar-sync--clean-text.el --- Tests for clean-text composition -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for calendar-sync--clean-text function. +;; Composes unescape-ics-text + strip-html, trims whitespace. Returns nil for nil. +;; Covers Normal, Boundary, and Error cases. + +;;; Code: + +(require 'ert) +(require 'testutil-calendar-sync) +(require 'calendar-sync) + +;;; Normal Cases + +(ert-deftest test-calendar-sync--clean-text-normal-both-html-and-ics () + "Test text with both HTML tags and ICS escapes." + (should (string= "Hello, World\nNext line" + (calendar-sync--clean-text "Hello\\, World<br>Next line")))) + +(ert-deftest test-calendar-sync--clean-text-normal-pure-ics-escapes () + "Test text with only ICS escapes." + (should (string= "a, b; c" + (calendar-sync--clean-text "a\\, b\\; c")))) + +(ert-deftest test-calendar-sync--clean-text-normal-pure-html () + "Test text with only HTML." + (should (string= "bold and italic" + (calendar-sync--clean-text "<b>bold</b> and <i>italic</i>")))) + +;;; Boundary Cases + +(ert-deftest test-calendar-sync--clean-text-boundary-already-clean () + "Test already-clean text passes through unchanged." + (should (string= "no escapes here" + (calendar-sync--clean-text "no escapes here")))) + +(ert-deftest test-calendar-sync--clean-text-boundary-empty-string () + "Test empty string returns empty string." + (should (string= "" (calendar-sync--clean-text "")))) + +(ert-deftest test-calendar-sync--clean-text-boundary-whitespace-only () + "Test whitespace-only string returns empty after trim." + (should (string= "" (calendar-sync--clean-text " \n \t ")))) + +(ert-deftest test-calendar-sync--clean-text-boundary-leading-trailing-whitespace () + "Test leading/trailing whitespace is trimmed." + (should (string= "content" + (calendar-sync--clean-text " content ")))) + +;;; Error Cases + +(ert-deftest test-calendar-sync--clean-text-error-nil-input () + "Test nil input returns nil." + (should (null (calendar-sync--clean-text nil)))) + +(provide 'test-calendar-sync--clean-text) +;;; test-calendar-sync--clean-text.el ends here diff --git a/tests/test-calendar-sync--convert-tz-to-local.el b/tests/test-calendar-sync--convert-tz-to-local.el index cf45aa61..07822780 100644 --- a/tests/test-calendar-sync--convert-tz-to-local.el +++ b/tests/test-calendar-sync--convert-tz-to-local.el @@ -9,8 +9,8 @@ ;;; Code: (require 'ert) -(require 'calendar-sync) (require 'testutil-calendar-sync) +(require 'calendar-sync) ;;; Normal Cases diff --git a/tests/test-calendar-sync--event-to-org.el b/tests/test-calendar-sync--event-to-org.el new file mode 100644 index 00000000..e6609e20 --- /dev/null +++ b/tests/test-calendar-sync--event-to-org.el @@ -0,0 +1,126 @@ +;;; test-calendar-sync--event-to-org.el --- Tests for updated event-to-org formatter -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for calendar-sync--event-to-org function (updated version). +;; Tests the new property drawer format with LOCATION, ORGANIZER, STATUS, URL. +;; Covers Normal, Boundary, and Error cases. + +;;; Code: + +(require 'ert) +(require 'testutil-calendar-sync) +(require 'calendar-sync) + +;;; Normal Cases + +(ert-deftest test-calendar-sync--event-to-org-normal-all-fields () + "Test event with all fields produces property drawer + description." + (let* ((start (test-calendar-sync-time-days-from-now 5 14 0)) + (end (test-calendar-sync-time-days-from-now 5 15 0)) + (event (list :summary "Team Standup" + :start start + :end end + :description "Daily sync meeting" + :location "Conference Room A" + :organizer (list :cn "John Smith" :email "john@example.com") + :status "accepted" + :url "https://meet.google.com/abc-defg-hij"))) + (let ((result (calendar-sync--event-to-org event))) + (should (string-match-p "\\* Team Standup" result)) + (should (string-match-p ":PROPERTIES:" result)) + (should (string-match-p ":LOCATION: Conference Room A" result)) + (should (string-match-p ":ORGANIZER: John Smith" result)) + (should (string-match-p ":STATUS: accepted" result)) + (should (string-match-p ":URL: https://meet.google.com/abc-defg-hij" result)) + (should (string-match-p ":END:" result)) + (should (string-match-p "Daily sync meeting" result))))) + +(ert-deftest test-calendar-sync--event-to-org-normal-summary-and-time-only () + "Test event with only summary and time (no drawer)." + (let* ((start (test-calendar-sync-time-days-from-now 5 14 0)) + (end (test-calendar-sync-time-days-from-now 5 15 0)) + (event (list :summary "Quick Chat" + :start start + :end end))) + (let ((result (calendar-sync--event-to-org event))) + (should (string-match-p "\\* Quick Chat" result)) + (should-not (string-match-p ":PROPERTIES:" result))))) + +;;; Boundary Cases + +(ert-deftest test-calendar-sync--event-to-org-boundary-no-description () + "Test event with location but no description." + (let* ((start (test-calendar-sync-time-days-from-now 5 14 0)) + (end (test-calendar-sync-time-days-from-now 5 15 0)) + (event (list :summary "Meeting" + :start start + :end end + :location "Room B"))) + (let ((result (calendar-sync--event-to-org event))) + (should (string-match-p ":LOCATION: Room B" result)) + ;; After :END: there should be no body text + (should-not (string-match-p ":END:\n." result))))) + +(ert-deftest test-calendar-sync--event-to-org-boundary-no-location-no-attendees () + "Test event without location or attendees produces no drawer." + (let* ((start (test-calendar-sync-time-days-from-now 5 14 0)) + (end (test-calendar-sync-time-days-from-now 5 15 0)) + (event (list :summary "Simple Event" + :start start + :end end + :description "Just a note"))) + (let ((result (calendar-sync--event-to-org event))) + (should (string-match-p "\\* Simple Event" result)) + ;; Description goes after timestamp, no drawer needed + (should (string-match-p "Just a note" result))))) + +(ert-deftest test-calendar-sync--event-to-org-boundary-only-status () + "Test event with only status produces drawer." + (let* ((start (test-calendar-sync-time-days-from-now 5 14 0)) + (end (test-calendar-sync-time-days-from-now 5 15 0)) + (event (list :summary "Status Only" + :start start + :end end + :status "tentative"))) + (let ((result (calendar-sync--event-to-org event))) + (should (string-match-p ":PROPERTIES:" result)) + (should (string-match-p ":STATUS: tentative" result)) + (should (string-match-p ":END:" result))))) + +(ert-deftest test-calendar-sync--event-to-org-boundary-description-with-asterisks () + "Test event description containing org-special asterisks." + (let* ((start (test-calendar-sync-time-days-from-now 5 14 0)) + (end (test-calendar-sync-time-days-from-now 5 15 0)) + (event (list :summary "Meeting" + :start start + :end end + :description "* agenda item 1\n** sub-item"))) + (let ((result (calendar-sync--event-to-org event))) + ;; Description should be present + (should (string-match-p "agenda item" result))))) + +(ert-deftest test-calendar-sync--event-to-org-boundary-organizer-email-only () + "Test organizer without CN shows email." + (let* ((start (test-calendar-sync-time-days-from-now 5 14 0)) + (end (test-calendar-sync-time-days-from-now 5 15 0)) + (event (list :summary "Meeting" + :start start + :end end + :organizer (list :cn nil :email "org@example.com")))) + (let ((result (calendar-sync--event-to-org event))) + (should (string-match-p ":ORGANIZER: org@example.com" result))))) + +;;; Error Cases + +(ert-deftest test-calendar-sync--event-to-org-error-missing-summary () + "Test event with nil summary still produces output." + (let* ((start (test-calendar-sync-time-days-from-now 5 14 0)) + (end (test-calendar-sync-time-days-from-now 5 15 0)) + (event (list :summary nil + :start start + :end end))) + ;; Should not error - produce some output + (should (stringp (calendar-sync--event-to-org event))))) + +(provide 'test-calendar-sync--event-to-org) +;;; test-calendar-sync--event-to-org.el ends here diff --git a/tests/test-calendar-sync--expand-weekly.el b/tests/test-calendar-sync--expand-weekly.el index fe333c98..a6143bce 100644 --- a/tests/test-calendar-sync--expand-weekly.el +++ b/tests/test-calendar-sync--expand-weekly.el @@ -8,8 +8,8 @@ ;;; Code: (require 'ert) -(require 'calendar-sync) (require 'testutil-calendar-sync) +(require 'calendar-sync) ;;; Setup and Teardown diff --git a/tests/test-calendar-sync--extract-meeting-url.el b/tests/test-calendar-sync--extract-meeting-url.el new file mode 100644 index 00000000..2f677991 --- /dev/null +++ b/tests/test-calendar-sync--extract-meeting-url.el @@ -0,0 +1,54 @@ +;;; test-calendar-sync--extract-meeting-url.el --- Tests for meeting URL extraction -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for calendar-sync--extract-meeting-url function. +;; Extracts URL from X-GOOGLE-CONFERENCE (preferred) or URL property. +;; Covers Normal, Boundary, and Error cases. + +;;; Code: + +(require 'ert) +(require 'testutil-calendar-sync) +(require 'calendar-sync) + +;;; Normal Cases + +(ert-deftest test-calendar-sync--extract-meeting-url-normal-google-conference () + "Test extracting X-GOOGLE-CONFERENCE URL." + (let ((event "BEGIN:VEVENT\nX-GOOGLE-CONFERENCE:https://meet.google.com/abc-defg-hij\nSUMMARY:Test\nEND:VEVENT")) + (should (string= "https://meet.google.com/abc-defg-hij" + (calendar-sync--extract-meeting-url event))))) + +(ert-deftest test-calendar-sync--extract-meeting-url-normal-url-property () + "Test extracting URL property." + (let ((event "BEGIN:VEVENT\nURL:https://zoom.us/j/123456\nSUMMARY:Test\nEND:VEVENT")) + (should (string= "https://zoom.us/j/123456" + (calendar-sync--extract-meeting-url event))))) + +;;; Boundary Cases + +(ert-deftest test-calendar-sync--extract-meeting-url-boundary-both-present () + "Test X-GOOGLE-CONFERENCE is preferred when both present." + (let ((event "BEGIN:VEVENT\nURL:https://zoom.us/j/123456\nX-GOOGLE-CONFERENCE:https://meet.google.com/abc\nSUMMARY:Test\nEND:VEVENT")) + (should (string= "https://meet.google.com/abc" + (calendar-sync--extract-meeting-url event))))) + +(ert-deftest test-calendar-sync--extract-meeting-url-boundary-neither-present () + "Test returns nil when neither URL property exists." + (let ((event "BEGIN:VEVENT\nSUMMARY:Test\nDTSTART:20260210T140000Z\nEND:VEVENT")) + (should (null (calendar-sync--extract-meeting-url event))))) + +(ert-deftest test-calendar-sync--extract-meeting-url-boundary-url-with-params () + "Test URL property with parameters." + (let ((event "BEGIN:VEVENT\nURL;VALUE=URI:https://teams.microsoft.com/l/meetup-join/abc\nSUMMARY:Test\nEND:VEVENT")) + (should (string-match-p "teams.microsoft.com" + (calendar-sync--extract-meeting-url event))))) + +;;; Error Cases + +(ert-deftest test-calendar-sync--extract-meeting-url-error-nil-event () + "Test nil event returns nil." + (should (null (calendar-sync--extract-meeting-url nil)))) + +(provide 'test-calendar-sync--extract-meeting-url) +;;; test-calendar-sync--extract-meeting-url.el ends here diff --git a/tests/test-calendar-sync--extract-tzid.el b/tests/test-calendar-sync--extract-tzid.el index d16aae40..95f51143 100644 --- a/tests/test-calendar-sync--extract-tzid.el +++ b/tests/test-calendar-sync--extract-tzid.el @@ -8,6 +8,7 @@ ;;; Code: (require 'ert) +(require 'testutil-calendar-sync) (require 'calendar-sync) ;;; Normal Cases diff --git a/tests/test-calendar-sync--find-user-status.el b/tests/test-calendar-sync--find-user-status.el new file mode 100644 index 00000000..523f9a9f --- /dev/null +++ b/tests/test-calendar-sync--find-user-status.el @@ -0,0 +1,89 @@ +;;; test-calendar-sync--find-user-status.el --- Tests for find-user-status -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for calendar-sync--find-user-status function. +;; Given attendees list and user emails, returns user's PARTSTAT as lowercase string. +;; Covers Normal, Boundary, and Error cases. + +;;; Code: + +(require 'ert) +(require 'testutil-calendar-sync) +(require 'calendar-sync) + +;;; Test Data + +(defun test-find-user-status--make-attendees () + "Create sample attendees list for testing." + (list (list :cn "Alice" :email "alice@example.com" :partstat "ACCEPTED" :role "REQ-PARTICIPANT") + (list :cn "Craig" :email "craig@example.com" :partstat "ACCEPTED" :role "REQ-PARTICIPANT") + (list :cn "Bob" :email "bob@example.com" :partstat "DECLINED" :role "OPT-PARTICIPANT"))) + +;;; Normal Cases + +(ert-deftest test-calendar-sync--find-user-status-normal-accepted () + "Test finding user who accepted." + (let ((attendees (test-find-user-status--make-attendees)) + (emails '("craig@example.com"))) + (should (string= "accepted" + (calendar-sync--find-user-status attendees emails))))) + +(ert-deftest test-calendar-sync--find-user-status-normal-declined () + "Test finding user who declined." + (let ((attendees (test-find-user-status--make-attendees)) + (emails '("bob@example.com"))) + (should (string= "declined" + (calendar-sync--find-user-status attendees emails))))) + +(ert-deftest test-calendar-sync--find-user-status-normal-tentative () + "Test finding user with tentative status." + (let ((attendees (list (list :cn "Test" :email "test@example.com" :partstat "TENTATIVE"))) + (emails '("test@example.com"))) + (should (string= "tentative" + (calendar-sync--find-user-status attendees emails))))) + +(ert-deftest test-calendar-sync--find-user-status-normal-needs-action () + "Test finding user with needs-action status." + (let ((attendees (list (list :cn "Test" :email "test@example.com" :partstat "NEEDS-ACTION"))) + (emails '("test@example.com"))) + (should (string= "needs-action" + (calendar-sync--find-user-status attendees emails))))) + +;;; Boundary Cases + +(ert-deftest test-calendar-sync--find-user-status-boundary-not-in-list () + "Test user not in attendee list returns nil." + (let ((attendees (test-find-user-status--make-attendees)) + (emails '("stranger@example.com"))) + (should (null (calendar-sync--find-user-status attendees emails))))) + +(ert-deftest test-calendar-sync--find-user-status-boundary-empty-attendees () + "Test empty attendee list returns nil." + (should (null (calendar-sync--find-user-status '() '("test@example.com"))))) + +(ert-deftest test-calendar-sync--find-user-status-boundary-multiple-emails () + "Test matching on second email in user emails list." + (let ((attendees (test-find-user-status--make-attendees)) + (emails '("primary@other.com" "craig@example.com"))) + (should (string= "accepted" + (calendar-sync--find-user-status attendees emails))))) + +(ert-deftest test-calendar-sync--find-user-status-boundary-case-insensitive () + "Test case-insensitive email matching." + (let ((attendees (list (list :cn "Test" :email "Craig@Example.COM" :partstat "ACCEPTED"))) + (emails '("craig@example.com"))) + (should (string= "accepted" + (calendar-sync--find-user-status attendees emails))))) + +;;; Error Cases + +(ert-deftest test-calendar-sync--find-user-status-error-nil-attendees () + "Test nil attendees returns nil." + (should (null (calendar-sync--find-user-status nil '("test@example.com"))))) + +(ert-deftest test-calendar-sync--find-user-status-error-nil-emails () + "Test nil emails list returns nil." + (should (null (calendar-sync--find-user-status (test-find-user-status--make-attendees) nil)))) + +(provide 'test-calendar-sync--find-user-status) +;;; test-calendar-sync--find-user-status.el ends here diff --git a/tests/test-calendar-sync--get-all-property-lines.el b/tests/test-calendar-sync--get-all-property-lines.el new file mode 100644 index 00000000..c95041c9 --- /dev/null +++ b/tests/test-calendar-sync--get-all-property-lines.el @@ -0,0 +1,61 @@ +;;; test-calendar-sync--get-all-property-lines.el --- Tests for get-all-property-lines -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for calendar-sync--get-all-property-lines function. +;; Like get-property-line but returns ALL matching lines. +;; Needed because ATTENDEE appears multiple times in an event. +;; Covers Normal, Boundary, and Error cases. + +;;; Code: + +(require 'ert) +(require 'testutil-calendar-sync) +(require 'calendar-sync) + +;;; Normal Cases + +(ert-deftest test-calendar-sync--get-all-property-lines-normal-multiple () + "Test extracting multiple ATTENDEE lines." + (let ((event "BEGIN:VEVENT\nATTENDEE;CN=Alice:mailto:alice@example.com\nATTENDEE;CN=Bob:mailto:bob@example.com\nEND:VEVENT")) + (let ((result (calendar-sync--get-all-property-lines event "ATTENDEE"))) + (should (= 2 (length result))) + (should (string-match-p "Alice" (nth 0 result))) + (should (string-match-p "Bob" (nth 1 result)))))) + +(ert-deftest test-calendar-sync--get-all-property-lines-normal-single () + "Test extracting single matching line." + (let ((event "BEGIN:VEVENT\nATTENDEE;CN=Alice:mailto:alice@example.com\nSUMMARY:Test\nEND:VEVENT")) + (let ((result (calendar-sync--get-all-property-lines event "ATTENDEE"))) + (should (= 1 (length result))) + (should (string-match-p "Alice" (car result)))))) + +;;; Boundary Cases + +(ert-deftest test-calendar-sync--get-all-property-lines-boundary-no-match () + "Test no matching property returns empty list." + (let ((event "BEGIN:VEVENT\nSUMMARY:Test\nEND:VEVENT")) + (should (null (calendar-sync--get-all-property-lines event "ATTENDEE"))))) + +(ert-deftest test-calendar-sync--get-all-property-lines-boundary-continuation () + "Test handling of continuation lines (lines starting with space)." + (let ((event "BEGIN:VEVENT\nATTENDEE;CN=Very Long Name;PARTSTAT=ACCEPTED:\n mailto:longname@example.com\nSUMMARY:Test\nEND:VEVENT")) + (let ((result (calendar-sync--get-all-property-lines event "ATTENDEE"))) + (should (= 1 (length result))) + (should (string-match-p "longname@example.com" (car result)))))) + +;;; Error Cases + +(ert-deftest test-calendar-sync--get-all-property-lines-error-nil-event () + "Test nil event returns nil." + (should (null (calendar-sync--get-all-property-lines nil "ATTENDEE")))) + +(ert-deftest test-calendar-sync--get-all-property-lines-error-nil-property () + "Test nil property returns nil." + (should (null (calendar-sync--get-all-property-lines "BEGIN:VEVENT\nEND:VEVENT" nil)))) + +(ert-deftest test-calendar-sync--get-all-property-lines-error-empty-event () + "Test empty event string returns nil." + (should (null (calendar-sync--get-all-property-lines "" "ATTENDEE")))) + +(provide 'test-calendar-sync--get-all-property-lines) +;;; test-calendar-sync--get-all-property-lines.el ends here diff --git a/tests/test-calendar-sync--get-property.el b/tests/test-calendar-sync--get-property.el index 79fefc8f..8b71f8e3 100644 --- a/tests/test-calendar-sync--get-property.el +++ b/tests/test-calendar-sync--get-property.el @@ -12,6 +12,7 @@ ;;; Code: (require 'ert) +(require 'testutil-calendar-sync) (require 'calendar-sync) ;;; Setup and Teardown diff --git a/tests/test-calendar-sync--helpers.el b/tests/test-calendar-sync--helpers.el index eb868952..864faa7f 100644 --- a/tests/test-calendar-sync--helpers.el +++ b/tests/test-calendar-sync--helpers.el @@ -7,6 +7,7 @@ ;;; Code: (require 'ert) +(require 'testutil-calendar-sync) (require 'calendar-sync) ;;; Setup and Teardown diff --git a/tests/test-calendar-sync--normalize-line-endings.el b/tests/test-calendar-sync--normalize-line-endings.el index 7f0830cc..cbada921 100644 --- a/tests/test-calendar-sync--normalize-line-endings.el +++ b/tests/test-calendar-sync--normalize-line-endings.el @@ -12,6 +12,7 @@ ;;; Code: (require 'ert) +(require 'testutil-calendar-sync) (require 'calendar-sync) ;;; Normal Cases diff --git a/tests/test-calendar-sync--parse-attendee-line.el b/tests/test-calendar-sync--parse-attendee-line.el new file mode 100644 index 00000000..5da7aacd --- /dev/null +++ b/tests/test-calendar-sync--parse-attendee-line.el @@ -0,0 +1,97 @@ +;;; test-calendar-sync--parse-attendee-line.el --- Tests for attendee parsing -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for calendar-sync--parse-attendee-line function. +;; Parses single ATTENDEE line into plist (:cn NAME :email EMAIL :partstat STATUS :role ROLE). +;; Covers Normal, Boundary, and Error cases. + +;;; Code: + +(require 'ert) +(require 'testutil-calendar-sync) +(require 'calendar-sync) + +;;; Normal Cases + +(ert-deftest test-calendar-sync--parse-attendee-line-normal-accepted () + "Test parsing attendee with PARTSTAT=ACCEPTED." + (let ((line "ATTENDEE;CN=Alice Smith;PARTSTAT=ACCEPTED;ROLE=REQ-PARTICIPANT:mailto:alice@example.com")) + (let ((result (calendar-sync--parse-attendee-line line))) + (should (string= "Alice Smith" (plist-get result :cn))) + (should (string= "alice@example.com" (plist-get result :email))) + (should (string= "ACCEPTED" (plist-get result :partstat))) + (should (string= "REQ-PARTICIPANT" (plist-get result :role)))))) + +(ert-deftest test-calendar-sync--parse-attendee-line-normal-declined () + "Test parsing attendee with PARTSTAT=DECLINED." + (let ((line "ATTENDEE;CN=Bob Jones;PARTSTAT=DECLINED:mailto:bob@example.com")) + (let ((result (calendar-sync--parse-attendee-line line))) + (should (string= "Bob Jones" (plist-get result :cn))) + (should (string= "bob@example.com" (plist-get result :email))) + (should (string= "DECLINED" (plist-get result :partstat)))))) + +(ert-deftest test-calendar-sync--parse-attendee-line-normal-tentative () + "Test parsing attendee with PARTSTAT=TENTATIVE." + (let ((line "ATTENDEE;CN=Carol;PARTSTAT=TENTATIVE:mailto:carol@example.com")) + (let ((result (calendar-sync--parse-attendee-line line))) + (should (string= "TENTATIVE" (plist-get result :partstat)))))) + +(ert-deftest test-calendar-sync--parse-attendee-line-normal-needs-action () + "Test parsing attendee with PARTSTAT=NEEDS-ACTION." + (let ((line "ATTENDEE;CN=Dave;PARTSTAT=NEEDS-ACTION:mailto:dave@example.com")) + (let ((result (calendar-sync--parse-attendee-line line))) + (should (string= "NEEDS-ACTION" (plist-get result :partstat)))))) + +;;; Boundary Cases + +(ert-deftest test-calendar-sync--parse-attendee-line-boundary-no-cn () + "Test attendee without CN uses email as fallback." + (let ((line "ATTENDEE;PARTSTAT=ACCEPTED:mailto:noCN@example.com")) + (let ((result (calendar-sync--parse-attendee-line line))) + (should (null (plist-get result :cn))) + (should (string= "noCN@example.com" (plist-get result :email)))))) + +(ert-deftest test-calendar-sync--parse-attendee-line-boundary-no-partstat () + "Test attendee without PARTSTAT." + (let ((line "ATTENDEE;CN=Eve:mailto:eve@example.com")) + (let ((result (calendar-sync--parse-attendee-line line))) + (should (string= "Eve" (plist-get result :cn))) + (should (string= "eve@example.com" (plist-get result :email))) + (should (null (plist-get result :partstat)))))) + +(ert-deftest test-calendar-sync--parse-attendee-line-boundary-minimal () + "Test minimal attendee line with just mailto." + (let ((line "ATTENDEE:mailto:min@example.com")) + (let ((result (calendar-sync--parse-attendee-line line))) + (should (string= "min@example.com" (plist-get result :email)))))) + +(ert-deftest test-calendar-sync--parse-attendee-line-boundary-cutype-resource () + "Test attendee with CUTYPE=RESOURCE." + (let ((line "ATTENDEE;CN=Room A;CUTYPE=RESOURCE;PARTSTAT=ACCEPTED:mailto:room-a@example.com")) + (let ((result (calendar-sync--parse-attendee-line line))) + (should (string= "Room A" (plist-get result :cn))) + (should (string= "ACCEPTED" (plist-get result :partstat)))))) + +(ert-deftest test-calendar-sync--parse-attendee-line-boundary-extra-params () + "Test attendee with extra unknown parameters." + (let ((line "ATTENDEE;CN=Frank;RSVP=TRUE;X-NUM-GUESTS=0;PARTSTAT=ACCEPTED:mailto:frank@example.com")) + (let ((result (calendar-sync--parse-attendee-line line))) + (should (string= "Frank" (plist-get result :cn))) + (should (string= "ACCEPTED" (plist-get result :partstat)))))) + +;;; Error Cases + +(ert-deftest test-calendar-sync--parse-attendee-line-error-nil () + "Test nil input returns nil." + (should (null (calendar-sync--parse-attendee-line nil)))) + +(ert-deftest test-calendar-sync--parse-attendee-line-error-empty () + "Test empty string returns nil." + (should (null (calendar-sync--parse-attendee-line "")))) + +(ert-deftest test-calendar-sync--parse-attendee-line-error-malformed () + "Test malformed line returns nil." + (should (null (calendar-sync--parse-attendee-line "not an attendee line")))) + +(provide 'test-calendar-sync--parse-attendee-line) +;;; test-calendar-sync--parse-attendee-line.el ends here diff --git a/tests/test-calendar-sync--parse-organizer.el b/tests/test-calendar-sync--parse-organizer.el new file mode 100644 index 00000000..5f21d902 --- /dev/null +++ b/tests/test-calendar-sync--parse-organizer.el @@ -0,0 +1,50 @@ +;;; test-calendar-sync--parse-organizer.el --- Tests for organizer parsing -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for calendar-sync--parse-organizer function. +;; Parses ORGANIZER property line into plist (:cn NAME :email EMAIL). +;; Covers Normal, Boundary, and Error cases. + +;;; Code: + +(require 'ert) +(require 'testutil-calendar-sync) +(require 'calendar-sync) + +;;; Normal Cases + +(ert-deftest test-calendar-sync--parse-organizer-normal-cn-and-email () + "Test parsing organizer with both CN and email." + (let ((event "BEGIN:VEVENT\nORGANIZER;CN=John Smith:mailto:john@example.com\nSUMMARY:Test\nEND:VEVENT")) + (let ((result (calendar-sync--parse-organizer event))) + (should (string= "John Smith" (plist-get result :cn))) + (should (string= "john@example.com" (plist-get result :email)))))) + +(ert-deftest test-calendar-sync--parse-organizer-normal-no-cn () + "Test parsing organizer without CN." + (let ((event "BEGIN:VEVENT\nORGANIZER:mailto:organizer@example.com\nSUMMARY:Test\nEND:VEVENT")) + (let ((result (calendar-sync--parse-organizer event))) + (should (null (plist-get result :cn))) + (should (string= "organizer@example.com" (plist-get result :email)))))) + +;;; Boundary Cases + +(ert-deftest test-calendar-sync--parse-organizer-boundary-no-organizer () + "Test event without ORGANIZER returns nil." + (let ((event "BEGIN:VEVENT\nSUMMARY:Test\nDTSTART:20260210T140000Z\nEND:VEVENT")) + (should (null (calendar-sync--parse-organizer event))))) + +(ert-deftest test-calendar-sync--parse-organizer-boundary-complex-cn () + "Test organizer with complex CN (quotes, special chars)." + (let ((event "BEGIN:VEVENT\nORGANIZER;CN=\"Dr. Jane O'Brien\":mailto:jane@example.com\nSUMMARY:Test\nEND:VEVENT")) + (let ((result (calendar-sync--parse-organizer event))) + (should (plist-get result :email))))) + +;;; Error Cases + +(ert-deftest test-calendar-sync--parse-organizer-error-nil-event () + "Test nil event returns nil." + (should (null (calendar-sync--parse-organizer nil)))) + +(provide 'test-calendar-sync--parse-organizer) +;;; test-calendar-sync--parse-organizer.el ends here diff --git a/tests/test-calendar-sync--parse-rrule.el b/tests/test-calendar-sync--parse-rrule.el index 123caa5c..099e4e44 100644 --- a/tests/test-calendar-sync--parse-rrule.el +++ b/tests/test-calendar-sync--parse-rrule.el @@ -7,6 +7,7 @@ ;;; Code: (require 'ert) +(require 'testutil-calendar-sync) (require 'calendar-sync) ;;; Setup and Teardown diff --git a/tests/test-calendar-sync--strip-html.el b/tests/test-calendar-sync--strip-html.el new file mode 100644 index 00000000..fda2bbc5 --- /dev/null +++ b/tests/test-calendar-sync--strip-html.el @@ -0,0 +1,99 @@ +;;; test-calendar-sync--strip-html.el --- Tests for HTML stripping -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for calendar-sync--strip-html function. +;; Converts <br>/<br/> to newline, strips all other tags, decodes HTML entities. +;; Covers Normal, Boundary, and Error cases. + +;;; Code: + +(require 'ert) +(require 'testutil-calendar-sync) +(require 'calendar-sync) + +;;; Normal Cases + +(ert-deftest test-calendar-sync--strip-html-normal-br-to-newline () + "Test <br> converted to newline." + (should (string= "line1\nline2" + (calendar-sync--strip-html "line1<br>line2")))) + +(ert-deftest test-calendar-sync--strip-html-normal-br-self-closing () + "Test <br/> converted to newline." + (should (string= "line1\nline2" + (calendar-sync--strip-html "line1<br/>line2")))) + +(ert-deftest test-calendar-sync--strip-html-normal-br-space-self-closing () + "Test <br /> converted to newline." + (should (string= "line1\nline2" + (calendar-sync--strip-html "line1<br />line2")))) + +(ert-deftest test-calendar-sync--strip-html-normal-strip-p-tags () + "Test <p> and </p> tags are stripped." + (should (string= "paragraph text" + (calendar-sync--strip-html "<p>paragraph text</p>")))) + +(ert-deftest test-calendar-sync--strip-html-normal-strip-bold () + "Test <b> and </b> tags are stripped." + (should (string= "bold text" + (calendar-sync--strip-html "<b>bold text</b>")))) + +(ert-deftest test-calendar-sync--strip-html-normal-combined-tags () + "Test mixed tags are handled." + (should (string= "Hello\nWorld" + (calendar-sync--strip-html "<p>Hello</p><br><b>World</b>")))) + +(ert-deftest test-calendar-sync--strip-html-normal-entity-amp () + "Test & decoded to &." + (should (string= "A & B" + (calendar-sync--strip-html "A & B")))) + +(ert-deftest test-calendar-sync--strip-html-normal-entity-lt () + "Test < decoded to <." + (should (string= "a < b" + (calendar-sync--strip-html "a < b")))) + +(ert-deftest test-calendar-sync--strip-html-normal-entity-gt () + "Test > decoded to >." + (should (string= "a > b" + (calendar-sync--strip-html "a > b")))) + +(ert-deftest test-calendar-sync--strip-html-normal-entity-quot () + "Test " decoded to double quote." + (should (string= "say \"hello\"" + (calendar-sync--strip-html "say "hello"")))) + +;;; Boundary Cases + +(ert-deftest test-calendar-sync--strip-html-boundary-empty-string () + "Test empty string returns empty string." + (should (string= "" (calendar-sync--strip-html "")))) + +(ert-deftest test-calendar-sync--strip-html-boundary-no-html () + "Test plain text passes through unchanged." + (should (string= "just plain text" + (calendar-sync--strip-html "just plain text")))) + +(ert-deftest test-calendar-sync--strip-html-boundary-only-tags () + "Test string of only tags returns empty." + (should (string= "" + (calendar-sync--strip-html "<p><b></b></p>")))) + +(ert-deftest test-calendar-sync--strip-html-boundary-multiple-br () + "Test multiple consecutive <br> collapse." + (should (string-match-p "^line1\n+line2$" + (calendar-sync--strip-html "line1<br><br><br>line2")))) + +(ert-deftest test-calendar-sync--strip-html-boundary-nested-tags () + "Test nested tags are stripped correctly." + (should (string= "nested text" + (calendar-sync--strip-html "<div><p><b>nested text</b></p></div>")))) + +;;; Error Cases + +(ert-deftest test-calendar-sync--strip-html-error-nil-input () + "Test nil input returns nil." + (should (null (calendar-sync--strip-html nil)))) + +(provide 'test-calendar-sync--strip-html) +;;; test-calendar-sync--strip-html.el ends here diff --git a/tests/test-calendar-sync--unescape-ics-text.el b/tests/test-calendar-sync--unescape-ics-text.el new file mode 100644 index 00000000..a83e97d3 --- /dev/null +++ b/tests/test-calendar-sync--unescape-ics-text.el @@ -0,0 +1,79 @@ +;;; test-calendar-sync--unescape-ics-text.el --- Tests for ICS text unescaping -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for calendar-sync--unescape-ics-text function. +;; RFC 5545 defines escape sequences: \n→newline, \,→comma, \\→backslash, \;→semicolon. +;; Covers Normal, Boundary, and Error cases. + +;;; Code: + +(require 'ert) +(require 'testutil-calendar-sync) +(require 'calendar-sync) + +;;; Normal Cases + +(ert-deftest test-calendar-sync--unescape-ics-text-normal-newline () + "Test \\n escape is converted to actual newline." + (should (string= "line1\nline2" + (calendar-sync--unescape-ics-text "line1\\nline2")))) + +(ert-deftest test-calendar-sync--unescape-ics-text-normal-comma () + "Test \\, escape is converted to comma." + (should (string= "one, two" + (calendar-sync--unescape-ics-text "one\\, two")))) + +(ert-deftest test-calendar-sync--unescape-ics-text-normal-backslash () + "Test \\\\ escape is converted to single backslash." + (should (string= "path\\file" + (calendar-sync--unescape-ics-text "path\\\\file")))) + +(ert-deftest test-calendar-sync--unescape-ics-text-normal-semicolon () + "Test \\; escape is converted to semicolon." + (should (string= "a;b" + (calendar-sync--unescape-ics-text "a\\;b")))) + +(ert-deftest test-calendar-sync--unescape-ics-text-normal-mixed () + "Test multiple different escapes in one string." + (should (string= "Hello, World\nPath\\to;file" + (calendar-sync--unescape-ics-text "Hello\\, World\\nPath\\\\to\\;file")))) + +;;; Boundary Cases + +(ert-deftest test-calendar-sync--unescape-ics-text-boundary-empty-string () + "Test empty string returns empty string." + (should (string= "" (calendar-sync--unescape-ics-text "")))) + +(ert-deftest test-calendar-sync--unescape-ics-text-boundary-no-escapes () + "Test string with no escapes passes through unchanged." + (should (string= "plain text" + (calendar-sync--unescape-ics-text "plain text")))) + +(ert-deftest test-calendar-sync--unescape-ics-text-boundary-escape-at-start () + "Test escape sequence at string start." + (should (string= "\nfoo" + (calendar-sync--unescape-ics-text "\\nfoo")))) + +(ert-deftest test-calendar-sync--unescape-ics-text-boundary-escape-at-end () + "Test escape sequence at string end." + (should (string= "foo\n" + (calendar-sync--unescape-ics-text "foo\\n")))) + +(ert-deftest test-calendar-sync--unescape-ics-text-boundary-consecutive-escapes () + "Test consecutive escape sequences." + (should (string= "\n\n" + (calendar-sync--unescape-ics-text "\\n\\n")))) + +(ert-deftest test-calendar-sync--unescape-ics-text-boundary-only-escapes () + "Test string composed entirely of escapes." + (should (string= ",;\n\\" + (calendar-sync--unescape-ics-text "\\,\\;\\n\\\\")))) + +;;; Error Cases + +(ert-deftest test-calendar-sync--unescape-ics-text-error-nil-input () + "Test nil input returns nil." + (should (null (calendar-sync--unescape-ics-text nil)))) + +(provide 'test-calendar-sync--unescape-ics-text) +;;; test-calendar-sync--unescape-ics-text.el ends here diff --git a/tests/test-calendar-sync-properties.el b/tests/test-calendar-sync-properties.el index 6054fc5e..c25bb99f 100644 --- a/tests/test-calendar-sync-properties.el +++ b/tests/test-calendar-sync-properties.el @@ -19,8 +19,8 @@ ;;; Code: (require 'ert) -(require 'calendar-sync) (require 'testutil-calendar-sync) +(require 'calendar-sync) (defconst test-calendar-sync-property-trials 30 "Number of random trials to run for each property test. diff --git a/tests/test-calendar-sync.el b/tests/test-calendar-sync.el index 7cda5e73..144d6486 100644 --- a/tests/test-calendar-sync.el +++ b/tests/test-calendar-sync.el @@ -8,8 +8,8 @@ ;;; Code: (require 'ert) -(require 'calendar-sync) (require 'testutil-calendar-sync) +(require 'calendar-sync) ;;; Test Data diff --git a/tests/test-integration-calendar-sync-event-details.el b/tests/test-integration-calendar-sync-event-details.el new file mode 100644 index 00000000..2ecc73e0 --- /dev/null +++ b/tests/test-integration-calendar-sync-event-details.el @@ -0,0 +1,177 @@ +;;; test-integration-calendar-sync-event-details.el --- Integration tests for event details -*- lexical-binding: t; -*- + +;;; Commentary: +;; Integration tests for the enhanced event details workflow. +;; Tests the complete flow from raw ICS with HTML description, attendees, +;; organizer through parse-ics to verify org output. +;; +;; Components integrated: +;; - calendar-sync--unescape-ics-text (ICS text unescaping) +;; - calendar-sync--strip-html (HTML tag removal) +;; - calendar-sync--clean-text (combined cleaning) +;; - calendar-sync--get-all-property-lines (multi-line property extraction) +;; - calendar-sync--parse-attendee-line (attendee parsing) +;; - calendar-sync--find-user-status (user status lookup) +;; - calendar-sync--parse-organizer (organizer extraction) +;; - calendar-sync--extract-meeting-url (meeting URL extraction) +;; - calendar-sync--parse-event (full event parsing) +;; - calendar-sync--event-to-org (org format output with property drawer) +;; - calendar-sync--parse-ics (full ICS pipeline) + +;;; Code: + +(require 'ert) +(require 'testutil-calendar-sync) +(require 'calendar-sync) + +;;; Test Helpers + +(defun test-integration-details--make-full-ics (start-time) + "Create ICS with HTML description, attendees, organizer, and meeting URL. +START-TIME is (year month day hour minute)." + (let ((dtstart (test-calendar-sync-ics-datetime start-time)) + (dtend (test-calendar-sync-ics-datetime + (list (nth 0 start-time) (nth 1 start-time) (nth 2 start-time) + (1+ (nth 3 start-time)) (nth 4 start-time))))) + (concat "BEGIN:VCALENDAR\n" + "VERSION:2.0\n" + "PRODID:-//Google Inc//Google Calendar//EN\n" + "BEGIN:VEVENT\n" + "UID:full-test@example.com\n" + "SUMMARY:Team Planning\n" + "DTSTART:" dtstart "\n" + "DTEND:" dtend "\n" + "DESCRIPTION:Discuss Q1 goals<br>Review metrics\\, update roadmap\n" + "LOCATION:Conference Room B\n" + "ORGANIZER;CN=John Smith:mailto:john@example.com\n" + "ATTENDEE;CN=John Smith;PARTSTAT=ACCEPTED:mailto:john@example.com\n" + "ATTENDEE;CN=Craig Jennings;PARTSTAT=ACCEPTED:mailto:craigmartinjennings@gmail.com\n" + "ATTENDEE;CN=Alice;PARTSTAT=DECLINED:mailto:alice@example.com\n" + "X-GOOGLE-CONFERENCE:https://meet.google.com/abc-defg-hij\n" + "END:VEVENT\n" + "END:VCALENDAR"))) + +(defun test-integration-details--make-plain-ics (start-time) + "Create simple ICS without attendees or special properties. +START-TIME is (year month day hour minute)." + (let ((dtstart (test-calendar-sync-ics-datetime start-time)) + (dtend (test-calendar-sync-ics-datetime + (list (nth 0 start-time) (nth 1 start-time) (nth 2 start-time) + (1+ (nth 3 start-time)) (nth 4 start-time))))) + (concat "BEGIN:VCALENDAR\n" + "VERSION:2.0\n" + "PRODID:-//Test//Test//EN\n" + "BEGIN:VEVENT\n" + "UID:plain-test@example.com\n" + "SUMMARY:Simple Reminder\n" + "DTSTART:" dtstart "\n" + "DTEND:" dtend "\n" + "END:VEVENT\n" + "END:VCALENDAR"))) + +(defun test-integration-details--make-recurring-with-attendees (start-time) + "Create ICS with recurring event that has attendees. +START-TIME is (year month day hour minute)." + (let ((dtstart (test-calendar-sync-ics-datetime start-time)) + (dtend (test-calendar-sync-ics-datetime + (list (nth 0 start-time) (nth 1 start-time) (nth 2 start-time) + (1+ (nth 3 start-time)) (nth 4 start-time))))) + (concat "BEGIN:VCALENDAR\n" + "VERSION:2.0\n" + "PRODID:-//Test//Test//EN\n" + "BEGIN:VEVENT\n" + "UID:recurring-attendees@example.com\n" + "SUMMARY:Weekly Standup\n" + "DTSTART:" dtstart "\n" + "DTEND:" dtend "\n" + "RRULE:FREQ=WEEKLY;COUNT=3\n" + "ORGANIZER;CN=Manager:mailto:manager@example.com\n" + "ATTENDEE;CN=Craig;PARTSTAT=ACCEPTED:mailto:craigmartinjennings@gmail.com\n" + "LOCATION:Virtual\n" + "END:VEVENT\n" + "END:VCALENDAR"))) + +(defun test-integration-details--make-ics-escaped-location (start-time) + "Create ICS with ICS-escaped location field. +START-TIME is (year month day hour minute)." + (let ((dtstart (test-calendar-sync-ics-datetime start-time)) + (dtend (test-calendar-sync-ics-datetime + (list (nth 0 start-time) (nth 1 start-time) (nth 2 start-time) + (1+ (nth 3 start-time)) (nth 4 start-time))))) + (concat "BEGIN:VCALENDAR\n" + "VERSION:2.0\n" + "PRODID:-//Test//Test//EN\n" + "BEGIN:VEVENT\n" + "UID:escaped-loc@example.com\n" + "SUMMARY:Offsite Meeting\n" + "DTSTART:" dtstart "\n" + "DTEND:" dtend "\n" + "LOCATION:123 Main St\\, Suite 400\\, New Orleans\\, LA\n" + "END:VEVENT\n" + "END:VCALENDAR"))) + +;;; Integration Tests + +(ert-deftest test-integration-details-full-pipeline () + "Test full pipeline: ICS with HTML + attendees + organizer → clean org output. +Verifies: +- HTML in description is cleaned +- ICS escapes in description are unescaped +- Property drawer contains LOCATION, ORGANIZER, STATUS, URL +- Description appears as body text after drawer" + (let* ((start-time (test-calendar-sync-time-days-from-now 7 14 0)) + (ics (test-integration-details--make-full-ics start-time)) + (calendar-sync-user-emails '("craigmartinjennings@gmail.com")) + (org-output (calendar-sync--parse-ics ics))) + (should org-output) + ;; Summary present + (should (string-match-p "Team Planning" org-output)) + ;; Clean description (HTML <br> → newline, ICS \, → comma) + (should (string-match-p "Discuss Q1 goals" org-output)) + (should (string-match-p "Review metrics, update roadmap" org-output)) + (should-not (string-match-p "<br>" org-output)) + ;; Property drawer + (should (string-match-p ":PROPERTIES:" org-output)) + (should (string-match-p ":LOCATION: Conference Room B" org-output)) + (should (string-match-p ":ORGANIZER: John Smith" org-output)) + (should (string-match-p ":STATUS: accepted" org-output)) + (should (string-match-p ":URL: https://meet.google.com/abc-defg-hij" org-output)) + (should (string-match-p ":END:" org-output)))) + +(ert-deftest test-integration-details-plain-event-no-drawer () + "Test plain event without attendees/location produces no property drawer." + (let* ((start-time (test-calendar-sync-time-days-from-now 7 14 0)) + (ics (test-integration-details--make-plain-ics start-time)) + (org-output (calendar-sync--parse-ics ics))) + (should org-output) + (should (string-match-p "Simple Reminder" org-output)) + (should-not (string-match-p ":PROPERTIES:" org-output)))) + +(ert-deftest test-integration-details-recurring-with-attendees () + "Test recurring event with attendees flows details to expanded instances." + (let* ((start-time (test-calendar-sync-time-days-from-now 7 14 0)) + (ics (test-integration-details--make-recurring-with-attendees start-time)) + (calendar-sync-user-emails '("craigmartinjennings@gmail.com")) + (org-output (calendar-sync--parse-ics ics))) + (should org-output) + ;; Should have multiple occurrences of Weekly Standup + (let ((count 0) + (pos 0)) + (while (string-match "Weekly Standup" org-output pos) + (setq count (1+ count)) + (setq pos (match-end 0))) + (should (>= count 2))))) + +(ert-deftest test-integration-details-escaped-location () + "Test ICS escapes in location are cleaned in org output." + (let* ((start-time (test-calendar-sync-time-days-from-now 7 14 0)) + (ics (test-integration-details--make-ics-escaped-location start-time)) + (org-output (calendar-sync--parse-ics ics))) + (should org-output) + (should (string-match-p "Offsite Meeting" org-output)) + ;; Location should have real commas, not escaped + (should (string-match-p "123 Main St, Suite 400" org-output)) + (should-not (string-match-p "\\\\," org-output)))) + +(provide 'test-integration-calendar-sync-event-details) +;;; test-integration-calendar-sync-event-details.el ends here diff --git a/tests/test-org-agenda-build-list.el b/tests/test-org-agenda-build-list.el index 9b9ba7f3..94e89a3d 100644 --- a/tests/test-org-agenda-build-list.el +++ b/tests/test-org-agenda-build-list.el @@ -16,6 +16,7 @@ (defvar schedule-file "/tmp/test-schedule.org") (defvar gcal-file "/tmp/test-gcal.org") (defvar pcal-file "/tmp/test-pcal.org") +(defvar dcal-file "/tmp/test-dcal.org") (defvar projects-dir "/tmp/test-projects/") ;; Now load the actual production module @@ -184,12 +185,12 @@ When directory scan returns empty: (cj/build-org-agenda-list) - ;; Should have base files only (inbox, schedule, gcal, pcal) - (should (= (length org-agenda-files) 4)) + ;; Should have base files only (inbox, schedule, gcal, pcal, dcal) + (should (= (length org-agenda-files) 5)) ;; Cache should contain base files (should cj/org-agenda-files-cache) - (should (= (length cj/org-agenda-files-cache) 4))) + (should (= (length cj/org-agenda-files-cache) 5))) (test-org-agenda-teardown))) (ert-deftest test-org-agenda-build-list-boundary-building-flag-set-during-build () |
