Commit 0fd472fb0975
Changed files (7)
dots
config
emacs
site-lisp
pi
agent
extensions
org-todos
dots/config/emacs/site-lisp/org-batch-functions.el
@@ -138,8 +138,8 @@ Uses org-ql's `ancestors' predicate for clean hierarchical queries."
(defun org-batch-count-by-state (file)
"Count TODOs in FILE by state.
Returns alist with counts for each state."
- (let ((counts '((total . 0) (TODO . 0) (NEXT . 0) (STRT . 0)
- (WAIT . 0) (DONE . 0) (CANX . 0))))
+ (let ((counts (list (cons 'total 0) (cons 'TODO 0) (cons 'NEXT 0) (cons 'STRT 0)
+ (cons 'WAIT 0) (cons 'DONE 0) (cons 'CANX 0))))
(org-ql-select file '(todo)
:action (lambda ()
(let* ((state (org-get-todo-state))
@@ -374,6 +374,25 @@ NOTE: Works on current buffer state - save before calling if disk sync needed."
(is-link . ,(string-match-p "^\\[\\[http" heading))
(position . ,(point))))))))
+;;; Buffer Sync Utility
+
+(defun org-batch--sync-file-buffer (file)
+ "Revert any existing Emacs buffer visiting FILE from disk.
+Write operations use `with-temp-buffer' + `write-region' which bypass
+existing buffers. Subsequent reads via `org-ql-select' use
+`find-file-noselect' which returns the stale cached buffer.
+Call this after writing to FILE to keep buffers in sync."
+ (let ((buf (find-buffer-visiting file)))
+ (when buf
+ (with-current-buffer buf
+ (revert-buffer t t t)))))
+
+(defun org-batch--write-and-sync (file)
+ "Write current buffer to FILE and sync any visiting buffer.
+Combines `write-region' and `org-batch--sync-file-buffer'."
+ (write-region (point-min) (point-max) file)
+ (org-batch--sync-file-buffer file))
+
;;; Write Operations (unchanged - org-ql is read-only)
(defun org-batch--adjust-heading-levels (content parent-level)
@@ -450,7 +469,7 @@ Returns t on success, nil if heading not found."
(when (looking-at "^\\*")
(unless (looking-back "\n\n" nil)
(insert "\n")))
- (write-region (point-min) (point-max) file)
+ (org-batch--write-and-sync file)
(setq found t)))
found))))
@@ -468,7 +487,7 @@ Returns t on success, nil if heading not found."
(org-back-to-heading)
(let ((org-log-done (if (string= new-state "DONE") 'time nil)))
(org-todo new-state))
- (write-region (point-min) (point-max) file)
+ (org-batch--write-and-sync file)
(setq found t))
found)))
@@ -498,7 +517,7 @@ TAGS: List of tag strings"
(insert (format ":CREATED: [%s]\n"
(format-time-string "%Y-%m-%d %a %H:%M")))
(insert ":END:\n")
- (write-region (point-min) (point-max) file)
+ (org-batch--write-and-sync file)
t)
nil))))
@@ -515,7 +534,7 @@ DATE should be \"YYYY-MM-DD\" format."
(when (re-search-forward heading-regexp nil t)
(org-back-to-heading)
(org-schedule nil date)
- (write-region (point-min) (point-max) file)
+ (org-batch--write-and-sync file)
(setq found t))
found)))
@@ -532,7 +551,7 @@ DATE should be \"YYYY-MM-DD\" format."
(when (re-search-forward heading-regexp nil t)
(org-back-to-heading)
(org-deadline nil date)
- (write-region (point-min) (point-max) file)
+ (org-batch--write-and-sync file)
(setq found t))
found)))
@@ -551,7 +570,7 @@ DATE should be \"YYYY-MM-DD\" format."
(when (looking-at " \\[#[1-5]\\]")
(delete-region (point) (+ (point) 5)))
(insert priority-cookie)
- (write-region (point-min) (point-max) file)
+ (org-batch--write-and-sync file)
(setq found t))
found)))
@@ -570,7 +589,7 @@ NEW-TAGS is a list of tag strings to add."
(let* ((current-tags (org-get-tags))
(combined-tags (delete-dups (append current-tags new-tags))))
(org-set-tags combined-tags)
- (write-region (point-min) (point-max) file)
+ (org-batch--write-and-sync file)
(setq found t)))
found)))
@@ -588,7 +607,7 @@ NEW-TAGS is a list of tag strings to add."
(let* ((current-tags (org-get-tags))
(remaining-tags (seq-difference current-tags tags-to-remove)))
(org-set-tags remaining-tags)
- (write-region (point-min) (point-max) file)
+ (org-batch--write-and-sync file)
(setq found t)))
found)))
@@ -604,7 +623,7 @@ NEW-TAGS is a list of tag strings to add."
(when (re-search-forward heading-regexp nil t)
(org-back-to-heading)
(org-set-tags new-tags)
- (write-region (point-min) (point-max) file)
+ (org-batch--write-and-sync file)
(setq found t))
found)))
@@ -643,7 +662,7 @@ NEW-TAGS is a list of tag strings to add."
(when (re-search-forward heading-regexp nil t)
(org-back-to-heading)
(org-set-property property-name value)
- (write-region (point-min) (point-max) file)
+ (org-batch--write-and-sync file)
(setq found t))
found)))
@@ -689,7 +708,7 @@ Returns count of archived items."
(org-archive-subtree))
(setq count (1+ count)))
(error nil)))
- (write-region (point-min) (point-max) file))
+ (org-batch--write-and-sync file))
count))
;;; Bulk Operations
@@ -714,7 +733,7 @@ Returns count of updated tasks."
(org-todo new-state))
(setq count (1+ count))))
(forward-line 1))
- (write-region (point-min) (point-max) file))
+ (org-batch--write-and-sync file))
count))
(defun org-batch-bulk-add-tags (file filter-state new-tags)
@@ -734,7 +753,7 @@ Returns count of updated tasks."
(org-set-tags combined-tags))
(setq count (1+ count))))
(forward-line 1))
- (write-region (point-min) (point-max) file))
+ (org-batch--write-and-sync file))
count))
(defun org-batch-bulk-set-priority (file filter-state priority)
@@ -752,7 +771,7 @@ Returns count of updated tasks."
(delete-region (point) (+ (point) 5)))
(insert priority-cookie)
(setq count (1+ count)))
- (write-region (point-min) (point-max) file))
+ (org-batch--write-and-sync file))
count))
;;; Time Tracking
@@ -769,7 +788,7 @@ Returns count of updated tasks."
(when (re-search-forward heading-regexp nil t)
(org-back-to-heading)
(org-clock-in)
- (write-region (point-min) (point-max) file)
+ (org-batch--write-and-sync file)
(setq found t))
found)))
@@ -791,7 +810,7 @@ Returns count of updated tasks."
(hours (floor (/ duration-seconds 3600)))
(minutes (floor (/ (mod duration-seconds 3600) 60))))
(replace-match (format "%s%s--%s => %2d:%02d" indent start-time end-time hours minutes))
- (write-region (point-min) (point-max) file)
+ (org-batch--write-and-sync file)
(setq found t))))
found)))
@@ -862,7 +881,7 @@ Returns minutes as integer."
(defun org-batch-get-priority-distribution (file)
"Get distribution of tasks by priority in FILE."
- (let ((distribution '((1 . 0) (2 . 0) (3 . 0) (4 . 0) (5 . 0))))
+ (let ((distribution (list (cons 1 0) (cons 2 0) (cons 3 0) (cons 4 0) (cons 5 0))))
(dolist (p '(1 2 3 4 5))
(let ((regexp (format "\\[#%d\\]" p)))
(setcdr (assoc p distribution)
@@ -930,7 +949,7 @@ Returns minutes as integer."
(insert (format "SCHEDULED: <%s %s>\n"
(format-time-string "%Y-%m-%d %a")
repeater-spec)))
- (write-region (point-min) (point-max) file)
+ (org-batch--write-and-sync file)
(setq found t))
found)))
dots/config/emacs/site-lisp/pi-org-todos-test.el
@@ -19,6 +19,12 @@
(require 'org-batch-functions)
(require 'pi-org-todos)
+;; Ensure custom TODO keywords are available in batch mode.
+;; In interactive mode these come from init.el, but batch mode
+;; starts with only (sequence "TODO" "DONE").
+(setq org-todo-keywords
+ '((sequence "STRT(s)" "NEXT(n)" "TODO(t)" "WAIT(w)" "|" "DONE(d!)" "CANX(c@/!)")))
+
;;; Test Fixtures
(defvar pi-org-todos-test-file nil
@@ -347,5 +353,630 @@ SCHEDULED: <2026-02-06 Fri>
(parsed (pi-org-todos-test-parse-result result)))
(should (eq :json-false (alist-get 'success parsed))))))
+;;; Deadline Tests
+
+(ert-deftest pi-org-todos-test-deadline ()
+ "Test setting a deadline on a TODO."
+ (unwind-protect
+ (progn
+ (pi-org-todos-test-setup)
+ (let* ((result (pi/org-todo-deadline "NixOS refactoring" "2026-04-01"))
+ (parsed (pi-org-todos-test-parse-result result)))
+ (should (eq t (alist-get 'success parsed)))
+ ;; Verify the deadline was set
+ (let* ((get-result (pi/org-todo-get "NixOS refactoring"))
+ (get-parsed (pi-org-todos-test-parse-result get-result)))
+ (should (string-match-p "2026-04-01"
+ (alist-get 'deadline (alist-get 'data get-parsed)))))))
+ (pi-org-todos-test-teardown)))
+
+(ert-deftest pi-org-todos-test-deadline-not-found ()
+ "Test setting deadline on non-existent heading."
+ (unwind-protect
+ (progn
+ (pi-org-todos-test-setup)
+ (let* ((result (pi/org-todo-deadline "Nonexistent heading" "2026-04-01"))
+ (parsed (pi-org-todos-test-parse-result result)))
+ (should (eq :json-false (alist-get 'success parsed)))))
+ (pi-org-todos-test-teardown)))
+
+;;; Append Content Tests
+
+(ert-deftest pi-org-todos-test-append ()
+ "Test appending content to a TODO."
+ (unwind-protect
+ (progn
+ (pi-org-todos-test-setup)
+ (let* ((result (pi/org-todo-append "Buy groceries" "Remember to check prices"))
+ (parsed (pi-org-todos-test-parse-result result)))
+ (should (eq t (alist-get 'success parsed)))
+ ;; Verify content was appended
+ (let* ((get-result (pi/org-todo-get "Buy groceries"))
+ (get-parsed (pi-org-todos-test-parse-result get-result))
+ (data (alist-get 'data get-parsed)))
+ (should (string-match-p "check prices" (alist-get 'content data))))))
+ (pi-org-todos-test-teardown)))
+
+(ert-deftest pi-org-todos-test-append-not-found ()
+ "Test appending content to non-existent heading."
+ (unwind-protect
+ (progn
+ (pi-org-todos-test-setup)
+ (let* ((result (pi/org-todo-append "Nonexistent" "some content"))
+ (parsed (pi-org-todos-test-parse-result result)))
+ (should (eq :json-false (alist-get 'success parsed)))))
+ (pi-org-todos-test-teardown)))
+
+;;; Tag Operation Tests
+
+(ert-deftest pi-org-todos-test-add-tags ()
+ "Test adding tags to a TODO."
+ (unwind-protect
+ (progn
+ (pi-org-todos-test-setup)
+ (let* ((result (pi/org-todo-add-tags "Buy groceries" '("shopping" "personal")))
+ (parsed (pi-org-todos-test-parse-result result)))
+ (should (eq t (alist-get 'success parsed)))
+ ;; Verify tags were added
+ (let* ((get-result (pi/org-todo-get "Buy groceries"))
+ (get-parsed (pi-org-todos-test-parse-result get-result))
+ (tags (append (alist-get 'tags (alist-get 'data get-parsed)) nil)))
+ (should (member "shopping" tags))
+ (should (member "personal" tags)))))
+ (pi-org-todos-test-teardown)))
+
+(ert-deftest pi-org-todos-test-remove-tags ()
+ "Test removing tags from a TODO."
+ (unwind-protect
+ (progn
+ (pi-org-todos-test-setup)
+ ;; The "Fix CI/CD issue" has :urgent: tag
+ (let* ((result (pi/org-todo-remove-tags "Fix CI/CD issue" '("urgent")))
+ (parsed (pi-org-todos-test-parse-result result)))
+ (should (eq t (alist-get 'success parsed)))
+ ;; Verify tag was removed
+ (let* ((get-result (pi/org-todo-get "Fix CI/CD issue"))
+ (get-parsed (pi-org-todos-test-parse-result get-result))
+ (tags (append (alist-get 'tags (alist-get 'data get-parsed)) nil)))
+ (should-not (member "urgent" tags)))))
+ (pi-org-todos-test-teardown)))
+
+(ert-deftest pi-org-todos-test-all-tags ()
+ "Test listing all tags."
+ (unwind-protect
+ (progn
+ (pi-org-todos-test-setup)
+ (let* ((result (pi/org-todo-all-tags))
+ (parsed (pi-org-todos-test-parse-result result)))
+ (should (eq t (alist-get 'success parsed)))
+ (let ((tags (append (alist-get 'data parsed) nil)))
+ ;; Should find the tags from test data
+ (should (member "urgent" tags))
+ (should (member "nixos" tags))
+ (should (member "homelab" tags)))))
+ (pi-org-todos-test-teardown)))
+
+;;; Property Operation Tests
+
+(ert-deftest pi-org-todos-test-get-property ()
+ "Test getting a property value."
+ (unwind-protect
+ (progn
+ (pi-org-todos-test-setup)
+ (let* ((result (pi/org-todo-get-property "Review PR for pipeline" "CREATED"))
+ (parsed (pi-org-todos-test-parse-result result)))
+ (should (eq t (alist-get 'success parsed)))
+ (should (string-match-p "2026-02-01"
+ (alist-get 'value (alist-get 'data parsed))))))
+ (pi-org-todos-test-teardown)))
+
+(ert-deftest pi-org-todos-test-set-property ()
+ "Test setting a property value."
+ (unwind-protect
+ (progn
+ (pi-org-todos-test-setup)
+ (let* ((result (pi/org-todo-set-property "Buy groceries" "EFFORT" "30min"))
+ (parsed (pi-org-todos-test-parse-result result)))
+ (should (eq t (alist-get 'success parsed)))
+ ;; Verify property was set
+ (let* ((get-result (pi/org-todo-get-property "Buy groceries" "EFFORT"))
+ (get-parsed (pi-org-todos-test-parse-result get-result)))
+ (should (equal "30min" (alist-get 'value (alist-get 'data get-parsed)))))))
+ (pi-org-todos-test-teardown)))
+
+;;; Upcoming Tests
+
+(ert-deftest pi-org-todos-test-upcoming ()
+ "Test getting upcoming tasks."
+ (unwind-protect
+ (progn
+ (pi-org-todos-test-setup)
+ (let* ((result (pi/org-todo-upcoming nil 365)) ;; Wide range to capture test data
+ (parsed (pi-org-todos-test-parse-result result)))
+ (should (eq t (alist-get 'success parsed)))
+ ;; Data is a JSON array (vector), convert to list for checking
+ (let ((data (append (alist-get 'data parsed) nil)))
+ (should (listp data)))))
+ (pi-org-todos-test-teardown)))
+
+;;; State Transition Tests
+
+(ert-deftest pi-org-todos-test-state-to-strt ()
+ "Test changing TODO state to STRT."
+ (unwind-protect
+ (progn
+ (pi-org-todos-test-setup)
+ (let* ((result (pi/org-todo-state "Buy groceries" "STRT"))
+ (parsed (pi-org-todos-test-parse-result result)))
+ (should (eq t (alist-get 'success parsed)))
+ (let* ((get-result (pi/org-todo-get "Buy groceries"))
+ (get-parsed (pi-org-todos-test-parse-result get-result)))
+ (should (equal "STRT" (alist-get 'todo (alist-get 'data get-parsed)))))))
+ (pi-org-todos-test-teardown)))
+
+(ert-deftest pi-org-todos-test-state-to-wait ()
+ "Test changing TODO state to WAIT."
+ (unwind-protect
+ (progn
+ (pi-org-todos-test-setup)
+ (let* ((result (pi/org-todo-state "Buy groceries" "WAIT"))
+ (parsed (pi-org-todos-test-parse-result result)))
+ (should (eq t (alist-get 'success parsed)))
+ (let* ((get-result (pi/org-todo-get "Buy groceries"))
+ (get-parsed (pi-org-todos-test-parse-result get-result)))
+ (should (equal "WAIT" (alist-get 'todo (alist-get 'data get-parsed)))))))
+ (pi-org-todos-test-teardown)))
+
+(ert-deftest pi-org-todos-test-state-to-canx ()
+ "Test changing TODO state to CANX."
+ (unwind-protect
+ (progn
+ (pi-org-todos-test-setup)
+ (let* ((result (pi/org-todo-state "Buy groceries" "CANX"))
+ (parsed (pi-org-todos-test-parse-result result)))
+ (should (eq t (alist-get 'success parsed)))
+ (let* ((get-result (pi/org-todo-get "Buy groceries"))
+ (get-parsed (pi-org-todos-test-parse-result get-result)))
+ (should (equal "CANX" (alist-get 'todo (alist-get 'data get-parsed)))))))
+ (pi-org-todos-test-teardown)))
+
+;;; List Filter Tests
+
+(ert-deftest pi-org-todos-test-list-filter-next ()
+ "Test listing only NEXT items."
+ (unwind-protect
+ (progn
+ (pi-org-todos-test-setup)
+ (let* ((result (pi/org-todo-list nil "NEXT"))
+ (parsed (pi-org-todos-test-parse-result result)))
+ (should (eq t (alist-get 'success parsed)))
+ (let ((data (append (alist-get 'data parsed) nil)))
+ (should (> (length data) 0))
+ ;; All should be NEXT
+ (dolist (todo data)
+ (should (equal "NEXT" (alist-get 'todo todo)))))))
+ (pi-org-todos-test-teardown)))
+
+(ert-deftest pi-org-todos-test-list-filter-strt ()
+ "Test listing only STRT items."
+ (unwind-protect
+ (progn
+ (pi-org-todos-test-setup)
+ (let* ((result (pi/org-todo-list nil "STRT"))
+ (parsed (pi-org-todos-test-parse-result result)))
+ (should (eq t (alist-get 'success parsed)))
+ (let ((data (append (alist-get 'data parsed) nil)))
+ (should (= 1 (length data)))
+ (should (string-match-p "documentation" (alist-get 'heading (car data)))))))
+ (pi-org-todos-test-teardown)))
+
+(ert-deftest pi-org-todos-test-list-filter-comma-separated ()
+ "Test listing with comma-separated state filter."
+ (unwind-protect
+ (progn
+ (pi-org-todos-test-setup)
+ (let* ((result (pi/org-todo-list nil "NEXT,STRT"))
+ (parsed (pi-org-todos-test-parse-result result)))
+ (should (eq t (alist-get 'success parsed)))
+ (let ((data (append (alist-get 'data parsed) nil)))
+ (should (= 2 (length data)))
+ (dolist (todo data)
+ (should (member (alist-get 'todo todo) '("NEXT" "STRT")))))))
+ (pi-org-todos-test-teardown)))
+
+;;; Search with Content Tests
+
+(ert-deftest pi-org-todos-test-search-heading ()
+ "Test search matches in heading."
+ (unwind-protect
+ (progn
+ (pi-org-todos-test-setup)
+ (let* ((result (pi/org-todo-search "pipeline"))
+ (parsed (pi-org-todos-test-parse-result result)))
+ (should (eq t (alist-get 'success parsed)))
+ (let ((data (append (alist-get 'data parsed) nil)))
+ ;; "pipeline" appears in heading of one TODO and in BLOCKER property of another
+ (should (>= (length data) 1))
+ ;; At least one should match in heading (note: key is 'matched-in with hyphen)
+ (should (cl-some (lambda (todo)
+ (equal "heading" (alist-get 'matched-in todo)))
+ data)))))
+ (pi-org-todos-test-teardown)))
+
+(ert-deftest pi-org-todos-test-search-content ()
+ "Test search matches in content."
+ (unwind-protect
+ (progn
+ (pi-org-todos-test-setup)
+ (let* ((result (pi/org-todo-search "check prices" nil t))
+ (parsed (pi-org-todos-test-parse-result result)))
+ ;; Should return empty since content doesn't contain "check prices" yet
+ (should (eq t (alist-get 'success parsed)))
+ (should (= 0 (length (alist-get 'data parsed))))))
+ (pi-org-todos-test-teardown)))
+
+(ert-deftest pi-org-todos-test-search-no-results ()
+ "Test search with no results."
+ (unwind-protect
+ (progn
+ (pi-org-todos-test-setup)
+ (let* ((result (pi/org-todo-search "zzzznonexistent"))
+ (parsed (pi-org-todos-test-parse-result result)))
+ (should (eq t (alist-get 'success parsed)))
+ (should (= 0 (length (alist-get 'data parsed))))))
+ (pi-org-todos-test-teardown)))
+
+;;; Add with Various Options Tests
+
+(ert-deftest pi-org-todos-test-add-minimal ()
+ "Test adding TODO with minimal options (no schedule, priority, tags)."
+ (unwind-protect
+ (progn
+ (pi-org-todos-test-setup)
+ (let* ((result (pi/org-todo-add "Minimal task" "Personal"))
+ (parsed (pi-org-todos-test-parse-result result)))
+ (should (eq t (alist-get 'success parsed)))
+ (let* ((get-result (pi/org-todo-get "Minimal task"))
+ (get-parsed (pi-org-todos-test-parse-result get-result))
+ (data (alist-get 'data get-parsed)))
+ (should data)
+ (should (equal "TODO" (alist-get 'todo data))))))
+ (pi-org-todos-test-teardown)))
+
+(ert-deftest pi-org-todos-test-add-with-tags ()
+ "Test adding TODO with tags."
+ (unwind-protect
+ (progn
+ (pi-org-todos-test-setup)
+ (let* ((result (pi/org-todo-add "Tagged task" "Work" nil nil nil '("review" "code")))
+ (parsed (pi-org-todos-test-parse-result result)))
+ (should (eq t (alist-get 'success parsed)))
+ (let* ((get-result (pi/org-todo-get "Tagged task"))
+ (get-parsed (pi-org-todos-test-parse-result get-result))
+ (tags (append (alist-get 'tags (alist-get 'data get-parsed)) nil)))
+ (should (member "review" tags))
+ (should (member "code" tags)))))
+ (pi-org-todos-test-teardown)))
+
+(ert-deftest pi-org-todos-test-add-nonexistent-section ()
+ "Test adding TODO to nonexistent section."
+ (unwind-protect
+ (progn
+ (pi-org-todos-test-setup)
+ (let* ((result (pi/org-todo-add "Task" "Nonexistent"))
+ (parsed (pi-org-todos-test-parse-result result)))
+ (should (eq :json-false (alist-get 'success parsed)))))
+ (pi-org-todos-test-teardown)))
+
+;;; Inbox Operations Tests
+
+(ert-deftest pi-org-todos-test-inbox-all-empty ()
+ "Test getting all entries from an empty inbox."
+ (let ((test-inbox (make-temp-file "inbox-test-" nil ".org")))
+ (unwind-protect
+ (progn
+ (with-temp-file test-inbox
+ (insert "#+title: Test Inbox\n"))
+ (let* ((result (pi/org-todo-inbox-all test-inbox))
+ (parsed (pi-org-todos-test-parse-result result)))
+ (should (eq t (alist-get 'success parsed)))
+ (should (= 0 (length (alist-get 'data parsed))))))
+ (delete-file test-inbox))))
+
+(ert-deftest pi-org-todos-test-inbox-all-with-items ()
+ "Test getting all entries from inbox with items."
+ (let ((test-inbox (make-temp-file "inbox-test-" nil ".org")))
+ (unwind-protect
+ (progn
+ (with-temp-file test-inbox
+ (insert "#+title: Test Inbox\n* TODO First item\n* Second item\n* TODO Third item\n"))
+ (let* ((result (pi/org-todo-inbox-all test-inbox))
+ (parsed (pi-org-todos-test-parse-result result)))
+ (should (eq t (alist-get 'success parsed)))
+ (let ((data (append (alist-get 'data parsed) nil)))
+ ;; Should have 3 entries total
+ (should (= 3 (length data)))
+ ;; Two are TODOs, one is plain
+ (let ((todos (seq-filter (lambda (e) (alist-get 'todo e)) data)))
+ (should (= 2 (length todos)))))))
+ (delete-file test-inbox))))
+
+;;; Refile Targets Tests
+
+(ert-deftest pi-org-todos-test-refile-targets ()
+ "Test getting refile targets."
+ (unwind-protect
+ (progn
+ (pi-org-todos-test-setup)
+ (let* ((result (pi/org-todo-get-refile-targets))
+ (parsed (pi-org-todos-test-parse-result result)))
+ (should (eq t (alist-get 'success parsed)))
+ (let ((targets (append (alist-get 'data parsed) nil)))
+ (should (> (length targets) 0))
+ ;; Each target should have section, path, level, position
+ (dolist (target targets)
+ (should (alist-get 'section target))
+ (should (alist-get 'level target))
+ (should (alist-get 'position target))))))
+ (pi-org-todos-test-teardown)))
+
+;;; Refile Tests
+
+(ert-deftest pi-org-todos-test-refile ()
+ "Test refiling an entry from inbox to todos."
+ (let ((test-inbox (make-temp-file "inbox-test-" nil ".org")))
+ (unwind-protect
+ (progn
+ (pi-org-todos-test-setup)
+ (with-temp-file test-inbox
+ (insert "* TODO Refile me\n"))
+ (let* ((result (pi/org-todo-refile "Refile me" "Work" test-inbox))
+ (parsed (pi-org-todos-test-parse-result result)))
+ (should (eq t (alist-get 'success parsed)))
+ ;; Verify it's in Work section now
+ (let* ((get-result (pi/org-todo-get "Refile me"))
+ (get-parsed (pi-org-todos-test-parse-result get-result)))
+ (should (alist-get 'data get-parsed)))
+ ;; Verify it's gone from inbox
+ (with-temp-buffer
+ (insert-file-contents test-inbox)
+ (should-not (string-match-p "Refile me" (buffer-string))))))
+ (when (file-exists-p test-inbox)
+ (delete-file test-inbox))
+ (pi-org-todos-test-teardown))))
+
+;;; Get with Content Tests
+
+(ert-deftest pi-org-todos-test-get-returns-content ()
+ "Test getting a TODO returns its body content."
+ (unwind-protect
+ (progn
+ (pi-org-todos-test-setup)
+ (let* ((result (pi/org-todo-get "Review PR for pipeline"))
+ (parsed (pi-org-todos-test-parse-result result))
+ (data (alist-get 'data parsed)))
+ (should (alist-get 'content data))
+ (should (string-match-p "test TODO with some content" (alist-get 'content data)))))
+ (pi-org-todos-test-teardown)))
+
+(ert-deftest pi-org-todos-test-get-returns-properties ()
+ "Test getting a TODO returns its properties."
+ (unwind-protect
+ (progn
+ (pi-org-todos-test-setup)
+ (let* ((result (pi/org-todo-get "Review PR for pipeline"))
+ (parsed (pi-org-todos-test-parse-result result))
+ (data (alist-get 'data parsed))
+ (properties (alist-get 'properties data)))
+ (should properties)
+ ;; Should include CREATED property
+ (should (alist-get 'CREATED properties))))
+ (pi-org-todos-test-teardown)))
+
+;;; Edge Case Tests
+
+(ert-deftest pi-org-todos-test-empty-file ()
+ "Test operations on an empty org file."
+ (let ((test-file (make-temp-file "empty-test-" nil ".org")))
+ (unwind-protect
+ (progn
+ (with-temp-file test-file
+ (insert "#+title: Empty\n"))
+ (let ((pi/org-todo-default-file test-file))
+ (let* ((result (pi/org-todo-list))
+ (parsed (pi-org-todos-test-parse-result result)))
+ (should (eq t (alist-get 'success parsed)))
+ (should (= 0 (length (alist-get 'data parsed)))))))
+ (delete-file test-file))))
+
+(ert-deftest pi-org-todos-test-statistics-empty-file ()
+ "Test statistics on empty file with no TODOs."
+ (let ((test-file (make-temp-file "empty-stats-" nil ".org")))
+ (unwind-protect
+ (progn
+ (with-temp-file test-file
+ (insert "#+title: Empty\n* Work\n"))
+ ;; Use explicit file arg to avoid stale default
+ (let* ((result (pi/org-todo-statistics test-file))
+ (parsed (pi-org-todos-test-parse-result result)))
+ (should (eq t (alist-get 'success parsed)))
+ (should (= 0 (alist-get 'total (alist-get 'data parsed))))))
+ (delete-file test-file))))
+
+(ert-deftest pi-org-todos-test-schedule-not-found ()
+ "Test scheduling a non-existent heading."
+ (unwind-protect
+ (progn
+ (pi-org-todos-test-setup)
+ (let* ((result (pi/org-todo-schedule "Nonexistent" "2026-03-01"))
+ (parsed (pi-org-todos-test-parse-result result)))
+ (should (eq :json-false (alist-get 'success parsed)))))
+ (pi-org-todos-test-teardown)))
+
+(ert-deftest pi-org-todos-test-priority-not-found ()
+ "Test setting priority on non-existent heading."
+ (unwind-protect
+ (progn
+ (pi-org-todos-test-setup)
+ (let* ((result (pi/org-todo-priority "Nonexistent" 1))
+ (parsed (pi-org-todos-test-parse-result result)))
+ (should (eq :json-false (alist-get 'success parsed)))))
+ (pi-org-todos-test-teardown)))
+
+(ert-deftest pi-org-todos-test-state-not-found ()
+ "Test changing state on non-existent heading."
+ (unwind-protect
+ (progn
+ (pi-org-todos-test-setup)
+ (let* ((result (pi/org-todo-state "Nonexistent" "NEXT"))
+ (parsed (pi-org-todos-test-parse-result result)))
+ (should (eq :json-false (alist-get 'success parsed)))))
+ (pi-org-todos-test-teardown)))
+
+;;; JSON Output Robustness Tests
+
+(ert-deftest pi-org-todos-test-json-response-nil-data ()
+ "Test JSON response with nil data."
+ (let* ((result (pi/org-todo--json-response t nil))
+ (parsed (json-read-from-string result)))
+ (should (eq t (alist-get 'success parsed)))))
+
+(ert-deftest pi-org-todos-test-json-response-list-data ()
+ "Test JSON response with list data."
+ (let* ((result (pi/org-todo--json-response t '(("a" . 1) ("b" . 2))))
+ (parsed (json-read-from-string result)))
+ (should (eq t (alist-get 'success parsed)))))
+
+(ert-deftest pi-org-todos-test-json-response-empty-error ()
+ "Test JSON response with nil error defaults to unknown."
+ (let* ((result (pi/org-todo--json-response nil nil nil))
+ (parsed (json-read-from-string result)))
+ (should (eq :json-false (alist-get 'success parsed)))
+ (should (equal "Unknown error" (alist-get 'error parsed)))))
+
+;;; File Path Resolution Tests
+
+(ert-deftest pi-org-todos-test-file-env-override ()
+ "Test that ORG_TODO_FILE env var overrides default."
+ (let ((test-file (make-temp-file "env-test-" nil ".org")))
+ (unwind-protect
+ (progn
+ (with-temp-file test-file
+ (insert "#+title: Env Test\n* Work\n** TODO Env task\n"))
+ (setenv "ORG_TODO_FILE" test-file)
+ (let ((pi/org-todo-default-file "/should/not/be/used"))
+ (let* ((result (pi/org-todo-list))
+ (parsed (pi-org-todos-test-parse-result result)))
+ (should (eq t (alist-get 'success parsed)))
+ (let ((data (append (alist-get 'data parsed) nil)))
+ (should (= 1 (length data)))
+ (should (equal "Env task" (alist-get 'heading (car data))))))))
+ (setenv "ORG_TODO_FILE" nil)
+ (delete-file test-file))))
+
+(ert-deftest pi-org-todos-test-file-explicit-overrides-env ()
+ "Test that explicit file arg overrides env var."
+ (let ((test-file (make-temp-file "explicit-test-" nil ".org")))
+ (unwind-protect
+ (progn
+ (with-temp-file test-file
+ (insert "#+title: Explicit\n* Work\n** TODO Explicit task\n"))
+ (setenv "ORG_TODO_FILE" "/should/not/be/used")
+ (let* ((result (pi/org-todo-list test-file))
+ (parsed (pi-org-todos-test-parse-result result)))
+ (should (eq t (alist-get 'success parsed)))
+ (let ((data (append (alist-get 'data parsed) nil)))
+ (should (= 1 (length data))))))
+ (setenv "ORG_TODO_FILE" nil)
+ (delete-file test-file))))
+
+;;; By-Section Edge Cases
+
+(ert-deftest pi-org-todos-test-by-section-empty ()
+ "Test getting TODOs from a section with no TODOs."
+ (let ((test-file (make-temp-file "section-empty-" nil ".org")))
+ (unwind-protect
+ (progn
+ (with-temp-file test-file
+ (insert "#+title: Test\n* Empty Section\nNo todos here.\n"))
+ (let* ((result (pi/org-todo-by-section "Empty Section" test-file))
+ (parsed (pi-org-todos-test-parse-result result)))
+ (should (eq t (alist-get 'success parsed)))
+ (should (= 0 (length (alist-get 'data parsed))))))
+ (delete-file test-file))))
+
+;;; Multiple Operations in Sequence
+
+(ert-deftest pi-org-todos-test-add-then-done ()
+ "Test adding a TODO then marking it done."
+ (unwind-protect
+ (progn
+ (pi-org-todos-test-setup)
+ ;; Add
+ (let* ((add-result (pi/org-todo-add "Sequential task" "Work"))
+ (add-parsed (pi-org-todos-test-parse-result add-result)))
+ (should (eq t (alist-get 'success add-parsed)))
+ ;; Verify it's TODO
+ (let* ((get-result (pi/org-todo-get "Sequential task"))
+ (get-parsed (pi-org-todos-test-parse-result get-result)))
+ (should (equal "TODO" (alist-get 'todo (alist-get 'data get-parsed)))))
+ ;; Mark done
+ (let* ((done-result (pi/org-todo-done "Sequential task"))
+ (done-parsed (pi-org-todos-test-parse-result done-result)))
+ (should (eq t (alist-get 'success done-parsed))))
+ ;; Verify it's DONE
+ (let* ((get-result (pi/org-todo-get "Sequential task"))
+ (get-parsed (pi-org-todos-test-parse-result get-result)))
+ (should (equal "DONE" (alist-get 'todo (alist-get 'data get-parsed)))))))
+ (pi-org-todos-test-teardown)))
+
+(ert-deftest pi-org-todos-test-add-schedule-priority ()
+ "Test adding a TODO then setting schedule and priority."
+ (unwind-protect
+ (progn
+ (pi-org-todos-test-setup)
+ ;; Add minimal
+ (pi/org-todo-add "Multi-update task" "Work")
+ ;; Schedule
+ (let* ((result (pi/org-todo-schedule "Multi-update task" "2026-06-15"))
+ (parsed (pi-org-todos-test-parse-result result)))
+ (should (eq t (alist-get 'success parsed))))
+ ;; Priority
+ (let* ((result (pi/org-todo-priority "Multi-update task" 1))
+ (parsed (pi-org-todos-test-parse-result result)))
+ (should (eq t (alist-get 'success parsed))))
+ ;; Verify all set
+ (let* ((get-result (pi/org-todo-get "Multi-update task"))
+ (get-parsed (pi-org-todos-test-parse-result get-result))
+ (data (alist-get 'data get-parsed)))
+ (should (equal 1 (alist-get 'priority data)))
+ (should (string-match-p "2026-06-15" (alist-get 'scheduled data)))))
+ (pi-org-todos-test-teardown)))
+
+;;; Safe Call Wrapper Tests
+
+(ert-deftest pi-org-todos-test-safe-call-success ()
+ "Test safe call wrapper with successful function."
+ (let* ((result (pi/org-todo--safe-call (lambda () '((test . "ok")))))
+ (parsed (json-read-from-string result)))
+ (should (eq t (alist-get 'success parsed)))))
+
+(ert-deftest pi-org-todos-test-safe-call-error ()
+ "Test safe call wrapper catches errors."
+ (let* ((result (pi/org-todo--safe-call (lambda () (error "test error"))))
+ (parsed (json-read-from-string result)))
+ (should (eq :json-false (alist-get 'success parsed)))
+ (should (string-match-p "test error" (alist-get 'error parsed)))))
+
+;;; Numeric Key Fix Tests
+
+(ert-deftest pi-org-todos-test-fix-numeric-keys ()
+ "Test numeric key conversion for JSON compatibility."
+ (let ((alist '((1 . "one") (2 . "two") ("three" . 3))))
+ (let ((fixed (pi/org-todo--fix-numeric-keys alist)))
+ (should (assoc "1" fixed))
+ (should (assoc "2" fixed))
+ (should (assoc "three" fixed)))))
+
(provide 'pi-org-todos-test)
;;; pi-org-todos-test.el ends here
dots/pi/agent/extensions/org-todos/bun.lock
@@ -7,9 +7,18 @@
"dependencies": {
"chrono-node": "^2.7.0",
},
+ "devDependencies": {
+ "bun-types": "^1.0.0",
+ },
},
},
"packages": {
+ "@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="],
+
+ "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
+
"chrono-node": ["chrono-node@2.9.0", "", {}, "sha512-glI4YY2Jy6JII5l3d5FN6rcrIbKSQqKPhWsIRYPK2IK8Mm4Q1ZZFdYIaDqglUNf7gNwG+kWIzTn0omzzE0VkvQ=="],
+
+ "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
}
}
dots/pi/agent/extensions/org-todos/index.test.ts
@@ -3,15 +3,16 @@
*
* Run with: bun test dots/pi/agent/extensions/org-todos/index.test.ts
*
- * Note: These tests require Emacs daemon running with pi-org-todos.el loaded.
- * For unit tests that don't require Emacs, see the mock tests below.
+ * Note: Integration tests require Emacs daemon running with pi-org-todos.el loaded.
+ * Unit tests can run without Emacs.
*/
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { execSync } from "node:child_process";
-import { writeFileSync, unlinkSync, existsSync } from "node:fs";
+import { writeFileSync, unlinkSync, readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
+import * as chrono from "chrono-node";
// Test org file content
const TEST_ORG_CONTENT = `#+title: Test TODOs
@@ -24,9 +25,21 @@ SCHEDULED: <2026-02-06 Fri>
:CREATED: [2026-02-01 Mon]
:END:
+This is a test TODO with some content.
+
** NEXT [#1] Fix CI/CD issue :urgent:
DEADLINE: <2026-02-05 Thu>
+** STRT Write documentation
+:PROPERTIES:
+:CREATED: [2026-02-03 Wed]
+:END:
+
+** WAIT Waiting for approval :blocked:
+:PROPERTIES:
+:BLOCKER: Review PR for pipeline
+:END:
+
** DONE Completed task
CLOSED: [2026-02-04 Thu 10:00]
@@ -36,6 +49,11 @@ CLOSED: [2026-02-04 Thu 10:00]
SCHEDULED: <2026-02-10 Wed>
** TODO NixOS refactoring :nixos:homelab:
+
+* Personal
+
+** TODO Buy groceries
+SCHEDULED: <2026-02-06 Fri>
`;
// Test file path
@@ -71,82 +89,353 @@ function execEmacs(elisp: string): any {
// Check if we can run integration tests
const canRunIntegrationTests = isEmacsDaemonRunning();
+// ============================
+// UNIT TESTS (no Emacs needed)
+// ============================
+
describe("org-todos extension", () => {
- // Unit tests (no Emacs required)
describe("unit tests", () => {
- test("formatTodo formats basic TODO", () => {
- const todo = {
- todo: "TODO",
- heading: "Test task",
- priority: 2,
- tags: ["work", "urgent"],
- scheduled: "<2026-02-06 Fri>",
- };
-
- // Inline the format logic for testing
- const parts: string[] = [];
- parts.push(`[${todo.todo}]`);
- if (todo.priority) parts.push(`[#${todo.priority}]`);
- parts.push(todo.heading);
- if (todo.tags?.length) parts.push(`:${todo.tags.join(":")}:`);
- if (todo.scheduled) parts.push(`(SCHEDULED: ${todo.scheduled})`);
-
- const formatted = parts.join(" ");
-
- expect(formatted).toContain("[TODO]");
- expect(formatted).toContain("[#2]");
- expect(formatted).toContain("Test task");
- expect(formatted).toContain(":work:urgent:");
- expect(formatted).toContain("SCHEDULED:");
- });
-
- test("formatTodo handles minimal TODO", () => {
- const todo = {
- todo: "NEXT",
- heading: "Simple task",
- };
-
- const parts: string[] = [];
- parts.push(`[${todo.todo}]`);
- parts.push(todo.heading);
- const formatted = parts.join(" ");
-
- expect(formatted).toBe("[NEXT] Simple task");
- });
-
- test("action validation", () => {
- const validActions = [
- "list",
- "scheduled",
- "upcoming",
- "overdue",
- "search",
- "get",
- "done",
- "state",
- "schedule",
- "deadline",
- "priority",
- "add",
- "append",
- "sections",
- "statistics",
- "archive",
- ];
-
- for (const action of validActions) {
- expect(validActions).toContain(action);
+ // --- stripOrgLinks ---
+ describe("stripOrgLinks", () => {
+ // Import logic inline since the function is not exported
+ function stripOrgLinks(text: string): string {
+ text = text.replace(/\[\[([^\]]*)\]\[([^\]]*)\]\]/g, "$2");
+ text = text.replace(/\[\[([^\]]*)\]\]/g, "$1");
+ return text;
}
+
+ test("strips [[url][title]] to title", () => {
+ expect(stripOrgLinks("See [[https://example.com][Example]]")).toBe("See Example");
+ });
+
+ test("strips [[url]] to url", () => {
+ expect(stripOrgLinks("See [[https://example.com]]")).toBe("See https://example.com");
+ });
+
+ test("handles multiple links", () => {
+ expect(stripOrgLinks("[[a][A]] and [[b][B]]")).toBe("A and B");
+ });
+
+ test("handles text with no links", () => {
+ expect(stripOrgLinks("No links here")).toBe("No links here");
+ });
+
+ test("handles empty string", () => {
+ expect(stripOrgLinks("")).toBe("");
+ });
+
+ test("handles nested brackets", () => {
+ expect(stripOrgLinks("[[file:todos.org::*Heading][Heading]]")).toBe("Heading");
+ });
});
- test("elisp escaping", () => {
- const heading = 'Task with "quotes" and \'apostrophes\'';
+ // --- formatTodo ---
+ describe("formatTodo", () => {
+ function formatTodo(todo: any): string {
+ const parts: string[] = [];
+ const state = todo.todo || "TODO";
+ parts.push(`[${state}]`);
+ if (todo.priority) parts.push(`[#${todo.priority}]`);
+ parts.push(todo.heading);
+ if (todo.tags && todo.tags.length > 0) parts.push(`:${todo.tags.join(":")}:`);
+ const dates: string[] = [];
+ if (todo.scheduled) dates.push(`SCHEDULED: ${todo.scheduled}`);
+ if (todo.deadline) dates.push(`DEADLINE: ${todo.deadline}`);
+ if (dates.length > 0) parts.push(`(${dates.join(", ")})`);
+ return parts.join(" ");
+ }
+
+ test("formats basic TODO", () => {
+ const result = formatTodo({ todo: "TODO", heading: "Test", priority: 2, tags: ["work"], scheduled: "<2026-02-06>" });
+ expect(result).toContain("[TODO]");
+ expect(result).toContain("[#2]");
+ expect(result).toContain("Test");
+ expect(result).toContain(":work:");
+ expect(result).toContain("SCHEDULED:");
+ });
+
+ test("formats minimal TODO", () => {
+ expect(formatTodo({ todo: "NEXT", heading: "Simple" })).toBe("[NEXT] Simple");
+ });
+
+ test("handles null state", () => {
+ expect(formatTodo({ heading: "No state" })).toBe("[TODO] No state");
+ });
+
+ test("formats with deadline only", () => {
+ const result = formatTodo({ todo: "TODO", heading: "Task", deadline: "<2026-03-01>" });
+ expect(result).toContain("DEADLINE:");
+ expect(result).not.toContain("SCHEDULED:");
+ });
+
+ test("formats with both scheduled and deadline", () => {
+ const result = formatTodo({ todo: "TODO", heading: "Task", scheduled: "<2026-02-06>", deadline: "<2026-03-01>" });
+ expect(result).toContain("SCHEDULED:");
+ expect(result).toContain("DEADLINE:");
+ });
+
+ test("formats with multiple tags", () => {
+ const result = formatTodo({ todo: "TODO", heading: "Task", tags: ["work", "urgent", "review"] });
+ expect(result).toContain(":work:urgent:review:");
+ });
+
+ test("formats with empty tags array", () => {
+ const result = formatTodo({ todo: "TODO", heading: "Task", tags: [] });
+ expect(result).not.toContain(":");
+ });
+ });
+
+ // --- formatTodoMarkdown ---
+ describe("formatTodoMarkdown", () => {
+ function stripOrgLinks(text: string): string {
+ text = text.replace(/\[\[([^\]]*)\]\[([^\]]*)\]\]/g, "$2");
+ text = text.replace(/\[\[([^\]]*)\]\]/g, "$1");
+ return text;
+ }
+
+ function formatTodoMarkdown(todo: any): string {
+ const parts: string[] = [];
+ const state = todo.todo || "TODO";
+ parts.push(`**[${state}]**`);
+ if (todo.priority) parts.push(`\`#${todo.priority}\``);
+ parts.push(stripOrgLinks(todo.heading));
+ if (todo.tags && todo.tags.length > 0) {
+ const tagStr = todo.tags.map((t: string) => `\`${t}\``).join(" ");
+ parts.push(tagStr);
+ }
+ const dates: string[] = [];
+ if (todo.scheduled) dates.push(`๐
${todo.scheduled}`);
+ if (todo.deadline) dates.push(`โฐ ${todo.deadline}`);
+ let result = parts.join(" ");
+ if (dates.length > 0) result += ` *(${dates.join(", ")})*`;
+ return result;
+ }
+
+ test("wraps state in bold", () => {
+ const result = formatTodoMarkdown({ todo: "NEXT", heading: "Task" });
+ expect(result).toContain("**[NEXT]**");
+ });
+
+ test("formats priority as inline code", () => {
+ const result = formatTodoMarkdown({ todo: "TODO", heading: "Task", priority: 1 });
+ expect(result).toContain("`#1`");
+ });
+
+ test("formats tags as inline code", () => {
+ const result = formatTodoMarkdown({ todo: "TODO", heading: "Task", tags: ["urgent"] });
+ expect(result).toContain("`urgent`");
+ });
+
+ test("strips org links in heading", () => {
+ const result = formatTodoMarkdown({ todo: "TODO", heading: "See [[https://example.com][docs]]" });
+ expect(result).toContain("See docs");
+ expect(result).not.toContain("[[");
+ });
+
+ test("formats dates with emoji", () => {
+ const result = formatTodoMarkdown({ todo: "TODO", heading: "Task", scheduled: "<2026-02-06>", deadline: "<2026-03-01>" });
+ expect(result).toContain("๐
");
+ expect(result).toContain("โฐ");
+ });
+ });
+
+ // --- parseNaturalDate ---
+ describe("parseNaturalDate", () => {
+ function parseNaturalDate(text: string): string | null {
+ const result = chrono.parseDate(text);
+ if (!result) return null;
+ const year = result.getFullYear();
+ const month = String(result.getMonth() + 1).padStart(2, "0");
+ const day = String(result.getDate()).padStart(2, "0");
+ return `${year}-${month}-${day}`;
+ }
+
+ test("parses 'tomorrow'", () => {
+ const result = parseNaturalDate("tomorrow");
+ expect(result).toBeDefined();
+ const tomorrow = new Date();
+ tomorrow.setDate(tomorrow.getDate() + 1);
+ const expected = `${tomorrow.getFullYear()}-${String(tomorrow.getMonth() + 1).padStart(2, "0")}-${String(tomorrow.getDate()).padStart(2, "0")}`;
+ expect(result).toBe(expected);
+ });
+
+ test("parses 'next friday'", () => {
+ const result = parseNaturalDate("next friday");
+ expect(result).toBeDefined();
+ expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/);
+ });
+
+ test("parses ISO date", () => {
+ const result = parseNaturalDate("2026-03-15");
+ expect(result).toBe("2026-03-15");
+ });
+
+ test("parses 'in 3 days'", () => {
+ const result = parseNaturalDate("in 3 days");
+ expect(result).toBeDefined();
+ });
+
+ test("returns null for invalid date", () => {
+ const result = parseNaturalDate("not a date zzzzz");
+ expect(result).toBeNull();
+ });
+ });
+
+ // --- parseCommandArgs ---
+ describe("parseCommandArgs", () => {
+ function parseNaturalDate(text: string): string | null {
+ const result = chrono.parseDate(text);
+ if (!result) return null;
+ const year = result.getFullYear();
+ const month = String(result.getMonth() + 1).padStart(2, "0");
+ const day = String(result.getDate()).padStart(2, "0");
+ return `${year}-${month}-${day}`;
+ }
+
+ function parseCommandArgs(args: string) {
+ let remaining = args;
+ let section: string | undefined;
+ let scheduled: string | undefined;
+ let deadline: string | undefined;
+ let priority: number | undefined;
+ let state: string | undefined;
+
+ const sectionMatch = remaining.match(/@(\w+)/);
+ if (sectionMatch) {
+ section = sectionMatch[1];
+ remaining = remaining.replace(/@\w+/, "").trim();
+ }
+
+ const scheduledMatch = remaining.match(/scheduled:([^\s]+(?:\s+[^\s@:]+)*?)(?=\s+(?:deadline:|priority:|state:|@|$)|$)/i);
+ if (scheduledMatch) {
+ const dateStr = scheduledMatch[1].trim();
+ scheduled = parseNaturalDate(dateStr) || dateStr;
+ remaining = remaining.replace(scheduledMatch[0], "").trim();
+ }
+
+ const deadlineMatch = remaining.match(/deadline:([^\s]+(?:\s+[^\s@:]+)*?)(?=\s+(?:scheduled:|priority:|state:|@|$)|$)/i);
+ if (deadlineMatch) {
+ const dateStr = deadlineMatch[1].trim();
+ deadline = parseNaturalDate(dateStr) || dateStr;
+ remaining = remaining.replace(deadlineMatch[0], "").trim();
+ }
+
+ const priorityMatch = remaining.match(/priority:(\d)/i);
+ if (priorityMatch) {
+ priority = parseInt(priorityMatch[1], 10);
+ remaining = remaining.replace(priorityMatch[0], "").trim();
+ }
+
+ const stateMatch = remaining.match(/state:(TODO|NEXT|STRT|WAIT|DONE|CANX)/i);
+ if (stateMatch) {
+ state = stateMatch[1].toUpperCase();
+ remaining = remaining.replace(stateMatch[0], "").trim();
+ }
+
+ return { title: remaining.trim(), section, scheduled, deadline, priority, state };
+ }
+
+ test("parses title only", () => {
+ const result = parseCommandArgs("Buy groceries");
+ expect(result.title).toBe("Buy groceries");
+ expect(result.section).toBeUndefined();
+ expect(result.scheduled).toBeUndefined();
+ });
+
+ test("parses @Section", () => {
+ const result = parseCommandArgs("Buy groceries @Personal");
+ expect(result.title).toBe("Buy groceries");
+ expect(result.section).toBe("Personal");
+ });
+
+ test("parses scheduled: with ISO date", () => {
+ const result = parseCommandArgs("Task scheduled:2026-03-15");
+ expect(result.title).toBe("Task");
+ expect(result.scheduled).toBe("2026-03-15");
+ });
+
+ test("parses deadline: with ISO date", () => {
+ const result = parseCommandArgs("Task deadline:2026-04-01");
+ expect(result.title).toBe("Task");
+ expect(result.deadline).toBe("2026-04-01");
+ });
+
+ test("parses priority:", () => {
+ const result = parseCommandArgs("Task priority:2");
+ expect(result.title).toBe("Task");
+ expect(result.priority).toBe(2);
+ });
+
+ test("parses state:", () => {
+ const result = parseCommandArgs("Task state:NEXT");
+ expect(result.title).toBe("Task");
+ expect(result.state).toBe("NEXT");
+ });
+
+ test("parses all options combined", () => {
+ const result = parseCommandArgs("Complex task @Work scheduled:2026-03-15 deadline:2026-04-01 priority:1 state:NEXT");
+ expect(result.title).toBe("Complex task");
+ expect(result.section).toBe("Work");
+ expect(result.scheduled).toBe("2026-03-15");
+ expect(result.deadline).toBe("2026-04-01");
+ expect(result.priority).toBe(1);
+ expect(result.state).toBe("NEXT");
+ });
+
+ test("parses state case-insensitive", () => {
+ const result = parseCommandArgs("Task state:next");
+ expect(result.state).toBe("NEXT");
+ });
+
+ test("handles scheduled:tomorrow", () => {
+ const result = parseCommandArgs("Task scheduled:tomorrow");
+ expect(result.scheduled).toBeDefined();
+ expect(result.scheduled).toMatch(/^\d{4}-\d{2}-\d{2}$/);
+ });
+ });
+
+ // --- action validation ---
+ test("all tool actions are enumerated", () => {
+ const validActions = [
+ "list", "scheduled", "upcoming", "overdue", "search", "get",
+ "done", "state", "schedule", "deadline", "priority", "add", "append",
+ "sections", "statistics", "archive",
+ "inbox-list", "inbox-count", "inbox-add",
+ "refile-targets", "refile"
+ ];
+ expect(validActions.length).toBe(21);
+ });
+
+ // --- elisp escaping ---
+ test("elisp escaping handles double quotes", () => {
+ const heading = 'Task with "quotes"';
const escaped = heading.replace(/"/g, '\\"');
- expect(escaped).toBe('Task with \\"quotes\\" and \'apostrophes\'');
+ expect(escaped).toBe('Task with \\"quotes\\"');
+ });
+
+ test("elisp escaping handles single quotes", () => {
+ const heading = "Task with 'apostrophes'";
+ // Single quotes don't need escaping in double-quoted elisp strings
+ expect(heading).toContain("'");
+ });
+
+ test("elisp escaping handles backslashes", () => {
+ const heading = "Path\\to\\file";
+ const escaped = heading.replace(/\\/g, '\\\\');
+ expect(escaped).toBe("Path\\\\to\\\\file");
+ });
+
+ test("elisp escaping handles newlines", () => {
+ const content = "Line 1\nLine 2";
+ const escaped = content.replace(/\n/g, '\\n');
+ expect(escaped).toBe("Line 1\\nLine 2");
});
});
- // Integration tests (require Emacs daemon)
+ // ==================================
+ // INTEGRATION TESTS (Emacs required)
+ // ==================================
+
describe("integration tests", () => {
beforeAll(() => {
if (!canRunIntegrationTests) {
@@ -174,227 +463,522 @@ describe("org-todos extension", () => {
}
});
+ // --- List Operations ---
test("list TODOs", () => {
if (!canRunIntegrationTests) return;
-
const result = execEmacs(`(pi/org-todo-list "${TEST_FILE}")`);
-
expect(result.success).toBe(true);
expect(Array.isArray(result.data)).toBe(true);
expect(result.data.length).toBeGreaterThan(0);
-
- // Should include TODO and NEXT but not DONE
const states = result.data.map((t: any) => t.todo);
expect(states).toContain("TODO");
expect(states).toContain("NEXT");
});
- test("search TODOs", () => {
+ test("list TODOs with state filter", () => {
if (!canRunIntegrationTests) return;
+ const result = execEmacs(`(pi/org-todo-list "${TEST_FILE}" "NEXT")`);
+ expect(result.success).toBe(true);
+ expect(result.data.length).toBeGreaterThan(0);
+ for (const todo of result.data) {
+ expect(todo.todo).toBe("NEXT");
+ }
+ });
+ test("list TODOs with comma-separated states", () => {
+ if (!canRunIntegrationTests) return;
+ const result = execEmacs(`(pi/org-todo-list "${TEST_FILE}" "NEXT,STRT")`);
+ expect(result.success).toBe(true);
+ for (const todo of result.data) {
+ expect(["NEXT", "STRT"]).toContain(todo.todo);
+ }
+ });
+
+ test("list all TODOs including DONE", () => {
+ if (!canRunIntegrationTests) return;
+ const result = execEmacs(`(pi/org-todo-list-all "${TEST_FILE}")`);
+ expect(result.success).toBe(true);
+ const states = result.data.map((t: any) => t.todo);
+ expect(states).toContain("DONE");
+ });
+
+ // --- Search ---
+ test("search TODOs by heading", () => {
+ if (!canRunIntegrationTests) return;
const result = execEmacs(`(pi/org-todo-search "pipeline" "${TEST_FILE}")`);
-
expect(result.success).toBe(true);
expect(result.data.length).toBeGreaterThan(0);
expect(result.data[0].heading).toContain("pipeline");
});
+ test("search TODOs returns no results for nonexistent term", () => {
+ if (!canRunIntegrationTests) return;
+ const result = execEmacs(`(pi/org-todo-search "zzzznonexistent" "${TEST_FILE}")`);
+ expect(result.success).toBe(true);
+ // data may be null or empty array when no results
+ expect(result.data === null || result.data.length === 0).toBe(true);
+ });
+
+ // --- Get ---
test("get specific TODO", () => {
if (!canRunIntegrationTests) return;
-
- const result = execEmacs(
- `(pi/org-todo-get "Review PR for pipeline" "${TEST_FILE}")`
- );
-
+ const result = execEmacs(`(pi/org-todo-get "Review PR for pipeline" "${TEST_FILE}")`);
expect(result.success).toBe(true);
expect(result.data.heading).toBe("Review PR for pipeline");
expect(result.data.todo).toBe("TODO");
expect(result.data.priority).toBe(2);
});
- test("get sections", () => {
+ test("get TODO includes content", () => {
if (!canRunIntegrationTests) return;
-
- const result = execEmacs(`(pi/org-todo-sections "${TEST_FILE}")`);
-
+ const result = execEmacs(`(pi/org-todo-get "Review PR for pipeline" "${TEST_FILE}")`);
expect(result.success).toBe(true);
- const sections = Array.isArray(result.data)
- ? result.data
- : Object.values(result.data);
- expect(sections).toContain("Work");
- expect(sections).toContain("Projects");
+ expect(result.data.content).toContain("test TODO with some content");
});
+ test("get TODO includes properties", () => {
+ if (!canRunIntegrationTests) return;
+ const result = execEmacs(`(pi/org-todo-get "Review PR for pipeline" "${TEST_FILE}")`);
+ expect(result.success).toBe(true);
+ expect(result.data.properties).toBeDefined();
+ expect(result.data.properties.CREATED).toContain("2026-02-01");
+ });
+
+ // --- Sections ---
+ test("get sections", () => {
+ if (!canRunIntegrationTests) return;
+ const result = execEmacs(`(pi/org-todo-sections "${TEST_FILE}")`);
+ expect(result.success).toBe(true);
+ const sections = Array.isArray(result.data) ? result.data : Object.values(result.data);
+ expect(sections).toContain("Work");
+ expect(sections).toContain("Projects");
+ expect(sections).toContain("Personal");
+ });
+
+ test("get TODOs by section", () => {
+ if (!canRunIntegrationTests) return;
+ const result = execEmacs(`(pi/org-todo-by-section "Projects" "${TEST_FILE}")`);
+ expect(result.success).toBe(true);
+ expect(result.data.length).toBe(2);
+ });
+
+ // --- Statistics ---
test("get statistics", () => {
if (!canRunIntegrationTests) return;
-
const result = execEmacs(`(pi/org-todo-statistics "${TEST_FILE}")`);
-
expect(result.success).toBe(true);
expect(result.data.total).toBeGreaterThan(0);
expect(result.data.by_state).toBeDefined();
});
+ // --- Write Operations (use fresh temp files) ---
test("mark TODO as done", () => {
if (!canRunIntegrationTests) return;
-
- // Create a fresh test file with unique name
const doneTestFile = join(tmpdir(), `pi-org-done-test-${Date.now()}.org`);
- writeFileSync(
- doneTestFile,
- `* Work
-** TODO Task to complete
-`
- );
-
+ writeFileSync(doneTestFile, `* Work\n** TODO Task to complete\n`);
try {
- const result = execEmacs(
- `(pi/org-todo-done "Task to complete" "${doneTestFile}")`
- );
-
+ const result = execEmacs(`(pi/org-todo-done "Task to complete" "${doneTestFile}")`);
expect(result.success).toBe(true);
expect(result.data.state).toBe("DONE");
-
- // Verify file content directly (more reliable than re-reading via elisp)
- const { readFileSync } = require("fs");
const content = readFileSync(doneTestFile, "utf-8");
expect(content).toContain("DONE Task to complete");
} finally {
- if (existsSync(doneTestFile)) {
- unlinkSync(doneTestFile);
- }
+ if (existsSync(doneTestFile)) unlinkSync(doneTestFile);
}
});
- test("change TODO state", () => {
+ test("change TODO state to NEXT", () => {
if (!canRunIntegrationTests) return;
-
- // Create a fresh test file with unique name
const stateTestFile = join(tmpdir(), `pi-org-state-test-${Date.now()}.org`);
- writeFileSync(
- stateTestFile,
- `* Work
-** TODO Task to change
-`
- );
-
+ writeFileSync(stateTestFile, `* Work\n** TODO Task to change\n`);
try {
- const result = execEmacs(
- `(pi/org-todo-state "Task to change" "NEXT" "${stateTestFile}")`
- );
-
+ const result = execEmacs(`(pi/org-todo-state "Task to change" "NEXT" "${stateTestFile}")`);
expect(result.success).toBe(true);
expect(result.data.state).toBe("NEXT");
-
- // Verify file content directly
- const { readFileSync } = require("fs");
const content = readFileSync(stateTestFile, "utf-8");
expect(content).toContain("NEXT Task to change");
} finally {
- if (existsSync(stateTestFile)) {
- unlinkSync(stateTestFile);
- }
+ if (existsSync(stateTestFile)) unlinkSync(stateTestFile);
+ }
+ });
+
+ test("change TODO state to STRT", () => {
+ if (!canRunIntegrationTests) return;
+ const f = join(tmpdir(), `pi-org-strt-${Date.now()}.org`);
+ writeFileSync(f, `* Work\n** TODO Task\n`);
+ try {
+ const result = execEmacs(`(pi/org-todo-state "Task" "STRT" "${f}")`);
+ expect(result.success).toBe(true);
+ expect(result.data.state).toBe("STRT");
+ } finally {
+ if (existsSync(f)) unlinkSync(f);
+ }
+ });
+
+ test("change TODO state to WAIT", () => {
+ if (!canRunIntegrationTests) return;
+ const f = join(tmpdir(), `pi-org-wait-${Date.now()}.org`);
+ writeFileSync(f, `* Work\n** TODO Task\n`);
+ try {
+ const result = execEmacs(`(pi/org-todo-state "Task" "WAIT" "${f}")`);
+ expect(result.success).toBe(true);
+ expect(result.data.state).toBe("WAIT");
+ } finally {
+ if (existsSync(f)) unlinkSync(f);
+ }
+ });
+
+ test("change TODO state to CANX", () => {
+ if (!canRunIntegrationTests) return;
+ const f = join(tmpdir(), `pi-org-canx-${Date.now()}.org`);
+ writeFileSync(f, `* Work\n** TODO Task\n`);
+ try {
+ const result = execEmacs(`(pi/org-todo-state "Task" "CANX" "${f}")`);
+ expect(result.success).toBe(true);
+ expect(result.data.state).toBe("CANX");
+ } finally {
+ if (existsSync(f)) unlinkSync(f);
}
});
test("add new TODO", () => {
if (!canRunIntegrationTests) return;
-
- // Create a fresh test file with unique name
const addTestFile = join(tmpdir(), `pi-org-add-test-${Date.now()}.org`);
- writeFileSync(
- addTestFile,
- `* Work
-** TODO Existing task
-`
- );
-
+ writeFileSync(addTestFile, `* Work\n** TODO Existing task\n`);
try {
const result = execEmacs(
`(pi/org-todo-add "New task from test" "Work" "${addTestFile}" "2026-03-01" 2 '("test"))`
);
-
expect(result.success).toBe(true);
expect(result.data.heading).toBe("New task from test");
-
- // Verify file content directly
- const { readFileSync } = require("fs");
const content = readFileSync(addTestFile, "utf-8");
- expect(content).toContain("TODO");
expect(content).toContain("New task from test");
} finally {
- if (existsSync(addTestFile)) {
- unlinkSync(addTestFile);
- }
+ if (existsSync(addTestFile)) unlinkSync(addTestFile);
}
});
+ test("add TODO with tags", () => {
+ if (!canRunIntegrationTests) return;
+ const f = join(tmpdir(), `pi-org-add-tags-${Date.now()}.org`);
+ writeFileSync(f, `* Work\n`);
+ try {
+ const result = execEmacs(`(pi/org-todo-add "Tagged task" "Work" "${f}" nil nil '("review" "code"))`);
+ expect(result.success).toBe(true);
+ const content = readFileSync(f, "utf-8");
+ expect(content).toContain(":review:code:");
+ } finally {
+ if (existsSync(f)) unlinkSync(f);
+ }
+ });
+
+ test("add TODO to nonexistent section fails", () => {
+ if (!canRunIntegrationTests) return;
+ const f = join(tmpdir(), `pi-org-add-fail-${Date.now()}.org`);
+ writeFileSync(f, `* Work\n`);
+ try {
+ const result = execEmacs(`(pi/org-todo-add "Task" "Nonexistent" "${f}")`);
+ expect(result.success).toBe(false);
+ } finally {
+ if (existsSync(f)) unlinkSync(f);
+ }
+ });
+
+ // --- Schedule & Deadline ---
+ test("schedule a TODO", () => {
+ if (!canRunIntegrationTests) return;
+ const f = join(tmpdir(), `pi-org-sched-${Date.now()}.org`);
+ writeFileSync(f, `* Work\n** TODO Schedule me\n`);
+ try {
+ const result = execEmacs(`(pi/org-todo-schedule "Schedule me" "2026-06-15" "${f}")`);
+ expect(result.success).toBe(true);
+ const content = readFileSync(f, "utf-8");
+ expect(content).toContain("SCHEDULED:");
+ expect(content).toContain("2026-06-15");
+ } finally {
+ if (existsSync(f)) unlinkSync(f);
+ }
+ });
+
+ test("set deadline on a TODO", () => {
+ if (!canRunIntegrationTests) return;
+ const f = join(tmpdir(), `pi-org-deadline-${Date.now()}.org`);
+ writeFileSync(f, `* Work\n** TODO Deadline me\n`);
+ try {
+ const result = execEmacs(`(pi/org-todo-deadline "Deadline me" "2026-07-01" "${f}")`);
+ expect(result.success).toBe(true);
+ const content = readFileSync(f, "utf-8");
+ expect(content).toContain("DEADLINE:");
+ expect(content).toContain("2026-07-01");
+ } finally {
+ if (existsSync(f)) unlinkSync(f);
+ }
+ });
+
+ // --- Priority ---
+ test("set priority on a TODO", () => {
+ if (!canRunIntegrationTests) return;
+ const f = join(tmpdir(), `pi-org-prio-${Date.now()}.org`);
+ writeFileSync(f, `* Work\n** TODO Priority me\n`);
+ try {
+ const result = execEmacs(`(pi/org-todo-priority "Priority me" 1 "${f}")`);
+ expect(result.success).toBe(true);
+ const content = readFileSync(f, "utf-8");
+ expect(content).toContain("[#1]");
+ } finally {
+ if (existsSync(f)) unlinkSync(f);
+ }
+ });
+
+ // --- Append ---
+ test("append content to a TODO", () => {
+ if (!canRunIntegrationTests) return;
+ const f = join(tmpdir(), `pi-org-append-${Date.now()}.org`);
+ writeFileSync(f, `* Work\n** TODO Append to me\n`);
+ try {
+ const result = execEmacs(`(pi/org-todo-append "Append to me" "Added content here" "${f}")`);
+ expect(result.success).toBe(true);
+ const content = readFileSync(f, "utf-8");
+ expect(content).toContain("Added content here");
+ } finally {
+ if (existsSync(f)) unlinkSync(f);
+ }
+ });
+
+ // --- Tags ---
+ test("add tags to a TODO", () => {
+ if (!canRunIntegrationTests) return;
+ const f = join(tmpdir(), `pi-org-tags-${Date.now()}.org`);
+ writeFileSync(f, `* Work\n** TODO Tag me\n`);
+ try {
+ const result = execEmacs(`(pi/org-todo-add-tags "Tag me" '("new" "tags") "${f}")`);
+ expect(result.success).toBe(true);
+ const content = readFileSync(f, "utf-8");
+ expect(content).toContain(":new:");
+ expect(content).toContain(":tags:");
+ } finally {
+ if (existsSync(f)) unlinkSync(f);
+ }
+ });
+
+ test("remove tags from a TODO", () => {
+ if (!canRunIntegrationTests) return;
+ const f = join(tmpdir(), `pi-org-rmtag-${Date.now()}.org`);
+ writeFileSync(f, `* Work\n** TODO Remove tag :urgent:review:\n`);
+ try {
+ const result = execEmacs(`(pi/org-todo-remove-tags "Remove tag" '("urgent") "${f}")`);
+ expect(result.success).toBe(true);
+ const content = readFileSync(f, "utf-8");
+ expect(content).not.toContain(":urgent:");
+ expect(content).toContain(":review:");
+ } finally {
+ if (existsSync(f)) unlinkSync(f);
+ }
+ });
+
+ // --- Properties ---
+ test("set and get property", () => {
+ if (!canRunIntegrationTests) return;
+ const f = join(tmpdir(), `pi-org-prop-${Date.now()}.org`);
+ writeFileSync(f, `* Work\n** TODO Property me\n`);
+ try {
+ const setResult = execEmacs(`(pi/org-todo-set-property "Property me" "EFFORT" "2h" "${f}")`);
+ expect(setResult.success).toBe(true);
+ const getResult = execEmacs(`(pi/org-todo-get-property "Property me" "EFFORT" "${f}")`);
+ expect(getResult.success).toBe(true);
+ expect(getResult.data.value).toBe("2h");
+ } finally {
+ if (existsSync(f)) unlinkSync(f);
+ }
+ });
+
+ // --- Inbox ---
+ test("inbox-all on empty inbox", () => {
+ if (!canRunIntegrationTests) return;
+ const f = join(tmpdir(), `pi-org-inbox-empty-${Date.now()}.org`);
+ writeFileSync(f, `#+title: Inbox\n`);
+ try {
+ const result = execEmacs(`(pi/org-todo-inbox-all "${f}")`);
+ expect(result.success).toBe(true);
+ // data may be null or empty array for empty inbox
+ expect(result.data === null || result.data.length === 0).toBe(true);
+ } finally {
+ if (existsSync(f)) unlinkSync(f);
+ }
+ });
+
+ test("inbox-all with mixed items", () => {
+ if (!canRunIntegrationTests) return;
+ const f = join(tmpdir(), `pi-org-inbox-${Date.now()}.org`);
+ writeFileSync(f, `#+title: Inbox\n* TODO Task one\n* Link item\n* TODO Task two\n`);
+ try {
+ const result = execEmacs(`(pi/org-todo-inbox-all "${f}")`);
+ expect(result.success).toBe(true);
+ expect(result.data.length).toBe(3);
+ const todos = result.data.filter((i: any) => i.todo);
+ expect(todos.length).toBe(2);
+ } finally {
+ if (existsSync(f)) unlinkSync(f);
+ }
+ });
+
+ // --- Refile ---
+ test("get refile targets", () => {
+ if (!canRunIntegrationTests) return;
+ const result = execEmacs(`(pi/org-todo-get-refile-targets "${TEST_FILE}")`);
+ expect(result.success).toBe(true);
+ expect(result.data.length).toBeGreaterThan(0);
+ for (const target of result.data) {
+ expect(target.section).toBeDefined();
+ expect(target.position).toBeDefined();
+ }
+ });
+
+ test("refile entry from inbox to target", () => {
+ if (!canRunIntegrationTests) return;
+ const inbox = join(tmpdir(), `pi-org-refile-inbox-${Date.now()}.org`);
+ const target = join(tmpdir(), `pi-org-refile-target-${Date.now()}.org`);
+ writeFileSync(inbox, `* TODO Refile me\n`);
+ writeFileSync(target, `* Work\n** TODO Existing\n* Projects\n`);
+ try {
+ const result = execEmacs(`(pi/org-todo-refile "Refile me" "Work" "${inbox}" "${target}")`);
+ expect(result.success).toBe(true);
+ // Verify it's gone from inbox
+ const inboxContent = readFileSync(inbox, "utf-8");
+ expect(inboxContent).not.toContain("Refile me");
+ // Verify it's in target
+ const targetContent = readFileSync(target, "utf-8");
+ expect(targetContent).toContain("Refile me");
+ } finally {
+ if (existsSync(inbox)) unlinkSync(inbox);
+ if (existsSync(target)) unlinkSync(target);
+ }
+ });
+
+ // --- Error Handling ---
test("error handling for non-existent heading", () => {
if (!canRunIntegrationTests) return;
-
- const result = execEmacs(
- `(pi/org-todo-done "This heading does not exist" "${TEST_FILE}")`
- );
-
+ const result = execEmacs(`(pi/org-todo-done "This heading does not exist" "${TEST_FILE}")`);
expect(result.success).toBe(false);
expect(result.error).toContain("not found");
});
test("error handling for non-existent file", () => {
if (!canRunIntegrationTests) return;
-
- const result = execEmacs(
- `(pi/org-todo-list "/nonexistent/file.org")`
- );
-
+ const result = execEmacs(`(pi/org-todo-list "/nonexistent/file.org")`);
expect(result.success).toBe(false);
});
+
+ test("error handling for schedule on non-existent heading", () => {
+ if (!canRunIntegrationTests) return;
+ const result = execEmacs(`(pi/org-todo-schedule "Nonexistent" "2026-03-01" "${TEST_FILE}")`);
+ expect(result.success).toBe(false);
+ });
+
+ test("error handling for deadline on non-existent heading", () => {
+ if (!canRunIntegrationTests) return;
+ const result = execEmacs(`(pi/org-todo-deadline "Nonexistent" "2026-03-01" "${TEST_FILE}")`);
+ expect(result.success).toBe(false);
+ });
+
+ test("error handling for priority on non-existent heading", () => {
+ if (!canRunIntegrationTests) return;
+ const result = execEmacs(`(pi/org-todo-priority "Nonexistent" 1 "${TEST_FILE}")`);
+ expect(result.success).toBe(false);
+ });
+
+ test("error handling for append on non-existent heading", () => {
+ if (!canRunIntegrationTests) return;
+ const result = execEmacs(`(pi/org-todo-append "Nonexistent" "content" "${TEST_FILE}")`);
+ expect(result.success).toBe(false);
+ });
+
+ // --- Sequential Operations ---
+ test("add then mark done (buffer sync)", () => {
+ if (!canRunIntegrationTests) return;
+ const f = join(tmpdir(), `pi-org-seq-${Date.now()}.org`);
+ writeFileSync(f, `* Work\n`);
+ try {
+ // Add
+ const addResult = execEmacs(`(pi/org-todo-add "Sequential task" "Work" "${f}")`);
+ expect(addResult.success).toBe(true);
+ // Done
+ const doneResult = execEmacs(`(pi/org-todo-done "Sequential task" "${f}")`);
+ expect(doneResult.success).toBe(true);
+ // Verify on disk
+ const content = readFileSync(f, "utf-8");
+ expect(content).toContain("DONE Sequential task");
+ } finally {
+ if (existsSync(f)) unlinkSync(f);
+ }
+ });
+
+ test("add, schedule, set priority, then verify", () => {
+ if (!canRunIntegrationTests) return;
+ const f = join(tmpdir(), `pi-org-multi-${Date.now()}.org`);
+ writeFileSync(f, `* Work\n`);
+ try {
+ execEmacs(`(pi/org-todo-add "Multi task" "Work" "${f}")`);
+ execEmacs(`(pi/org-todo-schedule "Multi task" "2026-08-15" "${f}")`);
+ execEmacs(`(pi/org-todo-priority "Multi task" 1 "${f}")`);
+ const getResult = execEmacs(`(pi/org-todo-get "Multi task" "${f}")`);
+ expect(getResult.success).toBe(true);
+ expect(getResult.data.priority).toBe(1);
+ expect(getResult.data.scheduled).toContain("2026-08-15");
+ } finally {
+ if (existsSync(f)) unlinkSync(f);
+ }
+ });
});
});
-// Tests for new command parsing
+// --- Command parsing regression tests ---
describe("command parsing", () => {
- // Import parseCommandArgs by extracting the logic
- // Since it's not exported, we test the patterns directly
-
- test("parseNaturalDate patterns", () => {
- const chrono = require("chrono-node");
-
- // Test chrono works
+ test("chrono-node parses tomorrow", () => {
const tomorrow = chrono.parseDate("tomorrow");
- expect(tomorrow).toBeDefined();
- expect(tomorrow.getDate()).toBe(new Date().getDate() + 1);
-
- const nextFriday = chrono.parseDate("next friday");
- expect(nextFriday).toBeDefined();
- expect(nextFriday.getDay()).toBe(5); // Friday
-
- const specific = chrono.parseDate("2026-03-15");
- expect(specific).toBeDefined();
- expect(specific.getMonth()).toBe(2); // March (0-indexed)
+ expect(tomorrow).not.toBeNull();
+ expect(tomorrow!.getDate()).toBe(new Date().getDate() + 1);
});
- test("command arg patterns", () => {
- // Test regex patterns used in parseCommandArgs
-
- // @Section pattern
- const sectionMatch = "Buy milk @Personal".match(/@(\w+)/);
- expect(sectionMatch).toBeDefined();
- expect(sectionMatch![1]).toBe("Personal");
-
- // scheduled: pattern
- const schedMatch = "Task scheduled:tomorrow".match(/scheduled:([^\s]+)/i);
- expect(schedMatch).toBeDefined();
- expect(schedMatch![1]).toBe("tomorrow");
-
- // priority: pattern
- const prioMatch = "Task priority:2".match(/priority:(\d)/i);
- expect(prioMatch).toBeDefined();
- expect(prioMatch![1]).toBe("2");
-
- // state: pattern
- const stateMatch = "Task state:NEXT".match(/state:(TODO|NEXT|STRT|WAIT|DONE|CANX)/i);
- expect(stateMatch).toBeDefined();
- expect(stateMatch![1]).toBe("NEXT");
+ test("chrono-node parses next friday", () => {
+ const nextFriday = chrono.parseDate("next friday");
+ expect(nextFriday).not.toBeNull();
+ expect(nextFriday!.getDay()).toBe(5);
+ });
+
+ test("chrono-node parses ISO date", () => {
+ const specific = chrono.parseDate("2026-03-15");
+ expect(specific).not.toBeNull();
+ expect(specific!.getMonth()).toBe(2); // March (0-indexed)
+ });
+
+ test("@Section pattern", () => {
+ const match = "Buy milk @Personal".match(/@(\w+)/);
+ expect(match).toBeDefined();
+ expect(match![1]).toBe("Personal");
+ });
+
+ test("scheduled: pattern", () => {
+ const match = "Task scheduled:tomorrow".match(/scheduled:([^\s]+)/i);
+ expect(match).toBeDefined();
+ expect(match![1]).toBe("tomorrow");
+ });
+
+ test("priority: pattern", () => {
+ const match = "Task priority:2".match(/priority:(\d)/i);
+ expect(match).toBeDefined();
+ expect(match![1]).toBe("2");
+ });
+
+ test("state: pattern", () => {
+ const match = "Task state:NEXT".match(/state:(TODO|NEXT|STRT|WAIT|DONE|CANX)/i);
+ expect(match).toBeDefined();
+ expect(match![1]).toBe("NEXT");
+ });
+
+ test("state: pattern case insensitive", () => {
+ const match = "Task state:done".match(/state:(TODO|NEXT|STRT|WAIT|DONE|CANX)/i);
+ expect(match).toBeDefined();
+ expect(match![1]).toBe("done");
});
});
dots/pi/agent/extensions/org-todos/Makefile
@@ -9,7 +9,7 @@
# make check-daemon - Check if Emacs daemon is running
# make clean - Clean build artifacts
-SHELL := /bin/bash
+SHELL := bash
# Paths
ELISP_DIR := ../../../../config/emacs/site-lisp
dots/pi/agent/extensions/org-todos/package.json
@@ -1,7 +1,14 @@
{
"name": "org-todos",
"version": "1.0.0",
+ "type": "module",
+ "scripts": {
+ "test": "bun test index.test.ts"
+ },
"dependencies": {
"chrono-node": "^2.7.0"
+ },
+ "devDependencies": {
+ "bun-types": "^1.0.0"
}
}
dots/pi/agent/extensions/org-todos/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "compilerOptions": {
+ "types": ["bun-types"],
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "target": "esnext",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true
+ },
+ "include": ["*.ts"]
+}