aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-22 15:01:14 -0500
committerCraig Jennings <c@cjennings.net>2026-05-22 15:01:14 -0500
commit082037ad75e21405655adb492d0c85e342f4f771 (patch)
tree67b239762729034b12304c71d945206a587c65e0 /tests
parent030bb13ba5742f331dd30a55bbce5a13917d4d63 (diff)
downloaddotemacs-082037ad75e21405655adb492d0c85e342f4f771.tar.gz
dotemacs-082037ad75e21405655adb492d0c85e342f4f771.zip
feat(org-config): add cj/org-finalize-task with tests
I added a command on C-; O d that finalizes the task at point. It prompts for a finalized keyword from org-done-keywords, so the picker tracks org-todo-keywords automatically. Marking the task done fires the org-roam journal-copy hook, so the completed task lands in today's daily. Then the heading is reshaped by depth. A sub-task (level 3 or deeper, or a VERIFY at any depth) becomes a dated log entry: the keyword and priority cookie are stripped, a sortable timestamp is prepended, and the tags are kept. A top-level task keeps its keyword and gains a date-only CLOSED line. The command binds org-inhibit-logging around the org-todo call so it owns the CLOSED line rather than depending on org-log-done, which is set inconsistently across two modules. The journal hook keys off org-state, not org-log-done, so the copy still fires. Tests run in org temp-buffers with the journal hook bound to nil, exercise the real org primitives, and inject a fixed time so the stamp shape is deterministic.
Diffstat (limited to 'tests')
-rw-r--r--tests/test-org-config-finalize-task.el140
1 files changed, 140 insertions, 0 deletions
diff --git a/tests/test-org-config-finalize-task.el b/tests/test-org-config-finalize-task.el
new file mode 100644
index 00000000..a0932919
--- /dev/null
+++ b/tests/test-org-config-finalize-task.el
@@ -0,0 +1,140 @@
+;;; test-org-config-finalize-task.el --- Tests for cj/org-finalize-task -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Covers `cj/org-finalize-task' and its helpers: the dated-rewrite vs
+;; close-in-place dispatch, the two heading transforms, and the guard.
+;;
+;; The journal-copy hook (`org-after-todo-state-change-hook') is bound to
+;; nil in every buffer test so the org-roam daily side effect never fires --
+;; that hook is the external boundary, mocked out here. `org-todo-keywords'
+;; is set to the project's full sequence so DOING / VERIFY / CANCELLED resolve
+;; as keywords inside the temp buffers regardless of load state.
+
+;;; Code:
+
+(require 'ert)
+(require 'org)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'org-config)
+
+;; Fixed instant so the stamp is deterministic in shape. The exact value and
+;; tz offset are still system-dependent, so assertions match the FORMAT, not a
+;; literal string (per testing.md: assert behavior, not exact text).
+(defconst test-finalize--time (encode-time 24 18 14 22 5 2026)
+ "2026-05-22 14:18:24, local time, for deterministic stamp shape.")
+
+(defmacro test-finalize--with-heading (text &rest body)
+ "Insert TEXT in a temp Org buffer, point on the first heading, run BODY.
+Returns the resulting buffer string. Binds the project keyword set, nils
+the todo-state-change hook (no journal side effect), and inhibits logging."
+ (declare (indent 1))
+ `(with-temp-buffer
+ (let ((org-todo-keywords
+ '((sequence "TODO" "PROJECT" "DOING" "WAITING" "VERIFY"
+ "STALLED" "DELEGATED" "|" "FAILED" "DONE" "CANCELLED")))
+ (org-mode-hook nil) ; isolate from unrelated hooks (org-tidy etc.)
+ (org-after-todo-state-change-hook nil)
+ (org-inhibit-logging t))
+ (insert ,text)
+ (org-mode)
+ (goto-char (point-min))
+ ,@body
+ (buffer-string))))
+
+;; ---------------------------- predicate --------------------------------------
+
+(ert-deftest test-org-finalize-dated-p-level3-true ()
+ "Normal: a level-3 sub-task takes the dated rewrite."
+ (should (cj/--org-finalize-dated-p 3 "TODO")))
+
+(ert-deftest test-org-finalize-dated-p-level2-false ()
+ "Normal: a level-2 task stays task-shaped (close in place)."
+ (should-not (cj/--org-finalize-dated-p 2 "TODO")))
+
+(ert-deftest test-org-finalize-dated-p-verify-true-at-level2 ()
+ "Boundary: VERIFY flips to dated at all depths (todo-format exception)."
+ (should (cj/--org-finalize-dated-p 2 "VERIFY")))
+
+(ert-deftest test-org-finalize-dated-p-level1-false ()
+ "Boundary: a level-1 heading is not dated."
+ (should-not (cj/--org-finalize-dated-p 1 "TODO")))
+
+;; ------------------------- dated rewrite -------------------------------------
+
+(ert-deftest test-org-finalize-rewrite-dated-strips-and-prepends ()
+ "Normal: keyword and cookie gone, tag kept, stamp prepended in exact format."
+ (let ((out (test-finalize--with-heading "*** TODO [#A] Buy milk :shop:\nbody\n"
+ (cj/--org-finalize-rewrite-dated test-finalize--time))))
+ (should (string-match-p
+ "^\\*\\*\\* [0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\} [A-Z][a-z]\\{2\\} @ [0-9]\\{2\\}:[0-9]\\{2\\}:[0-9]\\{2\\} [-+][0-9]\\{4\\} Buy milk[ \t]+:shop:"
+ out))
+ (should-not (string-match-p "TODO" out))
+ (should-not (string-match-p "\\[#A\\]" out))))
+
+(ert-deftest test-org-finalize-rewrite-dated-no-priority ()
+ "Boundary: a heading without a priority cookie still rewrites."
+ (let ((out (test-finalize--with-heading "*** DOING Refactor thing\n"
+ (cj/--org-finalize-rewrite-dated test-finalize--time))))
+ (should (string-match-p "Refactor thing" out))
+ (should-not (string-match-p "DOING" out))))
+
+(ert-deftest test-org-finalize-rewrite-dated-no-tags ()
+ "Boundary: a heading without tags rewrites cleanly."
+ (let ((out (test-finalize--with-heading "*** TODO Plain task\n"
+ (cj/--org-finalize-rewrite-dated test-finalize--time))))
+ (should (string-match-p "Plain task" out))
+ (should-not (string-match-p "TODO" out))))
+
+;; ------------------------- close in place ------------------------------------
+
+(ert-deftest test-org-finalize-close-in-place-adds-date-only-closed ()
+ "Normal: a date-only CLOSED line is added; keyword retained, no time part."
+ (let ((out (test-finalize--with-heading "** DONE [#A] Ship it :rel:\n"
+ (cj/--org-finalize-close-in-place test-finalize--time))))
+ (should (string-match-p
+ "CLOSED: \\[[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\} [A-Z][a-z]\\{2\\}\\]" out))
+ (should (string-match-p "DONE" out))
+ (should-not (string-match-p "CLOSED: \\[[0-9-]+ [A-Z][a-z]+ [0-9]+:[0-9]+" out))))
+
+;; ------------------------- command / guard -----------------------------------
+
+(ert-deftest test-org-finalize-task-errors-outside-org ()
+ "Error: the command refuses to run outside Org."
+ (with-temp-buffer
+ (fundamental-mode)
+ (should-error (cj/org-finalize-task "DONE") :type 'user-error)))
+
+(ert-deftest test-org-finalize-task-errors-on-non-task-heading ()
+ "Error: a heading with no actionable keyword cannot be finalized."
+ (with-temp-buffer
+ (let ((org-mode-hook nil)
+ (org-after-todo-state-change-hook nil))
+ (insert "* Just a section\n")
+ (org-mode)
+ (goto-char (point-min))
+ (should-error (cj/org-finalize-task "DONE") :type 'user-error))))
+
+(ert-deftest test-org-finalize-task-level3-produces-dated-entry ()
+ "Integration: finalizing a level-3 task yields a dated entry, no CLOSED cruft."
+ (let ((out (test-finalize--with-heading "*** TODO [#B] Wire the thing :feat:\n"
+ (cj/org-finalize-task "DONE" test-finalize--time))))
+ (should (string-match-p "^\\*\\*\\* [0-9]\\{4\\}-.*Wire the thing" out))
+ (should-not (string-match-p "TODO\\|\\[#B\\]\\|CLOSED" out))))
+
+(ert-deftest test-org-finalize-task-level2-keeps-keyword-adds-closed ()
+ "Integration: finalizing a level-2 task keeps the keyword and adds CLOSED."
+ (let ((out (test-finalize--with-heading "** TODO [#A] Big task :proj:\n"
+ (cj/org-finalize-task "DONE" test-finalize--time))))
+ (should (string-match-p "DONE" out))
+ (should (string-match-p "CLOSED: \\[" out))
+ (should-not (string-match-p "TODO" out))))
+
+;; ------------------------- keybinding wiring ---------------------------------
+
+(ert-deftest test-org-finalize-task-bound-on-org-map ()
+ "Normal: the command is bound to `d' under the org prefix (C-; O d)."
+ (should (eq (keymap-lookup cj/org-map "d") #'cj/org-finalize-task)))
+
+(provide 'test-org-config-finalize-task)
+;;; test-org-config-finalize-task.el ends here