Browse Source

org-timer.el: Merge API for the two timers

* lisp/org-timer.el (org-timer-stop): Support countdown timers in addition
to relative timers.

* lisp/org-timer.el (org-timer-cancel-timer): Remove function.

* lisp/org-timer.el (org-timer-pause-or-continue): Support countdown
timers in addition to relative timers.

* testing/lisp/test-org-timer.el: New file.

* doc/org.texi: Merge relative and countdown timer nodes.

Several previous issues are fixed with these changes.

- org-timer-set-timer and org-timer-cancel-timer did not reset
  org-timer-start-time after countdown completed.

- Because org-timer-start did not return org-timer-pause-time to nil,
  the modeline remained stuck at the paused time.

- When org-timer-start was called with a countdown timer, the modeline
  was updated for the new relative timer, but the countdown timer
  remained scheduled.

- When org-timer-pause-or-continue was called with a countdown timer
  running, the modeline was put in a paused state, but the countdown
  timer remained scheduled.

- When org-timer-stop was called with a countdown timer running, the
  timer was removed from the modeline, but the countdown timer remained
  scheduled.

- When org-timer-set-timer was called with a paused relative timer, the
  relative timer was not reset properly (org-timer-pause-time was still
  non-nil) and the modeline remained in the paused state of the relative
  timer, even though the countdown timer was scheduled with
  run-with-timer.

- Running org-timer-set-timer at the beginning of an empty buffer
  resulted in an args-out-of-range error (due to the org-get-at-eol
  call).
Kyle Meyer 5 years ago
parent
commit
173b0cb6d6
4 changed files with 438 additions and 131 deletions
  1. 39 45
      doc/org.texi
  2. 5 0
      etc/ORG-NEWS
  3. 121 86
      lisp/org-timer.el
  4. 273 0
      testing/lisp/test-org-timer.el

+ 39 - 45
doc/org.texi

@@ -462,8 +462,7 @@ Dates and times
 * Deadlines and scheduling::    Planning your work
 * Clocking work time::          Tracking how long you spend on a task
 * Effort estimates::            Planning work effort in advance
-* Relative timer::              Notes with a running timer
-* Countdown timer::             Starting a countdown timer for a task
+* Timers::                      Notes with a running timer
 
 Creating timestamps
 
@@ -5790,8 +5789,7 @@ is used in a much wider sense.
 * Deadlines and scheduling::    Planning your work
 * Clocking work time::          Tracking how long you spend on a task
 * Effort estimates::            Planning work effort in advance
-* Relative timer::              Notes with a running timer
-* Countdown timer::             Starting a countdown timer for a task
+* Timers::                      Notes with a running timer
 @end menu
 
 
@@ -6793,60 +6791,56 @@ with the @kbd{/} key in the agenda (@pxref{Agenda commands}).  If you have
 these estimates defined consistently, two or three key presses will narrow
 down the list to stuff that fits into an available time slot.
 
-@node Relative timer
-@section Taking notes with a relative timer
+@node Timers
+@section Taking notes with a timer
 @cindex relative timer
+@cindex countdown timer
+@kindex ;
+
+Org provides provides two types of timers.  There is a relative timer that
+counts up, which can be useful when taking notes during, for example, a
+meeting or a video viewing.  There is also a countdown timer.
+
+The relative and countdown are started with separate commands.
+
+@table @kbd
+@orgcmd{C-c C-x 0,org-timer-start}
+Start or reset the relative timer.  By default, the timer is set to 0.  When
+called with a @kbd{C-u} prefix, prompt the user for a starting offset.  If
+there is a timer string at point, this is taken as the default, providing a
+convenient way to restart taking notes after a break in the process.  When
+called with a double prefix argument @kbd{C-u C-u}, change all timer strings
+in the active region by a certain amount.  This can be used to fix timer
+strings if the timer was not started at exactly the right moment.
+@orgcmd{C-c C-x ;,org-timer-set-timer}
+Start a countdown timer.  The user is prompted for a duration.
+@code{org-timer-default-timer} sets the default countdown value.  Giving a
+prefix numeric argument overrides this default value.  This command is
+available as @kbd{;} in agenda buffers.
+@end table
 
