1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
|
;;; test-org-agenda-config--auto-refresh.el --- Tests for agenda auto-refresh -*- lexical-binding: t; -*-
;;; Commentary:
;; Tests for the wall-clock-aligned auto-refresh behind the F8 agenda:
;; the next-mark arithmetic, the on-screen-agenda lookup, the timer body's
;; error containment, and start/stop timer ownership.
;;; Code:
(require 'ert)
(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
(require 'org-agenda-config)
;; -- Fixtures ----------------------------------------------------------------
(defun test-org-agenda-auto-refresh--time (hh mm ss)
"Return an Emacs time value for today at HH:MM:SS local time."
(let ((now (decode-time)))
(encode-time (list ss mm hh
(nth 3 now) (nth 4 now) (nth 5 now)
nil -1 (nth 8 now)))))
(defmacro test-org-agenda-auto-refresh--with-agenda-window (&rest body)
"Run BODY with a window displaying a buffer in `org-agenda-mode'.
Sets `major-mode' directly rather than calling the mode function: the
lookup under test only asks what mode the window's buffer is in, and
`org-agenda-mode' setup wants a real agenda build behind it."
(declare (indent 0))
`(let ((buffer (get-buffer-create "*Org Agenda*")))
(unwind-protect
(save-window-excursion
(with-current-buffer buffer
(setq major-mode 'org-agenda-mode))
(set-window-buffer (selected-window) buffer)
,@body)
(kill-buffer buffer))))
;; -- cj/--org-agenda-seconds-to-next-mark ------------------------------------
(ert-deftest test-org-agenda-config-next-mark-mid-interval ()
"Normal: a time between marks returns the remainder to the next one."
(should (= 120 (cj/--org-agenda-seconds-to-next-mark
(test-org-agenda-auto-refresh--time 9 3 0) 300))))
(ert-deftest test-org-agenda-config-next-mark-exactly-on-mark ()
"Boundary: a time exactly on a mark returns a full period, never zero.
A zero delay would fire the timer immediately and again a period later."
(should (= 300 (cj/--org-agenda-seconds-to-next-mark
(test-org-agenda-auto-refresh--time 9 5 0) 300))))
(ert-deftest test-org-agenda-config-next-mark-one-second-before ()
"Boundary: one second short of a mark returns one second."
(should (= 1 (cj/--org-agenda-seconds-to-next-mark
(test-org-agenda-auto-refresh--time 9 4 59) 300))))
(ert-deftest test-org-agenda-config-next-mark-lands-on-a-multiple ()
"Normal: TIME plus the result is always a whole multiple of PERIOD.
This is the property that makes the refresh land on :00/:05 rather than
five minutes after whenever the agenda happened to open."
(dolist (seconds '(0 1 59 60 61 149 150 299))
(let* ((time (test-org-agenda-auto-refresh--time 9 0 0))
(start (+ (floor (float-time time)) seconds))
(delay (cj/--org-agenda-seconds-to-next-mark start 300)))
(should (zerop (mod (+ start delay) 300))))))
(ert-deftest test-org-agenda-config-next-mark-rejects-nonpositive-period ()
"Error: a zero or negative period is a caller bug, not a silent no-op."
(should-error (cj/--org-agenda-seconds-to-next-mark (current-time) 0))
(should-error (cj/--org-agenda-seconds-to-next-mark (current-time) -300)))
;; -- cj/--org-agenda-refresh-window ------------------------------------------
(ert-deftest test-org-agenda-config-refresh-window-finds-displayed-agenda ()
"Normal: the lookup returns the window showing an agenda buffer."
(test-org-agenda-auto-refresh--with-agenda-window
(should (eq (cj/--org-agenda-refresh-window) (selected-window)))))
(ert-deftest test-org-agenda-config-refresh-window-nil-when-not-displayed ()
"Boundary: an agenda buffer that exists but is off-screen is not a target.
Rebuilding an invisible agenda costs time and shows nobody anything."
(let ((buffer (get-buffer-create "*Org Agenda*")))
(unwind-protect
(progn
(with-current-buffer buffer (setq major-mode 'org-agenda-mode))
(should-not (cj/--org-agenda-refresh-window)))
(kill-buffer buffer))))
(ert-deftest test-org-agenda-config-refresh-window-nil-with-no-agenda ()
"Boundary: no agenda buffer anywhere returns nil rather than erroring."
(should-not (cj/--org-agenda-refresh-window)))
;; -- cj/--org-agenda-auto-refresh (the timer body) ---------------------------
(ert-deftest test-org-agenda-config-auto-refresh-noop-without-agenda ()
"Boundary: the tick does nothing, and signals nothing, with no agenda up."
(let ((called nil))
(cl-letf (((symbol-function 'org-agenda-redo)
(lambda (&rest _) (setq called t))))
(cj/--org-agenda-auto-refresh)
(should-not called))))
(ert-deftest test-org-agenda-config-auto-refresh-redoes-visible-agenda ()
"Normal: the tick redoes the agenda shown on screen."
(let ((called nil))
(cl-letf (((symbol-function 'org-agenda-redo)
(lambda (&rest _) (setq called t))))
(test-org-agenda-auto-refresh--with-agenda-window
(cj/--org-agenda-auto-refresh))
(should called))))
(ert-deftest test-org-agenda-config-auto-refresh-contains-errors ()
"Error: a failing redo must not escape the timer body.
An unguarded signal in a repeating timer resignals on every tick, which is
how a five-minute timer turns into an endless backtrace."
(cl-letf (((symbol-function 'org-agenda-redo)
(lambda (&rest _) (error "simulated redo failure")))
((symbol-function 'cj/log-silently) (lambda (&rest _) nil)))
(test-org-agenda-auto-refresh--with-agenda-window
(should (progn (cj/--org-agenda-auto-refresh) t)))))
(ert-deftest test-org-agenda-config-auto-refresh-restores-point-line ()
"Normal: the tick leaves point on the line it started on.
A refresh that scrolls the reader back to the top every five minutes is
worse than no refresh."
(cl-letf (((symbol-function 'org-agenda-redo) (lambda (&rest _) nil)))
(test-org-agenda-auto-refresh--with-agenda-window
(with-current-buffer "*Org Agenda*"
(erase-buffer)
(dotimes (i 10) (insert (format "agenda line %d\n" i)))
(goto-char (point-min))
(forward-line 4))
(cj/--org-agenda-auto-refresh)
(with-current-buffer "*Org Agenda*"
(should (= 5 (line-number-at-pos)))))))
;; -- start / stop ------------------------------------------------------------
(ert-deftest test-org-agenda-config-auto-refresh-start-owns-one-timer ()
"Normal: starting twice leaves exactly one timer, not two.
Re-loading the module in a live daemon re-runs the arming form, so a
non-idempotent start would stack a second ticker on every reload."
(let ((cj/--org-agenda-refresh-timer nil))
(unwind-protect
(progn
(cj/org-agenda-auto-refresh-start)
(let ((first cj/--org-agenda-refresh-timer))
(cj/org-agenda-auto-refresh-start)
(should (timerp cj/--org-agenda-refresh-timer))
(should-not (memq first timer-list))
(should (memq cj/--org-agenda-refresh-timer timer-list))))
(cj/org-agenda-auto-refresh-stop))))
(ert-deftest test-org-agenda-config-auto-refresh-stop-clears-timer ()
"Normal: stopping cancels the timer and clears the handle."
(let ((cj/--org-agenda-refresh-timer nil))
(cj/org-agenda-auto-refresh-start)
(let ((timer cj/--org-agenda-refresh-timer))
(cj/org-agenda-auto-refresh-stop)
(should-not cj/--org-agenda-refresh-timer)
(should-not (memq timer timer-list)))))
(ert-deftest test-org-agenda-config-auto-refresh-stop-is-safe-when-stopped ()
"Boundary: stopping an already-stopped refresh is a no-op, not an error."
(let ((cj/--org-agenda-refresh-timer nil))
(should (progn (cj/org-agenda-auto-refresh-stop) t))
(should-not cj/--org-agenda-refresh-timer)))
(provide 'test-org-agenda-config--auto-refresh)
;;; test-org-agenda-config--auto-refresh.el ends here
|