flake-update-20260505
1;;; pi-org-todos-test.el --- Tests for pi-org-todos.el -*- lexical-binding: t -*-
2
3;; Copyright (C) 2026 Vincent Demeester
4
5;;; Commentary:
6
7;; Unit tests for pi-org-todos.el functions.
8;; Run with: emacs --batch -l ert -l pi-org-todos-test.el -f ert-run-tests-batch-and-exit
9;;
10;; Or from Emacs: M-x ert RET t RET
11
12;;; Code:
13
14(require 'ert)
15(require 'json)
16
17;; Load the library under test
18(add-to-list 'load-path (file-name-directory (or load-file-name buffer-file-name)))
19(require 'org-batch-functions)
20(require 'pi-org-todos)
21
22;; Ensure custom TODO keywords are available in batch mode.
23;; In interactive mode these come from init.el, but batch mode
24;; starts with only (sequence "TODO" "DONE").
25(setq org-todo-keywords
26 '((sequence "STRT(s)" "NEXT(n)" "TODO(t)" "WAIT(w)" "|" "DONE(d!)" "CANX(c@/!)")))
27
28;;; Test Fixtures
29
30(defvar pi-org-todos-test-file nil
31 "Temporary test file path.")
32
33(defvar pi-org-todos-test-content
34 "#+title: Test TODOs
35#+startup: show2levels
36
37* Work
38:PROPERTIES:
39:CATEGORY: work
40:END:
41
42** TODO [#2] Review PR for pipeline
43SCHEDULED: <2026-02-06 Fri>
44:PROPERTIES:
45:CREATED: [2026-02-01 Mon]
46:END:
47
48This is a test TODO with some content.
49
50** NEXT [#1] Fix CI/CD issue :urgent:
51DEADLINE: <2026-02-05 Thu>
52:PROPERTIES:
53:CREATED: [2026-02-02 Tue]
54:END:
55
56** STRT Write documentation
57:PROPERTIES:
58:CREATED: [2026-02-03 Wed]
59:END:
60
61** WAIT Waiting for approval :blocked:
62:PROPERTIES:
63:BLOCKER: Review PR for pipeline
64:END:
65
66** DONE Completed task
67CLOSED: [2026-02-04 Thu 10:00]
68
69* Projects
70:PROPERTIES:
71:CATEGORY: projects
72:END:
73
74** TODO [#3] Homelab migration
75SCHEDULED: <2026-02-10 Wed>
76
77** TODO NixOS refactoring :nixos:homelab:
78
79* Personal
80:PROPERTIES:
81:CATEGORY: personal
82:END:
83
84** TODO Buy groceries
85SCHEDULED: <2026-02-06 Fri>
86"
87 "Test org file content.")
88
89(defun pi-org-todos-test-setup ()
90 "Create temporary test file."
91 (setq pi-org-todos-test-file (make-temp-file "pi-org-test-" nil ".org"))
92 (with-temp-file pi-org-todos-test-file
93 (insert pi-org-todos-test-content))
94 ;; Override the default file for tests
95 (setq pi/org-todo-default-file pi-org-todos-test-file))
96
97(defun pi-org-todos-test-teardown ()
98 "Clean up temporary test file."
99 (when (and pi-org-todos-test-file (file-exists-p pi-org-todos-test-file))
100 (delete-file pi-org-todos-test-file))
101 (setq pi-org-todos-test-file nil))
102
103(defun pi-org-todos-test-parse-result (json-string)
104 "Parse JSON-STRING result and return as alist."
105 (json-read-from-string json-string))
106
107;;; Test Helpers
108
109(ert-deftest pi-org-todos-test-json-response-success ()
110 "Test JSON response generation for success."
111 (let ((result (pi/org-todo--json-response t '((foo . "bar")))))
112 (should (stringp result))
113 (let ((parsed (json-read-from-string result)))
114 (should (eq t (alist-get 'success parsed)))
115 (should (equal "bar" (alist-get 'foo (alist-get 'data parsed)))))))
116
117(ert-deftest pi-org-todos-test-json-response-error ()
118 "Test JSON response generation for error."
119 (let ((result (pi/org-todo--json-response nil nil "Something went wrong")))
120 (should (stringp result))
121 (let ((parsed (json-read-from-string result)))
122 (should (eq :json-false (alist-get 'success parsed)))
123 (should (equal "Something went wrong" (alist-get 'error parsed))))))
124
125;;; Read Operation Tests
126
127(ert-deftest pi-org-todos-test-list ()
128 "Test listing TODOs."
129 (unwind-protect
130 (progn
131 (pi-org-todos-test-setup)
132 (let* ((result (pi/org-todo-list))
133 (parsed (pi-org-todos-test-parse-result result)))
134 (should (eq t (alist-get 'success parsed)))
135 (let ((data (alist-get 'data parsed)))
136 (should (> (length data) 0))
137 ;; Should include TODO, NEXT, STRT but not DONE or WAIT by default
138 (should (cl-some (lambda (todo)
139 (equal "TODO" (alist-get 'todo todo)))
140 data))
141 (should (cl-some (lambda (todo)
142 (equal "NEXT" (alist-get 'todo todo)))
143 data)))))
144 (pi-org-todos-test-teardown)))
145
146(ert-deftest pi-org-todos-test-list-all ()
147 "Test listing all TODOs including done."
148 (unwind-protect
149 (progn
150 (pi-org-todos-test-setup)
151 (let* ((result (pi/org-todo-list-all))
152 (parsed (pi-org-todos-test-parse-result result)))
153 (should (eq t (alist-get 'success parsed)))
154 (let ((data (alist-get 'data parsed)))
155 ;; Should include DONE
156 (should (cl-some (lambda (todo)
157 (equal "DONE" (alist-get 'todo todo)))
158 data)))))
159 (pi-org-todos-test-teardown)))
160
161(ert-deftest pi-org-todos-test-scheduled ()
162 "Test getting scheduled items."
163 (unwind-protect
164 (progn
165 (pi-org-todos-test-setup)
166 (let* ((result (pi/org-todo-scheduled nil "2026-02-06"))
167 (parsed (pi-org-todos-test-parse-result result)))
168 (should (eq t (alist-get 'success parsed)))
169 (let ((data (alist-get 'data parsed)))
170 ;; Should find items scheduled for 2026-02-06
171 (should (cl-some (lambda (todo)
172 (string-match-p "Review PR" (alist-get 'heading todo)))
173 data)))))
174 (pi-org-todos-test-teardown)))
175
176(ert-deftest pi-org-todos-test-overdue ()
177 "Test getting overdue items."
178 (unwind-protect
179 (progn
180 (pi-org-todos-test-setup)
181 (let* ((result (pi/org-todo-overdue))
182 (parsed (pi-org-todos-test-parse-result result)))
183 (should (eq t (alist-get 'success parsed)))
184 ;; The NEXT item has deadline 2026-02-05 which is before today (2026-02-06)
185 (let ((data (alist-get 'data parsed)))
186 (should (cl-some (lambda (todo)
187 (string-match-p "Fix CI/CD" (alist-get 'heading todo)))
188 data)))))
189 (pi-org-todos-test-teardown)))
190
191(ert-deftest pi-org-todos-test-search ()
192 "Test searching TODOs."
193 (unwind-protect
194 (progn
195 (pi-org-todos-test-setup)
196 (let* ((result (pi/org-todo-search "pipeline"))
197 (parsed (pi-org-todos-test-parse-result result)))
198 (should (eq t (alist-get 'success parsed)))
199 (let ((data (alist-get 'data parsed)))
200 (should (> (length data) 0))
201 (should (cl-some (lambda (todo)
202 (string-match-p "pipeline" (alist-get 'heading todo)))
203 data)))))
204 (pi-org-todos-test-teardown)))
205
206(ert-deftest pi-org-todos-test-get ()
207 "Test getting a specific TODO."
208 (unwind-protect
209 (progn
210 (pi-org-todos-test-setup)
211 (let* ((result (pi/org-todo-get "Review PR for pipeline"))
212 (parsed (pi-org-todos-test-parse-result result)))
213 (should (eq t (alist-get 'success parsed)))
214 (let ((data (alist-get 'data parsed)))
215 (should (equal "Review PR for pipeline" (alist-get 'heading data)))
216 (should (equal "TODO" (alist-get 'todo data)))
217 (should (equal 2 (alist-get 'priority data))))))
218 (pi-org-todos-test-teardown)))
219
220(ert-deftest pi-org-todos-test-sections ()
221 "Test getting sections."
222 (unwind-protect
223 (progn
224 (pi-org-todos-test-setup)
225 (let* ((result (pi/org-todo-sections))
226 (parsed (pi-org-todos-test-parse-result result)))
227 (should (eq t (alist-get 'success parsed)))
228 (let ((data (append (alist-get 'data parsed) nil))) ; Convert vector to list
229 (should (member "Work" data))
230 (should (member "Projects" data))
231 (should (member "Personal" data)))))
232 (pi-org-todos-test-teardown)))
233
234(ert-deftest pi-org-todos-test-by-section ()
235 "Test getting TODOs by section."
236 (unwind-protect
237 (progn
238 (pi-org-todos-test-setup)
239 (let* ((result (pi/org-todo-by-section "Projects"))
240 (parsed (pi-org-todos-test-parse-result result)))
241 (should (eq t (alist-get 'success parsed)))
242 (let ((data (alist-get 'data parsed)))
243 (should (= 2 (length data)))
244 (should (cl-some (lambda (todo)
245 (string-match-p "Homelab" (alist-get 'heading todo)))
246 data)))))
247 (pi-org-todos-test-teardown)))
248
249(ert-deftest pi-org-todos-test-statistics ()
250 "Test getting statistics."
251 (unwind-protect
252 (progn
253 (pi-org-todos-test-setup)
254 (let* ((result (pi/org-todo-statistics))
255 (parsed (pi-org-todos-test-parse-result result)))
256 (should (eq t (alist-get 'success parsed)))
257 (let ((data (alist-get 'data parsed)))
258 (should (> (alist-get 'total data) 0))
259 ;; by_state uses symbol keys after JSON parsing
260 (let ((by-state (alist-get 'by_state data)))
261 (should (>= (alist-get 'TODO by-state) 0))))))
262 (pi-org-todos-test-teardown)))
263
264;;; Write Operation Tests
265
266(ert-deftest pi-org-todos-test-done ()
267 "Test marking TODO as done."
268 (unwind-protect
269 (progn
270 (pi-org-todos-test-setup)
271 (let* ((result (pi/org-todo-done "Buy groceries"))
272 (parsed (pi-org-todos-test-parse-result result)))
273 (should (eq t (alist-get 'success parsed)))
274 ;; Verify the state changed
275 (let* ((get-result (pi/org-todo-get "Buy groceries"))
276 (get-parsed (pi-org-todos-test-parse-result get-result)))
277 (should (equal "DONE" (alist-get 'todo (alist-get 'data get-parsed)))))))
278 (pi-org-todos-test-teardown)))
279
280(ert-deftest pi-org-todos-test-state ()
281 "Test changing TODO state."
282 (unwind-protect
283 (progn
284 (pi-org-todos-test-setup)
285 (let* ((result (pi/org-todo-state "Buy groceries" "NEXT"))
286 (parsed (pi-org-todos-test-parse-result result)))
287 (should (eq t (alist-get 'success parsed)))
288 ;; Verify the state changed
289 (let* ((get-result (pi/org-todo-get "Buy groceries"))
290 (get-parsed (pi-org-todos-test-parse-result get-result)))
291 (should (equal "NEXT" (alist-get 'todo (alist-get 'data get-parsed)))))))
292 (pi-org-todos-test-teardown)))
293
294(ert-deftest pi-org-todos-test-schedule ()
295 "Test scheduling a TODO."
296 (unwind-protect
297 (progn
298 (pi-org-todos-test-setup)
299 (let* ((result (pi/org-todo-schedule "NixOS refactoring" "2026-03-01"))
300 (parsed (pi-org-todos-test-parse-result result)))
301 (should (eq t (alist-get 'success parsed)))
302 ;; Verify the schedule was set
303 (let* ((get-result (pi/org-todo-get "NixOS refactoring"))
304 (get-parsed (pi-org-todos-test-parse-result get-result)))
305 (should (string-match-p "2026-03-01"
306 (alist-get 'scheduled (alist-get 'data get-parsed)))))))
307 (pi-org-todos-test-teardown)))
308
309(ert-deftest pi-org-todos-test-priority ()
310 "Test setting priority."
311 (unwind-protect
312 (progn
313 (pi-org-todos-test-setup)
314 (let* ((result (pi/org-todo-priority "NixOS refactoring" 1))
315 (parsed (pi-org-todos-test-parse-result result)))
316 (should (eq t (alist-get 'success parsed)))
317 ;; Verify the priority was set
318 (let* ((get-result (pi/org-todo-get "NixOS refactoring"))
319 (get-parsed (pi-org-todos-test-parse-result get-result)))
320 (should (equal 1 (alist-get 'priority (alist-get 'data get-parsed)))))))
321 (pi-org-todos-test-teardown)))
322
323(ert-deftest pi-org-todos-test-add ()
324 "Test adding a new TODO."
325 (unwind-protect
326 (progn
327 (pi-org-todos-test-setup)
328 (let* ((result (pi/org-todo-add "New test task" "Work" nil "2026-02-15" 2 '("test")))
329 (parsed (pi-org-todos-test-parse-result result)))
330 (should (eq t (alist-get 'success parsed)))
331 ;; Verify the TODO was created
332 (let* ((get-result (pi/org-todo-get "New test task"))
333 (get-parsed (pi-org-todos-test-parse-result get-result)))
334 (should (alist-get 'data get-parsed))
335 (should (equal "TODO" (alist-get 'todo (alist-get 'data get-parsed)))))))
336 (pi-org-todos-test-teardown)))
337
338(ert-deftest pi-org-todos-test-not-found ()
339 "Test error handling for non-existent heading."
340 (unwind-protect
341 (progn
342 (pi-org-todos-test-setup)
343 (let* ((result (pi/org-todo-done "This heading does not exist"))
344 (parsed (pi-org-todos-test-parse-result result)))
345 (should (eq :json-false (alist-get 'success parsed)))
346 (should (string-match-p "not found" (alist-get 'error parsed)))))
347 (pi-org-todos-test-teardown)))
348
349(ert-deftest pi-org-todos-test-invalid-file ()
350 "Test error handling for non-existent file."
351 (let ((pi/org-todo-default-file "/nonexistent/path/todos.org"))
352 (let* ((result (pi/org-todo-list))
353 (parsed (pi-org-todos-test-parse-result result)))
354 (should (eq :json-false (alist-get 'success parsed))))))
355
356;;; Deadline Tests
357
358(ert-deftest pi-org-todos-test-deadline ()
359 "Test setting a deadline on a TODO."
360 (unwind-protect
361 (progn
362 (pi-org-todos-test-setup)
363 (let* ((result (pi/org-todo-deadline "NixOS refactoring" "2026-04-01"))
364 (parsed (pi-org-todos-test-parse-result result)))
365 (should (eq t (alist-get 'success parsed)))
366 ;; Verify the deadline was set
367 (let* ((get-result (pi/org-todo-get "NixOS refactoring"))
368 (get-parsed (pi-org-todos-test-parse-result get-result)))
369 (should (string-match-p "2026-04-01"
370 (alist-get 'deadline (alist-get 'data get-parsed)))))))
371 (pi-org-todos-test-teardown)))
372
373(ert-deftest pi-org-todos-test-deadline-not-found ()
374 "Test setting deadline on non-existent heading."
375 (unwind-protect
376 (progn
377 (pi-org-todos-test-setup)
378 (let* ((result (pi/org-todo-deadline "Nonexistent heading" "2026-04-01"))
379 (parsed (pi-org-todos-test-parse-result result)))
380 (should (eq :json-false (alist-get 'success parsed)))))
381 (pi-org-todos-test-teardown)))
382
383;;; Append Content Tests
384
385(ert-deftest pi-org-todos-test-append ()
386 "Test appending content to a TODO."
387 (unwind-protect
388 (progn
389 (pi-org-todos-test-setup)
390 (let* ((result (pi/org-todo-append "Buy groceries" "Remember to check prices"))
391 (parsed (pi-org-todos-test-parse-result result)))
392 (should (eq t (alist-get 'success parsed)))
393 ;; Verify content was appended
394 (let* ((get-result (pi/org-todo-get "Buy groceries"))
395 (get-parsed (pi-org-todos-test-parse-result get-result))
396 (data (alist-get 'data get-parsed)))
397 (should (string-match-p "check prices" (alist-get 'content data))))))
398 (pi-org-todos-test-teardown)))
399
400(ert-deftest pi-org-todos-test-append-not-found ()
401 "Test appending content to non-existent heading."
402 (unwind-protect
403 (progn
404 (pi-org-todos-test-setup)
405 (let* ((result (pi/org-todo-append "Nonexistent" "some content"))
406 (parsed (pi-org-todos-test-parse-result result)))
407 (should (eq :json-false (alist-get 'success parsed)))))
408 (pi-org-todos-test-teardown)))
409
410;;; Tag Operation Tests
411
412(ert-deftest pi-org-todos-test-add-tags ()
413 "Test adding tags to a TODO."
414 (unwind-protect
415 (progn
416 (pi-org-todos-test-setup)
417 (let* ((result (pi/org-todo-add-tags "Buy groceries" '("shopping" "personal")))
418 (parsed (pi-org-todos-test-parse-result result)))
419 (should (eq t (alist-get 'success parsed)))
420 ;; Verify tags were added
421 (let* ((get-result (pi/org-todo-get "Buy groceries"))
422 (get-parsed (pi-org-todos-test-parse-result get-result))
423 (tags (append (alist-get 'tags (alist-get 'data get-parsed)) nil)))
424 (should (member "shopping" tags))
425 (should (member "personal" tags)))))
426 (pi-org-todos-test-teardown)))
427
428(ert-deftest pi-org-todos-test-remove-tags ()
429 "Test removing tags from a TODO."
430 (unwind-protect
431 (progn
432 (pi-org-todos-test-setup)
433 ;; The "Fix CI/CD issue" has :urgent: tag
434 (let* ((result (pi/org-todo-remove-tags "Fix CI/CD issue" '("urgent")))
435 (parsed (pi-org-todos-test-parse-result result)))
436 (should (eq t (alist-get 'success parsed)))
437 ;; Verify tag was removed
438 (let* ((get-result (pi/org-todo-get "Fix CI/CD issue"))
439 (get-parsed (pi-org-todos-test-parse-result get-result))
440 (tags (append (alist-get 'tags (alist-get 'data get-parsed)) nil)))
441 (should-not (member "urgent" tags)))))
442 (pi-org-todos-test-teardown)))
443
444(ert-deftest pi-org-todos-test-all-tags ()
445 "Test listing all tags."
446 (unwind-protect
447 (progn
448 (pi-org-todos-test-setup)
449 (let* ((result (pi/org-todo-all-tags))
450 (parsed (pi-org-todos-test-parse-result result)))
451 (should (eq t (alist-get 'success parsed)))
452 (let ((tags (append (alist-get 'data parsed) nil)))
453 ;; Should find the tags from test data
454 (should (member "urgent" tags))
455 (should (member "nixos" tags))
456 (should (member "homelab" tags)))))
457 (pi-org-todos-test-teardown)))
458
459;;; Property Operation Tests
460
461(ert-deftest pi-org-todos-test-get-property ()
462 "Test getting a property value."
463 (unwind-protect
464 (progn
465 (pi-org-todos-test-setup)
466 (let* ((result (pi/org-todo-get-property "Review PR for pipeline" "CREATED"))
467 (parsed (pi-org-todos-test-parse-result result)))
468 (should (eq t (alist-get 'success parsed)))
469 (should (string-match-p "2026-02-01"
470 (alist-get 'value (alist-get 'data parsed))))))
471 (pi-org-todos-test-teardown)))
472
473(ert-deftest pi-org-todos-test-set-property ()
474 "Test setting a property value."
475 (unwind-protect
476 (progn
477 (pi-org-todos-test-setup)
478 (let* ((result (pi/org-todo-set-property "Buy groceries" "EFFORT" "30min"))
479 (parsed (pi-org-todos-test-parse-result result)))
480 (should (eq t (alist-get 'success parsed)))
481 ;; Verify property was set
482 (let* ((get-result (pi/org-todo-get-property "Buy groceries" "EFFORT"))
483 (get-parsed (pi-org-todos-test-parse-result get-result)))
484 (should (equal "30min" (alist-get 'value (alist-get 'data get-parsed)))))))
485 (pi-org-todos-test-teardown)))
486
487;;; Upcoming Tests
488
489(ert-deftest pi-org-todos-test-upcoming ()
490 "Test getting upcoming tasks."
491 (unwind-protect
492 (progn
493 (pi-org-todos-test-setup)
494 (let* ((result (pi/org-todo-upcoming nil 365)) ;; Wide range to capture test data
495 (parsed (pi-org-todos-test-parse-result result)))
496 (should (eq t (alist-get 'success parsed)))
497 ;; Data is a JSON array (vector), convert to list for checking
498 (let ((data (append (alist-get 'data parsed) nil)))
499 (should (listp data)))))
500 (pi-org-todos-test-teardown)))
501
502;;; State Transition Tests
503
504(ert-deftest pi-org-todos-test-state-to-strt ()
505 "Test changing TODO state to STRT."
506 (unwind-protect
507 (progn
508 (pi-org-todos-test-setup)
509 (let* ((result (pi/org-todo-state "Buy groceries" "STRT"))
510 (parsed (pi-org-todos-test-parse-result result)))
511 (should (eq t (alist-get 'success parsed)))
512 (let* ((get-result (pi/org-todo-get "Buy groceries"))
513 (get-parsed (pi-org-todos-test-parse-result get-result)))
514 (should (equal "STRT" (alist-get 'todo (alist-get 'data get-parsed)))))))
515 (pi-org-todos-test-teardown)))
516
517(ert-deftest pi-org-todos-test-state-to-wait ()
518 "Test changing TODO state to WAIT."
519 (unwind-protect
520 (progn
521 (pi-org-todos-test-setup)
522 (let* ((result (pi/org-todo-state "Buy groceries" "WAIT"))
523 (parsed (pi-org-todos-test-parse-result result)))
524 (should (eq t (alist-get 'success parsed)))
525 (let* ((get-result (pi/org-todo-get "Buy groceries"))
526 (get-parsed (pi-org-todos-test-parse-result get-result)))
527 (should (equal "WAIT" (alist-get 'todo (alist-get 'data get-parsed)))))))
528 (pi-org-todos-test-teardown)))
529
530(ert-deftest pi-org-todos-test-state-to-canx ()
531 "Test changing TODO state to CANX."
532 (unwind-protect
533 (progn
534 (pi-org-todos-test-setup)
535 (let* ((result (pi/org-todo-state "Buy groceries" "CANX"))
536 (parsed (pi-org-todos-test-parse-result result)))
537 (should (eq t (alist-get 'success parsed)))
538 (let* ((get-result (pi/org-todo-get "Buy groceries"))
539 (get-parsed (pi-org-todos-test-parse-result get-result)))
540 (should (equal "CANX" (alist-get 'todo (alist-get 'data get-parsed)))))))
541 (pi-org-todos-test-teardown)))
542
543;;; List Filter Tests
544
545(ert-deftest pi-org-todos-test-list-filter-next ()
546 "Test listing only NEXT items."
547 (unwind-protect
548 (progn
549 (pi-org-todos-test-setup)
550 (let* ((result (pi/org-todo-list nil "NEXT"))
551 (parsed (pi-org-todos-test-parse-result result)))
552 (should (eq t (alist-get 'success parsed)))
553 (let ((data (append (alist-get 'data parsed) nil)))
554 (should (> (length data) 0))
555 ;; All should be NEXT
556 (dolist (todo data)
557 (should (equal "NEXT" (alist-get 'todo todo)))))))
558 (pi-org-todos-test-teardown)))
559
560(ert-deftest pi-org-todos-test-list-filter-strt ()
561 "Test listing only STRT items."
562 (unwind-protect
563 (progn
564 (pi-org-todos-test-setup)
565 (let* ((result (pi/org-todo-list nil "STRT"))
566 (parsed (pi-org-todos-test-parse-result result)))
567 (should (eq t (alist-get 'success parsed)))
568 (let ((data (append (alist-get 'data parsed) nil)))
569 (should (= 1 (length data)))
570 (should (string-match-p "documentation" (alist-get 'heading (car data)))))))
571 (pi-org-todos-test-teardown)))
572
573(ert-deftest pi-org-todos-test-list-filter-comma-separated ()
574 "Test listing with comma-separated state filter."
575 (unwind-protect
576 (progn
577 (pi-org-todos-test-setup)
578 (let* ((result (pi/org-todo-list nil "NEXT,STRT"))
579 (parsed (pi-org-todos-test-parse-result result)))
580 (should (eq t (alist-get 'success parsed)))
581 (let ((data (append (alist-get 'data parsed) nil)))
582 (should (= 2 (length data)))
583 (dolist (todo data)
584 (should (member (alist-get 'todo todo) '("NEXT" "STRT")))))))
585 (pi-org-todos-test-teardown)))
586
587;;; Search with Content Tests
588
589(ert-deftest pi-org-todos-test-search-heading ()
590 "Test search matches in heading."
591 (unwind-protect
592 (progn
593 (pi-org-todos-test-setup)
594 (let* ((result (pi/org-todo-search "pipeline"))
595 (parsed (pi-org-todos-test-parse-result result)))
596 (should (eq t (alist-get 'success parsed)))
597 (let ((data (append (alist-get 'data parsed) nil)))
598 ;; "pipeline" appears in heading of one TODO and in BLOCKER property of another
599 (should (>= (length data) 1))
600 ;; At least one should match in heading (note: key is 'matched-in with hyphen)
601 (should (cl-some (lambda (todo)
602 (equal "heading" (alist-get 'matched-in todo)))
603 data)))))
604 (pi-org-todos-test-teardown)))
605
606(ert-deftest pi-org-todos-test-search-content ()
607 "Test search matches in content."
608 (unwind-protect
609 (progn
610 (pi-org-todos-test-setup)
611 (let* ((result (pi/org-todo-search "check prices" nil t))
612 (parsed (pi-org-todos-test-parse-result result)))
613 ;; Should return empty since content doesn't contain "check prices" yet
614 (should (eq t (alist-get 'success parsed)))
615 (should (= 0 (length (alist-get 'data parsed))))))
616 (pi-org-todos-test-teardown)))
617
618(ert-deftest pi-org-todos-test-search-no-results ()
619 "Test search with no results."
620 (unwind-protect
621 (progn
622 (pi-org-todos-test-setup)
623 (let* ((result (pi/org-todo-search "zzzznonexistent"))
624 (parsed (pi-org-todos-test-parse-result result)))
625 (should (eq t (alist-get 'success parsed)))
626 (should (= 0 (length (alist-get 'data parsed))))))
627 (pi-org-todos-test-teardown)))
628
629;;; Add with Various Options Tests
630
631(ert-deftest pi-org-todos-test-add-minimal ()
632 "Test adding TODO with minimal options (no schedule, priority, tags)."
633 (unwind-protect
634 (progn
635 (pi-org-todos-test-setup)
636 (let* ((result (pi/org-todo-add "Minimal task" "Personal"))
637 (parsed (pi-org-todos-test-parse-result result)))
638 (should (eq t (alist-get 'success parsed)))
639 (let* ((get-result (pi/org-todo-get "Minimal task"))
640 (get-parsed (pi-org-todos-test-parse-result get-result))
641 (data (alist-get 'data get-parsed)))
642 (should data)
643 (should (equal "TODO" (alist-get 'todo data))))))
644 (pi-org-todos-test-teardown)))
645
646(ert-deftest pi-org-todos-test-add-with-tags ()
647 "Test adding TODO with tags."
648 (unwind-protect
649 (progn
650 (pi-org-todos-test-setup)
651 (let* ((result (pi/org-todo-add "Tagged task" "Work" nil nil nil '("review" "code")))
652 (parsed (pi-org-todos-test-parse-result result)))
653 (should (eq t (alist-get 'success parsed)))
654 (let* ((get-result (pi/org-todo-get "Tagged task"))
655 (get-parsed (pi-org-todos-test-parse-result get-result))
656 (tags (append (alist-get 'tags (alist-get 'data get-parsed)) nil)))
657 (should (member "review" tags))
658 (should (member "code" tags)))))
659 (pi-org-todos-test-teardown)))
660
661(ert-deftest pi-org-todos-test-add-nonexistent-section ()
662 "Test adding TODO to nonexistent section."
663 (unwind-protect
664 (progn
665 (pi-org-todos-test-setup)
666 (let* ((result (pi/org-todo-add "Task" "Nonexistent"))
667 (parsed (pi-org-todos-test-parse-result result)))
668 (should (eq :json-false (alist-get 'success parsed)))))
669 (pi-org-todos-test-teardown)))
670
671;;; Inbox Operations Tests
672
673(ert-deftest pi-org-todos-test-inbox-all-empty ()
674 "Test getting all entries from an empty inbox."
675 (let ((test-inbox (make-temp-file "inbox-test-" nil ".org")))
676 (unwind-protect
677 (progn
678 (with-temp-file test-inbox
679 (insert "#+title: Test Inbox\n"))
680 (let* ((result (pi/org-todo-inbox-all test-inbox))
681 (parsed (pi-org-todos-test-parse-result result)))
682 (should (eq t (alist-get 'success parsed)))
683 (should (= 0 (length (alist-get 'data parsed))))))
684 (delete-file test-inbox))))
685
686(ert-deftest pi-org-todos-test-inbox-all-with-items ()
687 "Test getting all entries from inbox with items."
688 (let ((test-inbox (make-temp-file "inbox-test-" nil ".org")))
689 (unwind-protect
690 (progn
691 (with-temp-file test-inbox
692 (insert "#+title: Test Inbox\n* TODO First item\n* Second item\n* TODO Third item\n"))
693 (let* ((result (pi/org-todo-inbox-all test-inbox))
694 (parsed (pi-org-todos-test-parse-result result)))
695 (should (eq t (alist-get 'success parsed)))
696 (let ((data (append (alist-get 'data parsed) nil)))
697 ;; Should have 3 entries total
698 (should (= 3 (length data)))
699 ;; Two are TODOs, one is plain
700 (let ((todos (seq-filter (lambda (e) (alist-get 'todo e)) data)))
701 (should (= 2 (length todos)))))))
702 (delete-file test-inbox))))
703
704;;; Refile Targets Tests
705
706(ert-deftest pi-org-todos-test-refile-targets ()
707 "Test getting refile targets."
708 (unwind-protect
709 (progn
710 (pi-org-todos-test-setup)
711 (let* ((result (pi/org-todo-get-refile-targets))
712 (parsed (pi-org-todos-test-parse-result result)))
713 (should (eq t (alist-get 'success parsed)))
714 (let ((targets (append (alist-get 'data parsed) nil)))
715 (should (> (length targets) 0))
716 ;; Each target should have section, path, level, position
717 (dolist (target targets)
718 (should (alist-get 'section target))
719 (should (alist-get 'level target))
720 (should (alist-get 'position target))))))
721 (pi-org-todos-test-teardown)))
722
723;;; Refile Tests
724
725(ert-deftest pi-org-todos-test-refile ()
726 "Test refiling an entry from inbox to todos."
727 (let ((test-inbox (make-temp-file "inbox-test-" nil ".org")))
728 (unwind-protect
729 (progn
730 (pi-org-todos-test-setup)
731 (with-temp-file test-inbox
732 (insert "* TODO Refile me\n"))
733 (let* ((result (pi/org-todo-refile "Refile me" "Work" test-inbox))
734 (parsed (pi-org-todos-test-parse-result result)))
735 (should (eq t (alist-get 'success parsed)))
736 ;; Verify it's in Work section now
737 (let* ((get-result (pi/org-todo-get "Refile me"))
738 (get-parsed (pi-org-todos-test-parse-result get-result)))
739 (should (alist-get 'data get-parsed)))
740 ;; Verify it's gone from inbox
741 (with-temp-buffer
742 (insert-file-contents test-inbox)
743 (should-not (string-match-p "Refile me" (buffer-string))))))
744 (when (file-exists-p test-inbox)
745 (delete-file test-inbox))
746 (pi-org-todos-test-teardown))))
747
748;;; Get with Content Tests
749
750(ert-deftest pi-org-todos-test-get-returns-content ()
751 "Test getting a TODO returns its body content."
752 (unwind-protect
753 (progn
754 (pi-org-todos-test-setup)
755 (let* ((result (pi/org-todo-get "Review PR for pipeline"))
756 (parsed (pi-org-todos-test-parse-result result))
757 (data (alist-get 'data parsed)))
758 (should (alist-get 'content data))
759 (should (string-match-p "test TODO with some content" (alist-get 'content data)))))
760 (pi-org-todos-test-teardown)))
761
762(ert-deftest pi-org-todos-test-get-returns-properties ()
763 "Test getting a TODO returns its properties."
764 (unwind-protect
765 (progn
766 (pi-org-todos-test-setup)
767 (let* ((result (pi/org-todo-get "Review PR for pipeline"))
768 (parsed (pi-org-todos-test-parse-result result))
769 (data (alist-get 'data parsed))
770 (properties (alist-get 'properties data)))
771 (should properties)
772 ;; Should include CREATED property
773 (should (alist-get 'CREATED properties))))
774 (pi-org-todos-test-teardown)))
775
776;;; Edge Case Tests
777
778(ert-deftest pi-org-todos-test-empty-file ()
779 "Test operations on an empty org file."
780 (let ((test-file (make-temp-file "empty-test-" nil ".org")))
781 (unwind-protect
782 (progn
783 (with-temp-file test-file
784 (insert "#+title: Empty\n"))
785 (let ((pi/org-todo-default-file test-file))
786 (let* ((result (pi/org-todo-list))
787 (parsed (pi-org-todos-test-parse-result result)))
788 (should (eq t (alist-get 'success parsed)))
789 (should (= 0 (length (alist-get 'data parsed)))))))
790 (delete-file test-file))))
791
792(ert-deftest pi-org-todos-test-statistics-empty-file ()
793 "Test statistics on empty file with no TODOs."
794 (let ((test-file (make-temp-file "empty-stats-" nil ".org")))
795 (unwind-protect
796 (progn
797 (with-temp-file test-file
798 (insert "#+title: Empty\n* Work\n"))
799 ;; Use explicit file arg to avoid stale default
800 (let* ((result (pi/org-todo-statistics test-file))
801 (parsed (pi-org-todos-test-parse-result result)))
802 (should (eq t (alist-get 'success parsed)))
803 (should (= 0 (alist-get 'total (alist-get 'data parsed))))))
804 (delete-file test-file))))
805
806(ert-deftest pi-org-todos-test-schedule-not-found ()
807 "Test scheduling a non-existent heading."
808 (unwind-protect
809 (progn
810 (pi-org-todos-test-setup)
811 (let* ((result (pi/org-todo-schedule "Nonexistent" "2026-03-01"))
812 (parsed (pi-org-todos-test-parse-result result)))
813 (should (eq :json-false (alist-get 'success parsed)))))
814 (pi-org-todos-test-teardown)))
815
816(ert-deftest pi-org-todos-test-priority-not-found ()
817 "Test setting priority on non-existent heading."
818 (unwind-protect
819 (progn
820 (pi-org-todos-test-setup)
821 (let* ((result (pi/org-todo-priority "Nonexistent" 1))
822 (parsed (pi-org-todos-test-parse-result result)))
823 (should (eq :json-false (alist-get 'success parsed)))))
824 (pi-org-todos-test-teardown)))
825
826(ert-deftest pi-org-todos-test-state-not-found ()
827 "Test changing state on non-existent heading."
828 (unwind-protect
829 (progn
830 (pi-org-todos-test-setup)
831 (let* ((result (pi/org-todo-state "Nonexistent" "NEXT"))
832 (parsed (pi-org-todos-test-parse-result result)))
833 (should (eq :json-false (alist-get 'success parsed)))))
834 (pi-org-todos-test-teardown)))
835
836;;; JSON Output Robustness Tests
837
838(ert-deftest pi-org-todos-test-json-response-nil-data ()
839 "Test JSON response with nil data."
840 (let* ((result (pi/org-todo--json-response t nil))
841 (parsed (json-read-from-string result)))
842 (should (eq t (alist-get 'success parsed)))))
843
844(ert-deftest pi-org-todos-test-json-response-list-data ()
845 "Test JSON response with list data."
846 (let* ((result (pi/org-todo--json-response t '(("a" . 1) ("b" . 2))))
847 (parsed (json-read-from-string result)))
848 (should (eq t (alist-get 'success parsed)))))
849
850(ert-deftest pi-org-todos-test-json-response-empty-error ()
851 "Test JSON response with nil error defaults to unknown."
852 (let* ((result (pi/org-todo--json-response nil nil nil))
853 (parsed (json-read-from-string result)))
854 (should (eq :json-false (alist-get 'success parsed)))
855 (should (equal "Unknown error" (alist-get 'error parsed)))))
856
857;;; File Path Resolution Tests
858
859(ert-deftest pi-org-todos-test-file-env-override ()
860 "Test that ORG_TODO_FILE env var overrides default."
861 (let ((test-file (make-temp-file "env-test-" nil ".org")))
862 (unwind-protect
863 (progn
864 (with-temp-file test-file
865 (insert "#+title: Env Test\n* Work\n** TODO Env task\n"))
866 (setenv "ORG_TODO_FILE" test-file)
867 (let ((pi/org-todo-default-file "/should/not/be/used"))
868 (let* ((result (pi/org-todo-list))
869 (parsed (pi-org-todos-test-parse-result result)))
870 (should (eq t (alist-get 'success parsed)))
871 (let ((data (append (alist-get 'data parsed) nil)))
872 (should (= 1 (length data)))
873 (should (equal "Env task" (alist-get 'heading (car data))))))))
874 (setenv "ORG_TODO_FILE" nil)
875 (delete-file test-file))))
876
877(ert-deftest pi-org-todos-test-file-explicit-overrides-env ()
878 "Test that explicit file arg overrides env var."
879 (let ((test-file (make-temp-file "explicit-test-" nil ".org")))
880 (unwind-protect
881 (progn
882 (with-temp-file test-file
883 (insert "#+title: Explicit\n* Work\n** TODO Explicit task\n"))
884 (setenv "ORG_TODO_FILE" "/should/not/be/used")
885 (let* ((result (pi/org-todo-list test-file))
886 (parsed (pi-org-todos-test-parse-result result)))
887 (should (eq t (alist-get 'success parsed)))
888 (let ((data (append (alist-get 'data parsed) nil)))
889 (should (= 1 (length data))))))
890 (setenv "ORG_TODO_FILE" nil)
891 (delete-file test-file))))
892
893;;; By-Section Edge Cases
894
895(ert-deftest pi-org-todos-test-by-section-empty ()
896 "Test getting TODOs from a section with no TODOs."
897 (let ((test-file (make-temp-file "section-empty-" nil ".org")))
898 (unwind-protect
899 (progn
900 (with-temp-file test-file
901 (insert "#+title: Test\n* Empty Section\nNo todos here.\n"))
902 (let* ((result (pi/org-todo-by-section "Empty Section" test-file))
903 (parsed (pi-org-todos-test-parse-result result)))
904 (should (eq t (alist-get 'success parsed)))
905 (should (= 0 (length (alist-get 'data parsed))))))
906 (delete-file test-file))))
907
908;;; Multiple Operations in Sequence
909
910(ert-deftest pi-org-todos-test-add-then-done ()
911 "Test adding a TODO then marking it done."
912 (unwind-protect
913 (progn
914 (pi-org-todos-test-setup)
915 ;; Add
916 (let* ((add-result (pi/org-todo-add "Sequential task" "Work"))
917 (add-parsed (pi-org-todos-test-parse-result add-result)))
918 (should (eq t (alist-get 'success add-parsed)))
919 ;; Verify it's TODO
920 (let* ((get-result (pi/org-todo-get "Sequential task"))
921 (get-parsed (pi-org-todos-test-parse-result get-result)))
922 (should (equal "TODO" (alist-get 'todo (alist-get 'data get-parsed)))))
923 ;; Mark done
924 (let* ((done-result (pi/org-todo-done "Sequential task"))
925 (done-parsed (pi-org-todos-test-parse-result done-result)))
926 (should (eq t (alist-get 'success done-parsed))))
927 ;; Verify it's DONE
928 (let* ((get-result (pi/org-todo-get "Sequential task"))
929 (get-parsed (pi-org-todos-test-parse-result get-result)))
930 (should (equal "DONE" (alist-get 'todo (alist-get 'data get-parsed)))))))
931 (pi-org-todos-test-teardown)))
932
933(ert-deftest pi-org-todos-test-add-schedule-priority ()
934 "Test adding a TODO then setting schedule and priority."
935 (unwind-protect
936 (progn
937 (pi-org-todos-test-setup)
938 ;; Add minimal
939 (pi/org-todo-add "Multi-update task" "Work")
940 ;; Schedule
941 (let* ((result (pi/org-todo-schedule "Multi-update task" "2026-06-15"))
942 (parsed (pi-org-todos-test-parse-result result)))
943 (should (eq t (alist-get 'success parsed))))
944 ;; Priority
945 (let* ((result (pi/org-todo-priority "Multi-update task" 1))
946 (parsed (pi-org-todos-test-parse-result result)))
947 (should (eq t (alist-get 'success parsed))))
948 ;; Verify all set
949 (let* ((get-result (pi/org-todo-get "Multi-update task"))
950 (get-parsed (pi-org-todos-test-parse-result get-result))
951 (data (alist-get 'data get-parsed)))
952 (should (equal 1 (alist-get 'priority data)))
953 (should (string-match-p "2026-06-15" (alist-get 'scheduled data)))))
954 (pi-org-todos-test-teardown)))
955
956;;; Safe Call Wrapper Tests
957
958(ert-deftest pi-org-todos-test-safe-call-success ()
959 "Test safe call wrapper with successful function."
960 (let* ((result (pi/org-todo--safe-call (lambda () '((test . "ok")))))
961 (parsed (json-read-from-string result)))
962 (should (eq t (alist-get 'success parsed)))))
963
964(ert-deftest pi-org-todos-test-safe-call-error ()
965 "Test safe call wrapper catches errors."
966 (let* ((result (pi/org-todo--safe-call (lambda () (error "test error"))))
967 (parsed (json-read-from-string result)))
968 (should (eq :json-false (alist-get 'success parsed)))
969 (should (string-match-p "test error" (alist-get 'error parsed)))))
970
971;;; Numeric Key Fix Tests
972
973(ert-deftest pi-org-todos-test-fix-numeric-keys ()
974 "Test numeric key conversion for JSON compatibility."
975 (let ((alist '((1 . "one") (2 . "two") ("three" . 3))))
976 (let ((fixed (pi/org-todo--fix-numeric-keys alist)))
977 (should (assoc "1" fixed))
978 (should (assoc "2" fixed))
979 (should (assoc "three" fixed)))))
980
981(provide 'pi-org-todos-test)
982;;; pi-org-todos-test.el ends here