-When taking notes during, for example, a meeting or a video viewing, it can
-be useful to have access to times relative to a starting time.  Org provides
-such a relative timer and make it easy to create timed notes.
+Once started, relative and countdown timers are controlled with the same
+commands.
 
 @table @kbd
 @orgcmd{C-c C-x .,org-timer}
-Insert a relative time into the buffer.  The first time you use this, the
-timer will be started.  When called with a prefix argument, the timer is
-restarted.
+Insert the value of the current relative or countdown timer into the buffer.
+If no timer is running, the relative timer will be started.  When called with
+a prefix argument, the relative timer is restarted.
 @orgcmd{C-c C-x -,org-timer-item}
-Insert a description list item with the current relative time.  With a prefix
-argument, first reset the timer to 0.
+Insert a description list item with the value of the current relative or
+countdown timer.  With a prefix argument, first reset the relative timer to
+0.
 @orgcmd{M-@key{RET},org-insert-heading}
 Once the timer list is started, you can also use @kbd{M-@key{RET}} to insert
 new timer items.
-@c for key sequences with a comma, command name macros fail :(
-@kindex C-c C-x ,
-@item C-c C-x ,
-Pause the timer, or continue it if it is already paused
-(@command{org-timer-pause-or-continue}).
-@c removed the sentence because it is redundant to the following item
-@kindex C-u C-c C-x ,
-@item C-u C-c C-x ,
+@orgcmd{C-c C-x \,,org-timer-pause-or-continue}
+Pause the timer, or continue it if it is already paused.
+@orgcmd{C-c C-x _,org-timer-stop}
 Stop the timer.  After this, you can only start a new timer, not continue the
 old one.  This command also removes the timer from the mode line.
-@orgcmd{C-c C-x 0,org-timer-start}
-Reset the timer without inserting anything into the buffer.  By default, the
-timer is reset to 0.  When called with a @kbd{C-u} prefix, reset the timer to
-specific starting offset.  The user is prompted for the offset, with a
-default taken from a timer string at point, if any, So this can be used to
-restart taking notes after a break in the process.  When called with a double
-prefix argument @kbd{C-u C-u}, change all timer strings in the active region
-by a certain amount.  This can be used to fix timer strings if the timer was
-not started at exactly the right moment.
 @end table
 
-@node Countdown timer
-@section Countdown timer
-@cindex Countdown timer
-@kindex C-c C-x ;
-@kindex ;
-
-Calling @code{org-timer-set-timer} from an Org mode buffer runs a countdown
-timer.  Use @kbd{;} from agenda buffers, @key{C-c C-x ;} everywhere else.
-
-@code{org-timer-set-timer} prompts the user for a duration and displays a
-countdown timer in the modeline.  @code{org-timer-default-timer} sets the
-default countdown value.  Giving a prefix numeric argument overrides this
-default value.
-
 @node Capture - Refile - Archive
 @chapter Capture - Refile - Archive
 @cindex capture

+ 5 - 0
etc/ORG-NEWS

@@ -72,6 +72,8 @@ This function inserted a Beamer specific template at point or in
 current subtree.  Use ~org-export-insert-default-template~ instead, as
 it provides more features and covers all export back-ends.  It is also
 accessible from the export dispatcher.
+*** Removed function ~org-timer-cancel-timer~
+~org-timer-stop~ now stops both relative and countdown timers.
 ** Removed options
 *** ~org-list-empty-line-terminates-plain-lists~ is deprecated
 It will be kept in code base until next release, for backward
@@ -174,6 +176,9 @@ special blocks and images.  See docstring for more information.
 Headlines, for which the property ~UNNUMBERED~ is non-nil, are now
 exported without section numbers irrespective of their levels.  The
 property is inherited by children.
+*** Countdown timers can now be paused.
+~org-timer-pause-time~ wil now pause and restart both relative and
+countdown timers.
 ** Miscellaneous
 *** Strip all meta data from ITEM special property
 ITEM special property does not contain TODO, priority or tags anymore.

+ 121 - 86
lisp/org-timer.el

@@ -1,4 +1,4 @@
-;;; org-timer.el --- The relative timer code for Org-mode
+;;; org-timer.el --- Timer code for Org mode
 
 ;; Copyright (C) 2008-2014 Free Software Foundation, Inc.
 
@@ -24,13 +24,20 @@
 ;;
 ;;; Commentary:
 
-;; This file contains the relative timer code for Org-mode
+;; This file implements two types of timers for Org buffers:
+;;
+;; - A relative timer that counts up (from 0 or a specified offset)
+;; - A countdown timer that counts down from a specified time
+;;
+;; The relative and countdown timers differ in their entry points.
+;; Use `org-timer' or `org-timer-start' to start the relative timer,
+;; and `org-timer-set-timer' to start the countdown timer.
 
 ;;; Code:
 
 (require 'org)
+(require 'org-clock)
 
-(declare-function org-notify "org-clock" (notification &optional play-sound))
 (declare-function org-agenda-error "org-agenda" ())
 
 (defvar org-timer-start-time nil
@@ -39,13 +46,22 @@
 (defvar org-timer-pause-time nil
   "Time when the timer was paused.")
 
+(defvar org-timer-countdown-timer nil
+  "Current countdown timer.
+This is a timer object if there is an active countdown timer,
+'paused' if there is a paused countdown timer, and nil
+otherwise.")
+
+(defvar org-timer-countdown-timer-title nil
+  "Title for notification displayed when a countdown finishes.")
+
 (defconst org-timer-re "\\([-+]?[0-9]+\\):\\([0-9]\\{2\\}\\):\\([0-9]\\{2\\}\\)"
   "Regular expression used to match timer stamps.")
 
 (defcustom org-timer-format "%s "
   "The format to insert the time of the timer.
 This format must contain one instance of \"%s\" which will be replaced by
-the value of the relative timer."
+the value of the timer."
   :group 'org-time
   :type 'string)
 
@@ -76,13 +92,13 @@ nil          current timer is not displayed"
   "Hook run after relative timer is started.")
 
 (defvar org-timer-stop-hook nil
-  "Hook run before relative timer is stopped.")
+  "Hook run before relative or countdown timer is stopped.")
 
 (defvar org-timer-pause-hook nil
-  "Hook run before relative timer is paused.")
+  "Hook run before relative or countdown timer is paused.")
 
 (defvar org-timer-continue-hook nil
-  "Hook run after relative timer is continued.")
+  "Hook run after relative or countdown timer is continued.")
 
 (defvar org-timer-set-hook nil
   "Hook run after countdown timer is set.")
@@ -90,9 +106,6 @@ nil          current timer is not displayed"
 (defvar org-timer-done-hook nil
   "Hook run after countdown timer reaches zero.")
 
-(defvar org-timer-cancel-hook nil
-  "Hook run before countdown timer is canceled.")
-
 ;;;###autoload
 (defun org-timer-start (&optional offset)
   "Set the starting time for the relative timer to now.
@@ -105,8 +118,12 @@ region will be shifted by a specific amount.  You will be prompted for
 the amount, with the default to make the first timer string in
 the region 0:00:00."
   (interactive "P")
-  (if (equal offset '(16))
-      (call-interactively 'org-timer-change-times-in-region)
+  (cond
+   ((equal offset '(16))
+    (call-interactively 'org-timer-change-times-in-region))
+   (org-timer-countdown-timer
+    (user-error "Countdown timer is running.  Cancel first"))
+   (t
     (let (delta def s)
       (if (not offset)
 	  (setq org-timer-start-time (current-time))
@@ -123,45 +140,66 @@ the region 0:00:00."
 	  (setq delta (org-timer-hms-to-secs (org-timer-fix-incomplete s)))))
 	(setq org-timer-start-time
 	      (seconds-to-time
-	       (- (org-float-time) delta))))
+	       ;; Pass `current-time' result to `org-float-time'
+	       ;; (instead of calling without arguments) so that only
+	       ;; `current-time' has to be overriden in tests.
+	       (- (org-float-time (current-time)) delta))))
+      (setq org-timer-pause-time nil)
       (org-timer-set-mode-line 'on)
       (message "Timer start time set to %s, current value is %s"
 	       (format-time-string "%T" org-timer-start-time)
 	       (org-timer-secs-to-hms (or delta 0)))
-      (run-hooks 'org-timer-start-hook))))
+      (run-hooks 'org-timer-start-hook)))))
 
 (defun org-timer-pause-or-continue (&optional stop)
-  "Pause or continue the relative timer.
+  "Pause or continue the relative or countdown timer.
 With prefix arg STOP, stop it entirely."
   (interactive "P")
   (cond
    (stop (org-timer-stop))
    ((not org-timer-start-time) (error "No timer is running"))
    (org-timer-pause-time
-    ;; timer is paused, continue
-    (setq org-timer-start-time
-	  (seconds-to-time
-	   (-
-	    (org-float-time)
-	    (- (org-float-time org-timer-pause-time)
-	       (org-float-time org-timer-start-time))))
-	  org-timer-pause-time nil)
-    (org-timer-set-mode-line 'on)
-    (run-hooks 'org-timer-continue-hook)
-    (message "Timer continues at %s" (org-timer-value-string)))
+    (let ((start-secs (org-float-time org-timer-start-time))
+	  (pause-secs (org-float-time org-timer-pause-time)))
+      (if org-timer-countdown-timer
+	  (progn
+	    (let ((new-secs (- start-secs pause-secs)))
+	      (setq org-timer-countdown-timer
+		    (org-timer--run-countdown-timer
+		     new-secs org-timer-countdown-timer-title))
+	      (setq org-timer-start-time
+		    (time-add (current-time) (seconds-to-time new-secs)))))
+	(setq org-timer-start-time
+	      ;; Pass `current-time' result to `org-float-time'
+	      ;; (instead of calling without arguments) so that only
+	      ;; `current-time' has to be overriden in tests.
+	      (seconds-to-time (- (org-float-time (current-time))
+				  (- pause-secs start-secs)))))
+      (setq org-timer-pause-time nil)
+      (org-timer-set-mode-line 'on)
+      (run-hooks 'org-timer-continue-hook)
+      (message "Timer continues at %s" (org-timer-value-string))))
    (t
     ;; pause timer
+    (when org-timer-countdown-timer
+      (cancel-timer org-timer-countdown-timer)
+      (setq org-timer-countdown-timer 'pause))
     (run-hooks 'org-timer-pause-hook)
     (setq org-timer-pause-time (current-time))
     (org-timer-set-mode-line 'pause)
     (message "Timer paused at %s" (org-timer-value-string)))))
 
 (defun org-timer-stop ()
-  "Stop the relative timer."
+  "Stop the relative or countdown timer."
   (interactive)
+  (unless org-timer-start-time
+    (user-error "No timer running"))
+  (when (timerp org-timer-countdown-timer)
+    (cancel-timer org-timer-countdown-timer))
   (run-hooks 'org-timer-stop-hook)
   (setq org-timer-start-time nil
-	org-timer-pause-time nil)
+	org-timer-pause-time nil
+	org-timer-countdown-timer nil)
   (org-timer-set-mode-line 'off)
   (message "Timer stopped"))
 
@@ -191,11 +229,10 @@ it in the buffer."
 	  (org-timer-secs-to-hms
 	   (abs (floor (org-timer-seconds))))))
 
-(defvar org-timer-timer-is-countdown nil)
 (defun org-timer-seconds ()
-  (if org-timer-timer-is-countdown
+  (if org-timer-countdown-timer
       (- (org-float-time org-timer-start-time)
-	 (org-float-time (current-time)))
+	 (org-float-time (or org-timer-pause-time (current-time))))
     (- (org-float-time (or org-timer-pause-time (current-time)))
        (org-float-time org-timer-start-time))))
 
@@ -290,7 +327,7 @@ If the integer is negative, the string will start with \"-\"."
 (defvar org-timer-mode-line-string nil)
 
 (defun org-timer-set-mode-line (value)
-  "Set the mode-line display of the relative timer.
+  "Set the mode-line display for relative or countdown timer.
 VALUE can be `on', `off', or `pause'."
   (when (or (eq org-timer-display 'mode-line)
 	    (eq org-timer-display 'both))
@@ -349,35 +386,20 @@ VALUE can be `on', `off', or `pause'."
 	  (concat " <" (substring (org-timer-value-string) 0 -1) ">"))
     (force-mode-line-update)))
 
-(defvar org-timer-current-timer nil)
-(defun org-timer-cancel-timer ()
-  "Cancel the current timer."
-  (interactive)
-  (if (not org-timer-current-timer)
-      (message "No timer to cancel")
-    (run-hooks 'org-timer-cancel-hook)
-    (cancel-timer org-timer-current-timer)
-    (setq org-timer-current-timer nil
-	  org-timer-timer-is-countdown nil)
-    (org-timer-set-mode-line 'off)
-    (message "Last timer canceled")))
-
 (defun org-timer-show-remaining-time ()
   "Display the remaining time before the timer ends."
   (interactive)
   (require 'time)
-  (if (not org-timer-current-timer)
+  (if (not org-timer-countdown-timer)
       (message "No timer set")
     (let* ((rtime (decode-time
-		   (time-subtract (timer--time org-timer-current-timer)
+		   (time-subtract (timer--time org-timer-countdown-timer)
 				  (current-time))))
 	   (rsecs (nth 0 rtime))
 	   (rmins (nth 1 rtime)))
       (message "%d minute(s) %d seconds left before next time out"
 	       rmins rsecs))))
 
-(defvar org-clock-sound)
-
 ;;;###autoload
 (defun org-timer-set-timer (&optional opt)
   "Prompt for a duration and set a timer.
@@ -400,7 +422,10 @@ By default, the timer duration will be set to the number of
 minutes in the Effort property, if any.  You can ignore this by
 using three `C-u' prefix arguments."
   (interactive "P")
-  (let* ((effort-minutes (org-get-at-eol 'effort-minutes 1))
+  (when (and org-timer-start-time
+	     (not org-timer-countdown-timer))
+    (user-error "Relative timer is running.  Stop first"))
+  (let* ((effort-minutes (ignore-errors (org-get-at-eol 'effort-minutes 1)))
 	 (minutes (or (and (not (equal opt '(64)))
 			   effort-minutes
 			   (number-to-string effort-minutes))
@@ -415,47 +440,57 @@ using three `C-u' prefix arguments."
 	(org-timer-show-remaining-time)
       (let* ((mins (string-to-number (match-string 0 minutes)))
 	     (secs (* mins 60))
-	     (hl (cond
-		  ((string-match "Org Agenda" (buffer-name))
-		   (let* ((marker (or (get-text-property (point) 'org-marker)
-				      (org-agenda-error)))
-			  (hdmarker (or (get-text-property (point) 'org-hd-marker)
-					marker))
-			  (pos (marker-position marker)))
-		     (with-current-buffer (marker-buffer marker)
-		       (widen)
-		       (goto-char pos)
-		       (org-show-entry)
-		       (or (ignore-errors (org-get-heading))
-			   (concat "File:" (file-name-nondirectory (buffer-file-name)))))))
-		  ((derived-mode-p 'org-mode)
-		   (or (ignore-errors (org-get-heading))
-		       (concat "File:" (file-name-nondirectory (buffer-file-name)))))
-		  (t (error "Not in an Org buffer"))))
-	     timer-set)
-	(if (or (and org-timer-current-timer
-		     (or (equal opt '(16))
-			 (y-or-n-p "Replace current timer? ")))
-		(not org-timer-current-timer))
+	     (hl (org-timer--get-timer-title)))
+	(if (or (not org-timer-countdown-timer)
+		(equal opt '(16))
+		(y-or-n-p "Replace current timer? "))
 	    (progn
-	      (require 'org-clock)
-	      (when org-timer-current-timer
-		(cancel-timer org-timer-current-timer))
-	      (setq org-timer-current-timer
-		    (run-with-timer
-		     secs nil `(lambda ()
-				 (setq org-timer-current-timer nil)
-				 (org-notify ,(format "%s: time out" hl) ,org-clock-sound)
-				 (setq org-timer-timer-is-countdown nil)
-				 (org-timer-set-mode-line 'off)
-				 (run-hooks 'org-timer-done-hook))))
+	      (when (timerp org-timer-countdown-timer)
+		(cancel-timer org-timer-countdown-timer))
+	      (setq org-timer-countdown-timer-title
+		    (org-timer--get-timer-title))
+	      (setq org-timer-countdown-timer
+		    (org-timer--run-countdown-timer
+		     secs org-timer-countdown-timer-title))
 	      (run-hooks 'org-timer-set-hook)
-	      (setq org-timer-timer-is-countdown t
-		    org-timer-start-time
-		    (time-add (current-time) (seconds-to-time (* mins 60))))
+	      (setq org-timer-start-time
+		    (time-add (current-time) (seconds-to-time secs)))
+	      (setq org-timer-pause-time nil)
 	      (org-timer-set-mode-line 'on))
 	  (message "No timer set"))))))
 
+(defun org-timer--run-countdown-timer (secs title)
+  "Start countdown timer that will last SECS.
+TITLE will be appended to the notification message displayed when
+time is up."
+  (let ((msg (format "%s: time out" title)))
+    (run-with-timer
+     secs nil `(lambda ()
+		 (setq org-timer-countdown-timer nil
+		       org-timer-start-time nil)
+		 (org-notify ,msg ,org-clock-sound)
+		 (org-timer-set-mode-line 'off)
+		 (run-hooks 'org-timer-done-hook)))))
+
+(defun org-timer--get-timer-title ()
+  "Construct timer title from heading or file name of Org buffer."
+  (cond
+   ((derived-mode-p 'org-agenda-mode)
+    (let* ((marker (or (get-text-property (point) 'org-marker)
+		       (org-agenda-error)))
+	   (hdmarker (or (get-text-property (point) 'org-hd-marker)
+			 marker)))
+      (with-current-buffer (marker-buffer marker)
+	(org-with-wide-buffer
+	 (goto-char hdmarker)
+	 (org-show-entry)
+	 (or (ignore-errors (org-get-heading))
+	     (buffer-name (buffer-base-buffer)))))))
+   ((derived-mode-p 'org-mode)
+    (or (ignore-errors (org-get-heading))
+	(buffer-name (buffer-base-buffer))))
+   (t (error "Not in an Org buffer"))))
+
 (provide 'org-timer)
 
 ;; Local variables:

+ 273 - 0
testing/lisp/test-org-timer.el

@@ -0,0 +1,273 @@
+;;; test-org-timer.el --- Tests for org-timer.el
+
+;; Copyright (C) 2014  Kyle Meyer
+
+;; Author: Kyle Meyer <kyle@kyleam.com>
+
+;; This file is not part of GNU Emacs.
+
+;; This program is free software; you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This program is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+;;; Code:
+
+(defmacro test-org-timer/with-temp-text (text &rest body)
+  "Like `org-test-with-temp-text', but set timer-specific variables.
+Also, mute output from `message'."
+  (declare (indent 1))
+  `(cl-letf (((symbol-function 'message) (lambda (&rest args) nil)))
+     (org-test-with-temp-text ,text
+       (let (org-timer-start-time
+	     org-timer-pause-time
+	     org-timer-countdown-timer
+	     org-timer-display)
+	 (unwind-protect (progn ,@body)
+	   (when (timerp org-timer-countdown-timer)
+	     (cancel-timer org-timer-countdown-timer)))))))
+
+(defmacro test-org-timer/with-current-time (time &rest body)
+  "Run BODY, setting `current-time' output to TIME."
+  (declare (indent 1))
+  `(cl-letf (((symbol-function 'current-time) (lambda () ,time)))
+     ,@body))
+
+
+;;; Time conversion and formatting
+
+(ert-deftest test-org-timer/secs-to-hms ()
+  "Test conversion between HMS format and seconds."
+  ;; Seconds to HMS, and back again
+  (should
+   (equal "0:00:30"
+	  (org-timer-secs-to-hms 30)))
+  (should
+   (equal 30
+	  (org-timer-hms-to-secs (org-timer-secs-to-hms 30))))
+  ;; Minutes to HMS, and back again
+  (should
+   (equal "0:02:10"
+	  (org-timer-secs-to-hms 130)))
+  (should
+   (equal 130
+	  (org-timer-hms-to-secs (org-timer-secs-to-hms 130))))
+  ;; Hours to HMS, and back again
+  (should
+   (equal "1:01:30"
+	  (org-timer-secs-to-hms 3690)))
+  (should
+   (equal 3690
+	  (org-timer-hms-to-secs (org-timer-secs-to-hms 3690))))
+  ;; Negative seconds to HMS, and back again
+  (should
+   (equal "-1:01:30"
+	  (org-timer-secs-to-hms -3690)))
+  (should
+   (equal -3690
+	  (org-timer-hms-to-secs (org-timer-secs-to-hms -3690)))))
+
+(ert-deftest test-org-timer/fix-incomplete ()
+  "Test conversion to complete HMS format."
+  ;; No fix is needed.
+  (should
+   (equal "1:02:03"
+	  (org-timer-fix-incomplete "1:02:03")))
+  ;; Hour is missing.
+  (should
+   (equal "0:02:03"
+	  (org-timer-fix-incomplete "02:03")))
+  ;; Minute is missing.
+  (should
+   (equal "0:00:03"
+	  (org-timer-fix-incomplete "03"))))
+
+(ert-deftest test-org-timer/change-times ()
+  "Test changing HMS format by offset."
+  ;; Add time.
+  (should
+   (equal "
+1:31:15
+4:00:55"
+	  (org-test-with-temp-text "
+0:00:25
+2:30:05"
+	    (org-timer-change-times-in-region (point-min) (point-max)
+					      "1:30:50")
+	    (buffer-string))))
+  ;; Subtract time.
+  (should
+   (equal "
+-1:30:25
+0:59:15"
+	  (org-test-with-temp-text "
+0:00:25
+2:30:05"
+	    (org-timer-change-times-in-region (point-min) (point-max)
+					      "-1:30:50")
+	    (buffer-string)))))
+
+
+;;; Timers
+
+;; Dummy times for overriding `current-time'
+(defvar test-org-timer/time0 '(21635 62793 797149 675000))
+;; Add 3 minutes and 26 seconds.
+(defvar test-org-timer/time1
+  (time-add test-org-timer/time0 (seconds-to-time 206)))
+;; Add 2 minutes and 41 seconds (6 minutes and 7 seconds total).
+(defvar test-org-timer/time2
+  (time-add test-org-timer/time1 (seconds-to-time 161)))
+;; Add 4 minutes and 55 seconds (11 minutes and 2 seconds total).
+(defvar test-org-timer/time3
+  (time-add test-org-timer/time2 (seconds-to-time 295)))
+
+(ert-deftest test-org-timer/start-relative ()
+  "Test starting relative timer."
+  ;; Insert plain timer string, starting with `org-timer-start'.
+  (should
+   (equal "0:03:26"
+	  (test-org-timer/with-temp-text ""
+	    (test-org-timer/with-current-time test-org-timer/time0
+	      (org-timer-start))
+	    (test-org-timer/with-current-time test-org-timer/time1
+	      (org-timer))
+	    (org-trim (buffer-string)))))
+  ;; Insert item timer string.
+  (should
+   (equal "- 0:03:26 ::"
+	  (test-org-timer/with-temp-text ""
+	    (test-org-timer/with-current-time test-org-timer/time0
+	      (org-timer-start))
+	    (test-org-timer/with-current-time test-org-timer/time1
+	      (org-timer-item))
+	    (org-trim (buffer-string)))))
+  ;; Start with `org-timer'.
+  (should
+   (equal "0:00:00 0:03:26"
+	  (test-org-timer/with-temp-text ""
+	    (test-org-timer/with-current-time test-org-timer/time0
+	      (org-timer))
+	    (test-org-timer/with-current-time test-org-timer/time1
+	      (org-timer))
+	    (org-trim (buffer-string)))))
+  ;; Restart with `org-timer'.
+  (should
+   (equal "0:00:00"
+	  (test-org-timer/with-temp-text ""
+	    (test-org-timer/with-current-time test-org-timer/time0
+	      (org-timer-start))
+	    (test-org-timer/with-current-time test-org-timer/time1
+	      (org-timer '(4)))
+	    (org-trim (buffer-string))))))
+
+(ert-deftest test-org-timer/set-timer ()
+  "Test setting countdown timer."
+  (should
+   (equal "0:06:34"
+	  (test-org-timer/with-temp-text ""
+	    (test-org-timer/with-current-time test-org-timer/time0
+	      (org-timer-set-timer 10))
+	    (test-org-timer/with-current-time test-org-timer/time1
+	      (org-timer))
+	    (org-trim (buffer-string))))))
+
+(ert-deftest test-org-timer/pause-timer ()
+  "Test pausing relative and countdown timers."
+  ;; Pause relative timer.
+  (should
+   (equal "0:03:26"
+	  (test-org-timer/with-temp-text ""
+	    (test-org-timer/with-current-time test-org-timer/time0
+	      (org-timer-start))
+	    (test-org-timer/with-current-time test-org-timer/time1
+	      (org-timer-pause-or-continue))
+	    (org-timer)
+	    (org-trim (buffer-string)))))
+  ;; Pause then continue relative timer.
+  (should
+   (equal "0:08:21"
+	  (test-org-timer/with-temp-text ""
+	    (test-org-timer/with-current-time test-org-timer/time0
+	      (org-timer-start))
+	    (test-org-timer/with-current-time test-org-timer/time1
+	      (org-timer-pause-or-continue))
+	    (test-org-timer/with-current-time test-org-timer/time2
+	      (org-timer-pause-or-continue))
+	    (test-org-timer/with-current-time test-org-timer/time3
+	      (org-timer))
+	    (org-trim (buffer-string)))))
+  ;; Pause then continue countdown timer.
+  (should
+   (equal "0:01:39"
+	  (test-org-timer/with-temp-text ""
+	    (test-org-timer/with-current-time test-org-timer/time0
+	      (org-timer-set-timer 10))
+	    (test-org-timer/with-current-time test-org-timer/time1
+	      (org-timer-pause-or-continue))
+	    (test-org-timer/with-current-time test-org-timer/time2
+	      (org-timer-pause-or-continue))
+	    (test-org-timer/with-current-time test-org-timer/time3
+	      (org-timer))
+	    (org-trim (buffer-string))))))
+
+(ert-deftest test-org-timer/stop ()
+  "Test stopping relative and countdown timers."
+  ;; Stop running relative timer.
+  (test-org-timer/with-temp-text ""
+    (test-org-timer/with-current-time test-org-timer/time0
+      (org-timer-start))
+    (test-org-timer/with-current-time test-org-timer/time1
+      (org-timer-stop))
+    (should-not org-timer-start-time))
+  ;; Stop paused relative timer.
+  (test-org-timer/with-temp-text ""
+    (test-org-timer/with-current-time test-org-timer/time0
+      (org-timer-start))
+    (test-org-timer/with-current-time test-org-timer/time1
+      (org-timer-pause-or-continue)
+      (org-timer-stop))
+    (should-not org-timer-start-time)
+    (should-not org-timer-pause-time))
+  ;; Stop running countdown timer.
+  (test-org-timer/with-temp-text ""
+    (test-org-timer/with-current-time test-org-timer/time0
+      (org-timer-set-timer 10))
+    (test-org-timer/with-current-time test-org-timer/time1
+      (org-timer-stop))
+    (should-not org-timer-start-time)
+    (should-not org-timer-countdown-timer))
+  ;; Stop paused countdown timer.
+  (test-org-timer/with-temp-text ""
+    (test-org-timer/with-current-time test-org-timer/time0
+      (org-timer-set-timer 10))
+    (test-org-timer/with-current-time test-org-timer/time1
+      (org-timer-pause-or-continue)
+      (org-timer-stop))
+    (should-not org-timer-start-time)
+    (should-not org-timer-pause-time)
+    (should-not org-timer-countdown-timer)))
+
+(ert-deftest test-org-timer/other-timer-error ()
+  "Test for error when other timer running."
+  ;; Relative timer is running.
+  (should-error
+   (test-org-timer/with-temp-text ""
+     (org-timer-start)
+     (org-timer-set-timer 10)))
+  ;; Countdown timer is running.
+  (should-error
+   (test-org-timer/with-temp-text ""
+     (org-timer-set-timer 10)
+     (org-timer-start))))
+
+(provide 'test-org-timer)
+;;; test-org-timer.el end here