aboutsummaryrefslogtreecommitdiff
path: root/.ai/scripts/tests
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-30 13:43:25 -0400
committerCraig Jennings <c@cjennings.net>2026-06-30 13:43:25 -0400
commitf67e72430845236ab5ed4ca00ba13afe87eda53a (patch)
tree395c087693a99d5e2db93c8e0dfe42703bb59cbc /.ai/scripts/tests
parent324a52b511b8c54ec0e4e706df7a1d4568447efc (diff)
downloadrulesets-f67e72430845236ab5ed4ca00ba13afe87eda53a.tar.gz
rulesets-f67e72430845236ab5ed4ca00ba13afe87eda53a.zip
feat(todo-cleanup): age the Resolved section out to a tracked archive
A project's in-file Resolved section grew without bound: --archive-done only moved closures from Open Work into Resolved, same file. .emacs.d's todo.org reached 768KB that way. So --archive-done now has a second step: a Resolved subtree closed longer ago than tc-archive-retain-days (default 7), or with no parseable CLOSED date, moves out to archive/task-archive.org beside the todo file. Only the last week of closures stays in the file. The step honors --check, and tc-archive-retain-days nil disables it. I added a guard the proposal lacked: the archive inherits the todo file's gitignore status. gitignore-mode projects gitignore todo.org, so without this the aging step would shed previously-private task history into a tracked archive, a leak on any public repo. When the todo file is gitignored and the archive isn't, the script adds the archive path to .gitignore before the first write. Track-mode projects leave both tracked. Covered by two tests in a temp git repo, one per branch.
Diffstat (limited to '.ai/scripts/tests')
-rw-r--r--.ai/scripts/tests/test-todo-cleanup.el206
1 files changed, 202 insertions, 4 deletions
diff --git a/.ai/scripts/tests/test-todo-cleanup.el b/.ai/scripts/tests/test-todo-cleanup.el
index ad9260b..e569d9a 100644
--- a/.ai/scripts/tests/test-todo-cleanup.el
+++ b/.ai/scripts/tests/test-todo-cleanup.el
@@ -30,16 +30,20 @@
;;; Harness
(defun tc-test--reset (&optional check)
- (setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-issues nil
+ (setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-archived-to-file 0 tc-issues nil
tc-check-only (and check t)
tc-archive-done t tc-sync-child-priority nil
- tc-current-file nil))
+ tc-current-file nil
+ ;; Aging step OFF by default so the in-file-move tests are unaffected by
+ ;; the wall clock; the aging harness re-enables it with fixed params.
+ tc-archive-retain-days nil tc-archive-reference-date nil tc-archive-file nil))
(defun tc-test--reset-sync (&optional check)
- (setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-issues nil
+ (setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-archived-to-file 0 tc-issues nil
tc-check-only (and check t)
tc-archive-done nil tc-sync-child-priority t
- tc-current-file nil))
+ tc-current-file nil
+ tc-archive-retain-days nil tc-archive-reference-date nil tc-archive-file nil))
(defun tc-test--drop-buffer (file)
(let ((buf (find-buffer-visiting file)))
@@ -355,6 +359,200 @@ from the heading line through (not including) the next level-1 heading or EOF."
(should (tc-test--has (plist-get out :report) "skipped"))))
;;; ---------------------------------------------------------------------------
+;;; --archive-done file-aging: keep last week in-file, move older to task-archive
+
+(defun tc-test--age (content &optional opts)
+ "Run `--archive-done' with the file-aging step enabled.
+OPTS is a plist: :retain (days; default 7, may be nil to disable), :ref
+\(YEAR MONTH DAY reference date), :runs (default 1), :check. Writes CONTENT to a
+temp todo file and points `tc-archive-file' at a not-yet-existing temp archive.
+Returns a plist: :result (todo contents), :archive (archive-file contents or
+nil), :archived (in-file move count), :to-file (aged count), :issues — all from
+the last run."
+ (let* ((retain (if (plist-member opts :retain) (plist-get opts :retain) 7))
+ (ref (plist-get opts :ref))
+ (runs (or (plist-get opts :runs) 1))
+ (check (plist-get opts :check))
+ (todo (make-temp-file "tc-age-todo-" nil ".org"))
+ (adir (make-temp-file "tc-age-arch-" t))
+ (afile (expand-file-name "task-archive.org" adir))
+ last)
+ (unwind-protect
+ (progn
+ (with-temp-file todo (insert content))
+ (dotimes (_ runs)
+ (tc-test--reset check)
+ (setq tc-archive-retain-days retain
+ tc-archive-reference-date ref
+ tc-archive-file afile)
+ (tc-process-file todo)
+ (setq last (list :archived tc-archived :to-file tc-archived-to-file
+ :issues tc-issues))
+ (tc-test--drop-buffer todo))
+ (append
+ last
+ (list :result (with-temp-buffer (insert-file-contents todo) (buffer-string))
+ :archive (and (file-readable-p afile)
+ (with-temp-buffer (insert-file-contents afile)
+ (buffer-string))))))
+ (tc-test--drop-buffer todo)
+ (delete-file todo)
+ (delete-directory adir t))))
+
+;; Reference "today" for these fixtures is 2026-06-29; with retain 7 the cutoff
+;; is 2026-06-22, so a task closed on or after 2026-06-22 stays in-file.
+(defconst tc-test--age-resolved "\
+* Age Open Work
+** TODO [#A] still open
+* Age Resolved
+** DONE [#B] recent within window
+CLOSED: [2026-06-25 Thu]
+recent body
+** DONE [#C] old beyond window
+CLOSED: [2026-05-01 Fri]
+old body line
+** CANCELLED [#C] old cancelled too
+CLOSED: [2026-04-15 Wed]
+** DONE [#B] exactly at cutoff stays
+CLOSED: [2026-06-22 Sun]
+** DONE [#C] undated no-date archived
+no closed date in this body
+")
+
+(defconst tc-test--age-straggler "\
+* Age Open Work
+** TODO [#A] still open
+** DONE [#C] old straggler
+CLOSED: [2026-03-01 Sun]
+straggler body
+* Age Resolved
+** DONE [#B] recent stays
+CLOSED: [2026-06-26 Fri]
+")
+
+(ert-deftest tc-age-moves-old-and-undated-resolved ()
+ "Normal: closed-beyond-window AND undated subtrees leave the file; only those
+closed within the window (cutoff inclusive) stay."
+ (let* ((out (tc-test--age tc-test--age-resolved '(:ref (2026 6 29))))
+ (resolved (tc-test--section (plist-get out :result) "Age Resolved"))
+ (arch (plist-get out :archive)))
+ (should (= 3 (plist-get out :to-file)))
+ (should-not (tc-test--has resolved "old beyond window"))
+ (should-not (tc-test--has resolved "old cancelled too"))
+ (should-not (tc-test--has resolved "undated no-date archived"))
+ (should (tc-test--has resolved "recent within window"))
+ (should (tc-test--has resolved "exactly at cutoff stays"))
+ (should arch)
+ (should (tc-test--has arch "Resolved (archived)"))
+ (should (tc-test--has arch "old beyond window"))
+ (should (tc-test--has arch "old body line"))
+ (should (tc-test--has arch "old cancelled too"))
+ (should (tc-test--has arch "undated no-date archived"))
+ (should-not (tc-test--has arch "recent within window"))))
+
+(ert-deftest tc-age-disabled-when-retain-nil ()
+ "Boundary: nil retain disables the aging step entirely (legacy behavior)."
+ (let ((out (tc-test--age tc-test--age-resolved '(:retain nil :ref (2026 6 29)))))
+ (should (= 0 (plist-get out :to-file)))
+ (should (equal tc-test--age-resolved (plist-get out :result)))
+ (should-not (plist-get out :archive))))
+
+(ert-deftest tc-age-is-idempotent ()
+ "Boundary: a second run finds nothing new to age; the todo file is stable."
+ (let ((once (tc-test--age tc-test--age-resolved '(:ref (2026 6 29) :runs 1)))
+ (twice (tc-test--age tc-test--age-resolved '(:ref (2026 6 29) :runs 2))))
+ (should (equal (plist-get once :result) (plist-get twice :result)))
+ (should (= 0 (plist-get twice :to-file)))))
+
+(ert-deftest tc-age-check-mode-previews-without-writing ()
+ "Boundary: --check reports the aged count but writes neither file."
+ (let ((out (tc-test--age tc-test--age-resolved '(:ref (2026 6 29) :check t))))
+ (should (= 3 (plist-get out :to-file)))
+ (should (equal tc-test--age-resolved (plist-get out :result)))
+ (should-not (plist-get out :archive))))
+
+(ert-deftest tc-age-straggler-moves-through-to-archive ()
+ "Normal: an old-dated DONE in Open Work moves to Resolved then ages out in one run."
+ (let* ((out (tc-test--age tc-test--age-straggler '(:ref (2026 6 29))))
+ (open (tc-test--section (plist-get out :result) "Age Open Work"))
+ (resolved (tc-test--section (plist-get out :result) "Age Resolved"))
+ (arch (plist-get out :archive)))
+ (should-not (tc-test--has open "old straggler"))
+ (should-not (tc-test--has resolved "old straggler"))
+ (should (tc-test--has arch "old straggler"))
+ (should (tc-test--has arch "straggler body"))
+ (should (tc-test--has resolved "recent stays"))
+ (should (= 1 (plist-get out :archived)))
+ (should (= 1 (plist-get out :to-file)))))
+
+(ert-deftest tc-age-append-preserves-existing-archive ()
+ "Error/edge: appending to a populated archive keeps prior entries and one scaffold."
+ (let* ((adir (make-temp-file "tc-arch-" t))
+ (afile (expand-file-name "task-archive.org" adir)))
+ (unwind-protect
+ (progn
+ (tc--append-subtrees-to-archive-file afile (list "** DONE one\n"))
+ (tc--append-subtrees-to-archive-file afile (list "** DONE two\n"))
+ (let ((content (with-temp-buffer (insert-file-contents afile)
+ (buffer-string)))
+ (n 0) (start 0))
+ (should (tc-test--has content "** DONE one"))
+ (should (tc-test--has content "** DONE two"))
+ (should (tc-test--before-p content "** DONE one" "** DONE two"))
+ (while (string-match "\\* Resolved (archived)" content start)
+ (setq n (1+ n) start (match-end 0)))
+ (should (= 1 n))))
+ (delete-directory adir t))))
+
+;;; ---------------------------------------------------------------------------
+;;; --archive-done aging: the archive follows the todo file's gitignore status
+
+(defun tc-test--age-in-git-repo (gitignore-todo)
+ "Init a temp git repo, write todo.org with an old Resolved entry, optionally
+gitignore todo.org, then run `--archive-done' aging with the DEFAULT archive path
+(archive/task-archive.org beside the todo file). Return a plist: :gitignore (final
+.gitignore contents or nil), :archive-ignored (whether git ignores the archive),
+:archive-exists."
+ (let* ((root (make-temp-file "tc-git-" t))
+ (todo (expand-file-name "todo.org" root))
+ (archive (expand-file-name "archive/task-archive.org" root))
+ (gi (expand-file-name ".gitignore" root)))
+ (unwind-protect
+ (let ((default-directory root))
+ (call-process "git" nil nil nil "init" "-q")
+ (with-temp-file todo (insert tc-test--age-resolved))
+ (when gitignore-todo (with-temp-file gi (insert "/todo.org\n")))
+ (tc-test--reset nil)
+ (setq tc-archive-retain-days 7
+ tc-archive-reference-date '(2026 6 29)
+ tc-archive-file nil) ; default path, beside the todo file
+ (tc-process-file todo)
+ (tc-test--drop-buffer todo)
+ (list :gitignore (and (file-readable-p gi)
+ (with-temp-buffer (insert-file-contents gi)
+ (buffer-string)))
+ :archive-ignored
+ (eq 0 (call-process "git" nil nil nil "check-ignore" "-q" archive))
+ :archive-exists (file-readable-p archive)))
+ (delete-directory root t))))
+
+(ert-deftest tc-age-self-protect-gitignores-archive-when-todo-ignored ()
+ "When the todo file is gitignored, the aged-out archive is added to .gitignore
+so it inherits the same privacy."
+ (let ((out (tc-test--age-in-git-repo t)))
+ (should (plist-get out :archive-exists))
+ (should (string-match-p "task-archive" (or (plist-get out :gitignore) "")))
+ (should (plist-get out :archive-ignored))))
+
+(ert-deftest tc-age-self-protect-leaves-tracked-todo-archive-tracked ()
+ "When the todo file is tracked, the archive is not gitignored — no .gitignore
+entry is added for it."
+ (let ((out (tc-test--age-in-git-repo nil)))
+ (should (plist-get out :archive-exists))
+ (should-not (plist-get out :archive-ignored))
+ (should-not (string-match-p "task-archive" (or (plist-get out :gitignore) "")))))
+
+;;; ---------------------------------------------------------------------------
;;; Realistic synthetic sample (committed under fixtures/)
(defun tc-test--sample-file ()