]> git.eshelyaron.com Git - emacs.git/commitdiff
time-stamp: add principled, expressive %z
authorStephen Gildea <stepheng+emacs@gildea.com>
Tue, 22 Jun 2021 04:28:20 +0000 (21:28 -0700)
committerStephen Gildea <stepheng+emacs@gildea.com>
Tue, 22 Jun 2021 04:30:19 +0000 (21:30 -0700)
* lisp/time-stamp.el (time-stamp-formatz-from-parsed-options): New
function for time zone offset formatting ("%z" variants).

* test/lisp/time-stamp-tests.el (formatz*): New unit tests to cover
the new implementation of %5z.

lisp/time-stamp.el
test/lisp/time-stamp-tests.el

index 0cc566f0d8cdc6c2e096ac9eb7cef46511a57077..ae911717151b631be0b9ac1587e38598cd56eb1d 100644 (file)
@@ -25,7 +25,7 @@
 
 ;; A template in a file can be updated with a new time stamp when
 ;; you save the file.  For example:
-;;     static char *ts = "sdmain.c Time-stamp: <2001-08-13 10:20:51 gildea>";
+;;     static char *ts = "sdmain.c Time-stamp: <2020-04-18 14:10:21 gildea>";
 
 ;; To use time-stamping, add this line to your init file:
 ;;     (add-hook 'before-save-hook 'time-stamp)
@@ -278,7 +278,7 @@ look like one of the following:
       Time-stamp: <>
       Time-stamp: \" \"
 The time stamp is written between the brackets or quotes:
