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