aboutsummaryrefslogtreecommitdiff
path: root/.ai/scripts/tests
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-14 07:29:57 -0500
committerCraig Jennings <c@cjennings.net>2026-05-14 07:29:57 -0500
commit99252c04a5a0113947681dfb4276116d4aa69983 (patch)
treefbbbbdc64a92a02e3a9e36144dcece7cc9b7bd82 /.ai/scripts/tests
parent3abac9b797e31429b55cd08f5d102275c25b75a4 (diff)
downloadrulesets-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.el181
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