summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-04-05 06:51:20 -0500
committerCraig Jennings <c@cjennings.net>2026-04-05 06:51:20 -0500
commit7eb6c0692d48b8c3d01efba8abcd2bfdfaa7cd31 (patch)
tree2af7aa512feaae39b384939160af68fa0537fc22 /tests
parent47453fd03be79205c6209d31a26cb4f7724af317 (diff)
test(calendar-sync): add 68 tests across 13 files for untested pure functions
Covers core parsing (parse-ics-datetime, parse-timestamp, format-timestamp, split-events, parse-event), date utilities (add-months, add-days, weekday-to-number, date-weekday, event-start-time), and timezone (format-timezone-offset, convert-utc-to-local, localize-parsed-datetime).
Diffstat (limited to 'tests')
-rw-r--r--tests/test-calendar-sync--add-days.el44
-rw-r--r--tests/test-calendar-sync--add-months.el43
-rw-r--r--tests/test-calendar-sync--convert-utc-to-local.el41
-rw-r--r--tests/test-calendar-sync--date-weekday.el35
-rw-r--r--tests/test-calendar-sync--event-start-time.el41
-rw-r--r--tests/test-calendar-sync--format-timestamp.el60
-rw-r--r--tests/test-calendar-sync--format-timezone-offset.el44
-rw-r--r--tests/test-calendar-sync--localize-parsed-datetime.el54
-rw-r--r--tests/test-calendar-sync--parse-event.el82
-rw-r--r--tests/test-calendar-sync--parse-ics-datetime.el53
-rw-r--r--tests/test-calendar-sync--parse-timestamp.el59
-rw-r--r--tests/test-calendar-sync--split-events.el54
-rw-r--r--tests/test-calendar-sync--weekday-to-number.el37
13 files changed, 647 insertions, 0 deletions
diff --git a/tests/test-calendar-sync--add-days.el b/tests/test-calendar-sync--add-days.el
new file mode 100644
index 00000000..a7b5eb39
--- /dev/null
+++ b/tests/test-calendar-sync--add-days.el
@@ -0,0 +1,44 @@
+;;; test-calendar-sync--add-days.el --- Tests for day arithmetic -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for calendar-sync--add-days. Uses noon internally for DST safety.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-calendar-sync)
+(require 'calendar-sync)
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--add-days-normal-forward ()
+ "Adding 7 days advances by one week."
+ (should (equal '(2026 3 22) (calendar-sync--add-days '(2026 3 15) 7))))
+
+(ert-deftest test-calendar-sync--add-days-normal-backward ()
+ "Negative days go backward."
+ (should (equal '(2026 3 8) (calendar-sync--add-days '(2026 3 15) -7))))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--add-days-boundary-month-rollover ()
+ "Adding days past end of month rolls into next month."
+ (should (equal '(2026 4 1) (calendar-sync--add-days '(2026 3 31) 1))))
+
+(ert-deftest test-calendar-sync--add-days-boundary-leap-year ()
+ "Feb 28 + 1 = Feb 29 in leap year 2028."
+ (should (equal '(2028 2 29) (calendar-sync--add-days '(2028 2 28) 1))))
+
+(ert-deftest test-calendar-sync--add-days-boundary-zero ()
+ "Adding zero days returns same date."
+ (should (equal '(2026 3 15) (calendar-sync--add-days '(2026 3 15) 0))))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--add-days-error-large-offset ()
+ "Adding 365 days crosses into next year."
+ (let ((result (calendar-sync--add-days '(2026 1 1) 365)))
+ (should (equal 2027 (nth 0 result)))))
+
+(provide 'test-calendar-sync--add-days)
+;;; test-calendar-sync--add-days.el ends here
diff --git a/tests/test-calendar-sync--add-months.el b/tests/test-calendar-sync--add-months.el
new file mode 100644
index 00000000..fdc00208
--- /dev/null
+++ b/tests/test-calendar-sync--add-months.el
@@ -0,0 +1,43 @@
+;;; test-calendar-sync--add-months.el --- Tests for month arithmetic -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for calendar-sync--add-months. Pure date arithmetic.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-calendar-sync)
+(require 'calendar-sync)
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--add-months-normal-forward ()
+ "Adding 3 months advances correctly."
+ (should (equal '(2026 6 15) (calendar-sync--add-months '(2026 3 15) 3))))
+
+(ert-deftest test-calendar-sync--add-months-normal-backward ()
+ "Subtracting months goes backward."
+ (should (equal '(2025 12 15) (calendar-sync--add-months '(2026 3 15) -3))))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--add-months-boundary-year-rollover ()
+ "Adding months past December rolls into next year."
+ (should (equal '(2027 2 1) (calendar-sync--add-months '(2026 11 1) 3))))
+
+(ert-deftest test-calendar-sync--add-months-boundary-year-rollback ()
+ "Subtracting months past January rolls into previous year."
+ (should (equal '(2025 11 1) (calendar-sync--add-months '(2026 2 1) -3))))
+
+(ert-deftest test-calendar-sync--add-months-boundary-zero ()
+ "Adding zero months returns same date."
+ (should (equal '(2026 3 15) (calendar-sync--add-months '(2026 3 15) 0))))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--add-months-error-large-offset ()
+ "Adding 24 months (2 years) works correctly."
+ (should (equal '(2028 3 15) (calendar-sync--add-months '(2026 3 15) 24))))
+
+(provide 'test-calendar-sync--add-months)
+;;; test-calendar-sync--add-months.el ends here
diff --git a/tests/test-calendar-sync--convert-utc-to-local.el b/tests/test-calendar-sync--convert-utc-to-local.el
new file mode 100644
index 00000000..1a5be4d3
--- /dev/null
+++ b/tests/test-calendar-sync--convert-utc-to-local.el
@@ -0,0 +1,41 @@
+;;; test-calendar-sync--convert-utc-to-local.el --- Tests for UTC to local conversion -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for calendar-sync--convert-utc-to-local.
+;; Converts UTC datetime to local timezone. Results depend on system timezone.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-calendar-sync)
+(require 'calendar-sync)
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--convert-utc-to-local-normal-returns-5-elements ()
+ "Conversion returns (year month day hour minute) list."
+ (let ((result (calendar-sync--convert-utc-to-local 2026 3 15 18 0 0)))
+ (should (= 5 (length result)))
+ (should (numberp (nth 0 result)))
+ (should (numberp (nth 3 result)))))
+
+(ert-deftest test-calendar-sync--convert-utc-to-local-normal-offset-applied ()
+ "Hour differs from UTC input (unless system is UTC)."
+ (let* ((tz-offset (car (current-time-zone)))
+ (result (calendar-sync--convert-utc-to-local 2026 3 15 12 0 0)))
+ (if (= tz-offset 0)
+ (should (= 12 (nth 3 result)))
+ (should-not (= 12 (nth 3 result))))))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--convert-utc-to-local-boundary-date-change ()
+ "Late UTC time may shift to next day for western timezones."
+ (let* ((tz-offset (car (current-time-zone)))
+ (result (calendar-sync--convert-utc-to-local 2026 3 15 23 30 0)))
+ ;; For negative offsets, 23:30 UTC stays on same day
+ ;; For positive offsets > 30min, date rolls forward
+ (should (= 5 (length result)))))
+
+(provide 'test-calendar-sync--convert-utc-to-local)
+;;; test-calendar-sync--convert-utc-to-local.el ends here
diff --git a/tests/test-calendar-sync--date-weekday.el b/tests/test-calendar-sync--date-weekday.el
new file mode 100644
index 00000000..61a499bd
--- /dev/null
+++ b/tests/test-calendar-sync--date-weekday.el
@@ -0,0 +1,35 @@
+;;; test-calendar-sync--date-weekday.el --- Tests for date weekday calculation -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for calendar-sync--date-weekday. Returns 1 (Mon) through 7 (Sun).
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-calendar-sync)
+(require 'calendar-sync)
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--date-weekday-normal-known-date ()
+ "2026-03-15 is a Sunday (7)."
+ (should (= 7 (calendar-sync--date-weekday '(2026 3 15)))))
+
+(ert-deftest test-calendar-sync--date-weekday-normal-monday ()
+ "2026-03-16 is a Monday (1)."
+ (should (= 1 (calendar-sync--date-weekday '(2026 3 16)))))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--date-weekday-boundary-sunday-is-7 ()
+ "Sunday returns 7, not 0 (Emacs decode-time returns 0 for Sunday)."
+ ;; 2026-01-04 is a Sunday
+ (should (= 7 (calendar-sync--date-weekday '(2026 1 4)))))
+
+(ert-deftest test-calendar-sync--date-weekday-boundary-saturday ()
+ "Saturday returns 6."
+ ;; 2026-01-03 is a Saturday
+ (should (= 6 (calendar-sync--date-weekday '(2026 1 3)))))
+
+(provide 'test-calendar-sync--date-weekday)
+;;; test-calendar-sync--date-weekday.el ends here
diff --git a/tests/test-calendar-sync--event-start-time.el b/tests/test-calendar-sync--event-start-time.el
new file mode 100644
index 00000000..1a9a5f7e
--- /dev/null
+++ b/tests/test-calendar-sync--event-start-time.el
@@ -0,0 +1,41 @@
+;;; test-calendar-sync--event-start-time.el --- Tests for event start time extraction -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for calendar-sync--event-start-time. Extracts comparable time value from event plist.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-calendar-sync)
+(require 'calendar-sync)
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--event-start-time-normal-timed ()
+ "Timed event returns non-zero time value."
+ (let ((event (list :start '(2026 3 15 14 30) :summary "Test")))
+ (should (not (equal 0 (calendar-sync--event-start-time event))))))
+
+(ert-deftest test-calendar-sync--event-start-time-normal-ordering ()
+ "Earlier event has smaller time value than later event."
+ (let ((early (list :start '(2026 3 15 9 0)))
+ (late (list :start '(2026 3 15 17 0))))
+ (should (time-less-p (calendar-sync--event-start-time early)
+ (calendar-sync--event-start-time late)))))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--event-start-time-boundary-all-day ()
+ "All-day event (nil hour/minute) uses 0 for time components."
+ (let ((event (list :start '(2026 3 15 nil nil))))
+ (should (not (equal 0 (calendar-sync--event-start-time event))))))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--event-start-time-error-no-start ()
+ "Event without :start returns 0."
+ (let ((event (list :summary "No start")))
+ (should (equal 0 (calendar-sync--event-start-time event)))))
+
+(provide 'test-calendar-sync--event-start-time)
+;;; test-calendar-sync--event-start-time.el ends here
diff --git a/tests/test-calendar-sync--format-timestamp.el b/tests/test-calendar-sync--format-timestamp.el
new file mode 100644
index 00000000..5b8a6d02
--- /dev/null
+++ b/tests/test-calendar-sync--format-timestamp.el
@@ -0,0 +1,60 @@
+;;; test-calendar-sync--format-timestamp.el --- Tests for org timestamp formatting -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for calendar-sync--format-timestamp.
+;; Converts parsed start/end times to org-mode timestamp strings.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-calendar-sync)
+(require 'calendar-sync)
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--format-timestamp-normal-with-time ()
+ "Timed event produces '<YYYY-MM-DD Day HH:MM-HH:MM>'."
+ (let ((result (calendar-sync--format-timestamp
+ '(2026 3 15 14 0) '(2026 3 15 15 30))))
+ (should (string-match-p "^<2026-03-15 .* 14:00-15:30>$" result))))
+
+(ert-deftest test-calendar-sync--format-timestamp-normal-all-day ()
+ "All-day event (nil hours) produces '<YYYY-MM-DD Day>'."
+ (let ((result (calendar-sync--format-timestamp
+ '(2026 3 15 nil nil) '(2026 3 16 nil nil))))
+ (should (string-match-p "^<2026-03-15 .*>$" result))
+ (should-not (string-match-p "[0-9]:[0-9]" result))))
+
+(ert-deftest test-calendar-sync--format-timestamp-normal-includes-weekday ()
+ "Timestamp includes abbreviated weekday name."
+ (let ((result (calendar-sync--format-timestamp
+ '(2026 3 15 14 0) '(2026 3 15 15 0))))
+ ;; 2026-03-15 is a Sunday
+ (should (string-match-p "Sun" result))))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--format-timestamp-boundary-midnight ()
+ "Midnight start and end are formatted as 00:00."
+ (let ((result (calendar-sync--format-timestamp
+ '(2026 1 1 0 0) '(2026 1 1 1 0))))
+ (should (string-match-p "00:00-01:00" result))))
+
+(ert-deftest test-calendar-sync--format-timestamp-boundary-nil-end ()
+ "Nil end with timed start still produces time range."
+ (let ((result (calendar-sync--format-timestamp
+ '(2026 3 15 14 0) nil)))
+ ;; No end time means no time range
+ (should (string-match-p "^<2026-03-15" result))))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--format-timestamp-error-start-no-time-end-has-time ()
+ "All-day start with timed end produces date-only (no time range)."
+ (let ((result (calendar-sync--format-timestamp
+ '(2026 3 15 nil nil) '(2026 3 15 15 0))))
+ ;; start-hour is nil so time-str should be nil
+ (should-not (string-match-p "[0-9][0-9]:[0-9][0-9]-" result))))
+
+(provide 'test-calendar-sync--format-timestamp)
+;;; test-calendar-sync--format-timestamp.el ends here
diff --git a/tests/test-calendar-sync--format-timezone-offset.el b/tests/test-calendar-sync--format-timezone-offset.el
new file mode 100644
index 00000000..cf2655d1
--- /dev/null
+++ b/tests/test-calendar-sync--format-timezone-offset.el
@@ -0,0 +1,44 @@
+;;; test-calendar-sync--format-timezone-offset.el --- Tests for timezone offset formatting -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for calendar-sync--format-timezone-offset.
+;; Converts offset seconds to human-readable "UTC±H:MM" strings.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-calendar-sync)
+(require 'calendar-sync)
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--format-timezone-offset-normal-negative ()
+ "US Central (-6 hours) formats as UTC-6."
+ (should (equal "UTC-6" (calendar-sync--format-timezone-offset -21600))))
+
+(ert-deftest test-calendar-sync--format-timezone-offset-normal-positive ()
+ "India (+5:30) formats as UTC+5:30."
+ (should (equal "UTC+5:30" (calendar-sync--format-timezone-offset 19800))))
+
+(ert-deftest test-calendar-sync--format-timezone-offset-normal-utc ()
+ "Zero offset formats as UTC+0."
+ (should (equal "UTC+0" (calendar-sync--format-timezone-offset 0))))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--format-timezone-offset-boundary-half-hour ()
+ "Newfoundland (-3:30) includes minutes."
+ (should (equal "UTC-3:30" (calendar-sync--format-timezone-offset -12600))))
+
+(ert-deftest test-calendar-sync--format-timezone-offset-boundary-large-offset ()
+ "UTC+14 (Line Islands) formats correctly."
+ (should (equal "UTC+14" (calendar-sync--format-timezone-offset 50400))))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--format-timezone-offset-error-nil ()
+ "Nil offset returns 'unknown'."
+ (should (equal "unknown" (calendar-sync--format-timezone-offset nil))))
+
+(provide 'test-calendar-sync--format-timezone-offset)
+;;; test-calendar-sync--format-timezone-offset.el ends here
diff --git a/tests/test-calendar-sync--localize-parsed-datetime.el b/tests/test-calendar-sync--localize-parsed-datetime.el
new file mode 100644
index 00000000..ef9cf099
--- /dev/null
+++ b/tests/test-calendar-sync--localize-parsed-datetime.el
@@ -0,0 +1,54 @@
+;;; test-calendar-sync--localize-parsed-datetime.el --- Tests for datetime localization -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for calendar-sync--localize-parsed-datetime.
+;; Dispatches to UTC or TZID conversion, or passes through unchanged.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-calendar-sync)
+(require 'calendar-sync)
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--localize-parsed-datetime-normal-utc ()
+ "UTC flag triggers conversion via convert-utc-to-local."
+ (let ((parsed '(2026 3 15 18 0)))
+ (let ((result (calendar-sync--localize-parsed-datetime parsed t nil)))
+ (should (= 5 (length result)))
+ ;; Hour should differ from 18 unless we're in UTC
+ (let ((tz-offset (car (current-time-zone))))
+ (unless (= tz-offset 0)
+ (should-not (= 18 (nth 3 result))))))))
+
+(ert-deftest test-calendar-sync--localize-parsed-datetime-normal-tzid ()
+ "TZID triggers conversion via convert-tz-to-local."
+ (let ((parsed '(2026 3 15 14 0)))
+ (let ((result (calendar-sync--localize-parsed-datetime parsed nil "America/New_York")))
+ (should (= 5 (length result))))))
+
+(ert-deftest test-calendar-sync--localize-parsed-datetime-normal-local-passthrough ()
+ "No UTC, no TZID — returns parsed unchanged."
+ (let ((parsed '(2026 3 15 14 0)))
+ (should (equal parsed (calendar-sync--localize-parsed-datetime parsed nil nil)))))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--localize-parsed-datetime-boundary-date-only ()
+ "Date-only (nil hour/minute) with UTC uses 0 for time components."
+ (let ((parsed '(2026 3 15 nil nil)))
+ (let ((result (calendar-sync--localize-parsed-datetime parsed t nil)))
+ (should (= 5 (length result))))))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--localize-parsed-datetime-error-bad-tzid-fallback ()
+ "Invalid TZID falls back to returning parsed unchanged."
+ (let ((parsed '(2026 3 15 14 0)))
+ (let ((result (calendar-sync--localize-parsed-datetime parsed nil "Fake/Timezone")))
+ ;; convert-tz-to-local returns nil for bad TZID, so fallback to parsed
+ (should (= 5 (length result))))))
+
+(provide 'test-calendar-sync--localize-parsed-datetime)
+;;; test-calendar-sync--localize-parsed-datetime.el ends here
diff --git a/tests/test-calendar-sync--parse-event.el b/tests/test-calendar-sync--parse-event.el
new file mode 100644
index 00000000..9c343db2
--- /dev/null
+++ b/tests/test-calendar-sync--parse-event.el
@@ -0,0 +1,82 @@
+;;; test-calendar-sync--parse-event.el --- Tests for VEVENT parser -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for calendar-sync--parse-event.
+;; Parses VEVENT string into plist with :uid :summary :start :end etc.
+;; Uses dynamic timestamps via testutil-calendar-sync helpers.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-calendar-sync)
+(require 'calendar-sync)
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--parse-event-normal-basic-fields ()
+ "Basic event returns plist with summary, start, end."
+ (let* ((start (test-calendar-sync-time-days-from-now 5 14 0))
+ (end (test-calendar-sync-time-days-from-now 5 15 0))
+ (vevent (test-calendar-sync-make-vevent "Team Standup" start end)))
+ (let ((result (calendar-sync--parse-event vevent)))
+ (should result)
+ (should (equal "Team Standup" (plist-get result :summary)))
+ (should (plist-get result :start))
+ (should (plist-get result :end)))))
+
+(ert-deftest test-calendar-sync--parse-event-normal-optional-fields ()
+ "Event with description and location includes them."
+ (let* ((start (test-calendar-sync-time-days-from-now 5 14 0))
+ (end (test-calendar-sync-time-days-from-now 5 15 0))
+ (vevent (test-calendar-sync-make-vevent
+ "Meeting" start end "Discuss roadmap" "Room 42")))
+ (let ((result (calendar-sync--parse-event vevent)))
+ (should (equal "Discuss roadmap" (plist-get result :description)))
+ (should (equal "Room 42" (plist-get result :location))))))
+
+(ert-deftest test-calendar-sync--parse-event-normal-all-day ()
+ "All-day event (date-only) is parsed correctly."
+ (let* ((start (test-calendar-sync-time-date-only 5))
+ (end (test-calendar-sync-time-date-only 6))
+ (vevent (test-calendar-sync-make-vevent "Holiday" start end)))
+ (let ((result (calendar-sync--parse-event vevent)))
+ (should result)
+ (should (= 3 (length (plist-get result :start)))))))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--parse-event-boundary-recurrence-id-skipped ()
+ "Events with RECURRENCE-ID are skipped (return nil)."
+ (let* ((start (test-calendar-sync-time-days-from-now 5 14 0))
+ (vevent (concat "BEGIN:VEVENT\n"
+ "SUMMARY:Modified Instance\n"
+ "DTSTART:" (test-calendar-sync-ics-datetime start) "\n"
+ "RECURRENCE-ID:" (test-calendar-sync-ics-datetime start) "\n"
+ "END:VEVENT")))
+ (should (null (calendar-sync--parse-event vevent)))))
+
+(ert-deftest test-calendar-sync--parse-event-boundary-no-end-time ()
+ "Event without DTEND still parses (end is nil)."
+ (let* ((start (test-calendar-sync-time-days-from-now 5 14 0))
+ (vevent (concat "BEGIN:VEVENT\n"
+ "SUMMARY:Open-ended\n"
+ "DTSTART:" (test-calendar-sync-ics-datetime start) "\n"
+ "END:VEVENT")))
+ (let ((result (calendar-sync--parse-event vevent)))
+ (should result)
+ (should (null (plist-get result :end))))))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--parse-event-error-no-summary ()
+ "Event without SUMMARY returns nil."
+ (let ((vevent "BEGIN:VEVENT\nDTSTART:20260315T140000Z\nEND:VEVENT"))
+ (should (null (calendar-sync--parse-event vevent)))))
+
+(ert-deftest test-calendar-sync--parse-event-error-no-dtstart ()
+ "Event without DTSTART returns nil."
+ (let ((vevent "BEGIN:VEVENT\nSUMMARY:Orphan\nEND:VEVENT"))
+ (should (null (calendar-sync--parse-event vevent)))))
+
+(provide 'test-calendar-sync--parse-event)
+;;; test-calendar-sync--parse-event.el ends here
diff --git a/tests/test-calendar-sync--parse-ics-datetime.el b/tests/test-calendar-sync--parse-ics-datetime.el
new file mode 100644
index 00000000..6f1c083b
--- /dev/null
+++ b/tests/test-calendar-sync--parse-ics-datetime.el
@@ -0,0 +1,53 @@
+;;; test-calendar-sync--parse-ics-datetime.el --- Tests for iCal datetime parser -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for calendar-sync--parse-ics-datetime.
+;; Covers three formats: UTC datetime (Z suffix), local datetime, date-only.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-calendar-sync)
+(require 'calendar-sync)
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--parse-ics-datetime-normal-utc ()
+ "UTC datetime (Z suffix) returns (year month day hour minute)."
+ (let ((result (calendar-sync--parse-ics-datetime "20260203T090000Z")))
+ (should (equal '(2026 2 3 9 0) result))))
+
+(ert-deftest test-calendar-sync--parse-ics-datetime-normal-local ()
+ "Local datetime (no Z) returns (year month day hour minute)."
+ (let ((result (calendar-sync--parse-ics-datetime "20260315T143000")))
+ (should (equal '(2026 3 15 14 30) result))))
+
+(ert-deftest test-calendar-sync--parse-ics-datetime-normal-date-only ()
+ "Date-only returns (year month day nil nil)."
+ (let ((result (calendar-sync--parse-ics-datetime "20260203")))
+ (should (equal '(2026 2 3 nil nil) result))))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--parse-ics-datetime-boundary-midnight ()
+ "Midnight is parsed as hour 0, minute 0."
+ (let ((result (calendar-sync--parse-ics-datetime "20260101T000000Z")))
+ (should (equal '(2026 1 1 0 0) result))))
+
+(ert-deftest test-calendar-sync--parse-ics-datetime-boundary-end-of-day ()
+ "23:59 is parsed correctly."
+ (let ((result (calendar-sync--parse-ics-datetime "20261231T235900")))
+ (should (equal '(2026 12 31 23 59) result))))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--parse-ics-datetime-error-nil ()
+ "Nil input returns nil."
+ (should (null (calendar-sync--parse-ics-datetime nil))))
+
+(ert-deftest test-calendar-sync--parse-ics-datetime-error-empty-string ()
+ "Empty string returns nil."
+ (should (null (calendar-sync--parse-ics-datetime ""))))
+
+(provide 'test-calendar-sync--parse-ics-datetime)
+;;; test-calendar-sync--parse-ics-datetime.el ends here
diff --git a/tests/test-calendar-sync--parse-timestamp.el b/tests/test-calendar-sync--parse-timestamp.el
new file mode 100644
index 00000000..d05540f7
--- /dev/null
+++ b/tests/test-calendar-sync--parse-timestamp.el
@@ -0,0 +1,59 @@
+;;; test-calendar-sync--parse-timestamp.el --- Tests for timestamp parser -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for calendar-sync--parse-timestamp.
+;; Handles UTC conversion, TZID conversion, local passthrough, and date-only.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-calendar-sync)
+(require 'calendar-sync)
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--parse-timestamp-normal-local-no-tz ()
+ "Local datetime without timezone returns raw values."
+ (let ((result (calendar-sync--parse-timestamp "20260315T143000")))
+ (should (equal '(2026 3 15 14 30) result))))
+
+(ert-deftest test-calendar-sync--parse-timestamp-normal-utc-converts ()
+ "UTC datetime (Z suffix) is converted to local time."
+ (let ((result (calendar-sync--parse-timestamp "20260315T180000Z")))
+ ;; Result should be local time — verify it's a valid 5-element list
+ (should (= 5 (length result)))
+ ;; The hour should differ from 18 unless we're in UTC
+ (should (numberp (nth 3 result)))))
+
+(ert-deftest test-calendar-sync--parse-timestamp-normal-date-only ()
+ "Date-only returns 3-element list (year month day)."
+ (let ((result (calendar-sync--parse-timestamp "20260315")))
+ (should (equal '(2026 3 15) result))))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--parse-timestamp-boundary-with-tzid ()
+ "TZID parameter triggers timezone conversion."
+ (let ((result (calendar-sync--parse-timestamp "20260315T140000" "America/New_York")))
+ ;; Should return a 5-element list (converted from Eastern)
+ (should (= 5 (length result)))
+ (should (numberp (nth 0 result)))))
+
+(ert-deftest test-calendar-sync--parse-timestamp-boundary-midnight-utc ()
+ "Midnight UTC converts correctly (may change date for western timezones)."
+ (let ((result (calendar-sync--parse-timestamp "20260315T000000Z")))
+ (should (= 5 (length result)))
+ (should (numberp (nth 3 result)))))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--parse-timestamp-error-garbage ()
+ "Non-datetime string returns nil."
+ (should (null (calendar-sync--parse-timestamp "not-a-date"))))
+
+(ert-deftest test-calendar-sync--parse-timestamp-error-partial ()
+ "Truncated datetime returns nil."
+ (should (null (calendar-sync--parse-timestamp "2026031"))))
+
+(provide 'test-calendar-sync--parse-timestamp)
+;;; test-calendar-sync--parse-timestamp.el ends here
diff --git a/tests/test-calendar-sync--split-events.el b/tests/test-calendar-sync--split-events.el
new file mode 100644
index 00000000..a5a957c3
--- /dev/null
+++ b/tests/test-calendar-sync--split-events.el
@@ -0,0 +1,54 @@
+;;; test-calendar-sync--split-events.el --- Tests for VEVENT block splitter -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for calendar-sync--split-events.
+;; Splits raw .ics content into individual VEVENT block strings.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-calendar-sync)
+(require 'calendar-sync)
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--split-events-normal-single ()
+ "Single VEVENT returns one-element list."
+ (let* ((ics "BEGIN:VCALENDAR\nBEGIN:VEVENT\nSUMMARY:Test\nEND:VEVENT\nEND:VCALENDAR")
+ (result (calendar-sync--split-events ics)))
+ (should (= 1 (length result)))
+ (should (string-match-p "SUMMARY:Test" (car result)))))
+
+(ert-deftest test-calendar-sync--split-events-normal-multiple ()
+ "Multiple VEVENTs return corresponding list."
+ (let* ((ics (concat "BEGIN:VCALENDAR\n"
+ "BEGIN:VEVENT\nSUMMARY:First\nEND:VEVENT\n"
+ "BEGIN:VEVENT\nSUMMARY:Second\nEND:VEVENT\n"
+ "END:VCALENDAR"))
+ (result (calendar-sync--split-events ics)))
+ (should (= 2 (length result)))
+ (should (string-match-p "First" (nth 0 result)))
+ (should (string-match-p "Second" (nth 1 result)))))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--split-events-boundary-empty-calendar ()
+ "Calendar with no VEVENTs returns empty list."
+ (let ((ics "BEGIN:VCALENDAR\nVERSION:2.0\nEND:VCALENDAR"))
+ (should (null (calendar-sync--split-events ics)))))
+
+(ert-deftest test-calendar-sync--split-events-boundary-preserves-content ()
+ "Each extracted block contains BEGIN:VEVENT through END:VEVENT."
+ (let* ((ics "BEGIN:VEVENT\nUID:abc\nSUMMARY:Test\nEND:VEVENT")
+ (result (calendar-sync--split-events ics)))
+ (should (string-prefix-p "BEGIN:VEVENT" (car result)))
+ (should (string-suffix-p "END:VEVENT" (car result)))))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--split-events-error-empty-string ()
+ "Empty string returns empty list."
+ (should (null (calendar-sync--split-events ""))))
+
+(provide 'test-calendar-sync--split-events)
+;;; test-calendar-sync--split-events.el ends here
diff --git a/tests/test-calendar-sync--weekday-to-number.el b/tests/test-calendar-sync--weekday-to-number.el
new file mode 100644
index 00000000..bcad7917
--- /dev/null
+++ b/tests/test-calendar-sync--weekday-to-number.el
@@ -0,0 +1,37 @@
+;;; test-calendar-sync--weekday-to-number.el --- Tests for weekday mapping -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for calendar-sync--weekday-to-number. Pure string→number mapping.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-calendar-sync)
+(require 'calendar-sync)
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--weekday-to-number-normal-monday ()
+ "MO maps to 1."
+ (should (= 1 (calendar-sync--weekday-to-number "MO"))))
+
+(ert-deftest test-calendar-sync--weekday-to-number-normal-all-days ()
+ "All seven days map to 1-7."
+ (should (equal '(1 2 3 4 5 6 7)
+ (mapcar #'calendar-sync--weekday-to-number
+ '("MO" "TU" "WE" "TH" "FR" "SA" "SU")))))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--weekday-to-number-boundary-sunday ()
+ "SU maps to 7 (not 0)."
+ (should (= 7 (calendar-sync--weekday-to-number "SU"))))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--weekday-to-number-error-invalid ()
+ "Invalid weekday string returns nil."
+ (should (null (calendar-sync--weekday-to-number "XX"))))
+
+(provide 'test-calendar-sync--weekday-to-number)
+;;; test-calendar-sync--weekday-to-number.el ends here