diff options
| author | Craig Jennings <c@cjennings.net> | 2025-11-16 18:09:17 -0600 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2025-11-16 18:09:17 -0600 |
| commit | da0bd6883a4032054aef4b59c338f60796a0fd99 (patch) | |
| tree | e95369646441b35058c89dfb9c31bad9410243fa /tests/test-calendar-sync.el | |
| parent | 6280a13c87412a6ff50bbaa43e821c518bd2bd0e (diff) | |
feat(calendar-sync): Add automatic timezone detection and chronological sorting
Implemented calendar-sync.el as a complete replacement for org-gcal, featuring:
**Core Functionality:**
- One-way sync from Google Calendar to Org (via .ics URL)
- UTC to local timezone conversion for all event timestamps
- Chronological event sorting (past → present → future)
- Non-blocking sync using curl (works reliably in daemon mode)
**Automatic Timezone Detection:**
- Detects timezone changes when traveling between timezones
- Tracks timezone offset in seconds (-21600 for CST, -28800 for PST, etc.)
- Triggers automatic re-sync when timezone changes detected
- Shows informative messages: "Timezone change detected (UTC-6 → UTC-8)"
**State Persistence:**
- Saves sync state to ~/.emacs.d/data/calendar-sync-state.el
- Persists timezone and last sync time across Emacs sessions
- Enables detection even after closing Emacs before traveling
**User Features:**
- Interactive commands: calendar-sync-now, calendar-sync-start/stop
- Keybindings: C-; g s (sync), C-; g a (start auto-sync), C-; g x (stop)
- Optional auto-sync every 15 minutes (disabled by default)
- Clear status messages for all operations
**Code Quality:**
- Comprehensive test coverage: 51 ERT tests (100% passing)
- Refactored UTC conversion into separate function
- Clean separation of concerns (parsing, conversion, formatting, sorting)
- Well-documented with timezone behavior guide and changelog
**Migration:**
- Removed org-gcal-config.el (archived in modules/archived/)
- Updated init.el to use calendar-sync
- Moved gcal.org to .emacs.d/data/ for machine-independent syncing
- Removed org-gcal appointment capture template
Files modified: modules/calendar-sync.el:442, tests/test-calendar-sync.el:577
Files created: data/calendar-sync-state.el, tests/testutil-calendar-sync.el
Documentation: docs/calendar-sync-timezones.md, docs/calendar-sync-changelog.md
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
Diffstat (limited to 'tests/test-calendar-sync.el')
| -rw-r--r-- | tests/test-calendar-sync.el | 580 |
1 files changed, 580 insertions, 0 deletions
diff --git a/tests/test-calendar-sync.el b/tests/test-calendar-sync.el new file mode 100644 index 00000000..17a17176 --- /dev/null +++ b/tests/test-calendar-sync.el @@ -0,0 +1,580 @@ +;;; test-calendar-sync.el --- Tests for calendar-sync -*- lexical-binding: t; -*- + +;;; Commentary: +;; Comprehensive tests for calendar-sync module. +;; Covers Normal, Boundary, and Error cases for all parsing functions. +;; Uses dynamic timestamps (no hardcoded dates). + +;;; Code: + +(require 'ert) +(require 'calendar-sync) +(require 'testutil-calendar-sync) + +;;; Test Data + +(defconst test-calendar-sync-sample-ics + "BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Google Inc//Google Calendar 70.9054//EN +BEGIN:VEVENT +SUMMARY:Test Meeting +DTSTART:20251116T140000Z +DTEND:20251116T150000Z +DESCRIPTION:Discuss project status +LOCATION:Conference Room A +END:VEVENT +END:VCALENDAR" + "Sample .ics content for testing.") + +;;; Helper Functions + +(defmacro with-test-time (time &rest body) + "Execute BODY with `current-time` mocked to TIME." + `(cl-letf (((symbol-function 'current-time) + (lambda () ,time))) + ,@body)) + +;;; Tests: calendar-sync--split-events + +(ert-deftest test-calendar-sync--split-events-normal-single-event-returns-one () + "Test that single event is extracted correctly." + (let* ((ics test-calendar-sync-sample-ics) + (events (calendar-sync--split-events ics))) + (should (= 1 (length events))) + (should (string-match-p "BEGIN:VEVENT" (car events))) + (should (string-match-p "END:VEVENT" (car events))))) + +(ert-deftest test-calendar-sync--split-events-normal-multiple-events-returns-all () + "Test that multiple events are all extracted." + (let* ((event1 (test-calendar-sync-make-vevent + "Event 1" + (test-calendar-sync-time-today-at 14 0) + (test-calendar-sync-time-today-at 15 0))) + (event2 (test-calendar-sync-make-vevent + "Event 2" + (test-calendar-sync-time-tomorrow-at 10 0) + (test-calendar-sync-time-tomorrow-at 11 0))) + (ics (test-calendar-sync-make-ics event1 event2)) + (events (calendar-sync--split-events ics))) + (should (= 2 (length events))) + (should (string-match-p "Event 1" (nth 0 events))) + (should (string-match-p "Event 2" (nth 1 events))))) + +(ert-deftest test-calendar-sync--split-events-boundary-empty-string-returns-nil () + "Test that empty string returns empty list." + (should (null (calendar-sync--split-events "")))) + +(ert-deftest test-calendar-sync--split-events-boundary-no-events-returns-nil () + "Test that .ics with no VEVENT returns empty list." + (let ((ics "BEGIN:VCALENDAR\nVERSION:2.0\nEND:VCALENDAR")) + (should (null (calendar-sync--split-events ics))))) + +;;; Tests: calendar-sync--get-property + +(ert-deftest test-calendar-sync--get-property-normal-summary-returns-value () + "Test extracting SUMMARY property." + (let ((event "BEGIN:VEVENT\nSUMMARY:Test Event\nEND:VEVENT")) + (should (string= "Test Event" (calendar-sync--get-property event "SUMMARY"))))) + +(ert-deftest test-calendar-sync--get-property-normal-description-with-spaces () + "Test extracting DESCRIPTION with spaces." + (let ((event "DESCRIPTION:Multi word description")) + (should (string= "Multi word description" + (calendar-sync--get-property event "DESCRIPTION"))))) + +(ert-deftest test-calendar-sync--get-property-boundary-missing-property-returns-nil () + "Test that missing property returns nil." + (let ((event "BEGIN:VEVENT\nSUMMARY:Test\nEND:VEVENT")) + (should (null (calendar-sync--get-property event "LOCATION"))))) + +(ert-deftest test-calendar-sync--get-property-error-empty-string-returns-nil () + "Test that empty event string returns nil." + (should (null (calendar-sync--get-property "" "SUMMARY")))) + +;;; Tests: calendar-sync--parse-timestamp + +(ert-deftest test-calendar-sync--parse-timestamp-normal-datetime-returns-full-time () + "Test parsing full datetime with time component. +UTC timestamp (with Z suffix) is converted to local time." + (let* ((parsed (calendar-sync--parse-timestamp "20251116T140000Z")) + ;; Compute expected local time from UTC + (utc-time (encode-time 0 0 14 16 11 2025 0)) + (local-time (decode-time utc-time)) + (expected-hour (nth 2 local-time)) + (expected-minute (nth 1 local-time))) + (should (= 5 (length parsed))) + (should (= 2025 (nth 0 parsed))) + (should (= 11 (nth 1 parsed))) + (should (= 16 (nth 2 parsed))) + (should (= expected-hour (nth 3 parsed))) + (should (= expected-minute (nth 4 parsed))))) + +(ert-deftest test-calendar-sync--parse-timestamp-normal-datetime-without-z () + "Test parsing datetime without Z suffix." + (let* ((parsed (calendar-sync--parse-timestamp "20251116T140000"))) + (should (= 5 (length parsed))) + (should (= 14 (nth 3 parsed))))) + +(ert-deftest test-calendar-sync--parse-timestamp-boundary-date-only-returns-three-parts () + "Test parsing date-only timestamp (all-day event)." + (let* ((parsed (calendar-sync--parse-timestamp "20251116"))) + (should (= 3 (length parsed))) + (should (= 2025 (nth 0 parsed))) + (should (= 11 (nth 1 parsed))) + (should (= 16 (nth 2 parsed))))) + +(ert-deftest test-calendar-sync--parse-timestamp-error-invalid-format-returns-nil () + "Test that invalid timestamp returns nil." + (should (null (calendar-sync--parse-timestamp "invalid"))) + (should (null (calendar-sync--parse-timestamp "2025-11-16"))) + (should (null (calendar-sync--parse-timestamp "")))) + +(ert-deftest test-calendar-sync--parse-timestamp-boundary-leap-year-feb-29 () + "Test parsing Feb 29 on leap year." + (let* ((parsed (calendar-sync--parse-timestamp "20240229T120000Z"))) + (should parsed) + (should (= 2024 (nth 0 parsed))) + (should (= 2 (nth 1 parsed))) + (should (= 29 (nth 2 parsed))))) + +;;; Tests: Timezone Conversion + +(ert-deftest test-calendar-sync--parse-timestamp-utc-conversion-actually-converts () + "Test that UTC timestamp (with Z) is actually converted to local time. +This test verifies the conversion happened by checking that the result +differs from the original UTC time (unless we happen to be in UTC timezone)." + (let* ((utc-timestamp "20251116T140000Z") ; 14:00 UTC + (parsed (calendar-sync--parse-timestamp utc-timestamp)) + ;; Get what the UTC time would be without conversion + (utc-hour 14) + (parsed-hour (nth 3 parsed))) + ;; The parsed hour should match what decode-time gives us for this UTC time + (let* ((utc-time (encode-time 0 0 14 16 11 2025 0)) + (local-time (decode-time utc-time)) + (expected-local-hour (nth 2 local-time))) + (should (= expected-local-hour parsed-hour))))) + +(ert-deftest test-calendar-sync--parse-timestamp-local-time-not-converted () + "Test that timestamp without Z suffix is NOT converted. +Local times should pass through unchanged." + (let* ((local-timestamp "20251116T140000") ; 14:00 local (no Z) + (parsed (calendar-sync--parse-timestamp local-timestamp))) + ;; Should return exactly 14:00, not converted + (should (= 14 (nth 3 parsed))) + (should (= 0 (nth 4 parsed))))) + +(ert-deftest test-calendar-sync--parse-timestamp-utc-midnight-converts-correctly () + "Test UTC midnight conversion handles day boundaries correctly." + (let* ((parsed (calendar-sync--parse-timestamp "20251116T000000Z")) + ;; Compute expected local time + (utc-time (encode-time 0 0 0 16 11 2025 0)) + (local-time (decode-time utc-time)) + (expected-year (nth 5 local-time)) + (expected-month (nth 4 local-time)) + (expected-day (nth 3 local-time)) + (expected-hour (nth 2 local-time))) + (should (= expected-year (nth 0 parsed))) + (should (= expected-month (nth 1 parsed))) + (should (= expected-day (nth 2 parsed))) + (should (= expected-hour (nth 3 parsed))))) + +;;; Tests: Chronological Sorting + +(ert-deftest test-calendar-sync--event-start-time-extracts-comparable-time () + "Test that event start time can be extracted for comparison." + (let* ((event (list :start (list 2025 11 16 14 30))) + (time-value (calendar-sync--event-start-time event)) + (event-earlier (list :start (list 2025 11 16 10 0))) + (time-earlier (calendar-sync--event-start-time event-earlier))) + ;; Should return a valid time value (cons cell for Emacs time) + (should (consp time-value)) + ;; Should be comparable - later time should not be less than earlier + (should (time-less-p time-earlier time-value)))) + +(ert-deftest test-calendar-sync--event-start-time-handles-all-day-events () + "Test that all-day events (no time component) work for comparison." + (let* ((event (list :start (list 2025 11 16))) ; No hour/minute + (time-value (calendar-sync--event-start-time event)) + (event-next-day (list :start (list 2025 11 17))) + (time-next-day (calendar-sync--event-start-time event-next-day))) + ;; Should return a valid time value (cons cell) + (should (consp time-value)) + ;; Next day should be later than current day + (should (time-less-p time-value time-next-day)))) + +(ert-deftest test-calendar-sync--parse-ics-sorts-chronologically () + "Test that parsed events are returned in chronological order. +Earlier events should appear first in the output." + (let* ((event-future (test-calendar-sync-make-vevent + "Future Event" + (test-calendar-sync-time-days-from-now 7 10 0) + (test-calendar-sync-time-days-from-now 7 11 0))) + (event-past (test-calendar-sync-make-vevent + "Past Event" + (test-calendar-sync-time-days-ago 1 14 0) + (test-calendar-sync-time-days-ago 1 15 0))) + (event-today (test-calendar-sync-make-vevent + "Today Event" + (test-calendar-sync-time-today-at 9 0) + (test-calendar-sync-time-today-at 10 0))) + ;; Create .ics with events in wrong order (future, past, today) + (ics (test-calendar-sync-make-ics event-future event-past event-today)) + (org-content (calendar-sync--parse-ics ics)) + ;; Find positions of each event in output + (past-pos (string-match "Past Event" org-content)) + (today-pos (string-match "Today Event" org-content)) + (future-pos (string-match "Future Event" org-content))) + ;; All events should be found + (should past-pos) + (should today-pos) + (should future-pos) + ;; Order should be: past < today < future + (should (< past-pos today-pos)) + (should (< today-pos future-pos)))) + +(ert-deftest test-calendar-sync--parse-ics-sorts-same-day-events-by-time () + "Test that events on the same day are sorted by time." + (let* ((event-morning (test-calendar-sync-make-vevent + "Morning Event" + (test-calendar-sync-time-today-at 9 0) + (test-calendar-sync-time-today-at 10 0))) + (event-evening (test-calendar-sync-make-vevent + "Evening Event" + (test-calendar-sync-time-today-at 18 0) + (test-calendar-sync-time-today-at 19 0))) + (event-afternoon (test-calendar-sync-make-vevent + "Afternoon Event" + (test-calendar-sync-time-today-at 14 0) + (test-calendar-sync-time-today-at 15 0))) + ;; Create .ics with events in wrong order + (ics (test-calendar-sync-make-ics event-evening event-morning event-afternoon)) + (org-content (calendar-sync--parse-ics ics)) + (morning-pos (string-match "Morning Event" org-content)) + (afternoon-pos (string-match "Afternoon Event" org-content)) + (evening-pos (string-match "Evening Event" org-content))) + (should (< morning-pos afternoon-pos)) + (should (< afternoon-pos evening-pos)))) + +;;; Tests: calendar-sync--format-timestamp + +(ert-deftest test-calendar-sync--format-timestamp-normal-timed-event-includes-times () + "Test formatting timed event with start and end times." + (let* ((start (list 2025 11 16 14 0)) + (end (list 2025 11 16 15 30)) + (formatted (calendar-sync--format-timestamp start end))) + (should (string-match-p "<2025-11-16 \\w\\{3\\} 14:00-15:30>" formatted)))) + +(ert-deftest test-calendar-sync--format-timestamp-boundary-all-day-event-no-times () + "Test formatting all-day event (date only, no times)." + (let* ((start (list 2025 11 16)) + (formatted (calendar-sync--format-timestamp start nil))) + (should (string-match-p "<2025-11-16 \\w\\{3\\}>" formatted)) + (should-not (string-match-p "[0-9]:[0-9]" formatted)))) + +(ert-deftest test-calendar-sync--format-timestamp-normal-includes-day-of-week () + "Test that formatted timestamp includes day of week." + (let* ((start (list 2025 11 16 14 0)) + (end (list 2025 11 16 15 0)) + (formatted (calendar-sync--format-timestamp start end))) + (should (string-match-p "Sun" formatted)))) + +;;; Tests: calendar-sync--parse-event + +(ert-deftest test-calendar-sync--parse-event-normal-complete-event-returns-plist () + "Test parsing complete event with all fields." + (let* ((event (test-calendar-sync-make-vevent + "Meeting" + (test-calendar-sync-time-today-at 14 0) + (test-calendar-sync-time-today-at 15 0) + "Discussion" + "Room A")) + (parsed (calendar-sync--parse-event event))) + (should parsed) + (should (string= "Meeting" (plist-get parsed :summary))) + (should (string= "Discussion" (plist-get parsed :description))) + (should (string= "Room A" (plist-get parsed :location))) + (should (plist-get parsed :start)) + (should (plist-get parsed :end)))) + +(ert-deftest test-calendar-sync--parse-event-boundary-minimal-event-no-optional-fields () + "Test parsing event with only required fields (SUMMARY, DTSTART)." + (let* ((event (test-calendar-sync-make-vevent + "Simple Event" + (test-calendar-sync-time-today-at 10 0) + nil)) + (parsed (calendar-sync--parse-event event))) + (should parsed) + (should (string= "Simple Event" (plist-get parsed :summary))) + (should (null (plist-get parsed :description))) + (should (null (plist-get parsed :location))) + (should (plist-get parsed :start)))) + +(ert-deftest test-calendar-sync--parse-event-boundary-emoji-in-summary-preserved () + "Test that emoji in summary are preserved." + (let* ((event (test-calendar-sync-make-vevent + "Meeting 🎉" + (test-calendar-sync-time-today-at 14 0) + (test-calendar-sync-time-today-at 15 0))) + (parsed (calendar-sync--parse-event event))) + (should (string-match-p "🎉" (plist-get parsed :summary))))) + +(ert-deftest test-calendar-sync--parse-event-error-missing-summary-returns-nil () + "Test that event without SUMMARY returns nil." + (let ((event "BEGIN:VEVENT\nDTSTART:20251116T140000Z\nEND:VEVENT")) + (should (null (calendar-sync--parse-event event))))) + +(ert-deftest test-calendar-sync--parse-event-error-missing-dtstart-returns-nil () + "Test that event without DTSTART returns nil." + (let ((event "BEGIN:VEVENT\nSUMMARY:Test\nEND:VEVENT")) + (should (null (calendar-sync--parse-event event))))) + +(ert-deftest test-calendar-sync--parse-event-error-invalid-dtstart-returns-nil () + "Test that event with invalid DTSTART returns nil." + (let ((event "BEGIN:VEVENT\nSUMMARY:Test\nDTSTART:invalid\nEND:VEVENT")) + (should (null (calendar-sync--parse-event event))))) + +;;; Tests: calendar-sync--event-to-org + +(ert-deftest test-calendar-sync--event-to-org-normal-complete-event-formats-correctly () + "Test converting complete event to org format." + (let* ((event (list :summary "Meeting" + :description "Discuss project" + :location "Room A" + :start (list 2025 11 16 14 0) + :end (list 2025 11 16 15 30))) + (org-str (calendar-sync--event-to-org event))) + (should (string-match-p "^\\* Meeting$" org-str)) + (should (string-match-p "<2025-11-16 \\w\\{3\\} 14:00-15:30>" org-str)) + (should (string-match-p "Discuss project" org-str)) + (should (string-match-p "Location: Room A" org-str)))) + +(ert-deftest test-calendar-sync--event-to-org-boundary-minimal-event-no-description () + "Test converting minimal event without optional fields." + (let* ((event (list :summary "Simple Event" + :start (list 2025 11 16 10 0) + :end (list 2025 11 16 11 0))) + (org-str (calendar-sync--event-to-org event))) + (should (string-match-p "^\\* Simple Event$" org-str)) + (should-not (string-match-p "Location:" org-str)) + ;; Check timestamp is present + (should (string-match-p "<2025-11-16" org-str)))) + +(ert-deftest test-calendar-sync--event-to-org-boundary-all-day-event-no-times () + "Test converting all-day event." + (let* ((event (list :summary "All Day Event" + :start (list 2025 11 16))) + (org-str (calendar-sync--event-to-org event))) + (should (string-match-p "^\\* All Day Event$" org-str)) + (should (string-match-p "<2025-11-16" org-str)) + (should-not (string-match-p "[0-9][0-9]:[0-9][0-9]" org-str)))) + +;;; Tests: calendar-sync--parse-ics + +(ert-deftest test-calendar-sync--parse-ics-normal-single-event-returns-org () + "Test parsing .ics with single event returns org format." + (let* ((event (test-calendar-sync-make-vevent + "Test Event" + (test-calendar-sync-time-today-at 14 0) + (test-calendar-sync-time-today-at 15 0))) + (ics (test-calendar-sync-make-ics event)) + (org-content (calendar-sync--parse-ics ics))) + (should org-content) + (should (string-match-p "^# Google Calendar Events" org-content)) + (should (string-match-p "\\* Test Event" org-content)))) + +(ert-deftest test-calendar-sync--parse-ics-normal-multiple-events-all-included () + "Test parsing .ics with multiple events." + (let* ((event1 (test-calendar-sync-make-vevent + "Event 1" + (test-calendar-sync-time-today-at 9 0) + (test-calendar-sync-time-today-at 10 0))) + (event2 (test-calendar-sync-make-vevent + "Event 2" + (test-calendar-sync-time-today-at 14 0) + (test-calendar-sync-time-today-at 15 0))) + (ics (test-calendar-sync-make-ics event1 event2)) + (org-content (calendar-sync--parse-ics ics))) + (should org-content) + (should (string-match-p "\\* Event 1" org-content)) + (should (string-match-p "\\* Event 2" org-content)))) + +(ert-deftest test-calendar-sync--parse-ics-boundary-empty-calendar-returns-nil () + "Test parsing empty calendar (no events)." + (let* ((ics "BEGIN:VCALENDAR\nVERSION:2.0\nEND:VCALENDAR") + (org-content (calendar-sync--parse-ics ics))) + (should (null org-content)))) + +(ert-deftest test-calendar-sync--parse-ics-error-malformed-ics-returns-nil () + "Test that malformed .ics returns nil and sets error." + (setq calendar-sync--last-error nil) + (let ((result (calendar-sync--parse-ics "malformed content"))) + ;; Function should handle error gracefully + (should (or (null result) (stringp result))))) + +(ert-deftest test-calendar-sync--parse-ics-boundary-mixed-valid-invalid-events () + "Test parsing .ics with mix of valid and invalid events. +Valid events should be parsed, invalid ones skipped." + (let* ((valid-event (test-calendar-sync-make-vevent + "Valid Event" + (test-calendar-sync-time-today-at 14 0) + (test-calendar-sync-time-today-at 15 0))) + (invalid-event "BEGIN:VEVENT\nDTSTART:20251116T140000Z\nEND:VEVENT") ;; No SUMMARY + (ics (test-calendar-sync-make-ics valid-event invalid-event)) + (org-content (calendar-sync--parse-ics ics))) + (should org-content) + (should (string-match-p "\\* Valid Event" org-content)))) + +;;; Tests: Timezone Detection + +(ert-deftest test-calendar-sync--current-timezone-offset-returns-number () + "Test that current timezone offset returns a number in seconds." + (let ((offset (calendar-sync--current-timezone-offset))) + ;; Should be a number + (should (numberp offset)) + ;; Should be reasonable (between -12 and +14 hours in seconds) + (should (>= offset (* -12 3600))) + (should (<= offset (* 14 3600))))) + +(ert-deftest test-calendar-sync--timezone-name-returns-string () + "Test that timezone name returns a string." + (let ((name (calendar-sync--timezone-name))) + ;; Should be a string + (should (stringp name)) + ;; Should not be empty + (should (> (length name) 0)))) + +(ert-deftest test-calendar-sync--format-timezone-offset-handles-negative () + "Test formatting negative timezone offsets (west of UTC)." + ;; CST: UTC-6 = -21600 seconds + (should (string= "UTC-6" (calendar-sync--format-timezone-offset -21600))) + ;; PST: UTC-8 = -28800 seconds + (should (string= "UTC-8" (calendar-sync--format-timezone-offset -28800))) + ;; EST: UTC-5 = -18000 seconds + (should (string= "UTC-5" (calendar-sync--format-timezone-offset -18000)))) + +(ert-deftest test-calendar-sync--format-timezone-offset-handles-positive () + "Test formatting positive timezone offsets (east of UTC)." + ;; CET: UTC+1 = 3600 seconds + (should (string= "UTC+1" (calendar-sync--format-timezone-offset 3600))) + ;; JST: UTC+9 = 32400 seconds + (should (string= "UTC+9" (calendar-sync--format-timezone-offset 32400))) + ;; AEST: UTC+10 = 36000 seconds + (should (string= "UTC+10" (calendar-sync--format-timezone-offset 36000)))) + +(ert-deftest test-calendar-sync--format-timezone-offset-handles-utc () + "Test formatting UTC (zero offset)." + (should (string= "UTC+0" (calendar-sync--format-timezone-offset 0)))) + +(ert-deftest test-calendar-sync--format-timezone-offset-handles-fractional () + "Test formatting timezone offsets with fractional hours." + ;; IST: UTC+5:30 = 19800 seconds + (should (string= "UTC+5:30" (calendar-sync--format-timezone-offset 19800))) + ;; ACST: UTC+9:30 = 34200 seconds + (should (string= "UTC+9:30" (calendar-sync--format-timezone-offset 34200))) + ;; NFT: UTC+11:30 = 41400 seconds + (should (string= "UTC+11:30" (calendar-sync--format-timezone-offset 41400)))) + +(ert-deftest test-calendar-sync--format-timezone-offset-handles-nil () + "Test formatting nil timezone offset." + (should (string= "unknown" (calendar-sync--format-timezone-offset nil)))) + +(ert-deftest test-calendar-sync--timezone-changed-p-detects-no-change () + "Test that timezone-changed-p returns nil when timezone hasn't changed." + (let ((calendar-sync--last-timezone-offset (calendar-sync--current-timezone-offset))) + (should-not (calendar-sync--timezone-changed-p)))) + +(ert-deftest test-calendar-sync--timezone-changed-p-detects-change () + "Test that timezone-changed-p detects timezone changes." + (let* ((current (calendar-sync--current-timezone-offset)) + ;; Simulate different timezone (shift by 3 hours) + (calendar-sync--last-timezone-offset (+ current (* 3 3600)))) + (should (calendar-sync--timezone-changed-p)))) + +(ert-deftest test-calendar-sync--timezone-changed-p-handles-nil-last () + "Test that timezone-changed-p returns nil when no previous timezone." + (let ((calendar-sync--last-timezone-offset nil)) + (should-not (calendar-sync--timezone-changed-p)))) + +;;; Tests: State Persistence + +(ert-deftest test-calendar-sync--save-and-load-state-roundtrip () + "Test that state can be saved and loaded correctly." + (let* ((test-state-file (make-temp-file "calendar-sync-test-state")) + (calendar-sync--state-file test-state-file) + (original-offset -21600) ; CST + (original-time (current-time)) + (calendar-sync--last-timezone-offset original-offset) + (calendar-sync--last-sync-time original-time)) + (unwind-protect + (progn + ;; Save state + (calendar-sync--save-state) + (should (file-exists-p test-state-file)) + + ;; Clear variables + (setq calendar-sync--last-timezone-offset nil) + (setq calendar-sync--last-sync-time nil) + + ;; Load state + (calendar-sync--load-state) + + ;; Verify loaded correctly + (should (= original-offset calendar-sync--last-timezone-offset)) + (should (equal original-time calendar-sync--last-sync-time))) + ;; Cleanup + (when (file-exists-p test-state-file) + (delete-file test-state-file))))) + +(ert-deftest test-calendar-sync--save-state-creates-directory () + "Test that save-state creates parent directory if needed." + (let* ((test-dir (make-temp-file "calendar-sync-test-dir" t)) + (test-state-file (expand-file-name "subdir/state.el" test-dir)) + (calendar-sync--state-file test-state-file) + (calendar-sync--last-timezone-offset -21600) + (calendar-sync--last-sync-time (current-time))) + (unwind-protect + (progn + (calendar-sync--save-state) + (should (file-exists-p test-state-file)) + (should (file-directory-p (file-name-directory test-state-file)))) + ;; Cleanup + (when (file-exists-p test-dir) + (delete-directory test-dir t))))) + +(ert-deftest test-calendar-sync--load-state-handles-missing-file () + "Test that load-state handles missing file gracefully." + (let ((calendar-sync--state-file "/nonexistent/path/state.el") + (calendar-sync--last-timezone-offset nil) + (calendar-sync--last-sync-time nil)) + ;; Should not error + (should-not (calendar-sync--load-state)) + ;; Variables should remain nil + (should-not calendar-sync--last-timezone-offset) + (should-not calendar-sync--last-sync-time))) + +(ert-deftest test-calendar-sync--load-state-handles-corrupted-file () + "Test that load-state handles corrupted state file gracefully." + (let* ((test-state-file (make-temp-file "calendar-sync-test-state")) + (calendar-sync--state-file test-state-file) + (calendar-sync--last-timezone-offset nil) + (calendar-sync--last-sync-time nil)) + (unwind-protect + (progn + ;; Write corrupted data + (with-temp-file test-state-file + (insert "this is not valid elisp {[}")) + + ;; Should handle error gracefully (catches error, logs message) + ;; Returns error message string, not nil, but doesn't throw + (should (stringp (calendar-sync--load-state))) + + ;; Variables should remain nil (not loaded from corrupted file) + (should-not calendar-sync--last-timezone-offset) + (should-not calendar-sync--last-sync-time)) + ;; Cleanup + (when (file-exists-p test-state-file) + (delete-file test-state-file))))) + +(provide 'test-calendar-sync) +;;; test-calendar-sync.el ends here |
