Re: Diary icalendar tests still broken
Richard Lawrence <[email protected]> Thu, 30 Jul 2026 12:12:31 +0200
| Newsgroups | gmane.emacs.devel |
|---|---|
| Message-ID | <[email protected]> |
Richard Lawrence <[email protected]> writes: > Sean Whitton <[email protected]> writes: > >> I've included the log below. My timezone is BST. It looks like the >> problem may be assuming UTC? > > Ah ha! The problem is timezone specific but I can reliably reproduce it > now in at least these zones: > > - Europe/London > - Europe/Dublin > - Portugal > - Atlantic/Madeira > - Antarctica/Troll > - Africa/Bissau > - Africa/Abidjan > - Africa/Monrovia > - Atlantic/Faroe OK, here as promised is a patch for this issue. I've tested it in all of the above time zones and it works, here at least. Please test on your systems! -- Best, Richard
0001-Distinguish-UTC-vs.-UTC-0-00-times-in-iCalendar-libr.patch
(text/x-diff, 15.6 KB)
From 8f34899d74e8b086645da1b41afbb7348fa6ff0e Mon Sep 17 00:00:00 2001 From: Richard Lawrence <[email protected]> Date: Wed, 29 Jul 2026 09:29:25 +0200 Subject: [PATCH] Distinguish UTC vs. UTC+0:00 times in iCalendar library This fixes a bug discussed on emacs-devel; see the thread at https://https://lists.gnu.org/archive/html/emacs-devel/2026-07/msg00445.html The issue was that the library did not distinguish between UTC time and times that have an offset of 0 but are not in the UTC time zone, e.g. standard times in Europe/London or Europe/Dublin. This led to test failures on systems in these time zones. This fix represents true UTC times by inserting the special value `t' in the zone slot of a decoded time at parse time (which `encode-time' already handles correctly). This distinguishes them from decoded times with an offset of 0 seconds. * lisp/calendar/icalendar-parser.el (icalendar-read-time) (icalendar-read-date-time): Read iCalendar UTC times to the new representation. (icalendar-print-time): Print the new representation. (icalendar--decoded-time-p, icalendar--decoded-date-time-p) (icalendar-date-time-is-utc-p): Check for the new representation. (icalendar-date-time): Docstring improvement. * lisp/calendar/icalendar-recur.el (icalendar-recur-tz-decode-time): Decode to new representation. * lisp/calendar/icalendar-utils.el (icalendar-date-time<) (icalendar-date-time-simultaneous-p): Work with the new representation. * lisp/calendar/diary-icalendar.el (diary-icalendar-format-time-as-local): Work with the new representation. * test/lisp/calendar/icalendar-parser-tests.el (icalendar-parser-test-bad-hyphenated-dates): * test/lisp/calendar/icalendar-recur-tests.el (icalendar-test-recur-find-secondly-interval) (icalendar-test-recur-tz-observance-on): Update tests to use new representation. * test/lisp/calendar/icalendar-parser-tests.el (icalendar-parse-test-utc+0-is-not-utc): New regression test. * test/lisp/calendar/diary-icalendar-resources/import-bug-24199.diary-all: Update diary representation of UTC times in a recurrence rule. --- lisp/calendar/diary-icalendar.el | 2 +- lisp/calendar/icalendar-parser.el | 17 ++++----- lisp/calendar/icalendar-recur.el | 2 +- lisp/calendar/icalendar-utils.el | 8 ++-- .../import-bug-24199.diary-all | 6 +-- test/lisp/calendar/icalendar-parser-tests.el | 37 ++++++++++++++++++- test/lisp/calendar/icalendar-recur-tests.el | 16 ++++---- 7 files changed, 61 insertions(+), 27 deletions(-) diff --git a/lisp/calendar/diary-icalendar.el b/lisp/calendar/diary-icalendar.el index 9604dce8e4e..d6feecd012c 100644 --- a/lisp/calendar/diary-icalendar.el +++ b/lisp/calendar/diary-icalendar.el @@ -1365,7 +1365,7 @@ di:format-time-as-local (local-dt (decode-time ts local-tz)) (local-str (di:format-time local-dt))) (if (and original-tzname original-offset - (not (= original-offset local-offset))) + (not (eql original-offset local-offset))) (format "%s (%s)" local-str (di:format-time dt original-tzname)) local-str))))) diff --git a/lisp/calendar/icalendar-parser.el b/lisp/calendar/icalendar-parser.el index c6100c828e4..e415f1cb877 100644 --- a/lisp/calendar/icalendar-parser.el +++ b/lisp/calendar/icalendar-parser.el @@ -895,7 +895,7 @@ ical:read-time (second (string-to-number (substring s 4 6))) (utcoffset (if (and (length= s 7) (equal "Z" (substring s 6 7))) - 0 + t ; UTC ;; unknown/'floating' time zone: nil))) (ical:make-date-time :second second @@ -909,7 +909,7 @@ ical:print-time (decoded-time-hour time) (decoded-time-minute time) (decoded-time-second time) - (if (eql 0 (decoded-time-zone time)) + (if (eq t (decoded-time-zone time)) "Z" ""))) (defun ical:-decoded-time-p (val) @@ -922,7 +922,7 @@ ical:-decoded-time-p (cl-typep (decoded-time-minute val) 'ical:numeric-minute) (cl-typep (decoded-time-hour val) 'ical:numeric-hour) (cl-typep (decoded-time-dst val) '(member t nil -1)) - (cl-typep (decoded-time-zone val) '(or integer null)))) + (cl-typep (decoded-time-zone val) '(or integer boolean)))) (ical:define-type ical:time "TIME" "Type for Time values. @@ -966,7 +966,7 @@ ical:-decoded-date-time-p ;; `make-decoded-time': ;; (cl-typep (decoded-time-weekday val) '(integer 0 6)) (cl-typep (decoded-time-dst val) '(member t nil -1)) - (cl-typep (decoded-time-zone val) '(or integer null)))) + (cl-typep (decoded-time-zone val) '(or integer boolean)))) (defun ical:read-date-time (s) "Read an `icalendar-date-time' from a string S. @@ -981,7 +981,7 @@ ical:read-date-time (second (string-to-number (substring s 13 15))) (utcoffset (if (and (length= s 16) (equal "Z" (substring s 15 16))) - 0 + t ; UTC ;; unknown/'floating' time zone: nil))) (ical:make-date-time :second second @@ -1006,16 +1006,15 @@ ical:print-date-time (defun ical:date-time-is-utc-p (datetime) "Return non-nil if DATETIME is in UTC time." - (let ((offset (decoded-time-zone datetime))) - (and offset (= 0 offset)))) + (eq t (decoded-time-zone datetime))) (ical:define-type ical:date-time "DATE-TIME" "Type for Date-Time values. When printed, a date-time is a string of digits like: YYYYMMDDTHHMMSS -where the 'T' is literal, and separates the date string from the -time string. +where the 'T' is literal, and separates the date string from the time +string. If followed by a 'Z', the string represents a UTC date-time. When read, a date-time is a decoded time, i.e. a list in the format (SEC MINUTE HOUR DAY MONTH YEAR DOW DST UTCOFF). See diff --git a/lisp/calendar/icalendar-recur.el b/lisp/calendar/icalendar-recur.el index fbbfb209ca7..2b70695ea2f 100644 --- a/lisp/calendar/icalendar-recur.el +++ b/lisp/calendar/icalendar-recur.el @@ -1976,7 +1976,7 @@ icr:tz-decode-time (offset (cond (observance (icr:tz-offset-in observance)) ((cl-typep vtimezone 'ical:utc-offset) vtimezone) - (t 0)))) + (t t)))) ; decode to UTC (ical:date-time-variant ; ensures weekday gets set, too (decode-time ts offset) diff --git a/lisp/calendar/icalendar-utils.el b/lisp/calendar/icalendar-utils.el index 565901940f0..ab5af4f5532 100644 --- a/lisp/calendar/icalendar-utils.el +++ b/lisp/calendar/icalendar-utils.el @@ -288,7 +288,7 @@ ical:date-time< signaled." (let ((zone1 (decoded-time-zone dt1)) (zone2 (decoded-time-zone dt2))) - (cond ((and (integerp zone1) (integerp zone2)) + (cond ((and zone1 zone2) (time-less-p (encode-time dt1) (encode-time dt2))) ((and (null zone1) (null zone2)) (ical:date-time-locally< dt1 dt2)) @@ -356,11 +356,11 @@ ical:date-time-simultaneous-p signaled." (let ((zone1 (decoded-time-zone dt1)) (zone2 (decoded-time-zone dt2))) - (cond ((and (integerp zone1) (integerp zone2)) + (cond ((and zone1 zone2) (time-equal-p (encode-time dt1) (encode-time dt2))) ((and (null zone1) (null zone2)) - (time-equal-p (encode-time (ical:date-time-variant dt1 :zone 0)) - (encode-time (ical:date-time-variant dt2 :zone 0)))) + (time-equal-p (encode-time (ical:date-time-variant dt1 :zone t)) + (encode-time (ical:date-time-variant dt2 :zone t)))) (t ;; Best effort: ;; TODO: I'm not convinced this is the right thing to do yet. diff --git a/test/lisp/calendar/diary-icalendar-resources/import-bug-24199.diary-all b/test/lisp/calendar/diary-icalendar-resources/import-bug-24199.diary-all index cf3e5884710..9e60011535e 100644 --- a/test/lisp/calendar/diary-icalendar-resources/import-bug-24199.diary-all +++ b/test/lisp/calendar/diary-icalendar-resources/import-bug-24199.diary-all @@ -1,8 +1,8 @@ &%%(diary-rrule :rule '((FREQ MONTHLY) (BYDAY ((3 . 1))) (INTERVAL 1)) :exclude - '((0 46 11 6 1 2016 3 -1 0) (0 46 11 3 2 2016 3 -1 0) - (0 46 11 2 3 2016 3 -1 0) (0 46 10 4 5 2016 3 -1 0) - (0 46 10 1 6 2016 3 -1 0)) + '((0 46 11 6 1 2016 3 -1 t) (0 46 11 3 2 2016 3 -1 t) + (0 46 11 2 3 2016 3 -1 t) (0 46 10 4 5 2016 3 -1 t) + (0 46 10 1 6 2016 3 -1 t)) :start '(0 46 12 2 12 2015 3 -1 nil) :duration '(0 14 3 0 nil nil nil -1 nil)) Summary Location: Loc diff --git a/test/lisp/calendar/icalendar-parser-tests.el b/test/lisp/calendar/icalendar-parser-tests.el index 8215f977e26..4b56edb8780 100644 --- a/test/lisp/calendar/icalendar-parser-tests.el +++ b/test/lisp/calendar/icalendar-parser-tests.el @@ -1962,7 +1962,7 @@ ipt:bad-hyphenated-dates (expected-dtstamp (ical:make-date-time :year 2023 :month 7 :day 30 :hour 19 :minute 47 :second 0 - :zone 0))) + :zone t))) (should (not (ical:errors-p))) (should (ical:ast-node-valid-p vcal t)) (ical:with-component vcal @@ -2023,6 +2023,41 @@ ipt:bad-user-addresses ((ical:sentbyparam :value sent-by)) (should (equal sent-by expected-sender)))))))))) + +;; Tests for bugfixes: +(ert-deftest ipt:utc+0-is-not-utc () + "Are UTC times parsed distinctly from times in other zones with 0 offset?" + ;; An explicit UTC time should parse with `t' in its zone field; this + ;; distinguishes it from times that merely have a 0 second offset from + ;; UTC, but might have a defined non-UTC time zone, e.g. in Europe/London + ;; or Europe/Dublin. Bug discussion: + ;; https://https://lists.gnu.org/archive/html/emacs-devel/2026-07/msg00445.html + (let* ((s-utc "20260101T111111Z") + (s-non "20260101T111111") + (parsed-utc (ical:ast-node-value + (ical:parse-from-string 'ical:date-time s-utc))) + (expected-utc (ical:make-date-time :year 2026 :month 1 :day 1 + :hour 11 :minute 11 :second 11 + :zone t)) + (parsed-non (ical:ast-node-value + (ical:parse-from-string 'ical:date-time s-non))) + (parsed-non-zoned (ical:date-time-variant parsed-non :zone 0)) + (expected-non (ical:make-date-time :year 2026 :month 1 :day 1 + :hour 11 :minute 11 :second 11 + :zone nil)) + (expected-non-zoned (ical:date-time-variant expected-non :zone 0))) + ;; Test that the two times are parsed distinctly: + (should (equal parsed-utc expected-utc)) + (should (equal parsed-non expected-non)) + (should-not (equal (decoded-time-zone parsed-utc) + (decoded-time-zone parsed-non))) + ;; Test that `icalendar-date-time-is-utc-p' distinguishes the two cases: + (should (ical:date-time-is-utc-p parsed-utc)) + (should-not (ical:date-time-is-utc-p parsed-non)) + (should-not (ical:date-time-is-utc-p parsed-non-zoned)) + ;; but also that `icalendar-date-time-simultaneous-p' does not: + (should (ical:date-time-simultaneous-p parsed-utc expected-utc)) + (should (ical:date-time-simultaneous-p parsed-utc parsed-non-zoned)))) diff --git a/test/lisp/calendar/icalendar-recur-tests.el b/test/lisp/calendar/icalendar-recur-tests.el index 199d6c4aa25..e3432bfdeb4 100644 --- a/test/lisp/calendar/icalendar-recur-tests.el +++ b/test/lisp/calendar/icalendar-recur-tests.el @@ -271,7 +271,7 @@ ict:recur-find-secondly-interval ;; Use UTC for the tests with no ;; time zone, so that the results ;; don't depend on system's local time - :zone 0)) + :zone t)) (dtstart/tz (ical:date-time-variant dtstart :zone ict:est :dst nil))) ;; Year numbers are monotonically increasing in the following test cases, @@ -279,7 +279,7 @@ ict:recur-find-secondly-interval ;; No timezone, just clock time, around a target that doesn't fall on ;; an interval boundary: - (let* ((target (ical:date-time-variant dtstart :year 2026 :second 5 :zone 0)) + (let* ((target (ical:date-time-variant dtstart :year 2026 :second 5 :zone t)) (expected-int (icr:make-interval (ical:date-time-variant target :second 0 :tz 'preserve) @@ -291,7 +291,7 @@ ict:recur-find-secondly-interval ;; No timezone, just clock time, around a target that does fall on ;; an interval boundary: - (let* ((target (ical:date-time-variant dtstart :year 2027 :second 10 :zone 0)) + (let* ((target (ical:date-time-variant dtstart :year 2027 :second 10 :zone t)) (expected-int (icr:make-interval (ical:date-time-variant target :second 10 :tz 'preserve) @@ -1334,7 +1334,7 @@ ict:recur-tz-observance-on ;; A date matching the end of a STANDARD observance: (let* ((ut (ical:make-date-time :year 2006 :month 10 :day 29 :hour 6 :minute 0 :second 0 - :zone 0 :dst nil)) ; UNTIL is in UTC + :zone t :dst nil)) ; UNTIL is in UTC (dt (ical:make-date-time :year 2006 :month 10 :day 29 :hour 2 :minute 0 :second 0 :zone ict:edt :dst t)) @@ -1352,7 +1352,7 @@ ict:recur-tz-observance-on ;; A date matching the end of a DAYLIGHT observance: (let* ((ut (ical:make-date-time :year 2006 :month 4 :day 2 :hour 7 :minute 0 :second 0 - :zone 0 :dst nil)) ; UNTIL is in UTC + :zone t :dst nil)) ; UNTIL is in UTC (dt (ical:make-date-time :year 2006 :month 4 :day 2 :hour 2 :minute 0 :second 0 :zone ict:est :dst nil)) @@ -1374,7 +1374,7 @@ ict:recur-tz-observance-on :zone ict:est :dst nil)) (end (ical:make-date-time :year 1986 :month 4 :day 27 :hour 7 :minute 0 :second 0 - :zone 0)) ; UNTIL is in UTC + :zone t)) ; UNTIL is in UTC (obs/onset (icr:tz-observance-on dt ict:tz-eastern)) (obs (car obs/onset)) (onset (cadr obs/onset)) @@ -2009,7 +2009,7 @@ ict:rrule-test :hour 9 :minute 0 :second 0 :zone ict:edt :dst t) :high (ical:make-date-time :year 1997 :month 10 :day 8 - :hour 0 :minute 0 :second 0 :zone 0) + :hour 0 :minute 0 :second 0 :zone t) :members (list ;; ==> (1997 9:00 AM EDT) September 2,4,9,11,16,18,23,25,30; @@ -2034,7 +2034,7 @@ ict:rrule-test :hour 9 :minute 0 :second 0 :zone ict:edt :dst t) :high (ical:make-date-time :year 1997 :month 10 :day 8 - :hour 0 :minute 0 :second 0 :zone 0) + :hour 0 :minute 0 :second 0 :zone t) :members (list ;; ==> (1997 9:00 AM EDT) September 2,4,9,11,16,18,23,25,30; -- 2.39.5