diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-14 07:29:57 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-14 07:29:57 -0500 |
| commit | 99252c04a5a0113947681dfb4276116d4aa69983 (patch) | |
| tree | fbbbbdc64a92a02e3a9e36144dcece7cc9b7bd82 /.ai/scripts/tests | |
| parent | 3abac9b797e31429b55cd08f5d102275c25b75a4 (diff) | |
| download | rulesets-99252c04a5a0113947681dfb4276116d4aa69983.tar.gz rulesets-99252c04a5a0113947681dfb4276116d4aa69983.zip | |
feat(todo-cleanup): add --sync-child-priority mode for drifted children
When a parent task in todo.org gets reprioritized, its children frequently keep their original (lower) priority cookies, which then mismatches the parent's new importance. The new mode walks every heading with a priority cookie and bumps any direct child whose own cookie is lower (D ranks below A in org's default scheme). Down-only: parents are never bumped up to a child's priority. Priority-less parents and priority-less children are both left alone — sync does not invent priorities.
Children opt out by carrying the :no-sync: literal tag, useful for Follow-up:/Spike: sub-tasks that are deliberately deprioritized. The tag match is literal regex against the heading line rather than going through org-get-tags, because org's default tag character class excludes hyphens — :no-sync: would not be parsed as a real tag in batch mode without a custom org-tag-re.
org-map-entries visits headings in document order, so a multi-level chain [#A] → [#B] → [#D] collapses to the top priority in one pass: the middle bumps to [#A] before the walk reaches the leaf.
wrap-it-up.org Step 3 now invokes --sync-child-priority after --archive-done. --check-child-priority is the report-only alias (--sync-child-priority --check) for previewing before applying. Default cadence is auto-apply, same as --archive-done.
Diffstat (limited to '.ai/scripts/tests')
| -rw-r--r-- | .ai/scripts/tests/test-todo-cleanup.el | 181 |
1 files changed, 179 insertions, 2 deletions
diff --git a/.ai/scripts/tests/test-todo-cleanup.el b/.ai/scripts/tests/test-todo-cleanup.el index 9d11b5c..2a9184d 100644 --- a/.ai/scripts/tests/test-todo-cleanup.el +++ b/.ai/scripts/tests/test-todo-cleanup.el @@ -30,8 +30,16 @@ ;;; Harness (defun tc-test--reset (&optional check) - (setq tc-fixes 0 tc-archived 0 tc-issues nil - tc-check-only (and check t) tc-archive-done t tc-current-file nil)) + (setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-issues nil + tc-check-only (and check t) + tc-archive-done t tc-sync-child-priority nil + tc-current-file nil)) + +(defun tc-test--reset-sync (&optional check) + (setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-issues nil + tc-check-only (and check t) + tc-archive-done nil tc-sync-child-priority t + tc-current-file nil)) (defun tc-test--drop-buffer (file) (let ((buf (find-buffer-visiting file))) @@ -315,5 +323,174 @@ from the heading line through (not including) the next level-1 heading or EOF." ;; something actually moved (should (> (plist-get out :archived) 0))))) +;;; --------------------------------------------------------------------------- +;;; Sync-child-priority harness + fixtures + +(defun tc-test--sync (content &optional runs check) + "Write CONTENT to a temp .org file, run `--sync-child-priority' RUNS times +\(default 1\). Return a plist: :result final file contents, :bumped count from +the last run, :issues from the last run. CHECK non-nil ⇒ --check (preview)." + (let ((file (make-temp-file "tc-test-sync-" nil ".org")) + last-bumped last-issues) + (unwind-protect + (progn + (with-temp-file file (insert content)) + (dotimes (_ (or runs 1)) + (tc-test--reset-sync check) + (tc-process-file file) + (setq last-bumped tc-bumped last-issues tc-issues) + (tc-test--drop-buffer file)) + (list :result (with-temp-buffer (insert-file-contents file) + (buffer-string)) + :bumped last-bumped + :issues last-issues)) + (tc-test--drop-buffer file) + (delete-file file)))) + +(defun tc-test--priority-of (content heading-substring) + "Return the priority letter (a string like \"A\") on the first heading line +in CONTENT that contains HEADING-SUBSTRING, or nil if the heading has no +priority cookie." + (with-temp-buffer + (insert content) + (goto-char (point-min)) + (let (found-line found-prio) + (while (and (not found-line) (re-search-forward "^\\*+ .*$" nil t)) + (let ((line (match-string 0))) + (when (string-match-p (regexp-quote heading-substring) line) + (setq found-line line) + (when (string-match "\\[#\\([A-Z]\\)\\]" line) + (setq found-prio (match-string 1 line)))))) + (unless found-line + (error "no heading containing %S" heading-substring)) + found-prio))) + +(defun tc-test--sync-bumped-headings (issues) + "Return the heading texts of every `:kind' sync-bumped or sync-would entry +in ISSUES, in document order." + (mapcar (lambda (i) (plist-get i :child-heading)) + (cl-remove-if-not + (lambda (i) (memq (plist-get i :kind) '(sync-bumped sync-would))) + (reverse issues)))) + +(defconst tc-test--sync-basic "\ +* Open Work +** TODO [#B] Parent +*** TODO [#D] Drifted child +*** TODO [#B] Already in sync +") + +(defconst tc-test--sync-multi "\ +* Open Work +** TODO [#B] Parent +*** TODO [#A] Higher-priority child stays +*** TODO [#B] Equal-priority child stays +*** TODO [#C] Lower-priority child bumps +*** TODO [#D] Way-lower-priority child bumps +*** TODO Priority-less child stays +") + +(defconst tc-test--sync-no-sync-tag "\ +* Open Work +** TODO [#B] Parent +*** TODO [#D] Regular drifted child +*** TODO [#D] Follow-up: opted-out :no-sync: +") + +(defconst tc-test--sync-priority-less-parent "\ +* Open Work +** TODO Parent with no priority +*** TODO [#D] Child with priority should not move +") + +(defconst tc-test--sync-cascade "\ +* Open Work +** TODO [#A] Top +*** TODO [#B] Middle +**** TODO [#D] Leaf +") + +(defconst tc-test--sync-no-change "\ +* Open Work +** TODO [#B] Parent +*** TODO [#A] Child higher +*** TODO [#B] Child equal +") + +;;; --------------------------------------------------------------------------- +;;; Sync-child-priority tests + +(ert-deftest tc-sync-bumps-lower-priority-child () + (let* ((out (tc-test--sync tc-test--sync-basic)) + (res (plist-get out :result))) + (should (= 1 (plist-get out :bumped))) + (should (equal "B" (tc-test--priority-of res "Drifted child"))) + (should (equal "B" (tc-test--priority-of res "Already in sync"))) + (should (equal "B" (tc-test--priority-of res "Parent"))))) + +(ert-deftest tc-sync-leaves-higher-and-equal-children-alone () + (let* ((out (tc-test--sync tc-test--sync-multi)) + (res (plist-get out :result))) + (should (= 2 (plist-get out :bumped))) + (should (equal "A" (tc-test--priority-of res "Higher-priority child"))) + (should (equal "B" (tc-test--priority-of res "Equal-priority child"))) + (should (equal "B" (tc-test--priority-of res "Lower-priority child"))) + (should (equal "B" (tc-test--priority-of res "Way-lower-priority child"))) + (should-not (tc-test--priority-of res "Priority-less child")))) + +(ert-deftest tc-sync-skips-no-sync-tagged-child () + (let* ((out (tc-test--sync tc-test--sync-no-sync-tag)) + (res (plist-get out :result))) + (should (= 1 (plist-get out :bumped))) + (should (equal "B" (tc-test--priority-of res "Regular drifted child"))) + (should (equal "D" (tc-test--priority-of res "Follow-up: opted-out"))))) + +(ert-deftest tc-sync-leaves-priority-less-parent-alone () + (let ((out (tc-test--sync tc-test--sync-priority-less-parent))) + (should (= 0 (plist-get out :bumped))) + (should (equal tc-test--sync-priority-less-parent (plist-get out :result))))) + +(ert-deftest tc-sync-cascades-through-multiple-levels () + (let* ((out (tc-test--sync tc-test--sync-cascade)) + (res (plist-get out :result))) + ;; one pass should collapse [#A] → [#B] → [#D] to all [#A] because + ;; org-map-entries visits the parent first, bumps the middle, then visits + ;; the (now bumped) middle and bumps its leaf + (should (= 2 (plist-get out :bumped))) + (should (equal "A" (tc-test--priority-of res "Top"))) + (should (equal "A" (tc-test--priority-of res "Middle"))) + (should (equal "A" (tc-test--priority-of res "Leaf"))))) + +(ert-deftest tc-sync-no-change-when-all-children-at-or-above-parent () + (let ((out (tc-test--sync tc-test--sync-no-change))) + (should (= 0 (plist-get out :bumped))) + (should (equal tc-test--sync-no-change (plist-get out :result))))) + +(ert-deftest tc-sync-is-idempotent () + (dolist (fixture (list tc-test--sync-basic + tc-test--sync-multi + tc-test--sync-no-sync-tag + tc-test--sync-priority-less-parent + tc-test--sync-cascade + tc-test--sync-no-change)) + (let ((once (plist-get (tc-test--sync fixture 1) :result)) + (twice (plist-get (tc-test--sync fixture 2) :result))) + (should (equal once twice))))) + +(ert-deftest tc-sync-check-mode-previews-without-writing () + (let ((out (tc-test--sync tc-test--sync-basic 1 t))) + (should (= 1 (plist-get out :bumped))) + (should (equal tc-test--sync-basic (plist-get out :result))) + (should (member "Drifted child" + (tc-test--sync-bumped-headings (plist-get out :issues)))))) + +(ert-deftest tc-sync-check-mode-is-idempotent () + (let ((once (tc-test--sync tc-test--sync-cascade 1 t)) + (twice (tc-test--sync tc-test--sync-cascade 2 t))) + (should (equal tc-test--sync-cascade (plist-get once :result))) + (should (equal tc-test--sync-cascade (plist-get twice :result))) + (should (= 2 (plist-get once :bumped))) + (should (= 2 (plist-get twice :bumped))))) + (provide 'test-todo-cleanup) ;;; test-todo-cleanup.el ends here |
