aboutsummaryrefslogtreecommitdiff
path: root/.ai/scripts/tests/test-todo-cleanup.el
diff options
context:
space:
mode:
Diffstat (limited to '.ai/scripts/tests/test-todo-cleanup.el')
-rw-r--r--.ai/scripts/tests/test-todo-cleanup.el243
1 files changed, 238 insertions, 5 deletions
diff --git a/.ai/scripts/tests/test-todo-cleanup.el b/.ai/scripts/tests/test-todo-cleanup.el
index ffbf2fb..1e964b3 100644
--- a/.ai/scripts/tests/test-todo-cleanup.el
+++ b/.ai/scripts/tests/test-todo-cleanup.el
@@ -31,6 +31,7 @@
(defun tc-test--reset (&optional check)
(setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-archived-to-file 0 tc-issues nil
+ tc-sealed 0 tc-seal nil tc-convert-subtasks nil
tc-check-only (and check t)
tc-archive-done t tc-sync-child-priority nil
tc-current-file nil
@@ -40,6 +41,7 @@
(defun tc-test--reset-sync (&optional check)
(setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-archived-to-file 0 tc-issues nil
+ tc-sealed 0 tc-seal nil
tc-check-only (and check t)
tc-archive-done nil tc-sync-child-priority t
tc-current-file nil
@@ -514,6 +516,12 @@ gitignore todo.org, then run `--archive-done' aging with the DEFAULT archive pat
.gitignore contents or nil), :archive-ignored (whether git ignores the archive),
:archive-exists."
(let* ((root (make-temp-file "tc-git-" t))
+ ;; Private backup dir: this helper writes a file literally named
+ ;; todo.org and runs a real (non-check) pass, so without this its
+ ;; backup lands in the shared temp dir under the exact production
+ ;; name and is indistinguishable from a real one.
+ (temporary-file-directory
+ (file-name-as-directory (make-temp-file "tc-git-bk-" t)))
(todo (expand-file-name "todo.org" root))
(archive (expand-file-name "archive/task-archive.org" root))
(gi (expand-file-name ".gitignore" root)))
@@ -534,7 +542,8 @@ gitignore todo.org, then run `--archive-done' aging with the DEFAULT archive pat
:archive-ignored
(eq 0 (call-process "git" nil nil nil "check-ignore" "-q" archive))
:archive-exists (file-readable-p archive)))
- (delete-directory root t))))
+ (delete-directory root t)
+ (delete-directory temporary-file-directory 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
@@ -578,6 +587,95 @@ entry is added for it."
(should (> (plist-get out :archived) 0)))))
;;; ---------------------------------------------------------------------------
+;;; --archive-done retention default
+
+(ert-deftest tc-archive-retain-default-is-one-month ()
+ "The shipped retention default is one month (31 days), not the legacy 7.
+The defvar initializes from this defconst; the live var itself is mutated by
+other tests, so the immutable defconst is the stable contract to pin."
+ (should (= 31 tc-archive-retain-days-default)))
+
+;;; ---------------------------------------------------------------------------
+;;; --seal: rename the working archive to resolved-YYYY-MM-DD.org
+
+(defun tc-test--seal (&optional opts)
+ "Run `--seal' against a temp todo file with a temp archive dir.
+OPTS is a plist: :archive-content (seed task-archive.org with this; nil = no
+working archive), :ref (YEAR MONTH DAY seal date; default (2026 7 18)),
+:check, :presealed (also create resolved-<ref>.org first, to test collision).
+Returns a plist: :sealed count, :issues, :working-exists, :sealed-exists,
+:sealed-name, :report."
+ (let* ((ref (or (plist-get opts :ref) '(2026 7 18)))
+ (check (plist-get opts :check))
+ (archive-content (plist-get opts :archive-content))
+ (todo (make-temp-file "tc-seal-todo-" nil ".org"))
+ (adir (make-temp-file "tc-seal-arch-" t))
+ (afile (expand-file-name "task-archive.org" adir))
+ (sealed-name (format "resolved-%04d-%02d-%02d.org"
+ (nth 0 ref) (nth 1 ref) (nth 2 ref)))
+ (sealed (expand-file-name sealed-name adir)))
+ (unwind-protect
+ (progn
+ (with-temp-file todo (insert "* Open Work\n** TODO [#A] live\n"))
+ (when archive-content (with-temp-file afile (insert archive-content)))
+ (when (plist-get opts :presealed)
+ (with-temp-file sealed (insert "pre-existing seal\n")))
+ (tc-test--reset check)
+ ;; Set every mode flag explicitly: tc-test--reset leaves
+ ;; tc-convert-subtasks untouched, so a convert test running earlier in
+ ;; the suite would otherwise still own the dispatch and run convert.
+ (setq tc-archive-done nil tc-sync-child-priority nil
+ tc-convert-subtasks nil tc-seal t tc-sealed 0
+ tc-archive-reference-date ref
+ tc-archive-file afile)
+ (let ((report (with-output-to-string (tc-process-file todo) (tc-emit-report))))
+ (tc-test--drop-buffer todo)
+ (list :sealed tc-sealed
+ :issues tc-issues
+ :working-exists (file-readable-p afile)
+ :sealed-exists (file-readable-p sealed)
+ :sealed-name sealed-name
+ :report report)))
+ (tc-test--drop-buffer todo)
+ (delete-file todo)
+ (delete-directory adir t))))
+
+(ert-deftest tc-seal-renames-working-archive-to-dated-file ()
+ "Normal: --seal renames task-archive.org to resolved-<seal-date>.org."
+ (let ((out (tc-test--seal '(:archive-content "* Resolved (archived)\n** DONE old\n"
+ :ref (2026 7 18)))))
+ (should (= 1 (plist-get out :sealed)))
+ (should-not (plist-get out :working-exists))
+ (should (plist-get out :sealed-exists))
+ (should (equal "resolved-2026-07-18.org" (plist-get out :sealed-name)))
+ (should (tc-test--has (plist-get out :report) "sealed task-archive.org → resolved-2026-07-18.org"))))
+
+(ert-deftest tc-seal-nothing-to-seal-is-a-reported-noop ()
+ "Boundary: no working archive present — reported no-op, nothing created."
+ (let ((out (tc-test--seal '(:ref (2026 7 18)))))
+ (should (= 0 (plist-get out :sealed)))
+ (should-not (plist-get out :sealed-exists))
+ (should (tc-test--has (plist-get out :report) "no working archive to seal"))))
+
+(ert-deftest tc-seal-check-mode-previews-without-renaming ()
+ "Boundary: --check reports the seal but leaves the working archive in place."
+ (let ((out (tc-test--seal '(:archive-content "* Resolved (archived)\n"
+ :ref (2026 7 18) :check t))))
+ (should (= 1 (plist-get out :sealed)))
+ (should (plist-get out :working-exists))
+ (should-not (plist-get out :sealed-exists))
+ (should (tc-test--has (plist-get out :report) "would seal"))))
+
+(ert-deftest tc-seal-refuses-to-clobber-existing-sealed-file ()
+ "Error: resolved-<today>.org already exists — refuse, leave both files intact."
+ (let ((out (tc-test--seal '(:archive-content "* Resolved (archived)\n"
+ :ref (2026 7 18) :presealed t))))
+ (should (= 0 (plist-get out :sealed)))
+ (should (plist-get out :working-exists))
+ (should (plist-get out :sealed-exists))
+ (should (tc-test--has (plist-get out :report) "already exists"))))
+
+;;; ---------------------------------------------------------------------------
;;; Sync-child-priority harness + fixtures
(defun tc-test--sync (content &optional runs check)
@@ -773,7 +871,7 @@ in ISSUES, in document order."
(defun tc-test--reset-convert (&optional check)
(setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-converted 0 tc-archived-to-file 0
- tc-issues nil
+ tc-issues nil tc-sealed 0 tc-seal nil
tc-check-only (and check t)
tc-archive-done nil tc-sync-child-priority nil tc-convert-subtasks t
tc-current-file nil
@@ -927,8 +1025,9 @@ CLOSED: [2026-06-27 Sat 12:50] DEADLINE: <2026-06-30 Tue>
Body line.
")
-(ert-deftest tc-convert-preserves-deadline-on-shared-planning-line-boundary ()
- "Boundary: removing the CLOSED cookie keeps a DEADLINE sharing its planning line."
+(ert-deftest tc-convert-strips-deadline-sharing-the-planning-line-boundary ()
+ "Boundary: a DEADLINE sharing the CLOSED planning line goes too — a dated-log
+entry carries no active planning timestamp (todo-format.md). Body survives."
(let* ((out (tc-test--convert tc-test--convert-closed-with-deadline))
(res (plist-get out :result)))
(should (= 1 (plist-get out :converted)))
@@ -936,8 +1035,142 @@ Body line.
"^\\*\\*\\* 2026-06-27 Sat @ 12:50:00 [-+][0-9]\\{4\\} Ship the panel$"
res))
(should-not (string-match-p "CLOSED:" res))
- (should (string-match-p "^DEADLINE: <2026-06-30 Tue>$" res))
+ (should-not (string-match-p "DEADLINE:" res))
+ (should (string-match-p "^Body line\\.$" res))))
+
+(defconst tc-test--convert-closed-and-scheduled-separate-lines
+ "* Project Open Work
+** TODO [#B] Parent task
+*** DONE [#C] Book the venue :feature:
+CLOSED: [2026-06-27 Sat 12:50]
+SCHEDULED: <2026-06-20 Sat>
+Body line.
+")
+
+(ert-deftest tc-convert-strips-scheduled-on-its-own-line ()
+ "Normal (the home bug): a SCHEDULED planning line on its own — the completion
+rewrite dropped keyword/priority/tags but left the SCHEDULED, pinning the dated
+entry to the agenda as weeks-overdue. Both planning lines go; body survives."
+ (let* ((out (tc-test--convert tc-test--convert-closed-and-scheduled-separate-lines))
+ (res (plist-get out :result)))
+ (should (= 1 (plist-get out :converted)))
+ (should (string-match-p
+ "^\\*\\*\\* 2026-06-27 Sat @ 12:50:00 [-+][0-9]\\{4\\} Book the venue$"
+ res))
+ (should-not (string-match-p "CLOSED:" res))
+ (should-not (string-match-p "SCHEDULED:" res))
(should (string-match-p "^Body line\\.$" res))))
+(defconst tc-test--convert-scheduled-in-body-prose
+ "* Project Open Work
+** TODO [#B] Parent task
+*** DONE [#C] Note the mechanism :feature:
+CLOSED: [2026-06-27 Sat 12:50]
+An active SCHEDULED: <2026-06-20 Sat> in prose must survive.
+")
+
+(ert-deftest tc-convert-leaves-planning-shaped-body-prose-alone ()
+ "Boundary: a planning-shaped token inside body prose (not a canonical planning
+line) is left untouched — the strip stops at the first non-planning line."
+ (let* ((out (tc-test--convert tc-test--convert-scheduled-in-body-prose))
+ (res (plist-get out :result)))
+ (should (= 1 (plist-get out :converted)))
+ (should-not (string-match-p "CLOSED:" res))
+ (should (string-match-p "An active SCHEDULED: <2026-06-20 Sat> in prose must survive\\." res))))
+
(provide 'test-todo-cleanup)
;;; test-todo-cleanup.el ends here
+
+;;; ---------------------------------------------------------------------------
+;;; Backup before mutating (parity with lint-org.el / wrap-org-table.el)
+;;
+;; todo-cleanup rewrites todo.org in place and left no copy behind, while both
+;; sibling org-mutators back up to /tmp first. It is also the one that runs most
+;; often (every wrap, every sentry cycle). Emacs's own backup does not fire under
+;; --batch -q, so there was genuinely no undo short of git.
+
+(ert-deftest tc-backup-written-before-a-real-mutation ()
+ "A real (non-check) run leaves a copy holding the pre-edit content.
+
+`temporary-file-directory' is rebound to a private dir for the duration: the
+backup name derives from the *file's* basename, and the real todo.org shares
+that basename, so a live sentry run writing /tmp/todo.org.before-todo-cleanup.*
+would otherwise be indistinguishable from this test's own artifact. The first
+version of this test globbed the shared /tmp and passed only until a real run
+created one (2026-07-24)."
+ (let* ((dir (make-temp-file "tc-backup-" t))
+ (bdir (file-name-as-directory (make-temp-file "tc-bk-" t)))
+ (file (expand-file-name "todo.org" dir))
+ (before "* P Open Work\n** TODO [#B] parent\n*** DONE a subtask\nCLOSED: [2026-07-01 Tue]\n"))
+ (unwind-protect
+ (progn
+ (with-temp-file file (insert before))
+ (let ((tc-check-only nil)
+ (tc-convert-subtasks t)
+ (temporary-file-directory bdir))
+ (tc-process-file file))
+ (let ((backups (file-expand-wildcards
+ (concat bdir "todo.org.before-todo-cleanup.*"))))
+ (should backups)
+ (should (string-match-p
+ "a subtask"
+ (with-temp-buffer (insert-file-contents (car backups))
+ (buffer-string))))))
+ (delete-directory dir t)
+ (delete-directory bdir t))))
+
+(ert-deftest tc-no-backup-in-check-mode ()
+ "--check writes nothing, so it must not leave a backup either.
+Uses a private `temporary-file-directory' for the same isolation reason."
+ (let* ((dir (make-temp-file "tc-backup-" t))
+ (bdir (file-name-as-directory (make-temp-file "tc-bk-" t)))
+ (file (expand-file-name "todo.org" dir)))
+ (unwind-protect
+ (progn
+ (with-temp-file file
+ (insert "* P Open Work\n** TODO [#B] parent\n*** DONE sub\nCLOSED: [2026-07-01 Tue]\n"))
+ (let ((tc-check-only t)
+ (tc-convert-subtasks t)
+ (temporary-file-directory bdir))
+ (tc-process-file file))
+ (should-not (file-expand-wildcards
+ (concat bdir "todo.org.before-todo-cleanup.*"))))
+ (delete-directory dir t)
+ (delete-directory bdir t))))
+
+(ert-deftest tc-backup-never-overwrites-an-earlier-one ()
+ "Two invocations in the same second must not collapse to one backup.
+
+open-tasks.org runs --convert-subtasks then --archive-done back to back, each
+a sub-second batch run. With a second-resolution stamp and copy-file's
+OK-IF-ALREADY-EXISTS, the second invocation overwrote the first's backup with
+already-mutated content, so the true pre-session original was unrecoverable —
+the exact state the backup exists to preserve (found 2026-07-24 in review)."
+ (let* ((dir (make-temp-file "tc-collide-" t))
+ (bdir (file-name-as-directory (make-temp-file "tc-cbk-" t)))
+ (file (expand-file-name "todo.org" dir))
+ (original (concat "* P Open Work\n** TODO [#B] parent\n*** DONE sub\n"
+ "CLOSED: [2026-07-01 Tue]\n"
+ "* P Resolved\n** DONE [#C] old\nCLOSED: [2025-01-01 Wed]\n")))
+ (unwind-protect
+ (progn
+ (with-temp-file file (insert original))
+ ;; Two back-to-back invocations, as the shipped workflow does.
+ (let ((temporary-file-directory bdir))
+ (let ((tc-check-only nil) (tc-convert-subtasks t))
+ (tc-process-file file))
+ (let ((tc-check-only nil) (tc-convert-subtasks nil) (tc-archive-done t)
+ (tc-archive-retain-days nil))
+ (tc-process-file file)))
+ (let ((backups (file-expand-wildcards
+ (concat bdir "todo.org.before-todo-cleanup.*"))))
+ ;; Both invocations kept their own backup.
+ (should (= (length backups) 2))
+ ;; And one of them still holds the true original.
+ (should (cl-some (lambda (b)
+ (string= original
+ (with-temp-buffer (insert-file-contents b)
+ (buffer-string))))
+ backups))))
+ (delete-directory dir t)
+ (delete-directory bdir t))))