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
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
|
;;; test-chime--deduplicate-events-by-title.el --- Tests for event deduplication by title -*- lexical-binding: t; -*-
;; Copyright (C) 2024-2026 Craig Jennings
;; Author: Craig Jennings <c@cjennings.net>
;; 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/>.
;;; Commentary:
;; Unit tests for chime--deduplicate-events-by-title.
;; Tests that recurring events (expanded into multiple instances by org-agenda-list)
;; are deduplicated to show only the soonest occurrence of each title.
;;
;; This fixes bug001: Recurring Events Show Duplicate Entries in Tooltip
;;; Code:
(setq chime-debug t)
(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
;; Load test utilities
(require 'testutil-general (expand-file-name "testutil-general.el"))
(require 'testutil-time (expand-file-name "testutil-time.el"))
;;; Test Helpers
(defun test-make-event (title)
"Create a test event object with TITLE."
`((title . ,title)))
(defun test-make-upcoming-item (title minutes)
"Create a test upcoming-events item with TITLE and MINUTES until event.
Returns format: (EVENT TIME-INFO MINUTES)"
(list (test-make-event title)
'("dummy-time-string" . nil) ; TIME-INFO (not used in deduplication)
minutes))
(defun test-make-upcoming-item-with-source (title minutes file pos)
"Build an upcoming-events item carrying source-heading identity.
TITLE / MINUTES match `test-make-upcoming-item'; FILE and POS attach
`marker-file' and `marker-pos' to the event alist so the dedup key
can use heading identity instead of title."
(list `((title . ,title) (marker-file . ,file) (marker-pos . ,pos))
'("dummy-time-string" . nil)
minutes))
;;; Normal Cases
(ert-deftest test-chime--deduplicate-events-by-title-normal-recurring-daily-keeps-soonest ()
"Test that recurring daily event keeps only the soonest occurrence."
(let* ((events (list
(test-make-upcoming-item "Daily Standup" 60) ; 1 hour away
(test-make-upcoming-item "Daily Standup" 1500) ; tomorrow
(test-make-upcoming-item "Daily Standup" 2940))) ; day after
(result (chime--deduplicate-events-by-title events)))
(should (= 1 (length result)))
(should (string= "Daily Standup" (cdr (assoc 'title (car (car result))))))
(should (= 60 (caddr (car result))))))
(ert-deftest test-chime--deduplicate-events-by-title-normal-multiple-different-events ()
"Test that different event titles are all preserved."
(let* ((events (list
(test-make-upcoming-item "Meeting A" 30)
(test-make-upcoming-item "Meeting B" 60)
(test-make-upcoming-item "Meeting C" 90)))
(result (chime--deduplicate-events-by-title events)))
(should (= 3 (length result)))
;; All three events should be present
(should (cl-find-if (lambda (item) (string= "Meeting A" (cdr (assoc 'title (car item))))) result))
(should (cl-find-if (lambda (item) (string= "Meeting B" (cdr (assoc 'title (car item))))) result))
(should (cl-find-if (lambda (item) (string= "Meeting C" (cdr (assoc 'title (car item))))) result))))
(ert-deftest test-chime--deduplicate-events-by-title-normal-mixed-recurring-and-unique ()
"Test mix of recurring (duplicated) and unique events."
(let* ((events (list
(test-make-upcoming-item "Daily Wrap Up" 120) ; 2 hours
(test-make-upcoming-item "Team Sync" 180) ; 3 hours (unique)
(test-make-upcoming-item "Daily Wrap Up" 1560) ; tomorrow
(test-make-upcoming-item "Daily Wrap Up" 3000))) ; day after
(result (chime--deduplicate-events-by-title events)))
(should (= 2 (length result)))
;; Daily Wrap Up should appear once (soonest at 120 minutes)
(let ((daily-wrap-up (cl-find-if (lambda (item)
(string= "Daily Wrap Up" (cdr (assoc 'title (car item)))))
result)))
(should daily-wrap-up)
(should (= 120 (caddr daily-wrap-up))))
;; Team Sync should appear once
(let ((team-sync (cl-find-if (lambda (item)
(string= "Team Sync" (cdr (assoc 'title (car item)))))
result)))
(should team-sync)
(should (= 180 (caddr team-sync))))))
;;; Boundary Cases
(ert-deftest test-chime--deduplicate-events-by-title-boundary-empty-list-returns-empty ()
"Test that empty list returns empty list."
(let ((result (chime--deduplicate-events-by-title '())))
(should (null result))))
(ert-deftest test-chime--deduplicate-events-by-title-boundary-single-event-returns-same ()
"Test that single event is returned unchanged."
(let* ((events (list (test-make-upcoming-item "Solo Event" 45)))
(result (chime--deduplicate-events-by-title events)))
(should (= 1 (length result)))
(should (string= "Solo Event" (cdr (assoc 'title (car (car result))))))
(should (= 45 (caddr (car result))))))
(ert-deftest test-chime--deduplicate-events-by-title-boundary-all-same-title-keeps-soonest ()
"Test that when all events have same title, only the soonest is kept."
(let* ((events (list
(test-make-upcoming-item "Recurring Task" 300)
(test-make-upcoming-item "Recurring Task" 100) ; soonest
(test-make-upcoming-item "Recurring Task" 500)
(test-make-upcoming-item "Recurring Task" 200)))
(result (chime--deduplicate-events-by-title events)))
(should (= 1 (length result)))
(should (= 100 (caddr (car result))))))
(ert-deftest test-chime--deduplicate-events-by-title-boundary-two-events-same-title-keeps-soonest ()
"Test that with two events of same title, soonest is kept."
(let* ((events (list
(test-make-upcoming-item "Daily Check" 200)
(test-make-upcoming-item "Daily Check" 50))) ; soonest
(result (chime--deduplicate-events-by-title events)))
(should (= 1 (length result)))
(should (= 50 (caddr (car result))))))
(ert-deftest test-chime--deduplicate-events-by-title-boundary-same-title-same-time ()
"Test events with same title and same time (edge case).
One instance should be kept."
(let* ((events (list
(test-make-upcoming-item "Duplicate Time" 100)
(test-make-upcoming-item "Duplicate Time" 100)))
(result (chime--deduplicate-events-by-title events)))
(should (= 1 (length result)))
(should (= 100 (caddr (car result))))))
(ert-deftest test-chime--deduplicate-events-by-title-boundary-zero-minutes ()
"Test event happening right now (0 minutes away)."
(let* ((events (list
(test-make-upcoming-item "Happening Now" 0)
(test-make-upcoming-item "Happening Now" 1440))) ; tomorrow
(result (chime--deduplicate-events-by-title events)))
(should (= 1 (length result)))
(should (= 0 (caddr (car result))))))
(ert-deftest test-chime--deduplicate-events-by-title-boundary-large-minute-values ()
"Test with very large minute values (1 year lookahead)."
(let* ((events (list
(test-make-upcoming-item "Annual Review" 60)
(test-make-upcoming-item "Annual Review" 525600))) ; 365 days
(result (chime--deduplicate-events-by-title events)))
(should (= 1 (length result)))
(should (= 60 (caddr (car result))))))
(ert-deftest test-chime--deduplicate-events-by-title-boundary-title-with-special-chars ()
"Test titles with special characters."
(let* ((events (list
(test-make-upcoming-item "Review: Q1 Report (Draft)" 100)
(test-make-upcoming-item "Review: Q1 Report (Draft)" 200)))
(result (chime--deduplicate-events-by-title events)))
(should (= 1 (length result)))
(should (= 100 (caddr (car result))))))
(ert-deftest test-chime--deduplicate-events-by-title-boundary-empty-title ()
"Test events with empty string titles."
(let* ((events (list
(test-make-upcoming-item "" 100)
(test-make-upcoming-item "" 200)))
(result (chime--deduplicate-events-by-title events)))
(should (= 1 (length result)))
(should (= 100 (caddr (car result))))))
;;; Error Cases
(ert-deftest test-chime--deduplicate-events-by-title-error-nil-input-returns-empty ()
"Test that nil input returns empty list."
(let ((result (chime--deduplicate-events-by-title nil)))
(should (null result))))
;;; Source-heading Cases (distinct headings with shared title)
(ert-deftest test-chime--deduplicate-events-by-title-distinct-headings-same-title-both-kept ()
"Two events that share a title but live at different markers must both
survive the dedup pass. Recurring-event collapse keys off the source
heading (file + position), not the user-facing title."
(let* ((events (list
(test-make-upcoming-item-with-source
"1:1" 30 "/work.org" 100)
(test-make-upcoming-item-with-source
"1:1" 60 "/work.org" 500)))
(result (chime--deduplicate-events-by-title events)))
(should (= 2 (length result)))))
(ert-deftest test-chime--deduplicate-events-by-title-recurring-same-marker-collapsed ()
"Multiple expansions of one recurring entry share the same marker
and should still collapse to the soonest occurrence."
(let* ((events (list
(test-make-upcoming-item-with-source
"Standup" 60 "/work.org" 100)
(test-make-upcoming-item-with-source
"Standup" 1500 "/work.org" 100)
(test-make-upcoming-item-with-source
"Standup" 2940 "/work.org" 100)))
(result (chime--deduplicate-events-by-title events)))
(should (= 1 (length result)))
(should (= 60 (caddr (car result))))))
(ert-deftest test-chime--deduplicate-events-by-title-mixed-source-and-title-fallback ()
"Sourced and unsourced events coexist: sourced ones key off the marker,
unsourced ones still collapse by title."
(let* ((events (list
;; Two distinct headings sharing a title
(test-make-upcoming-item-with-source
"Sync" 30 "/a.org" 100)
(test-make-upcoming-item-with-source
"Sync" 90 "/b.org" 200)
;; Test event without marker info — fallback to title
(test-make-upcoming-item "Standalone" 45)
(test-make-upcoming-item "Standalone" 600)))
(result (chime--deduplicate-events-by-title events)))
;; Two Syncs (distinct headings) plus one Standalone (title-collapsed)
(should (= 3 (length result)))))
(provide 'test-chime--deduplicate-events-by-title)
;;; test-chime--deduplicate-events-by-title.el ends here
|