main
  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