diff options
| -rw-r--r-- | modules/org-config.el | 70 | ||||
| -rw-r--r-- | tests/test-org-config-finalize-task.el | 140 |
2 files changed, 210 insertions, 0 deletions
diff --git a/modules/org-config.el b/modules/org-config.el index c04683c0..31ed7f6c 100644 --- a/modules/org-config.el +++ b/modules/org-config.el @@ -348,6 +348,76 @@ status to preserve priority ordering within TODO groups." (user-error nil))) (message "Sorted entries by TODO status and priority")) +;; --------------------- Finalize Task (done + journal + log) ------------------ +;; `cj/org-finalize-task' (C-; O d) marks the task at point done with a chosen +;; finalized keyword -- which fires the org-roam journal-copy hook -- then +;; reshapes the heading per todo-format: a dated log entry for sub-tasks (and +;; VERIFY at any depth), or a kept keyword plus a date-only CLOSED line for +;; top-level tasks. + +(defun cj/--org-finalize-dated-p (level keyword) + "Return non-nil when a finalized heading should become a dated log entry. +Per todo-format: sub-tasks at LEVEL 3 or deeper flip to a dated entry, and a +VERIFY (by KEYWORD) flips at any depth. Top-level tasks stay task-shaped." + (or (>= level 3) + (equal keyword "VERIFY"))) + +(defun cj/--org-finalize-rewrite-dated (&optional time) + "Rewrite the heading at point as a dated log entry. +Strip the todo keyword and [#X] priority cookie, prepend a sortable timestamp +built from TIME (default now), and keep the tags." + (org-back-to-heading t) + (let ((stamp (format-time-string "%Y-%m-%d %a @ %H:%M:%S %z" time)) + (org-inhibit-logging t)) + (when (org-get-todo-state) (org-todo 'none)) + (when (nth 3 (org-heading-components)) ; only if a [#X] cookie is present + (org-priority 'remove)) + (org-edit-headline (concat stamp " " (org-get-heading t t t t))))) + +(defun cj/--org-finalize-close-in-place (&optional time) + "Add a date-only CLOSED line under the heading at point, keeping the keyword. +TIME defaults to now. Used for top-level tasks that stay task-shaped." + (org-back-to-heading t) + (let ((org-inhibit-logging t)) + (org-add-planning-info 'closed (or time (current-time)))) + ;; org's CLOSED stamp may carry HH:MM; normalize to date-only per todo-format. + (save-excursion + (org-back-to-heading t) + (let ((end (save-excursion (outline-next-heading) (point))) + (stamp (format-time-string "[%Y-%m-%d %a]" time))) + (when (re-search-forward "CLOSED: \\[[^]]*\\]" end t) + (replace-match (concat "CLOSED: " stamp) t t))))) + +(defun cj/org-finalize-task (&optional state time) + "Finalize the task at point: mark it done and reshape it per todo-format. +Prompt for a finalized keyword from `org-done-keywords' (STATE skips the +prompt, TIME sets the timestamp -- both for testing). Marking the task done +fires the journal-copy hook, then a sub-task (level >= 3, or a VERIFY at any +depth) becomes a dated log entry while a top-level task keeps its keyword and +gains a date-only CLOSED line." + (interactive) + (unless (derived-mode-p 'org-mode) + (user-error "Only available in Org buffers")) + (org-back-to-heading t) + (let ((keyword (org-get-todo-state)) + (level (org-current-level))) + (unless (member keyword org-not-done-keywords) + (user-error "Not on an open task (no actionable TODO keyword)")) + (let ((finalized + (or state + (let ((default (if (member "DONE" org-done-keywords) + "DONE" + (car org-done-keywords)))) + (completing-read "Finalize as: " org-done-keywords + nil t nil nil default))))) + (let ((org-inhibit-logging t)) + (org-todo finalized)) ; fires the journal-copy hook + (if (cj/--org-finalize-dated-p level keyword) + (cj/--org-finalize-rewrite-dated time) + (cj/--org-finalize-close-in-place time))))) + +(keymap-set cj/org-map "d" #'cj/org-finalize-task) + ;; ------------------------------ Org Keybindings ------------------------------ ;; which-key labels for org keymaps 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 |