-      Time-stamp: <2001-02-18 10:20:51 gildea>
+      Time-stamp: <2020-08-07 17:10:21 gildea>
 
 The time stamp is updated only if the variable
 `time-stamp-active' is non-nil.
@@ -422,7 +422,7 @@ Returns the end point, which is where `time-stamp' begins the next search."
 ;;;###autoload
 (defun time-stamp-toggle-active (&optional arg)
   "Toggle `time-stamp-active', setting whether \\[time-stamp] updates a buffer.
-With ARG, turn time stamping on if and only if arg is positive."
+With ARG, turn time stamping on if and only if ARG is positive."
   (interactive "P")
   (setq time-stamp-active
        (if (null arg)
@@ -457,7 +457,7 @@ normally the current time is used."
 (defun time-stamp-string-preprocess (format &optional time)
   "Use a FORMAT to format date, time, file, and user information.
 Optional second argument TIME is only for testing.
-Implements non-time extensions to `format-time-string'
+Implements extensions to `format-time-string'
 and all `time-stamp-format' compatibility."
   (let ((fmt-len (length format))
        (ind 0)
@@ -477,6 +477,9 @@ and all `time-stamp-format' compatibility."
                (alt-form 0)
                (change-case nil)
                (upcase nil)
+               (flag-pad-with-spaces nil)
+               (flag-pad-with-zeros nil)
+               (flag-minimize nil)
                (paren-level 0))
            ;; eat any additional args to allow for future expansion
            (while (progn
@@ -521,10 +524,12 @@ and all `time-stamp-format' compatibility."
                     (setq change-case t))
                    ((eq cur-char ?^)
                     (setq upcase t))
+                   ((eq cur-char ?0)
+                    (setq flag-pad-with-zeros t))
                    ((eq cur-char ?-)
-                    (setq field-width "1"))
+                    (setq field-width "1" flag-minimize t))
                    ((eq cur-char ?_)
-                    (setq field-width "2"))))
+                    (setq field-width "2" flag-pad-with-spaces t))))
            (setq field-result
                  (cond
                   ((eq cur-char ?%)
@@ -586,26 +591,37 @@ and all `time-stamp-format' compatibility."
                   ((eq cur-char ?Y)    ;4-digit year
                    (string-to-number (time-stamp--format "%Y" time)))
                   ((eq cur-char ?z)    ;time zone offset
-                   (if change-case
-                       ""              ;discourage %z variations
-                      (cond ((= alt-form 0)
-                             (if (string-equal field-width "")
-                                 (progn
-                                   (time-stamp-conv-warn "%z" "%#Z")
-                                   (time-stamp--format "%#Z" time))
-                               (cond ((string-equal field-width "1")
-                                      (setq field-width "3")) ;%-z -> "+00"
-                                     ((string-equal field-width "2")
-                                      (setq field-width "5")) ;%_z -> "+0000"
-                                     ((string-equal field-width "4")
-                                      (setq field-width "0"))) ;discourage %4z
-                               (time-stamp--format "%z" time)))
-                            ((= alt-form 1)
-                             (time-stamp--format "%:z" time))
-                            ((= alt-form 2)
-                             (time-stamp--format "%::z" time))
-                            ((= alt-form 3)
-                             (time-stamp--format "%:::z" time)))))
+                    (let ((field-width-num (string-to-number field-width))
+                          ;; Handle numeric time zone ourselves, because
+                          ;; current-time-zone cannot handle offsets
+                          ;; greater than 24 hours.
+                          (offset-secs
+                           (cond ((numberp time-stamp-time-zone)
+                                  time-stamp-time-zone)
+                                 ((and (consp time-stamp-time-zone)
+                                       (numberp (car time-stamp-time-zone)))
+                                  (car time-stamp-time-zone))
+                                 ;; interpret text time zone
+                                 (t (car (current-time-zone
+                                          time time-stamp-time-zone))))))
+                     ;; we do our own padding; do not let it be updated further
+                     (setq field-width "")
+                     (cond (change-case
+                            "")        ;discourage %z variations
+                           ((and (= alt-form 0)
+                                 (not flag-minimize)
+                                 (not flag-pad-with-spaces)
+                                 (not flag-pad-with-zeros)
+                                 (= field-width-num 0))
+                            (time-stamp-conv-warn "%z" "%#Z")
+                            (time-stamp--format "%#Z" time))
+                           (t (time-stamp-formatz-from-parsed-options
+                               flag-minimize
+                               flag-pad-with-spaces
+                               flag-pad-with-zeros
+                               alt-form
+                               field-width-num
+                               offset-secs)))))
                   ((eq cur-char ?Z)    ;time zone name
                    (if change-case
                        (time-stamp--format "%#Z" time)
@@ -653,7 +669,8 @@ and all `time-stamp-format' compatibility."
                                       (string-to-number field-width))))
                (if (> initial-length desired-length)
                    ;; truncate strings on right
-                   (if (stringp field-result)
+                   (if (and (stringp field-result)
+                            (not (eq cur-char ?z))) ;offset does not truncate
                        (substring padded-result 0 desired-length)
                       padded-result)   ;numbers don't truncate
                  padded-result)))))
@@ -698,6 +715,176 @@ Suggests replacing OLD-FORM with NEW-FORM."
       (insert "\"" old-form "\" -- use " new-form "\n"))
     (display-buffer "*Time-stamp-compatibility*"))))
 
+;;; A principled, expressive implementation of time zone offset
+;;; formatting ("%z" and variants).
+
+;;; * Overarching principle for %z
+
+;; The output should be clear and complete.
+;;
+;; That is,
+;; a) it should be unambiguous what offset is represented, and
+;; b) it should be possible to exactly recreate the offset.
+
+;;; * Principles for %z
+
+;; - The numeric fields are HHMMSS.
+;; - The fixed point is at the left.  The first 2 digits are always
+;;   hours, the next 2 (if they exist) minutes, and next 2 (if they
+;;   exist) seconds.  "+11" is 11 hours (not 11 minutes, not 11 seconds).
+;;   "+1015" is 10 hours 15 minutes (not 10 minutes 15 seconds).
+;; - Each of the three numeric fields is two digits.
+;;   "+1" and "+100" are illegal.  (Is that 1 hour? 10 hours? 100 hours?)
+;; - The MMSS fields may be omitted only if both are 00.  Thus, the width
+;;   of the field depends on the data.  (This is similar to how
+;;   %B is always long enough to spell the entire month name.)
+;; - The SS field may be omitted only if it is 00.
+;; - Colons between the numeric fields are an option, unless the hours
+;;   field is greater than 99, when colons are needed to prevent ambiguity.
+;; - If padding with zeros, we must pad on the right, because the
+;;   fixed point is at the left.  (This is similar to how %N,
+;;   fractional seconds, must add its zeros on the right.)
+;; - After zero-padding has filled out minutes and seconds with zeros,
+;;   further padding can be blanks only.
+;;   Any additional zeros would be confusing.
+
+;;; * Padding for %z
+
+;; Padding is under-specified, so we had to make choices.
+;;
+;; Principles guiding our choices:
+;;
+;; - The syntax should be easy to remember and the effect predictable.
+;; - It should be possible to produces as many useful effects as possible.
+;;
+;; Padding choices:
+;;
+;; - By default, pad with spaces, as other formats with non-digits do.
+;;   The "0" flag pads first with zeros, until seconds are filled out.
+;; - If padding with spaces, pad on the right.  This is consistent with
+;;   how zero-padding works.  Padding on the right also keeps the fixed
+;;   point in the same place, as other formats do for any given width.
+;; - The %_z format always outputs seconds, allowing all added padding
+;;   to be spaces.  Without this rule, there would be no way to
+;;   request seconds that worked for both 2- and 3-digit hours.
+;; - Conflicting options are rejected, lest users depend
+;;   on incidental behavior.
+;;
+;; Padding combos that make no sense and are thus disallowed:
+;;
+;; %-:z   - minus minimizes to hours, : expands to minutes
+;; %-::z  - minus minimizes to hours, :: expands to seconds
+;; %_:z   - underscore requires seconds, : displays minutes
+;; %_:::z - underscore requires seconds, ::: minimizes to hours
+;;
+;; Example padding effects (with offsets of 99 and 100 hours):
+;;
+;; %-7z   "+99    "   "+100:00"
+;;  %7z   "+9900  "   "+100:00"
+;; %07z   "+990000"   "+100:00"
+;; %_7z   "+990000"   "+100:00:00"
+;;
+;; %7:::z "+99    "   "+100:00"
+;; %7:z   "+99:00 "   "+100:00"
+;; %07:z  "+99:00:00" "+100:00"
+;; %7::z  "+99:00:00" "+100:00:00"
+
+;;; * BNF syntax of the offset string produced by %z
+
+;; <offset> ::= <sign><hours>[<minutes>[<seconds>]]<padding> |
+;;              <sign><hours>[<colonminutes>[<colonseconds>]]<padding> |
+;;              <sign><bighours><colonminutes>[<colonseconds>]<padding>
+;; <sign> ::= "+"|"-"
+;; <hours> ::= <2digits>
+;; <minutes> ::= <2digits>
+;; <seconds> ::= <2digits>
+;; <colonminutes> ::= ":"<minutes>
+;; <colonseconds> ::= ":"<seconds>
+;; <2digits> ::= <digit><digit>
+;; <digit> ::= "0"|"1"|"2"|"3"|"4"|"5"|"6"|"7"|"8"|"9"
+;; <bighours> ::= <digit>*<digit><2digits>
+;; <padding> ::= " "*
+
+(defun time-stamp-formatz-from-parsed-options (flag-minimize
+                                               flag-pad-spaces-only
+                                               flag-pad-zeros-first
+                                               colon-count
+                                               field-width
+                                               offset-secs)
+  "Formats a time offset according to a %z variation.
+The caller of this function must have already parsed the %z format
+string; this function accepts just the parts of the format.
+
+With no flags, the output includes hours and minutes: +-HHMM
+unless there is a non-zero seconds part, in which case the seconds
+are included: +-HHMMSS
+
+FLAG-MINIMIZE is whether \"-\" was specified.  If non-nil, the
+output may be limited to hours if minutes and seconds are zero.
+
+FLAG-PAD-SPACES-ONLY is whether \"_\" was specified.  If non-nil,
+seconds must be output, so that any padding can be spaces only.
+
+FLAG-PAD-ZEROS-FIRST is whether \"0\" was specified.  If non-nil,
+padding to the requested FIELD-WIDTH (if any) is done by adding
+00 seconds before padding with spaces.
+
+COLON-COUNT is the number of colons preceding the \"z\" (0-3).  One or
+two colons put that many colons in the output (+-HH:MM or +-HH:MM:SS).
+Three colons outputs only hours if minutes and seconds are zero and
+includes colon separators if minutes and seconds are output.
+
+FIELD-WIDTH is a whole number giving the minimum number of characters
+in the output; 0 specifies no minimum.  Additional characters will be
+added on the right if necessary.  The added characters will be spaces
+unless FLAG-PAD-ZEROS-FIRST is non-nil.
+
+OFFSET-SECS is the time zone offset (in seconds east of UTC) to be
+formatted according to the preceding parameters."
+  (let ((hrs (/ (abs offset-secs) 3600))
+        (mins (/ (% (abs offset-secs) 3600) 60))
+        (secs (% (abs offset-secs) 60))
+        (result ""))
+    ;; valid option combo?
+    (cond
+     ((not (or (and flag-minimize (> colon-count 0))
+               (and flag-pad-spaces-only (> colon-count 0))
+               (and flag-pad-spaces-only flag-minimize)
+               (and flag-pad-spaces-only flag-pad-zeros-first)
+               (and flag-pad-zeros-first flag-minimize)))
+      (setq result (concat result (if (>= offset-secs 0) "+" "-")))
+      (setq result (concat result (format "%02d" hrs)))
+      ;; Need minutes?
+      (cond
+       ((or (> hrs 99)
+            (> mins 0)
+            (> secs 0)
+            (not (or flag-minimize (= colon-count 3)))
+            (and (> field-width (length result))
+                 flag-pad-zeros-first))
+        ;; Need colon before minutes?
+        (if (or (> colon-count 0)
+                (> hrs 99))
+            (setq result (concat result ":")))
+        (setq result (concat result (format "%02d" mins)))
+        ;; Need seconds, too?
+        (cond
+         ((or (> secs 0)
+              (= colon-count 2)
+              flag-pad-spaces-only
+              (and (> field-width (length result))
+                   flag-pad-zeros-first))
+          ;; Need colon before seconds?
+          (if (or (> colon-count 0)
+                  (> hrs 99))
+              (setq result (concat result ":")))
+          (setq result (concat result (format "%02d" secs)))))))
+      ;; Need padding?
+      (let ((needed-padding (- field-width (length result))))
+        (if (> needed-padding 0)
+            (setq result (concat result (make-string needed-padding ?\s)))))))
+    result))
+
 (provide 'time-stamp)
 
 ;;; time-stamp.el ends here
index b42271e4e51ec66825bf30f322f9499ecb0462c6..e42a58a168540c94d79dad1dd1ec4f5419f1af88 100644 (file)
       (should (equal (time-stamp-string "%#Z" ref-time1) utc-abbr)))))
 
 (ert-deftest time-stamp-format-time-zone-offset ()
-  "Tests time-stamp legacy format %z and new offset format %5z."
+  "Tests time-stamp legacy format %z and spot tests of new offset format %5z."
   (with-time-stamp-test-env
     (let ((utc-abbr (format-time-string "%#Z" ref-time1 t)))
     ;; documented 1995-2019, warned since 2019, will change
     (let ((time-stamp-time-zone "CET-1"))
       (should (equal (time-stamp-string "%5z" ref-time1) "+0100")))
     ;; implemented since 2019, verify that these don't warn
+    ;; See also the "formatz" tests below, which since 2021 test more
+    ;; variants with more offsets.
     (should (equal (time-stamp-string "%-z" ref-time1) "+00"))
-    (should (equal (time-stamp-string "%_z" ref-time1) "+0000"))
     (should (equal (time-stamp-string "%:z" ref-time1) "+00:00"))
     (should (equal (time-stamp-string "%::z" ref-time1) "+00:00:00"))
     (should (equal (time-stamp-string "%9::z" ref-time1) "+00:00:00"))
                      (concat Mon "." MON "." Mon)))
       ;; underscore flag is independent
       (should (equal (time-stamp-string "%_d.%d.%_d" ref-time1) " 2.02. 2"))
-      ;; minus flag is independendent
+      (should (equal (time-stamp-string "%_7z.%7z.%_7z" ref-time1)
+                     "+000000.+0000  .+000000"))
+      ;; minus flag is independent
       (should (equal (time-stamp-string "%d.%-d.%d" ref-time1) "02.2.02"))
-      ;; 0 flag is independendent
+      (should (equal (time-stamp-string "%3z.%-3z.%3z" ref-time1)
+                     "+0000.+00.+0000"))
+      ;; 0 flag is independent
       (should (equal (time-stamp-string "%2d.%02d.%2d" ref-time1) " 2.02. 2"))
+      (should (equal (time-stamp-string "%6:::z.%06:::z.%6:::z" ref-time1)
+                     "+00   .+00:00.+00   "))
       ;; field width is independent
       (should (equal
                (time-stamp-string "%6Y.%Y.%6Y" ref-time1) "  2006.2006.  2006"))
       ;; colon modifier is independent
       (should (equal (time-stamp-string "%a.%:a.%a" ref-time1)
                      (concat Mon "." Monday "." Mon)))
+      (should (equal (time-stamp-string "%5z.%5::z.%5z" ref-time1)
+                     "+0000.+00:00:00.+0000"))
       ;; format letter is independent
       (should (equal (time-stamp-string "%H:%M" ref-time1) "15:04")))))
 
   (should (safe-local-variable-p 'time-stamp-pattern "a string"))
   (should-not (safe-local-variable-p 'time-stamp-pattern 17)))
 
+;;;; Setup for tests of time offset formatting with %z
+
+(defun formatz (format zone)
+  "Uses time FORMAT string to format the offset of ZONE, returning the result.
+FORMAT is \"%z\" or a variation.
+ZONE is as the ZONE argument of the `format-time-string' function."
+  (with-time-stamp-test-env
+   (let ((time-stamp-time-zone zone))
+     ;; Call your favorite time formatter here.
+     ;; For narrower-scope unit testing,
+     ;; instead of calling time-stamp-string here,
+     ;; we could directly call (format-time-offset format zone)
+     (time-stamp-string format)
+     )))
+
+(defun format-time-offset (format offset-secs)
+  "Uses FORMAT to format the time zone represented by OFFSET-SECS.
+FORMAT must be \"%z\", possibly with a flag and padding.
+This function is a wrapper around `time-stamp-formatz-from-parsed-options'
+and is used for testing."
+  ;; This wrapper adds a simple regexp-based parser that handles only
+  ;; %z and variants.  In normal use, time-stamp-formatz-from-parsed-options
+  ;; is called from a parser that handles all time string formats.
+  (string-match
+   "\\`\\([^%]*\\)%\\([-_]?\\)\\(0?\\)\\([1-9][0-9]*\\)?\\([EO]?\\)\\(:*\\)\\([^a-zA-Z]+\\)?z\\(.*\\)"
+   format)
+  (let ((leading-string (match-string 1 format))
+        (flag-minimize (seq-find (lambda (x) (eq x ?-))
+                                 (match-string 2 format)))
+        (flag-pad-with-spaces (seq-find (lambda (x) (eq x ?_))
+                                        (match-string 2 format)))
+        (flag-pad-with-zeros (equal (match-string 3 format) "0"))
+        (field-width (string-to-number (or (match-string 4 format) "")))
+        (colon-count (length (match-string 6 format)))
+        (garbage (match-string 7 format))
+        (trailing-string (match-string 8 format)))
+    (concat leading-string
+            (if garbage
+                ""
+              (time-stamp-formatz-from-parsed-options flag-minimize
+                                                      flag-pad-with-spaces
+                                                      flag-pad-with-zeros
+                                                      colon-count
+                                                      field-width
+                                                      offset-secs))
+            trailing-string)))
+
+(defun fz-make+zone (h &optional m s)
+  "Creates a non-negative offset."
+  (let ((m (or m 0))
+        (s (or s 0)))
+    (+ (* 3600 h) (* 60 m) s)))
+
+(defun fz-make-zone (h &optional m s)
+  "Creates a negative offset.  The arguments are all non-negative."
+  (- (fz-make+zone h m s)))
+
+(defmacro formatz-should-equal (zone expect)
+  "Formats ZONE and compares it to EXPECT.
+Uses the free variables `form-string' and `pattern-mod'.
+The functions in `pattern-mod' are composed left to right."
+  `(let ((result ,expect))
+     (dolist (fn pattern-mod)
+       (setq result (funcall fn result)))
+     (should (equal (formatz form-string ,zone) result))))
+
+;; These test cases have zeros in all places (first, last, none, both)
+;; for hours, minutes, and seconds.
+
+(defun formatz-hours-exact-helper (form-string pattern-mod)
+  "Tests format %z with whole hours."
+  (formatz-should-equal (fz-make+zone 0) "+00") ;0 sign always +, both digits
+  (formatz-should-equal (fz-make+zone 10) "+10")
+  (formatz-should-equal (fz-make-zone 10) "-10")
+  (formatz-should-equal (fz-make+zone 2) "+02")
+  (formatz-should-equal (fz-make-zone 2) "-02")
+  (formatz-should-equal (fz-make+zone 13) "+13")
+  (formatz-should-equal (fz-make-zone 13) "-13")
+  )
+
+(defun formatz-nonzero-minutes-helper (form-string pattern-mod)
+  "Tests format %z with whole minutes."
+  (formatz-should-equal (fz-make+zone 0 30) "+00:30") ;has hours even though 0
+  (formatz-should-equal (fz-make-zone 0 30) "-00:30")
+  (formatz-should-equal (fz-make+zone 0 4) "+00:04")
+  (formatz-should-equal (fz-make-zone 0 4) "-00:04")
+  (formatz-should-equal (fz-make+zone 8 40) "+08:40")
+  (formatz-should-equal (fz-make-zone 8 40) "-08:40")
+  (formatz-should-equal (fz-make+zone 0 15) "+00:15")
+  (formatz-should-equal (fz-make-zone 0 15) "-00:15")
+  (formatz-should-equal (fz-make+zone 11 30) "+11:30")
+  (formatz-should-equal (fz-make-zone 11 30) "-11:30")
+  (formatz-should-equal (fz-make+zone 3 17) "+03:17")
+  (formatz-should-equal (fz-make-zone 3 17) "-03:17")
+  (formatz-should-equal (fz-make+zone 12 45) "+12:45")
+  (formatz-should-equal (fz-make-zone 12 45) "-12:45")
+  )
+
+(defun formatz-nonzero-seconds-helper (form-string pattern-mod)
+  "Tests format %z with non-0 seconds."
+  ;; non-0 seconds are always included
+  (formatz-should-equal (fz-make+zone 0 0 50) "+00:00:50")
+  (formatz-should-equal (fz-make-zone 0 0 50) "-00:00:50")
+  (formatz-should-equal (fz-make+zone 0 0 06) "+00:00:06")
+  (formatz-should-equal (fz-make-zone 0 0 06) "-00:00:06")
+  (formatz-should-equal (fz-make+zone 0 7 50) "+00:07:50")
+  (formatz-should-equal (fz-make-zone 0 7 50) "-00:07:50")
+  (formatz-should-equal (fz-make+zone 0 0 16) "+00:00:16")
+  (formatz-should-equal (fz-make-zone 0 0 16) "-00:00:16")
+  (formatz-should-equal (fz-make+zone 0 12 36) "+00:12:36")
+  (formatz-should-equal (fz-make-zone 0 12 36) "-00:12:36")
+  (formatz-should-equal (fz-make+zone 0 3 45) "+00:03:45")
+  (formatz-should-equal (fz-make-zone 0 3 45) "-00:03:45")
+  (formatz-should-equal (fz-make+zone 8 45 30) "+08:45:30")
+  (formatz-should-equal (fz-make-zone 8 45 30) "-08:45:30")
+  (formatz-should-equal (fz-make+zone 0 11 45) "+00:11:45")
+  (formatz-should-equal (fz-make-zone 0 11 45) "-00:11:45")
+  (formatz-should-equal (fz-make+zone 3 20 15) "+03:20:15")
+  (formatz-should-equal (fz-make-zone 3 20 15) "-03:20:15")
+  (formatz-should-equal (fz-make+zone 11 14 30) "+11:14:30")
+  (formatz-should-equal (fz-make-zone 11 14 30) "-11:14:30")
+  (formatz-should-equal (fz-make+zone 12 30 49) "+12:30:49")
+  (formatz-should-equal (fz-make-zone 12 30 49) "-12:30:49")
+  (formatz-should-equal (fz-make+zone 12 0 34) "+12:00:34")
+  (formatz-should-equal (fz-make-zone 12 0 34) "-12:00:34")
+  )
+
+(defun formatz-hours-big-helper (form-string pattern-mod)
+  "Tests format %z with hours that don't fit in two digits."
+  (formatz-should-equal (fz-make+zone 101) "+101:00")
+  (formatz-should-equal (fz-make+zone 123 10) "+123:10")
+  (formatz-should-equal (fz-make-zone 123 10) "-123:10")
+  (formatz-should-equal (fz-make+zone 123 2) "+123:02")
+  (formatz-should-equal (fz-make-zone 123 2) "-123:02")
+  )
+
+(defun formatz-seconds-big-helper (form-string pattern-mod)
+  "Tests format %z with hours greater than 99 and non-zero seconds."
+  (formatz-should-equal (fz-make+zone 123 0 30) "+123:00:30")
+  (formatz-should-equal (fz-make-zone 123 0 30) "-123:00:30")
+  (formatz-should-equal (fz-make+zone 120 0 4) "+120:00:04")
+  (formatz-should-equal (fz-make-zone 120 0 4) "-120:00:04")
+  )
+
+;; Functions that modify the expected output string, so that we can
+;; use the above test cases for multiple formats.
+
+(defun formatz-mod-del-colons (string)
+  "Returns STRING with any colons removed."
+  (replace-regexp-in-string ":" "" string))
+
+(defun formatz-mod-add-00 (string)
+  "Returns STRING with \"00\" appended."
+  (concat string "00"))
+
+(defun formatz-mod-add-colon00 (string)
+  "Returns STRING with \":00\" appended."
+  (concat string ":00"))
+
+(defun formatz-mod-pad-r10 (string)
+  "Returns STRING padded on the right to 10 characters."
+  (concat string (make-string (- 10 (length string)) ?\s)))
+
+(defun formatz-mod-pad-r12 (string)
+  "Returns STRING padded on the right to 12 characters."
+  (concat string (make-string (- 12 (length string)) ?\s)))
+
+;; Convenience macro for generating groups of test cases.
+
+(defmacro formatz-generate-tests
+    (form-strings hour-mod mins-mod secs-mod big-mod secbig-mod)
+  "Defines ert-deftest tests for time formats FORM-STRINGS.
+FORM-STRINGS is a list of formats, each \"%z\" or some variation thereof.
+
+Each of the remaining arguments is an unquoted list of the form
+(SAMPLE-OUTPUT . MODIFIERS).  SAMPLE-OUTPUT is the result of the
+FORM-STRINGS for a particular offset, detailed below for each argument.
+The remaining elements of the list, the MODIFIERS, are the names of
+functions to modify the expected results for sets of tests.
+The MODIFIERS do not modify the SAMPLE-OUTPUT.
+
+The one, literal sample output is given in the call to this macro
+to provide a visual check at the call site that the format
+behaves as expected.
+
+HOUR-MOD is the result for offset 0 and modifiers for the other
+expected results for whole hours.
+MINS-MOD is the result for offset +30 minutes and modifiers for the
+other expected results for whole minutes.
+SECS-MOD is the result for offset +30 seconds and modifiers for the
+other expected results for offsets with non-zero seconds.
+BIG-MOD is the result for offset +100 hours and modifiers for the other
+expected results for hours greater than 99 with a whole number of minutes.
+SECBIG-MOD is the result for offset +100 hours 30 seconds and modifiers for
+the other expected results for hours greater than 99 with non-zero seconds."
+  (declare (indent 1))
+  ;; Generate a form to create a list of tests to define.  When this
+  ;; macro is called, the form is evaluated, thus defining the tests.
+  (let ((ert-test-list '(list)))
+    (dolist (form-string form-strings ert-test-list)
+      (nconc
+       ert-test-list
+       (list
+        `(ert-deftest ,(intern (concat "formatz-" form-string "-hhmm")) ()
+           (should (equal (formatz ,form-string (fz-make+zone 0))
+                          ,(car hour-mod)))
+           (formatz-hours-exact-helper ,form-string ',(cdr hour-mod))
+           (should (equal (formatz ,form-string (fz-make+zone 0 30))
+                          ,(car mins-mod)))
+           (formatz-nonzero-minutes-helper ,form-string ',(cdr mins-mod)))
+        `(ert-deftest ,(intern (concat "formatz-" form-string "-secs")) ()
+           (should (equal (formatz ,form-string (fz-make+zone 0 0 30))
+                          ,(car secs-mod)))
+           (formatz-nonzero-seconds-helper ,form-string ',(cdr secs-mod)))
+        `(ert-deftest ,(intern (concat "formatz-" form-string "-big")) ()
+           (should (equal (formatz ,form-string (fz-make+zone 100))
+                          ,(car big-mod)))
+           (formatz-hours-big-helper ,form-string ',(cdr big-mod))
+           (should (equal (formatz ,form-string (fz-make+zone 100 0 30))
+                          ,(car secbig-mod)))
+           (formatz-seconds-big-helper ,form-string ',(cdr secbig-mod)))
+        )))))
+
+;;;; The actual test cases for %z
+
+;;; %z formats without colons.
+
+;; Option character "-" (minus) minimizes; it removes "00" minutes.
+(formatz-generate-tests ("%-z" "%-3z")
+  ("+00")
+  ("+0030" formatz-mod-del-colons)
+  ("+000030" formatz-mod-del-colons)
+  ("+100:00")
+  ("+100:00:30"))
+;; Tests that minus with padding pads with spaces.
+(formatz-generate-tests ("%-12z")
+  ("+00         " formatz-mod-pad-r12)
+  ("+0030       " formatz-mod-del-colons formatz-mod-pad-r12)
+  ("+000030     " formatz-mod-del-colons formatz-mod-pad-r12)
+  ("+100:00     " formatz-mod-pad-r12)
+  ("+100:00:30  " formatz-mod-pad-r12))
+;; Tests that 0 after other digits becomes padding of ten, not zero flag.
+(formatz-generate-tests ("%-10z")
+  ("+00       " formatz-mod-pad-r10)
+  ("+0030     " formatz-mod-del-colons formatz-mod-pad-r10)
+  ("+000030   " formatz-mod-del-colons formatz-mod-pad-r10)
+  ("+100:00   " formatz-mod-pad-r10)
+  ("+100:00:30"))
+
+;; Although time-stamp doesn't call us for %z, we do want to spot-check
+;; it here, to verify the implementation we will eventually use.
+;; The legacy exception for %z in time-stamp will need to remain
+;; through at least 2024 and Emacs 28.
+(ert-deftest formatz-%z-spotcheck ()
+  (should (equal (format-time-offset "%z" (fz-make+zone 0)) "+0000"))
+  (should (equal (format-time-offset "%z" (fz-make+zone 0 30)) "+0030"))
+  (should (equal (format-time-offset "%z" (fz-make+zone 0 0 30)) "+000030"))
+  (should (equal (format-time-offset "%z" (fz-make+zone 100)) "+100:00"))
+  (should (equal (format-time-offset "%z" (fz-make+zone 100 0 30)) "+100:00:30"))
+  )
+
+;; Basic %z outputs 4 digits.
+;; Small padding values do not extend the result.
+(formatz-generate-tests (;; We don't check %z here because time-stamp
+                         ;; has a legacy behavior for it.
+                         ;;"%z"
+                         "%5z" "%0z" "%05z")
+  ("+0000" formatz-mod-add-00)
+  ("+0030" formatz-mod-del-colons)
+  ("+000030" formatz-mod-del-colons)
+  ("+100:00")
+  ("+100:00:30"))
+
+;; Tests that padding adds spaces.
+(formatz-generate-tests ("%12z")
+  ("+0000       " formatz-mod-add-00 formatz-mod-pad-r12)
+  ("+0030       " formatz-mod-del-colons formatz-mod-pad-r12)
+  ("+000030     " formatz-mod-del-colons formatz-mod-pad-r12)
+  ("+100:00     " formatz-mod-pad-r12)
+  ("+100:00:30  " formatz-mod-pad-r12))
+
+;; Requiring 0-padding to 6 adds seconds (only) as needed.
+(formatz-generate-tests ("%06z")
+  ("+000000" formatz-mod-add-00 formatz-mod-add-00)
+  ("+003000" formatz-mod-del-colons formatz-mod-add-00)
+  ("+000030" formatz-mod-del-colons)
+  ("+100:00")
+  ("+100:00:30"))
+
+;; Option character "_" always adds seconds.
+(formatz-generate-tests ("%_z" "%_7z")
+  ("+000000" formatz-mod-add-00 formatz-mod-add-00)
+  ("+003000" formatz-mod-del-colons formatz-mod-add-00)
+  ("+000030" formatz-mod-del-colons)
+  ("+100:00:00" formatz-mod-add-colon00)
+  ("+100:00:30"))
+
+;; Enough 0-padding adds seconds, then adds spaces.
+(formatz-generate-tests ("%012z" "%_12z")
+  ("+000000     " formatz-mod-add-00 formatz-mod-add-00 formatz-mod-pad-r12)
+  ("+003000     " formatz-mod-del-colons formatz-mod-add-00 formatz-mod-pad-r12)
+  ("+000030     " formatz-mod-del-colons formatz-mod-pad-r12)
+  ("+100:00:00  " formatz-mod-add-colon00 formatz-mod-pad-r12)
+  ("+100:00:30  " formatz-mod-pad-r12))
+
+;;; %z formats with colons
+
+;; Three colons can output hours only,
+;; like %-z, but uses colons with non-zero minutes and seconds.
+(formatz-generate-tests ("%:::z" "%0:::z"
+                         "%3:::z" "%03:::z")
+  ("+00")
+  ("+00:30")
+  ("+00:00:30")
+  ("+100:00")
+  ("+100:00:30"))
+
+;; Padding with three colons adds spaces
+(formatz-generate-tests ("%12:::z")
+  ("+00         " formatz-mod-pad-r12)
+  ("+00:30      " formatz-mod-pad-r12)
+  ("+00:00:30   " formatz-mod-pad-r12)
+  ("+100:00     " formatz-mod-pad-r12)
+  ("+100:00:30  " formatz-mod-pad-r12))
+;; Tests that 0 after other digits becomes padding of ten, not zero flag.
+(formatz-generate-tests ("%10:::z")
+  ("+00       " formatz-mod-pad-r10)
+  ("+00:30    " formatz-mod-pad-r10)
+  ("+00:00:30 " formatz-mod-pad-r10)
+  ("+100:00   " formatz-mod-pad-r10)
+  ("+100:00:30"))
+
+;; One colon outputs minutes, like %z but with colon.
+(formatz-generate-tests ("%:z" "%6:z" "%0:z" "%06:z" "%06:::z")
+  ("+00:00" formatz-mod-add-colon00)
+  ("+00:30")
+  ("+00:00:30")
+  ("+100:00")
+  ("+100:00:30"))
+
+;; Padding with one colon adds spaces
+(formatz-generate-tests ("%12:z")
+  ("+00:00      " formatz-mod-add-colon00 formatz-mod-pad-r12)
+  ("+00:30      " formatz-mod-pad-r12)
+  ("+00:00:30   " formatz-mod-pad-r12)
+  ("+100:00     " formatz-mod-pad-r12)
+  ("+100:00:30  " formatz-mod-pad-r12))
+
+;; Requiring 0-padding to 7 adds seconds (only) as needed.
+(formatz-generate-tests ("%07:z" "%07:::z")
+  ("+00:00:00" formatz-mod-add-colon00 formatz-mod-add-colon00)
+  ("+00:30:00" formatz-mod-add-colon00)
+  ("+00:00:30")
+  ("+100:00")
+  ("+100:00:30"))
+
+;; Two colons outputs HH:MM:SS, like %_z but with colons.
+(formatz-generate-tests ("%::z" "%9::z" "%0::z" "%09::z")
+  ("+00:00:00" formatz-mod-add-colon00 formatz-mod-add-colon00)
+  ("+00:30:00" formatz-mod-add-colon00)
+  ("+00:00:30")
+  ("+100:00:00" formatz-mod-add-colon00)
+  ("+100:00:30"))
+
+;; Enough padding adds minutes and seconds, then adds spaces.
+(formatz-generate-tests ("%012:z" "%012::z" "%12::z" "%012:::z")
+  ("+00:00:00   " formatz-mod-add-colon00 formatz-mod-add-colon00
+                  formatz-mod-pad-r12)
+  ("+00:30:00   " formatz-mod-add-colon00 formatz-mod-pad-r12)
+  ("+00:00:30   " formatz-mod-pad-r12)
+  ("+100:00:00  " formatz-mod-add-colon00 formatz-mod-pad-r12)
+  ("+100:00:30  " formatz-mod-pad-r12))
+
+;;; Illegal %z formats
+
+(ert-deftest formatz-illegal-options ()
+  "Tests that illegal/nonsensical/ambiguous %z formats don't produce output."
+  ;; multiple options
+  (should (equal "" (formatz "%_-z" 0)))
+  (should (equal "" (formatz "%-_z" 0)))
+  (should (equal "" (formatz "%_0z" 0)))
+  (should (equal "" (formatz "%0_z" 0)))
+  (should (equal "" (formatz "%0-z" 0)))
+  (should (equal "" (formatz "%-0z" 0)))
+  ;; inconsistent to both minimize and require mins or secs
+  (should (equal "" (formatz "%-:z" 0)))
+  (should (equal "" (formatz "%-::z" 0)))
+  ;; consistent, but redundant
+  (should (equal "" (formatz "%-:::z" 0)))
+  (should (equal "" (formatz "%_::z" 0)))
+  ;; inconsistent to both pre-expand and default to hours or mins
+  (should (equal "" (formatz "%_:::z" 0)))
+  (should (equal "" (formatz "%_:z" 0)))
+  ;; options that don't make sense with %z
+  (should (equal "" (formatz "%#z" 0)))
+  )
+
 ;;; time-stamp-tests.el ends here